Give your CLI a diagnostic error path¶
By the end you'll have a working command-line tool whose failures print a
searchable error code, a line telling the user what to do next, and a support
footer — and which exits with a code you chose rather than a blanket 1.
Panics will render the same way.
Allow about twenty minutes. The first build pulls down miette and its
terminal-rendering dependencies, which takes a minute or two on a cold cache.
Before you start¶
You need a Rust toolchain of 1.82 or newer (rustc --version will tell you)
and a terminal. Nothing else — no framework, no scaffolding tool.
Create the project¶
Three dependencies, and each earns its place. rtb-error gives you the hook
pipeline and the exit-code attachment. miette gives you the Diagnostic
derive and the renderer. thiserror gives you the #[error(...)] derive that
writes the Display implementation for you.
Define an error that knows what to tell the user¶
Replace src/main.rs with this:
use miette::Diagnostic;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum GreetError {
#[error("no name given")]
#[diagnostic(code(greet::no_name), help("pass a name: `greet Ada`"))]
NoName,
}
fn run() -> Result<(), GreetError> {
let name = std::env::args().nth(1).ok_or(GreetError::NoName)?;
println!("Hello, {name}!");
Ok(())
}
fn main() -> miette::Result<()> {
run()?;
Ok(())
}
Three attributes are doing the work. #[error("no name given")] is the message.
code(greet::no_name) is the identity — the thing a user pastes into a search
box or quotes in a bug report. help(...) is the instruction that tells them
how to get out of it. Write help as something to do, not as a restatement of
what went wrong.
Run it both ways:
$ cargo run -- Ada
Hello, Ada!
$ cargo run
Error: greet::no_name
x no name given
help: pass a name: `greet Ada`
That already looks better than most CLI errors, and you haven't installed
anything yet — miette falls back to its own default renderer when nothing
else is registered.
Install the report handler, and install it first¶
rtb-error's handler is what lets you add a footer later. Add one line to the
top of main:
The position of that line matters more than it looks. miette keeps its
renderer in a slot that can only be written once, and the slot is filled by
whichever comes first — your install call, or the very first error constructed
anywhere in the process. Lose that race and your handler is never installed,
the footer you add in a moment never appears, and nothing warns you. Putting
the call first is the whole mitigation.
Run it again. The output is near enough identical:
Add a support footer¶
Swap install_report_handler for install_with_footer, which installs the
handler and registers a closure whose output is appended to every rendered
diagnostic:
fn main() -> miette::Result<()> {
rtb_error::hook::install_with_footer(|| {
"Report bugs at https://example.invalid/greet/issues".to_string()
});
run()?;
Ok(())
}
$ cargo run
Error: greet::no_name
x no name given
help: pass a name: `greet Ada`
Report bugs at https://example.invalid/greet/issues
The closure runs on every render rather than once at install time, so you can
call install_with_footer again later — after loading configuration, say — and
the new footer takes effect immediately. Keep it trivial, though: if the
closure panics, the footer is silently dropped and nothing tells you why.
Make panics render the same way¶
A panic still prints Rust's default thread 'main' panicked at …, which looks
nothing like the rest of your output. One more line fixes that:
Add it just after the footer call. To see it work, temporarily add a
panic!("something went badly wrong"); to run:
$ cargo run -- Ada
Error: x Main thread panicked.
|-> at src/main.rs:13:5
`-> something went badly wrong
help: set the `RUST_BACKTRACE=1` environment variable to display a backtrace.
Report bugs at https://example.invalid/greet/issues
Same renderer, same footer, and the RUST_BACKTRACE hint your users will need
when they report it. Take the panic! back out before continuing.
Exit with a code you chose¶
main currently exits 1 on any error, because that is what returning
miette::Result does. To exit 64 — the conventional code for a usage error —
attach the code to the error and read it back at the boundary.
Attach it where the error is raised:
use rtb_error::WithExitCode;
fn run() -> miette::Result<()> {
let name = std::env::args().nth(1)
.ok_or(GreetError::NoName.with_exit_code(64))?;
println!("Hello, {name}!");
Ok(())
}
Then change main to return ExitCode rather than miette::Result, because
miette::Result has no way to carry anything but 1:
use std::process::ExitCode;
use miette::Diagnostic;
use rtb_error::{exit_code_of, WithExitCode};
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum GreetError {
#[error("no name given")]
#[diagnostic(code(greet::no_name), help("pass a name: `greet Ada`"))]
NoName,
}
fn run() -> miette::Result<()> {
let name = std::env::args().nth(1)
.ok_or(GreetError::NoName.with_exit_code(64))?;
println!("Hello, {name}!");
Ok(())
}
fn main() -> ExitCode {
rtb_error::hook::install_with_footer(|| {
"Report bugs at https://example.invalid/greet/issues".to_string()
});
rtb_error::hook::install_panic_hook();
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(report) => {
eprintln!("Error: {report:?}");
ExitCode::from(exit_code_of(&report).unwrap_or(1))
}
}
}
{report:?} and not {report} — Debug is the formatting path that goes
through the installed hook, and Display prints only the bare message with no
code, no help and no footer. unwrap_or(1) is the default for every error that
carries no code, which will be most of them.
Check both paths:
$ cargo run -- Ada
Hello, Ada!
$ echo $?
0
$ cargo run
Error: greet::no_name
x no name given
help: pass a name: `greet Ada`
Report bugs at https://example.invalid/greet/issues
$ echo $?
64
Attaching the code changed nothing about what the user sees. ExitCoded
delegates every part of the diagnostic to the error it wraps, so the code is
metadata for the shell and invisible on screen.
One thing to watch out for¶
The exit code is recovered by downcasting the report, so it survives ?, and
it survives Report::wrap_err. It does not survive being boxed into
rtb_error::Error::Other — that report downcasts to Error, not to
ExitCoded, and the code silently reverts to your default. If you find
yourself wrapping, attach the exit code at the outermost layer.
Where to go next¶
- Define your own error type —
code namespaces,
#[non_exhaustive], and when to convert at a boundary. hookreference — what installing the handler changes about rendering, including the fixed 200-column wrap width.- Errors are values, not handled events — why there is no error-handler object anywhere in this design.