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
ErrorHandlertrait. Errors are values. Propagation uses?; rendering happens once, atmain(), via an installedmiettehook. - No
WithHinthelper. Callers use#[diagnostic(help = "…")]on their own types, ormiette::miette!(help = "…", "…")for ad-hoc cases. - No stack-trace capture helper.
miettehandles backtraces via the standardRUST_BACKTRACEenv 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 nevermatchexhaustively, keeping future-variant addition a minor-version change.- Every variant carries a
codeattribute. Thertb::namespace is reserved; downstream crates use their own tool-name prefix. - The
Othervariant's bound isDiagnostic + Send + Sync + 'static; this matchesmiette::Report's internal bound and keeps the enumSend + Syncso it crossestokio::spawnboundaries.
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 Errororimpl From<String> for Error— would hide unsafe stringification at call sites and force a variant choice we cannot make for callers. Callers opt intoError::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 —
Resultalias:rtb_error::Result<()>is a type alias forstd::result::Result<(), rtb_error::Error>. - T2 —
ErrorisSend + Sync + 'static: a compile-time check asserts the bounds needed to crosstokio::spawn. - T3 —
Error::Iofromstd::io::Error:?on anio::Errorsucceeds and preserves the underlying kind. - T4 —
Error::Otherfrom a downstream typed diagnostic: a#[derive(thiserror::Error, miette::Diagnostic)]enum Box'd intoError::Otherrenders its owncodeandhelpthrough miette (transparent). - T5 —
codeattribute present on every variant: a reflective check viamiette::Diagnostic::code()returnsSomefor every enumerated variant constructed from a minimal witness. - T6 —
helpattribute present onCommandNotFoundandFeatureDisabled:Diagnostic::help()returnsSomefor those two variants. - T7 —
Displayis concise:format!("{err}")for each variant matches the spec's#[error(...)]format without the code or help — those belong to the diagnostic report surface, notDisplay. - T8 —
Debugdoes not leak secrets: constructingError::Config(String::from("password=hunter2"))and callingformat!("{:?}", err)must not panic; a separate guard inrtb-telemetryredacts at the telemetry boundary (tested there, not here). - T9 —
#[non_exhaustive]: acompiletest-style negative test asserts thatmatchwithout a wildcard fails to compile on the currentErrorenum. (Covered by atrybuildfixture.) - T10 —
hook::install_report_handleris idempotent: calling twice does not panic or double-install (verified by checkingmiette::set_hookbehaviour via a counter fixture). - T11 —
hook::install_panic_hookpreserves the original hook chain: a panic raised after install is rendered through miette; the priorstd::panic::set_hookis called after miette's formatter. - T12 —
hook::install_with_footerappends 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_hookrenders as a graphical diagnostic, not the defaultRUST_BACKTRACEtrace. - S4 — A footer installed via
install_with_footeris appended to every rendered diagnostic. - S5 —
install_report_handleris safe to call before and afterinstall_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 —
Errorrenders via static enum dispatch; only theOthervariant 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 —
tracingis a dependency concern; errors are returned, not emitted here. Consumers decide whether to log.
5. Non-goals (explicit)¶
- No custom
Reporttype. We re-exportmiette::Reportverbatim. - 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.
Erroris notSerialize. Turning an error into JSON for--output jsonisrtb-cli's concern and uses its own DTO.
6. Rollout plan¶
- Land the spec (this document), Gherkin, and failing unit tests with API stubs in one commit so the tree stays green.
- Implement the
Errorenum andhookmodule to turn the suite green. - Audit: every downstream
rtb-*crate'sCargo.tomllistsrtb-error = { path = "…", version = "0.1" }. No crate defines its ownResultalias that shadows this one.
7. Open questions¶
- O1 — Should
Error::Config(String)carry a typed source field? AConfig(#[source] ConfigError)variant would give richer diagnostics, butConfigErrorlives inrtb-configwhich depends onrtb-error, creating a cycle. Proposed resolution: leave asStringfor 0.1; revisit in 0.2 via aBox<dyn Diagnostic>or an indirection crate. - O2 — Do we need an
Error::Cancelledvariant for the shutdown-token path? It's a common enough concern to warrant a distinguished variant. Leaning yes. Needs a decision beforertb-cliv0.1. - O3 —
install_with_footerfooter callback signature —Fn() -> StringvsFn(&dyn Diagnostic) -> String. The latter lets the footer vary by severity or code; the former is simpler. LeaningFn() -> Stringfor v0.1, upgrade path to the richer signature via a newinstall_with_report_footerfunction without breaking callers. - O4 — Should we gate the
hookmodule behind a Cargo feature? Library consumers who don't want miette's global hooks installed today (e.g. embedded test binaries) can currently skip callinginstall_*— the global hooks only activate on call. Proposed resolution: no feature gate; opt-in via call site is sufficient.