rtb-error 0.6.1

Error types and the diagnostic report pipeline. Part of the phpboyscout Rust toolkit.
Documentation
//! Process exit-code attachment.
//!
//! RTB exits `1` on any error by default. When a command needs a specific
//! process exit code, attach it to the error with [`WithExitCode`]; the
//! application boundary reads it back once with [`exit_code_of`] and exits
//! accordingly. This is a value attached to an error — **not** an
//! `ErrorHandler`/`.check()` funnel. Errors stay values, propagated with
//! `?` and reported once at the edge.
//!
//! See `docs/development/specs/2026-06-26-rtb-error-exit-code-attachment.md`.

use std::fmt;

use miette::{Diagnostic, Report};

/// A diagnostic with an attached process exit code.
///
/// Renders **transparently**: its `Display`, `Debug`, error source, and
/// every [`Diagnostic`] facet delegate to the wrapped error, so attaching a
/// code never alters the underlying diagnostic output. The code is recovered
/// at the process boundary via [`exit_code_of`].
pub struct ExitCoded {
    code: u8,
    source: Box<dyn Diagnostic + Send + Sync + 'static>,
}

impl ExitCoded {
    /// Wrap `source`, attaching process exit `code`.
    pub fn new(code: u8, source: impl Diagnostic + Send + Sync + 'static) -> Self {
        Self { code, source: Box::new(source) }
    }

    /// The attached process exit code.
    #[must_use]
    pub const fn exit_code(&self) -> u8 {
        self.code
    }
}

impl fmt::Debug for ExitCoded {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Debug::fmt(&self.source, f)
    }
}

impl fmt::Display for ExitCoded {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(&self.source, f)
    }
}

impl std::error::Error for ExitCoded {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        self.source.source()
    }
}

impl Diagnostic for ExitCoded {
    fn code(&self) -> Option<Box<dyn fmt::Display + '_>> {
        self.source.code()
    }

    fn severity(&self) -> Option<miette::Severity> {
        self.source.severity()
    }

    fn help(&self) -> Option<Box<dyn fmt::Display + '_>> {
        self.source.help()
    }

    fn url(&self) -> Option<Box<dyn fmt::Display + '_>> {
        self.source.url()
    }

    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
        self.source.source_code()
    }

    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
        self.source.labels()
    }

    fn related(&self) -> Option<Box<dyn Iterator<Item = &dyn Diagnostic> + '_>> {
        self.source.related()
    }

    fn diagnostic_source(&self) -> Option<&dyn Diagnostic> {
        self.source.diagnostic_source()
    }
}

/// Attach a process exit code to a diagnostic.
pub trait WithExitCode: Sized {
    /// Wrap `self` in an [`ExitCoded`] carrying `code`.
    fn with_exit_code(self, code: u8) -> ExitCoded;
}

impl<E> WithExitCode for E
where
    E: Diagnostic + Send + Sync + 'static,
{
    fn with_exit_code(self, code: u8) -> ExitCoded {
        ExitCoded::new(code, self)
    }
}

/// Read an exit code attached to `report`, if any.
///
/// Returns `None` for an ordinary error — the boundary then applies its
/// default (`1`).
#[must_use]
pub fn exit_code_of(report: &Report) -> Option<u8> {
    report.downcast_ref::<ExitCoded>().map(ExitCoded::exit_code)
}

#[cfg(test)]
mod tests {
    use super::{exit_code_of, ExitCoded, WithExitCode};
    use miette::{Diagnostic, Report};
    use thiserror::Error;

    #[derive(Debug, Error, Diagnostic)]
    #[error("boom: {0}")]
    #[diagnostic(code(test::boom), help("try again"))]
    struct Boom(&'static str);

    #[test]
    fn attaches_and_reads_back_the_code() {
        let report: Report = Boom("x").with_exit_code(2).into();
        assert_eq!(exit_code_of(&report), Some(2));
    }

    #[test]
    fn plain_error_has_no_attached_code() {
        let report: Report = Boom("x").into();
        assert_eq!(exit_code_of(&report), None);
    }

    #[test]
    fn delegates_diagnostic_facets_transparently() {
        let coded = Boom("x").with_exit_code(7);
        assert_eq!(coded.to_string(), "boom: x");
        assert_eq!(coded.code().map(|c| c.to_string()).as_deref(), Some("test::boom"));
        assert_eq!(coded.help().map(|h| h.to_string()).as_deref(), Some("try again"));
        assert_eq!(coded.exit_code(), 7);
    }

    #[test]
    fn survives_question_mark_propagation() {
        fn inner() -> Result<(), ExitCoded> {
            Err(Boom("deep").with_exit_code(3))
        }
        fn outer() -> miette::Result<()> {
            inner()?;
            Ok(())
        }
        let report = outer().expect_err("inner fails");
        assert_eq!(exit_code_of(&report), Some(3));
    }
}