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 tostderr. - 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
anyhowwithwith_contextfor 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 showDebugoutput to users.