Skip to main content

anodizer_core/
error_class.rs

1//! Deterministic-error classification for the CLI exit contract.
2//!
3//! Retry wrappers around anodizer (CI actions, shell loops) need to tell a
4//! transient failure (network flake, registry 5xx — worth retrying) from a
5//! deterministic one (unparseable config, invalid flag values, the
6//! dist-not-empty guard — retrying burns attempts on an identical failure).
7//! Two signals carry that classification:
8//!
9//! * **exit code [`EXIT_DETERMINISTIC`] (2)** — the Unix usage-error
10//!   convention; everything else keeps exit 1.
11//! * **stderr marker [`CLASS_MARKER`]** — a machine-readable line for
12//!   wrappers that cannot rely on the exit code (or pin an older anodizer
13//!   whose deterministic paths still exited 1).
14//!
15//! Classification is a conservative allowlist: an error is deterministic
16//! only if its construction site wrapped it via [`deterministic`] /
17//! [`deterministic_msg`]. Anything unwrapped — however config-shaped its
18//! message looks — stays exit 1, so a transient failure can never be
19//! misfiled as unretryable.
20
21use std::fmt;
22
23/// Exit code for deterministic config/usage errors (Unix usage-error
24/// convention, and the code clap already uses for flag-parse errors).
25pub const EXIT_DETERMINISTIC: i32 = 2;
26
27/// Machine-readable stderr marker emitted alongside every deterministic
28/// error, mirroring the `anodizer-output <key>=<value>` payload convention.
29pub const CLASS_MARKER: &str = "anodizer-error-class: deterministic";
30
31/// Transparent marker wrapper: its presence anywhere in an `anyhow` chain
32/// classifies the whole error as deterministic. Display and `source()`
33/// forward to the wrapped error, so rendered output (top message + `caused
34/// by:` chain) is byte-identical to the unwrapped error.
35#[derive(Debug)]
36pub struct DeterministicError(anyhow::Error);
37
38impl fmt::Display for DeterministicError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        self.0.fmt(f)
41    }
42}
43
44impl std::error::Error for DeterministicError {
45    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46        // Deref to the wrapped error's top and continue ITS chain — the
47        // wrapper's own Display already carries the top message, so
48        // returning the top itself would print it twice.
49        self.0.source()
50    }
51}
52
53/// Mark an error as deterministic. The returned error renders identically;
54/// only [`is_deterministic`] can tell the difference.
55pub fn deterministic(err: anyhow::Error) -> anyhow::Error {
56    anyhow::Error::new(DeterministicError(err))
57}
58
59/// [`deterministic`] for message-shaped errors — the `Result<_, String>`
60/// validator idiom (`.map_err(deterministic_msg)?`).
61pub fn deterministic_msg<M>(msg: M) -> anyhow::Error
62where
63    M: fmt::Display + fmt::Debug + Send + Sync + 'static,
64{
65    deterministic(anyhow::Error::msg(msg))
66}
67
68/// True when any link of the chain is a [`DeterministicError`] marker —
69/// context layered on top of a marked error does not hide it.
70pub fn is_deterministic(err: &anyhow::Error) -> bool {
71    err.chain().any(|c| c.is::<DeterministicError>())
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use anyhow::Context as _;
78
79    #[test]
80    fn plain_error_is_not_deterministic() {
81        let err = anyhow::anyhow!("network timeout");
82        assert!(!is_deterministic(&err));
83    }
84
85    #[test]
86    fn wrapped_error_is_deterministic() {
87        let err = deterministic(anyhow::anyhow!("bad config"));
88        assert!(is_deterministic(&err));
89    }
90
91    #[test]
92    fn msg_wrapper_is_deterministic() {
93        let err = deterministic_msg("unknown publisher 'nmp'".to_string());
94        assert!(is_deterministic(&err));
95    }
96
97    #[test]
98    fn context_on_top_still_detected() {
99        let err: anyhow::Error = Err::<(), _>(deterministic_msg("parse failure"))
100            .context("failed to load config")
101            .unwrap_err();
102        assert!(is_deterministic(&err));
103    }
104
105    #[test]
106    fn display_and_chain_render_identically_to_unwrapped() {
107        let unwrapped: anyhow::Error = Err::<(), _>(anyhow::anyhow!("root cause"))
108            .context("middle")
109            .context("top")
110            .unwrap_err();
111        let wrapped = deterministic(
112            Err::<(), _>(anyhow::anyhow!("root cause"))
113                .context("middle")
114                .context("top")
115                .unwrap_err(),
116        );
117        assert_eq!(wrapped.to_string(), unwrapped.to_string());
118        let unwrapped_chain: Vec<String> = unwrapped.chain().map(|c| c.to_string()).collect();
119        let wrapped_chain: Vec<String> = wrapped.chain().map(|c| c.to_string()).collect();
120        assert_eq!(wrapped_chain, unwrapped_chain);
121    }
122}