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