3 min read
On this page

Error Reporting & Exit Codes

A CLI tool that fails silently or dumps a stack trace is a bad CLI tool. Good error reporting means clear messages, proper exit codes, and output that other tools can parse. This is the difference between a tool people curse at and one they reach for.

Exit Codes Matter

Unix convention: 0 means success, non-zero means failure. Different codes communicate different failures:

use std::process;

fn main() {
    match run() {
        Ok(()) => process::exit(0),
        Err(e) => {
            eprintln!("error: {}", e);
            match e.kind() {
                ErrorKind::NotFound => process::exit(1),
                ErrorKind::InvalidInput => process::exit(2),
                ErrorKind::PermissionDenied => process::exit(3),
                _ => process::exit(1),
            }
        }
    }
}

Common conventions:

0   — success
1   — general error
2   — usage error (bad arguments, typically set by clap)
126 — command found but not executable
127 — command not found
130 — terminated by Ctrl-C (128 + signal 2)

Shell scripts and CI pipelines depend on exit codes. A tool that always exits 0 regardless of outcome breaks every script that uses it.

Returning Result from main

Rust allows main to return Result. On Err, it prints the Debug representation and exits with code 1:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = load_config("config.toml")?;
    let result = process(&config)?;
    println!("{}", result);
    Ok(())
}
$ tool
Error: Os { code: 2, kind: NotFound, message: "No such file or directory" }

This works but the output is ugly — it shows the Debug format, not a human-readable message. For production tools, handle errors explicitly.

anyhow for Human-Readable Errors

anyhow provides error chaining with context. It is designed for applications (not libraries):

use anyhow::{Context, Result, bail};
use std::fs;
use std::path::Path;

fn load_config(path: &Path) -> Result<Config> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read config from {}", path.display()))?;

    let config: Config = toml::from_str(&content)
        .with_context(|| format!("failed to parse config in {}", path.display()))?;

    if config.port == 0 {
        bail!("port must be non-zero in {}", path.display());
    }

    Ok(config)
}

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {}", e);

        // Print the chain of causes
        let mut cause = e.source();
        while let Some(c) = cause {
            eprintln!("  caused by: {}", c);
            cause = c.source();
        }

        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let config = load_config(Path::new("config.toml"))?;
    start_server(&config)?;
    Ok(())
}
$ tool
error: failed to read config from config.toml
  caused by: No such file or directory (os error 2)

The error chain tells the user exactly what happened and why. with_context adds a layer of explanation at each level.

color-eyre for Beautiful Error Reports

color-eyre extends eyre (anyhow's cousin) with colored output, span traces, and suggestions:

use color_eyre::eyre::{Result, WrapErr};
use color_eyre::Section;

fn main() -> Result<()> {
    color_eyre::install()?;
    run()
}

fn run() -> Result<()> {
    let path = std::path::Path::new("data.csv");

    let content = std::fs::read_to_string(path)
        .wrap_err("failed to load data file")
        .suggestion("make sure data.csv exists in the current directory")?;

    let records = parse_csv(&content)
        .wrap_err("failed to parse CSV data")?;

    println!("Loaded {} records", records.len());
    Ok(())
}
$ tool

Error:
   0: failed to load data file
   1: No such file or directory (os error 2)

  Suggestion: make sure data.csv exists in the current directory

In debug mode, color-eyre also shows backtraces and span traces. For user-facing tools, this is the gold standard of error reporting.

Structured vs Unstructured Output

Human-readable output is the default. But when other programs consume your output, provide structured formats. Add a --format flag with text and json options. For text, format columns with padding. For JSON, use serde_json::to_string_pretty. This lets users pipe your output into jq or other tools reliably. Text is for humans; JSON is for machines. Support both.

Stderr vs Stdout

Follow the convention: data goes to stdout, diagnostic messages go to stderr:

use std::io::{self, Write, BufWriter};

fn main() {
    let stdout = io::stdout();
    let stderr = io::stderr();
    let mut out = BufWriter::new(stdout.lock());
    let mut err = stderr.lock();

    // Progress and status go to stderr
    writeln!(err, "Processing 3 files...").unwrap();

    // Data goes to stdout
    writeln!(out, "file1.txt: 42 lines").unwrap();
    writeln!(out, "file2.txt: 17 lines").unwrap();
    writeln!(out, "file3.txt: 93 lines").unwrap();

    // Warnings go to stderr
    writeln!(err, "warning: file3.txt has trailing whitespace").unwrap();
}
$ tool > results.txt
Processing 3 files...
warning: file3.txt has trailing whitespace

$ cat results.txt
file1.txt: 42 lines
file2.txt: 17 lines
file3.txt: 93 lines

When stdout is redirected to a file, only the data goes there. Diagnostic messages remain visible in the terminal.

Progress Bars with indicatif

For long-running operations, show progress:

use indicatif::{ProgressBar, ProgressStyle};
use std::time::Duration;

fn process_files(files: &[String]) {
    let pb = ProgressBar::new(files.len() as u64);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.green} [{bar:40.cyan/blue}] {pos}/{len} {msg}")
            .unwrap()
            .progress_chars("=> "),
    );

    for file in files {
        pb.set_message(file.clone());
        // Simulate work
        std::thread::sleep(Duration::from_millis(200));
        pb.inc(1);
    }

    pb.finish_with_message("done");
}
  [========================================] 5/5 done

indicatif writes to stderr by default, so progress bars do not interfere with piped output. Use ProgressBar::hidden() when stdout is not a terminal (detected with atty or is-terminal crate).

Common Pitfalls

  • Exit code 0 on failure — the most common CLI bug. If something went wrong, exit non-zero. Scripts depend on this.
  • Errors to stdout — error messages written to stdout contaminate piped output. Always use eprintln! or write to stderr.
  • Debug format in user-facing errors{:?} shows Rust's debug representation. Users want {:#} (anyhow's display chain) or manually formatted messages.
  • No error chain — "failed to start server" is useless. "failed to start server: could not bind to port 8080: address already in use" tells the user exactly what to fix.
  • Progress bars in non-interactive mode — progress bars to a pipe or file corrupt the output. Check if stdout is a terminal before showing them.
  • Swallowing errors — using .unwrap_or_default() or .ok() to silence errors hides bugs. If an operation can fail, report the failure.

Key Takeaways

  • Exit code 0 means success, non-zero means failure. This is not optional — scripts depend on it.
  • Use anyhow with with_context for error chains that tell users what went wrong and why.
  • Data goes to stdout, diagnostics go to stderr. This lets users pipe output without contamination.
  • Support structured output (JSON) alongside human-readable text for tool interoperability.
  • Show progress for long operations with indicatif, but only when output is a terminal.
  • Format errors for humans: {:#} with anyhow, or manual formatting. Never show Debug output to users.