Skip to main content

rtb_error/
exit_code.rs

1//! Process exit-code attachment.
2//!
3//! RTB exits `1` on any error by default. When a command needs a specific
4//! process exit code, attach it to the error with [`WithExitCode`]; the
5//! application boundary reads it back once with [`exit_code_of`] and exits
6//! accordingly. This is a value attached to an error — **not** an
7//! `ErrorHandler`/`.check()` funnel. Errors stay values, propagated with
8//! `?` and reported once at the edge.
9//!
10//! See `docs/development/specs/2026-06-26-rtb-error-exit-code-attachment.md`.
11
12use std::fmt;
13
14use miette::{Diagnostic, Report};
15
16/// A diagnostic with an attached process exit code.
17///
18/// Renders **transparently**: its `Display`, `Debug`, error source, and
19/// every [`Diagnostic`] facet delegate to the wrapped error, so attaching a
20/// code never alters the underlying diagnostic output. The code is recovered
21/// at the process boundary via [`exit_code_of`].
22pub struct ExitCoded {
23    code: u8,
24    source: Box<dyn Diagnostic + Send + Sync + 'static>,
25}
26
27impl ExitCoded {
28    /// Wrap `source`, attaching process exit `code`.
29    pub fn new(code: u8, source: impl Diagnostic + Send + Sync + 'static) -> Self {
30        Self { code, source: Box::new(source) }
31    }
32
33    /// The attached process exit code.
34    #[must_use]
35    pub const fn exit_code(&self) -> u8 {
36        self.code
37    }
38}
39
40impl fmt::Debug for ExitCoded {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        fmt::Debug::fmt(&self.source, f)
43    }
44}
45
46impl fmt::Display for ExitCoded {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        fmt::Display::fmt(&self.source, f)
49    }
50}
51
52impl std::error::Error for ExitCoded {
53    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
54        self.source.source()
55    }
56}
57
58impl Diagnostic for ExitCoded {
59    fn code(&self) -> Option<Box<dyn fmt::Display + '_>> {
60        self.source.code()
61    }
62
63    fn severity(&self) -> Option<miette::Severity> {
64        self.source.severity()
65    }
66
67    fn help(&self) -> Option<Box<dyn fmt::Display + '_>> {
68        self.source.help()
69    }
70
71    fn url(&self) -> Option<Box<dyn fmt::Display + '_>> {
72        self.source.url()
73    }
74
75    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
76        self.source.source_code()
77    }
78
79    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
80        self.source.labels()
81    }
82
83    fn related(&self) -> Option<Box<dyn Iterator<Item = &dyn Diagnostic> + '_>> {
84        self.source.related()
85    }
86
87    fn diagnostic_source(&self) -> Option<&dyn Diagnostic> {
88        self.source.diagnostic_source()
89    }
90}
91
92/// Attach a process exit code to a diagnostic.
93pub trait WithExitCode: Sized {
94    /// Wrap `self` in an [`ExitCoded`] carrying `code`.
95    fn with_exit_code(self, code: u8) -> ExitCoded;
96}
97
98impl<E> WithExitCode for E
99where
100    E: Diagnostic + Send + Sync + 'static,
101{
102    fn with_exit_code(self, code: u8) -> ExitCoded {
103        ExitCoded::new(code, self)
104    }
105}
106
107/// Read an exit code attached to `report`, if any.
108///
109/// Returns `None` for an ordinary error — the boundary then applies its
110/// default (`1`).
111#[must_use]
112pub fn exit_code_of(report: &Report) -> Option<u8> {
113    report.downcast_ref::<ExitCoded>().map(ExitCoded::exit_code)
114}
115
116#[cfg(test)]
117mod tests {
118    use super::{exit_code_of, ExitCoded, WithExitCode};
119    use miette::{Diagnostic, Report};
120    use thiserror::Error;
121
122    #[derive(Debug, Error, Diagnostic)]
123    #[error("boom: {0}")]
124    #[diagnostic(code(test::boom), help("try again"))]
125    struct Boom(&'static str);
126
127    #[test]
128    fn attaches_and_reads_back_the_code() {
129        let report: Report = Boom("x").with_exit_code(2).into();
130        assert_eq!(exit_code_of(&report), Some(2));
131    }
132
133    #[test]
134    fn plain_error_has_no_attached_code() {
135        let report: Report = Boom("x").into();
136        assert_eq!(exit_code_of(&report), None);
137    }
138
139    #[test]
140    fn delegates_diagnostic_facets_transparently() {
141        let coded = Boom("x").with_exit_code(7);
142        assert_eq!(coded.to_string(), "boom: x");
143        assert_eq!(coded.code().map(|c| c.to_string()).as_deref(), Some("test::boom"));
144        assert_eq!(coded.help().map(|h| h.to_string()).as_deref(), Some("try again"));
145        assert_eq!(coded.exit_code(), 7);
146    }
147
148    #[test]
149    fn survives_question_mark_propagation() {
150        fn inner() -> Result<(), ExitCoded> {
151            Err(Boom("deep").with_exit_code(3))
152        }
153        fn outer() -> miette::Result<()> {
154            inner()?;
155            Ok(())
156        }
157        let report = outer().expect_err("inner fails");
158        assert_eq!(exit_code_of(&report), Some(3));
159    }
160}