Skip to main content

proef_core/
error.rs

1//! Fault taxonomy and stable exit codes (ADR-0009).
2//!
3//! Errors are categorized by *who is at fault*: the user's input ([`CoreError::User`]),
4//! the system under test ([`CoreError::TestFailure`]), or the environment / proef
5//! itself ([`CoreError::System`]). The mapping to process exit codes is **total and a
6//! public contract**, pinned by the CLI integration test suite.
7//!
8//! Engines report through the separate [`EngineError`] layer — engines cannot know
9//! exit codes, and the core cannot know engine internals; [`EngineErrorClass`] folds
10//! into the core taxonomy at the seam.
11
12use std::error::Error;
13use std::fmt;
14
15/// Process exit codes — a public contract (`0` ok · `1` test failure · `2` user
16/// error · `3` system error), pinned by `assert_cmd` integration tests.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18#[repr(u8)]
19pub enum ExitCode {
20    /// Everything ran and every test passed.
21    Success = 0,
22    /// The suite executed; at least one test failed.
23    TestFailure = 1,
24    /// The user's input is at fault (bad flags, unbound steps, invalid packs, …).
25    UserError = 2,
26    /// The environment or proef itself is at fault.
27    SystemError = 3,
28}
29
30impl ExitCode {
31    /// The numeric process exit code.
32    pub fn code(self) -> u8 {
33        self as u8
34    }
35}
36
37impl fmt::Display for ExitCode {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "{}", self.code())
40    }
41}
42
43/// Core error taxonomy, categorized by fault (ADR-0009).
44#[derive(Debug, thiserror::Error)]
45pub enum CoreError {
46    /// The user's input is at fault → exit code 2.
47    #[error("{0}")]
48    User(String),
49    /// A test genuinely failed → exit code 1.
50    #[error("{0}")]
51    TestFailure(String),
52    /// The environment or proef itself is at fault → exit code 3.
53    #[error("{message}")]
54    System {
55        /// Human-readable description of the failure.
56        message: String,
57        /// Underlying cause, when one exists.
58        #[source]
59        source: Option<Box<dyn Error + Send + Sync>>,
60    },
61}
62
63impl CoreError {
64    /// A user-fault error (exit 2).
65    pub fn user(message: impl Into<String>) -> Self {
66        Self::User(message.into())
67    }
68
69    /// A test-failure error (exit 1).
70    pub fn test_failure(message: impl Into<String>) -> Self {
71        Self::TestFailure(message.into())
72    }
73
74    /// A system-fault error (exit 3) without an underlying cause.
75    pub fn system(message: impl Into<String>) -> Self {
76        Self::System {
77            message: message.into(),
78            source: None,
79        }
80    }
81
82    /// A system-fault error (exit 3) with an underlying cause.
83    pub fn system_with(
84        message: impl Into<String>,
85        source: impl Error + Send + Sync + 'static,
86    ) -> Self {
87        Self::System {
88            message: message.into(),
89            source: Some(Box::new(source)),
90        }
91    }
92
93    /// The stable exit code this error maps to. Total by construction.
94    pub fn exit_code(&self) -> ExitCode {
95        match self {
96            Self::User(_) => ExitCode::UserError,
97            Self::TestFailure(_) => ExitCode::TestFailure,
98            Self::System { .. } => ExitCode::SystemError,
99        }
100    }
101}
102
103impl From<&CoreError> for ExitCode {
104    fn from(err: &CoreError) -> Self {
105        err.exit_code()
106    }
107}
108
109/// How an engine failure folds into the core taxonomy (ADR-0009).
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum EngineErrorClass {
112    /// Infrastructure trouble (connection, native library, …) → [`CoreError::System`].
113    Infra,
114    /// An assertion in the system under test failed → [`CoreError::TestFailure`].
115    AssertFailed,
116    /// The engine could not be set up or configured → [`CoreError::System`].
117    Setup,
118}
119
120/// An error reported by an engine across the seam (ADR-0002 / ADR-0009).
121#[derive(Debug, thiserror::Error)]
122#[error("{message}")]
123pub struct EngineError {
124    /// Fault classification, folded into [`CoreError`] by the dispatcher.
125    pub class: EngineErrorClass,
126    /// Human-readable description of the failure.
127    pub message: String,
128    /// Underlying cause, when one exists.
129    #[source]
130    pub source: Option<Box<dyn Error + Send + Sync>>,
131}
132
133impl EngineError {
134    /// An infrastructure failure ([`EngineErrorClass::Infra`]).
135    pub fn infra(message: impl Into<String>) -> Self {
136        Self {
137            class: EngineErrorClass::Infra,
138            message: message.into(),
139            source: None,
140        }
141    }
142
143    /// An assertion failure ([`EngineErrorClass::AssertFailed`]).
144    pub fn assert_failed(message: impl Into<String>) -> Self {
145        Self {
146            class: EngineErrorClass::AssertFailed,
147            message: message.into(),
148            source: None,
149        }
150    }
151
152    /// A setup/configuration failure ([`EngineErrorClass::Setup`]).
153    pub fn setup(message: impl Into<String>) -> Self {
154        Self {
155            class: EngineErrorClass::Setup,
156            message: message.into(),
157            source: None,
158        }
159    }
160
161    /// Attach an underlying cause.
162    #[must_use]
163    pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
164        self.source = Some(Box::new(source));
165        self
166    }
167}
168
169impl From<EngineError> for CoreError {
170    fn from(err: EngineError) -> Self {
171        match err.class {
172            EngineErrorClass::AssertFailed => Self::TestFailure(err.message),
173            EngineErrorClass::Infra | EngineErrorClass::Setup => Self::System {
174                message: err.message,
175                source: err.source,
176            },
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    /// The exit-code mapping is total and pinned.
186    #[test]
187    fn core_error_exit_codes_are_stable() {
188        assert_eq!(CoreError::user("bad flag").exit_code().code(), 2);
189        assert_eq!(
190            CoreError::test_failure("assert failed").exit_code().code(),
191            1
192        );
193        assert_eq!(CoreError::system("no network").exit_code().code(), 3);
194        assert_eq!(ExitCode::Success.code(), 0);
195    }
196
197    #[test]
198    fn engine_errors_fold_into_the_core_taxonomy() {
199        let assert_failed: CoreError = EngineError::assert_failed("status != 200").into();
200        assert_eq!(assert_failed.exit_code(), ExitCode::TestFailure);
201
202        let infra: CoreError = EngineError::infra("connection refused").into();
203        assert_eq!(infra.exit_code(), ExitCode::SystemError);
204
205        let setup: CoreError = EngineError::setup("libcurl missing").into();
206        assert_eq!(setup.exit_code(), ExitCode::SystemError);
207    }
208
209    #[test]
210    fn engine_error_sources_survive_the_fold() {
211        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
212        let core: CoreError = EngineError::infra("connect failed").with_source(io).into();
213        let CoreError::System { source, .. } = &core else {
214            panic!("expected System variant");
215        };
216        assert!(source.is_some());
217    }
218}