Skip to content

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

$ 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:

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:

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:

$ cargo run
Error: greet::no_name

  x no name given
  help: pass a name: `greet Ada`

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:

    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:

$ 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