rtb-error 0.6.1

Error types and the diagnostic report pipeline. Part of the phpboyscout Rust toolkit.
Documentation
---
title: rtb-error
description: Canonical Error enum, Result alias, and the miette diagnostic-hook pipeline every rtb-* crate funnels through.
---

# `rtb-error`

`rtb-error` is the error-handling foundation of the Rust Tool Base
framework. It defines the canonical [`Error`](#error) enum, the
`Result<T, E = Error>` type alias, and the process-edge diagnostic
pipeline built on [`miette`][miette] and [`thiserror`][thiserror].
Every other `rtb-*` crate imports it; every downstream tool renders
errors through it.

Part of the [phpboyscout Rust toolkit](https://rust.phpboyscout.uk);
extracted from — and battle-tested by —
[rust-tool-base](https://gitlab.com/phpboyscout/rust-tool-base).

## Overview

Go Tool Base threads an `ErrorHandler.Check()` funnel through every
command handler. `rtb-error` deliberately **does not** do that —
errors are values, propagated with `?`, and rendered once at `main()`
via an installed `miette` hook. This is the idiomatic Rust pattern;
the funnel approach is a Go paradigm the framework explicitly rejects
(framework spec, Appendix B).

## Design rationale

Three decisions shape this crate:

1. **`thiserror` for authoring, `miette::Diagnostic` for rendering.**
   Every public enum derives both. `thiserror` gives us `#[error(...)]`
   and `#[from]`; `miette` gives us `code(...)`, `help(...)`, source
   spans, and the terminal renderer.

2. **`#[non_exhaustive]` everywhere.** Adding a variant to a public
   error enum must be a minor-version change, not a breaking one.
   Downstream `match` arms must always carry a wildcard.

3. **Mutable footer, immutable hook.** `miette::set_hook` is a
   `OnceLock`-backed set-once function; calling it twice fails with
   `InstallError`. `rtb-error` installs its hook once, then reads
   the footer from its own `RwLock<Option<Footer>>` at render time
   — so callers can update the footer freely without touching
   miette's global.

## Core types

### `Error`

```rust
#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum Error {
    #[error("configuration error: {0}")]
    #[diagnostic(code(rtb::config))]
    Config(String),

    #[error("I/O error: {0}")]
    #[diagnostic(code(rtb::io))]
    Io(#[from] std::io::Error),

    #[error("command not found: {0}")]
    #[diagnostic(
        code(rtb::command_not_found),
        help("run `--help` to list available commands"),
    )]
    CommandNotFound(String),

    #[error("feature `{0}` is not compiled in")]
    #[diagnostic(
        code(rtb::feature_disabled),
        help("rebuild with the appropriate Cargo feature enabled"),
    )]
    FeatureDisabled(&'static str),

    /// Escape hatch for downstream-crate diagnostics.
    #[error("{0}")]
    #[diagnostic(transparent)]
    Other(#[from] Box<dyn Diagnostic + Send + Sync + 'static>),
}
```

Every variant carries a `code` under the `rtb::` namespace. The
`Other` variant is the escape hatch: downstream crates define their
own `thiserror::Error + miette::Diagnostic` enums and box them in
here at boundaries.

### `Result`

```rust
pub type Result<T, E = Error> = std::result::Result<T, E>;
```

Used everywhere inside the framework. Downstream tools typically
alias `rtb_error::Result` as `RtbResult` via the `rtb_cli::prelude`.

### `hook` module

```rust
pub mod hook {
    /// Install the default graphical report handler.
    /// Idempotent; first caller wins (miette's hook is set-once).
    pub fn install_report_handler();

    /// Install miette's panic hook. Idempotent —
    /// `std::panic::set_hook` overwrites on every call.
    pub fn install_panic_hook();

    /// Install a footer closure read on every diagnostic render.
    /// Safe to call multiple times; the most recent closure wins.
    pub fn install_with_footer<F>(footer: F)
    where
        F: Fn() -> String + Send + Sync + 'static;
}
```

`rtb_cli::Application::run` calls all three during startup, with the
footer sourced from `ToolMetadata::help.footer()`.

Full API reference (including the `exit_code` module):
[docs.rs/rtb-error](https://docs.rs/rtb-error).

## Usage patterns

### Authoring a downstream error enum

```rust
use miette::Diagnostic;
use thiserror::Error;

#[derive(Debug, Error, Diagnostic)]
#[non_exhaustive]
pub enum MyCrateError {
    #[error("bad config value {name}: {value}")]
    #[diagnostic(
        code(mytool::bad_value),
        help("pick a value between 1 and 65535"),
    )]
    BadValue { name: String, value: String },
}

// Box across crate boundaries:
fn returns_framework_result() -> rtb_error::Result<()> {
    Err(MyCrateError::BadValue { .. }).map_err(|e| {
        rtb_error::Error::Other(Box::new(e))
    })
}
```

### Ad-hoc diagnostics

```rust
return Err(miette::miette!(
    code = "mytool::no_config",
    help = "run `mytool init` first",
    "no config file at {}",
    path.display()
));
```

### `main()` wiring

`rtb_cli::Application::run` already does the install; tools that
bypass `Application` install manually:

```rust
#[tokio::main]
async fn main() -> miette::Result<()> {
    rtb_error::hook::install_report_handler();
    rtb_error::hook::install_panic_hook();
    // ...
}
```

## Hook safety

!!! warning "Footer closures must not panic"
    The installed footer closure is invoked on every render. A
    panicking closure is caught via `catch_unwind` and the footer is
    silently suppressed for that render, but the framework logs a
    diagnostic about it. Thread-local re-entry guard prevents a
    panicking footer from recursing through miette's panic hook and
    producing a double-panic abort.

    See [Engineering Standards §1.3](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/engineering-standards.md#13-hook-and-panic-hook-safety)
    for the full rules around hook + panic-hook safety.

## Consumers

| Crate | Uses |
|---|---|
| [rtb-app]https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/components/rtb-app.md | `Error` for `FeatureDisabled` / `CommandNotFound` variants. |
| [rtb-config]https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/components/rtb-config.md | Converts `ConfigError::Parse` into `Error::Other`. |
| [rtb-cli]https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/components/rtb-cli.md | `Application::run` installs all three hooks + wires the `ToolMetadata::help` footer. |
| [rtb-credentials]https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/components/rtb-credentials.md | `CredentialError` is Boxed into `Error::Other` at the app boundary. |
| [rtb-telemetry]https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/components/rtb-telemetry.md | `TelemetryError` likewise. |

## Testing

20 acceptance criteria across:

- 13 unit tests (`tests/unit.rs`) — T1–T13 including panic resilience.
- 6 Gherkin scenarios (`tests/features/error.feature`).
- 1 trybuild fixture — exhaustive `match` on the `#[non_exhaustive]`
  enum must fail to compile.

## Design record

The authoritative contract is the crate's v0.1 spec, retained in the
rust-tool-base
[spec series](https://gitlab.com/phpboyscout/rust-tool-base/-/blob/main/docs/development/specs/2026-04-22-rtb-error-v0.1.md).

[miette]: https://crates.io/crates/miette
[thiserror]: https://crates.io/crates/thiserror