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 system-fault error (exit 3) without an underlying cause.
70    pub fn system(message: impl Into<String>) -> Self {
71        Self::System {
72            message: message.into(),
73            source: None,
74        }
75    }
76
77    /// A system-fault error (exit 3) with an underlying cause.
78    pub fn system_with(
79        message: impl Into<String>,
80        source: impl Error + Send + Sync + 'static,
81    ) -> Self {
82        Self::System {
83            message: message.into(),
84            source: Some(Box::new(source)),
85        }
86    }
87
88    /// The stable exit code this error maps to. Total by construction.
89    pub fn exit_code(&self) -> ExitCode {
90        match self {
91            Self::User(_) => ExitCode::UserError,
92            Self::TestFailure(_) => ExitCode::TestFailure,
93            Self::System { .. } => ExitCode::SystemError,
94        }
95    }
96}
97
98/// How an engine failure folds into the core taxonomy (ADR-0009).
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum EngineErrorClass {
101    /// Infrastructure trouble (connection, native library, …) → [`CoreError::System`].
102    Infra,
103    /// An assertion in the system under test failed → [`CoreError::TestFailure`].
104    AssertFailed,
105    /// A mistake in the test's own text the author must fix (bad query
106    /// expression, undefined runtime variable, unreadable body file) →
107    /// [`CoreError::User`].
108    UserInput,
109    /// The engine could not be set up or configured → [`CoreError::System`].
110    Setup,
111}
112
113/// An error reported by an engine across the seam (ADR-0002 / ADR-0009).
114#[derive(Debug, thiserror::Error)]
115#[error("{message}")]
116pub struct EngineError {
117    /// Fault classification, folded into [`CoreError`] by the dispatcher.
118    pub class: EngineErrorClass,
119    /// Human-readable description of the failure.
120    pub message: String,
121    /// Underlying cause, when one exists.
122    #[source]
123    pub source: Option<Box<dyn Error + Send + Sync>>,
124}
125
126impl EngineError {
127    /// An infrastructure failure ([`EngineErrorClass::Infra`]).
128    pub fn infra(message: impl Into<String>) -> Self {
129        Self {
130            class: EngineErrorClass::Infra,
131            message: message.into(),
132            source: None,
133        }
134    }
135
136    /// An assertion failure ([`EngineErrorClass::AssertFailed`]).
137    pub fn assert_failed(message: impl Into<String>) -> Self {
138        Self {
139            class: EngineErrorClass::AssertFailed,
140            message: message.into(),
141            source: None,
142        }
143    }
144
145    /// A user-input failure ([`EngineErrorClass::UserInput`]).
146    pub fn user_input(message: impl Into<String>) -> Self {
147        Self {
148            class: EngineErrorClass::UserInput,
149            message: message.into(),
150            source: None,
151        }
152    }
153
154    /// A setup/configuration failure ([`EngineErrorClass::Setup`]).
155    pub fn setup(message: impl Into<String>) -> Self {
156        Self {
157            class: EngineErrorClass::Setup,
158            message: message.into(),
159            source: None,
160        }
161    }
162
163    /// Attach an underlying cause.
164    #[must_use]
165    pub fn with_source(mut self, source: impl Error + Send + Sync + 'static) -> Self {
166        self.source = Some(Box::new(source));
167        self
168    }
169}
170
171impl From<EngineError> for CoreError {
172    fn from(err: EngineError) -> Self {
173        match err.class {
174            EngineErrorClass::AssertFailed => Self::TestFailure(err.message),
175            EngineErrorClass::UserInput => Self::User(err.message),
176            EngineErrorClass::Infra | EngineErrorClass::Setup => Self::System {
177                message: err.message,
178                source: err.source,
179            },
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    /// The exit-code mapping is total and pinned.
189    #[test]
190    fn core_error_exit_codes_are_stable() {
191        assert_eq!(CoreError::user("bad flag").exit_code().code(), 2);
192        assert_eq!(
193            CoreError::TestFailure("assert failed".to_owned())
194                .exit_code()
195                .code(),
196            1
197        );
198        assert_eq!(CoreError::system("no network").exit_code().code(), 3);
199        assert_eq!(ExitCode::Success.code(), 0);
200    }
201
202    #[test]
203    fn engine_errors_fold_into_the_core_taxonomy() {
204        let assert_failed: CoreError = EngineError::assert_failed("status != 200").into();
205        assert_eq!(assert_failed.exit_code(), ExitCode::TestFailure);
206
207        let infra: CoreError = EngineError::infra("connection refused").into();
208        assert_eq!(infra.exit_code(), ExitCode::SystemError);
209
210        let setup: CoreError = EngineError::setup("libcurl missing").into();
211        assert_eq!(setup.exit_code(), ExitCode::SystemError);
212    }
213
214    #[test]
215    fn engine_error_sources_survive_the_fold() {
216        let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
217        let core: CoreError = EngineError::infra("connect failed").with_source(io).into();
218        let CoreError::System { source, .. } = &core else {
219            panic!("expected System variant");
220        };
221        assert!(source.is_some());
222    }
223}