---
title: Give your CLI a diagnostic error path
description: Build a small Rust CLI from scratch whose errors carry a code, a help line, a support footer and a chosen exit code.
tags: [tutorials, getting-started]
---
# 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. It will exit 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
```console
$ cargo new greet
$ cd greet
$ cargo add rtb-error miette thiserror
```
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:
```rust
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:
```console
$ 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`:
```rust
fn main() -> miette::Result<()> {
rtb_error::hook::install_report_handler();
run()?;
Ok(())
}
```
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:
```console
$ cargo run
Error: greet::no_name
x no name given
help: pass a name: `greet Ada`
```
## 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:
```rust
fn main() -> miette::Result<()> {
rtb_error::hook::install_with_footer(|| {
"Report bugs at https://example.invalid/greet/issues".to_string()
});
run()?;
Ok(())
}
```
```console
$ 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:
```rust
rtb_error::hook::install_panic_hook();
```
Add it just after the footer call. To see it work, temporarily add a
`panic!("something went badly wrong");` to `run`:
```console
$ 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:
```rust
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`:
```rust
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))
}
}
}
```
Use `{report:?}`, 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:
```console
$ 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](../how-to/02-define-your-own-error-type.md)
covers code namespaces, `#[non_exhaustive]`, and when to convert at a
boundary.
- The [`hook` reference](../reference/03-hook.md) says what installing the
handler changes about rendering, including the fixed 200-column wrap width.
- [Errors are values, not handled events](../explanation/01-errors-are-values.md)
explains why there is no error-handler object anywhere in this design.