rtb-error 0.6.4

Error types and the diagnostic report pipeline. Part of the phpboyscout Rust toolkit.
Documentation
---
title: exit_code module
description: ExitCoded, WithExitCode and exit_code_of — how an error carries a process exit code, and the wrapping that loses it.
tags: [reference, api, exit-code]
---

# `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.

```rust
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:

```rust
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`

```rust
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`:

```rust
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](../how-to/03-set-the-process-exit-code.md) for the
whole pattern.