Error Reporting
Present actionable errors to CLI users with anyhow and miette.
Recipe
use anyhow::Context;
fn read_config(path: &str) -> anyhow::Result<String> {
std::fs::read_to_string(path)
.with_context(|| format!("failed to read config at {path}"))
}
fn main() -> anyhow::Result<()> {
let cfg = read_config("app.toml")?;
println!("loaded {cfg}");
Ok(())
}When to reach for this: Application binaries where users need context-rich messages, not library-style typed errors.
Working Example
use miette::{miette, IntoDiagnostic, Result};
#[derive(Debug, thiserror::Error, Diagnostic)]
#[error("invalid port: {0}")]
#[diagnostic(code(app::invalid_port))]
struct InvalidPort(u16);
fn parse_port(s: &str) -> Result<u16> {
let port: u16 = s.parse().into_diagnostic()?;
if port == 0 {
return Err(miette!(InvalidPort(port)));
}
Ok(port)
}
fn main() -> Result<()> {
let port = parse_port("0")?;
println!("listening on {port}");
Ok(())
}What this demonstrates:
anyhow::Contextadds location context tostderrorsmietteprovides diagnostic codes and formatted reportsthiserrordefines small domain error typesmainreturnsResultfor automatic non-zero exit
Deep Dive
anyhow vs thiserror vs miette
| Crate | Role |
|---|---|
anyhow | Ergonomic Result in binaries |
thiserror | Typed errors in libraries |
miette | Beautiful reports with source spans |
CLI Error Guidelines
- One problem per error message
- Say what failed, why, and how to fix it
- Print to stderr; never mix errors into stdout JSON
- Chain context as you cross subsystem boundaries
Exit Codes
use std::process::ExitCode;
fn main() -> ExitCode {
if let Err(e) = run() {
eprintln!("{e:#}");
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}Use {:#} for the full anyhow chain.
Gotchas
- Printing
Displayonly - Users miss the error chain. Fix: Use{:#}ormiette::Report. - Panics in CLI - Ugly stack traces for users. Fix: Return
Resultand reserve panics for bugs. - Library uses anyhow - Callers cannot match error types. Fix: Libraries use
thiserror; binaries wrap withanyhow. - Verbose RUST_BACKTRACE by default - Overwhelms users. Fix: Show backtrace only with
--verboseor env hint. - Leaking internal paths -
/home/ci/...confuses users. Fix: Strip or relativize paths in user messages.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
thiserror only | Libraries with matchable errors | Top-level binary needing context chains |
Plain eprintln! | Tiny scripts | Multi-step pipelines needing context |
color-eyre | Dev tools during development | Minimal dependency production CLIs |
FAQs
anyhow in libraries?
Avoid it. Library consumers need typed errors. Use thiserror and let binaries wrap with anyhow.
How do I add error codes?
Use miette::Diagnostic with a code(...) attribute for searchable error IDs.
JSON error output?
Define an ErrorResponse { code, message, details } struct and print serde JSON on failure when --json is set.
Suppress warnings but show errors?
Warnings go to stderr with WARN: prefix. Errors use non-zero exit and distinct formatting.
Localized errors?
Use fluent or rust-i18n for translated messages. Keep error codes stable across locales.
Testing error messages?
Assert on err.to_string() or format!("{err:#}") in integration tests.
Log errors and print them?
Log full detail with tracing::error!. Print a short user-facing summary to stderr.
Multiple errors at once?
Collect errors in a Vec and print all before exiting. Common in linters and validators.
Caused by chains?
anyhow supports .context() and #[source]. Each layer adds operator-relevant detail.
miette without nightly?
miette works on stable Rust. Enable fancy feature for graphical reports when stderr is a TTY.
Related
- CLI Basics - exit codes
- clap - validation errors
- Configuration & Env - config load failures
- CLI Best Practices - actionable messages
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.