Skip to content
rusthacks.dev
CLI

Exit Codes and Error Output for Rust Command Line Tools

Exit codes are how scripts know your tool failed. Return 0 for success, a nonzero code for failure, send errors to stderr, and let main return ExitCode or Result.

2 min read
Summarize in AI
Flat illustration for the post “Exit Codes and Error Output for Rust Command Line Tools”, composed around cli.

A shell script that runs your tool only has one question: did it work. The exit status is the answer, and getting it wrong means && chains continue after a failure or set -e scripts stop on success. The Rust side takes four lines. This post covers main signatures, a small code scheme and the stderr rule. It is part of the command line tool series.

Rust 1.90, anyhow 1.

What main can return

rust
use std::process::ExitCode;

fn main() -> ExitCode {
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(e) => {
            eprintln!("error: {e:#}");
            ExitCode::from(1)
        }
    }
}

fn run() -> anyhow::Result<()> {
    anyhow::bail!("config file not found");
}

Running it prints error: config file not found on stderr and the shell sees $? equal to 1. Returning ExitCode gives you control of the message format, which the next form does not.

main returning Result

rust
fn main() -> Result<(), Box<dyn std::error::Error>> {
    std::fs::read_to_string("missing.txt")?;
    Ok(())
}

On failure it prints Error: Os { code: 2, kind: NotFound, message: "No such file or directory" } using Debug formatting, and exits with 1. That is acceptable for scripts and ugly for users. The format with anyhow is nicer, as shown in anyhow versus thiserror.

A small scheme

CodeMeaning
0Success
1The operation failed
2Usage error (clap does this)
3Not found, if scripts need to tell
130Killed by Ctrl+C, by Unix convention (128 + 2)

Do not return more than a handful. ExitCode::from(u8) only takes 0 to 255, and the shell truncates larger values.

stdout, stderr

Results go to stdout so tool | next works. Messages, warnings and errors go to stderr so they appear on the terminal even when stdout is redirected. A tool that prints "error: ..." to stdout will write it into the user's output file.

rust
println!("{}", result);
eprintln!("warning: skipped {path}");

Why not process::exit

std::process::exit(1) ends the process immediately. Values in scope are not dropped, so a BufWriter holding unflushed data loses it. Return up to main and let it end normally. The only common exception is a signal handler.

Closed pipes

When stdout is a closed pipe, such as tool | head -1, writes fail with BrokenPipe. Rust ignores SIGPIPE, so println! panics. Handle it as shown in broken pipe when writing to standard output.

Where conventions end

The sysexits.h set of codes (64 and up) exists but is rarely followed outside mail software. Windows treats codes as 32 bit values. In scripts, check $? immediately, because the next command overwrites it. The standard library documents ExitCode, and signals and Ctrl C handling covers interrupted exits.

Also inRust
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.