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}