Skip to content

rtb-error v0.1 — Error types and diagnostic reporting

Status: IMPLEMENTED in commits 0fcc30b (test+spec), 482c027 (feat). Target crate: rtb-error Feeds: every other rtb-* crate (depends on rtb-error). Parent contract: §7 of the framework spec.


1. Motivation

Every other rtb-* crate imports rtb-error for its canonical Result alias and to funnel typed diagnostics through a single rendering surface at the process edge. We pin the contract early so downstream crates can #[derive(thiserror::Error, miette::Diagnostic)] their own variants and convert into rtb_error::Error at boundaries without ambiguity.

Explicitly out of scope:

  • No ErrorHandler trait. Errors are values. Propagation uses ?; rendering happens once, at main(), via an installed miette hook.
  • No WithHint helper. Callers use #[diagnostic(help = "…")] on their own types, or miette::miette!(help = "…", "…") for ad-hoc cases.
  • No stack-trace capture helper. miette handles backtraces via the standard RUST_BACKTRACE env var and its graphical reporter.

2. Public API

2.1 Crate root

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

pub use miette::{Diagnostic, Report};

pub mod hook;

2.2 The Error enum

#[derive(Debug, thiserror::Error, miette::Diagnostic)]
#[non_exhaustive]
pub enum Error {
    /// Configuration source rejected the value.
    #[error("configuration error: {0}")]
    #[diagnostic(code(rtb::config))]
    Config(String),

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

    /// No registered command matches the user-supplied name.
    #[error("command not found: {0}")]
    #[diagnostic(
        code(rtb::command_not_found),
        help("run `--help` to list available commands"),
    )]
    CommandNotFound(String),

    /// A built-in command was requested but its Cargo feature is off.
    #[error("feature `{0}` is not compiled in")]
    #[diagnostic(
        code(rtb::feature_disabled),
        help("rebuild with the appropriate Cargo feature enabled"),
    )]
    FeatureDisabled(&'static str),

    /// A downstream crate's typed diagnostic, kept live for rendering.
    #[error("{0}")]
    #[diagnostic(transparent)]
    Other(#[from] Box<dyn Diagnostic + Send + Sync + 'static>),
}

Normative:

  • #[non_exhaustive] — consumers must never match exhaustively, keeping future-variant addition a minor-version change.
  • Every variant carries a code attribute. The rtb:: namespace is reserved; downstream crates use their own tool-name prefix.
  • The Other variant's bound is Diagnostic + Send + Sync + 'static; this matches miette::Report's internal bound and keeps the enum Send + Sync so it crosses tokio::spawn boundaries.

2.3 From conversions

Provided by-derive:

  • impl From<std::io::Error> for Error (via #[from]).
  • impl From<Box<dyn Diagnostic + …>> for Error (via #[from]).

Explicitly not provided:

  • impl From<&str> for Error or impl From<String> for Error — would hide unsafe stringification at call sites and force a variant choice we cannot make for callers. Callers opt into Error::Config(s) explicitly.

2.4 hook module

pub mod hook {
    /// Install the default miette graphical report handler.
    ///
    /// Idempotent — calling twice is a no-op. Safe to call from
    /// `main()` before `tokio::main` expansion or from inside an
    /// `Application::run()` invocation.
    pub fn install_report_handler();

    /// Install the miette panic hook, routing panics through the same
    /// graphical report pipeline.
    pub fn install_panic_hook();

    /// Install both hooks and register a closure that appends a
    /// tool-specific support footer to every rendered diagnostic.
    ///
    /// `footer` is called once per diagnostic render and may return an
    /// empty string to suppress the footer.
    pub fn install_with_footer<F>(footer: F)
    where
        F: Fn() -> String + Send + Sync + 'static;
}

The install_with_footer variant is what rtb-cli::Application::run() calls after reading ToolMetadata::help. For unit tests and one-off binaries that don't have a ToolMetadata, install_report_handler() + install_panic_hook() is sufficient.

3. Acceptance criteria

Every criterion below is encoded as either a unit test (T#) or a Gherkin scenario (S#). The lists are exhaustive — implementation is complete when every line turns green.

3.1 Unit-test acceptance (T#)

  • T1 — Result alias: rtb_error::Result<()> is a type alias for std::result::Result<(), rtb_error::Error>.
  • T2 — Error is Send + Sync + 'static: a compile-time check asserts the bounds needed to cross tokio::spawn.
  • T3 — Error::Io from std::io::Error: ? on an io::Error succeeds and preserves the underlying kind.
  • T4 — Error::Other from a downstream typed diagnostic: a #[derive(thiserror::Error, miette::Diagnostic)] enum Box'd into Error::Other renders its own code and help through miette (transparent).
  • T5 — code attribute present on every variant: a reflective check via miette::Diagnostic::code() returns Some for every enumerated variant constructed from a minimal witness.
  • T6 — help attribute present on CommandNotFound and FeatureDisabled: Diagnostic::help() returns Some for those two variants.
  • T7 — Display is concise: format!("{err}") for each variant matches the spec's #[error(...)] format without the code or help — those belong to the diagnostic report surface, not Display.
  • T8 — Debug does not leak secrets: constructing Error::Config(String::from("password=hunter2")) and calling format!("{:?}", err) must not panic; a separate guard in rtb-telemetry redacts at the telemetry boundary (tested there, not here).
  • T9 — #[non_exhaustive]: a compiletest-style negative test asserts that match without a wildcard fails to compile on the current Error enum. (Covered by a trybuild fixture.)
  • T10 — hook::install_report_handler is idempotent: calling twice does not panic or double-install (verified by checking miette::set_hook behaviour via a counter fixture).
  • T11 — hook::install_panic_hook preserves the original hook chain: a panic raised after install is rendered through miette; the prior std::panic::set_hook is called after miette's formatter.
  • T12 — hook::install_with_footer appends the footer: a custom footer returning "support: slack://#team" appears in the rendered output of a non-trivial diagnostic.

3.2 Gherkin acceptance (S#)

All scenarios live in crates/rtb-error/tests/features/error.feature.

  • S1 — Rendering a typed diagnostic includes its code, help, and URL.
  • S2 — A wrapped downstream diagnostic renders its own code and help, not the wrapper's. (Transparent)
  • S3 — A panic after install_panic_hook renders as a graphical diagnostic, not the default RUST_BACKTRACE trace.
  • S4 — A footer installed via install_with_footer is appended to every rendered diagnostic.
  • S5 — install_report_handler is safe to call before and after install_with_footer. (Idempotency at user level)
  • S6 — Error::FeatureDisabled("mcp") renders with the help text "rebuild with the appropriate Cargo feature enabled".

4. Security & operational requirements

  • #![forbid(unsafe_code)] at the crate root.
  • No dynamic dispatch on the hot path — Error renders via static enum dispatch; only the Other variant boxes.
  • No panic! in public API functions. hook::install_* returns () on success; any internal failure is silently idempotent (double-install is by design a no-op).
  • No logging from this crate — tracing is a dependency concern; errors are returned, not emitted here. Consumers decide whether to log.

5. Non-goals (explicit)

  • No custom Report type. We re-export miette::Report verbatim.
  • No backtrace capture helpers. RUST_BACKTRACE=1 + miette's renderer already do the right thing.
  • No error-code registry. Codes are string literals in #[diagnostic(code = "…")]; collisions are a design review concern, not a runtime one.
  • No serialisation. Error is not Serialize. Turning an error into JSON for --output json is rtb-cli's concern and uses its own DTO.

6. Rollout plan

  1. Land the spec (this document), Gherkin, and failing unit tests with API stubs in one commit so the tree stays green.
  2. Implement the Error enum and hook module to turn the suite green.
  3. Audit: every downstream rtb-* crate's Cargo.toml lists rtb-error = { path = "…", version = "0.1" }. No crate defines its own Result alias that shadows this one.

7. Open questions

  • O1 — Should Error::Config(String) carry a typed source field? A Config(#[source] ConfigError) variant would give richer diagnostics, but ConfigError lives in rtb-config which depends on rtb-error, creating a cycle. Proposed resolution: leave as String for 0.1; revisit in 0.2 via a Box<dyn Diagnostic> or an indirection crate.
  • O2 — Do we need an Error::Cancelled variant for the shutdown-token path? It's a common enough concern to warrant a distinguished variant. Leaning yes. Needs a decision before rtb-cli v0.1.
  • O3 — install_with_footer footer callback signatureFn() -> String vs Fn(&dyn Diagnostic) -> String. The latter lets the footer vary by severity or code; the former is simpler. Leaning Fn() -> String for v0.1, upgrade path to the richer signature via a new install_with_report_footer function without breaking callers.
  • O4 — Should we gate the hook module behind a Cargo feature? Library consumers who don't want miette's global hooks installed today (e.g. embedded test binaries) can currently skip calling install_* — the global hooks only activate on call. Proposed resolution: no feature gate; opt-in via call site is sufficient.