rtb-error 0.6.4

Error types and the diagnostic report pipeline. Part of the phpboyscout Rust toolkit.
Documentation
---
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 — 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

```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))
        }
    }
}
```

`{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:

```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 —
  code namespaces, `#[non_exhaustive]`, and when to convert at a boundary.
- [`hook` reference]../reference/03-hook.md — 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
  — why there is no error-handler object anywhere in this design.