[Go to site: main page, start]

Skip to content

exit_code module

A process exits 1 on any error unless something says otherwise. This module is the "otherwise": a wrapper that carries a u8 alongside a diagnostic, and a function that reads it back at the process boundary.

pub struct ExitCoded { /* private */ }

impl ExitCoded {
    pub fn new(code: u8, source: impl Diagnostic + Send + Sync + 'static) -> Self;
    pub const fn exit_code(&self) -> u8;
}

pub trait WithExitCode: Sized {
    fn with_exit_code(self, code: u8) -> ExitCoded;
}

impl<E: Diagnostic + Send + Sync + 'static> WithExitCode for E { /* … */ }

pub fn exit_code_of(report: &Report) -> Option<u8>;

WithExitCode::with_exit_code

A blanket impl, so every Diagnostic + Send + Sync + 'static gets the method:

use rtb_error::WithExitCode;

return Err(MyError::BadInput.with_exit_code(64));

The code is a u8, matching std::process::ExitCode::from. Values above 255 are a compile error rather than a silent truncation.

0 is accepted and is almost never what you want. with_exit_code(0) attaches "success" to an error, and a boundary that does ExitCode::from(exit_code_of(&report).unwrap_or(1)) will report success while printing a diagnostic. Nothing rejects it.

ExitCoded renders as though it were not there

ExitCoded delegates Display, Debug, Error::source, and every Diagnostic method — code, severity, help, url, source_code, labels, related, diagnostic_source — to the wrapped error. Attaching a code never changes a single character of the rendered diagnostic. The code is metadata for the process boundary and invisible to the user.

exit_code_of

pub fn exit_code_of(report: &Report) -> Option<u8>

Returns Some(code) when the report's error downcasts to ExitCoded, and None for an ordinary error — at which point the boundary applies its own default, conventionally 1.

When does the exit code survive?

exit_code_of is a downcast, so it depends on how the error reached the Report.

How the error got to main exit_code_of
ExitCoded converted straight into a Report (? into miette::Result) Some(code)
ExitCoded propagated through several ? in functions returning miette::Result Some(code)
Report::wrap_err("context") applied on top Some(code)
Boxed into rtb_error::Error::Other(Box::new(coded)) None — the code is lost

The last row is the one that bites, and it is not obvious from the types. There is no From<ExitCoded> for rtb_error::Error, so wrapping is always something you wrote deliberately — but once the ExitCoded is inside an Error::Other, the report downcasts to Error, not to ExitCoded, and the code silently becomes the default. Attach the exit code at the outermost layer, after any conversion into Error, not before.

Reading it at the boundary

rtb-error provides no main wrapper. The boundary is yours to write, and it needs to return ExitCode rather than miette::Result, because miette::Result always exits 1:

use std::process::ExitCode;
use rtb_error::exit_code_of;

fn main() -> ExitCode {
    rtb_error::hook::install_report_handler();
    match run() {
        Ok(()) => ExitCode::SUCCESS,
        Err(report) => {
            eprintln!("Error: {report:?}");
            ExitCode::from(exit_code_of(&report).unwrap_or(1))
        }
    }
}

The {report:?} is deliberate: Report's Debug implementation is what routes through the installed hook. {report} prints the bare Display message with no code, no help and no footer. See Set the process exit code for the whole pattern.