Skip to main content

magi_core/
error.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use thiserror::Error;
6
7/// Errors originating from LLM provider implementations.
8///
9/// Each variant represents a distinct failure mode that providers
10/// can encounter when communicating with LLM backends.
11#[derive(Debug, Clone, Error)]
12pub enum ProviderError {
13    /// HTTP response with a non-success status code.
14    #[error("http error {status}: {body}")]
15    Http {
16        /// HTTP status code.
17        status: u16,
18        /// Response body text.
19        body: String,
20    },
21
22    /// Network-level failure (DNS, connection refused, etc.).
23    #[error("network error: {message}")]
24    Network {
25        /// Description of the network failure.
26        message: String,
27    },
28
29    /// Provider did not respond within the allowed time.
30    #[error("timeout: {message}")]
31    Timeout {
32        /// Description of the timeout condition.
33        message: String,
34    },
35
36    /// Authentication or authorization failure.
37    #[error("auth error: {message}")]
38    Auth {
39        /// Description of the authentication failure.
40        message: String,
41    },
42
43    /// CLI subprocess provider failed.
44    #[error("process error (exit_code={exit_code:?}): {stderr}")]
45    Process {
46        /// Exit code of the child process, if available.
47        exit_code: Option<i32>,
48        /// Standard error output from the child process.
49        stderr: String,
50    },
51
52    /// Detected nested session (e.g., `CLAUDECODE` env var present).
53    #[error("nested session detected: cannot launch CLI provider from within an existing session")]
54    NestedSession,
55}
56
57/// Unified error type for the magi-core crate.
58///
59/// All public APIs return `Result<T, MagiError>`. This enum unifies
60/// provider errors, validation failures, and I/O errors into a single type.
61#[derive(Debug, Error)]
62pub enum MagiError {
63    /// Invalid input or schema violation.
64    #[error("validation error: {0}")]
65    Validation(String),
66
67    /// Wraps a provider-specific error.
68    #[error(transparent)]
69    Provider(#[from] ProviderError),
70
71    /// Fewer agents completed successfully than the minimum threshold.
72    #[error("insufficient agents: {succeeded} succeeded, {required} required")]
73    InsufficientAgents {
74        /// Number of agents that completed successfully.
75        succeeded: usize,
76        /// Minimum number of agents required.
77        required: usize,
78    },
79
80    /// JSON deserialization failure.
81    #[error("deserialization error: {0}")]
82    Deserialization(String),
83
84    /// Content exceeds configured maximum input size.
85    #[error("input too large: {size} bytes exceeds maximum of {max} bytes")]
86    InputTooLarge {
87        /// Actual size of the input in bytes.
88        size: usize,
89        /// Maximum allowed size in bytes.
90        max: usize,
91    },
92
93    /// Input rejected by invariant check (e.g., prompt nonce collision).
94    #[error("invalid input: {reason}")]
95    InvalidInput {
96        /// Description of the invariant violation.
97        reason: String,
98    },
99
100    /// Filesystem I/O error.
101    #[error(transparent)]
102    Io(#[from] std::io::Error),
103}
104
105impl From<serde_json::Error> for MagiError {
106    fn from(err: serde_json::Error) -> Self {
107        MagiError::Deserialization(err.to_string())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    // -- ProviderError Display tests --
116
117    /// ProviderError::Http contains status code and body in Display output.
118    #[test]
119    fn test_provider_error_http_display_contains_status_and_body() {
120        let err = ProviderError::Http {
121            status: 500,
122            body: "Internal Server Error".to_string(),
123        };
124        let display = format!("{err}");
125        assert!(
126            display.contains("500"),
127            "Display should contain status code"
128        );
129        assert!(
130            display.contains("Internal Server Error"),
131            "Display should contain body"
132        );
133    }
134
135    /// ProviderError::Network includes message in Display.
136    #[test]
137    fn test_provider_error_network_display_contains_message() {
138        let err = ProviderError::Network {
139            message: "connection refused".to_string(),
140        };
141        let display = format!("{err}");
142        assert!(
143            display.contains("connection refused"),
144            "Display should contain message"
145        );
146    }
147
148    /// ProviderError::Timeout includes message in Display.
149    #[test]
150    fn test_provider_error_timeout_display_contains_message() {
151        let err = ProviderError::Timeout {
152            message: "exceeded 30s".to_string(),
153        };
154        let display = format!("{err}");
155        assert!(
156            display.contains("exceeded 30s"),
157            "Display should contain message"
158        );
159    }
160
161    /// ProviderError::Auth includes message in Display.
162    #[test]
163    fn test_provider_error_auth_display_contains_message() {
164        let err = ProviderError::Auth {
165            message: "invalid api key".to_string(),
166        };
167        let display = format!("{err}");
168        assert!(
169            display.contains("invalid api key"),
170            "Display should contain message"
171        );
172    }
173
174    /// ProviderError::Process includes exit_code and stderr in Display.
175    #[test]
176    fn test_provider_error_process_display_includes_exit_code_and_stderr() {
177        let err = ProviderError::Process {
178            exit_code: Some(1),
179            stderr: "segfault".to_string(),
180        };
181        let display = format!("{err}");
182        assert!(display.contains("1"), "Display should contain exit code");
183        assert!(
184            display.contains("segfault"),
185            "Display should contain stderr"
186        );
187    }
188
189    /// ProviderError::Process with no exit code still displays stderr.
190    #[test]
191    fn test_provider_error_process_display_none_exit_code() {
192        let err = ProviderError::Process {
193            exit_code: None,
194            stderr: "killed".to_string(),
195        };
196        let display = format!("{err}");
197        assert!(display.contains("killed"), "Display should contain stderr");
198    }
199
200    /// ProviderError::NestedSession has a meaningful Display.
201    #[test]
202    fn test_provider_error_nested_session_display() {
203        let err = ProviderError::NestedSession;
204        let display = format!("{err}");
205        assert!(!display.is_empty(), "Display should not be empty");
206    }
207
208    // -- MagiError Display tests --
209
210    /// MagiError::Validation contains descriptive message.
211    #[test]
212    fn test_magi_error_validation_contains_message() {
213        let err = MagiError::Validation("confidence out of range".to_string());
214        let display = format!("{err}");
215        assert!(
216            display.contains("confidence out of range"),
217            "Display should contain validation message"
218        );
219    }
220
221    /// MagiError::InsufficientAgents formats succeeded and required in Display.
222    #[test]
223    fn test_magi_error_insufficient_agents_formats_counts() {
224        let err = MagiError::InsufficientAgents {
225            succeeded: 1,
226            required: 2,
227        };
228        let display = format!("{err}");
229        assert!(
230            display.contains("1"),
231            "Display should contain succeeded count"
232        );
233        assert!(
234            display.contains("2"),
235            "Display should contain required count"
236        );
237    }
238
239    /// MagiError::InputTooLarge formats size and max in Display.
240    #[test]
241    fn test_magi_error_input_too_large_formats_size_and_max() {
242        let err = MagiError::InputTooLarge {
243            size: 2_000_000,
244            max: 1_048_576,
245        };
246        let display = format!("{err}");
247        assert!(
248            display.contains("2000000"),
249            "Display should contain actual size"
250        );
251        assert!(
252            display.contains("1048576"),
253            "Display should contain max size"
254        );
255    }
256
257    // -- From impls --
258
259    /// From<ProviderError> for MagiError wraps correctly into Provider variant.
260    #[test]
261    fn test_from_provider_error_wraps_into_magi_error_provider() {
262        let pe = ProviderError::Timeout {
263            message: "timed out".to_string(),
264        };
265        let me: MagiError = pe.into();
266        assert!(
267            matches!(me, MagiError::Provider(_)),
268            "Should wrap into Provider variant"
269        );
270    }
271
272    /// From<serde_json::Error> for MagiError produces Deserialization variant.
273    #[test]
274    fn test_from_serde_json_error_produces_deserialization_variant() {
275        let result: Result<String, _> = serde_json::from_str("not json");
276        let serde_err = result.unwrap_err();
277        let me: MagiError = serde_err.into();
278        assert!(
279            matches!(me, MagiError::Deserialization(_)),
280            "Should produce Deserialization variant"
281        );
282    }
283
284    /// From<std::io::Error> for MagiError produces Io variant.
285    #[test]
286    fn test_from_io_error_produces_io_variant() {
287        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
288        let me: MagiError = io_err.into();
289        assert!(matches!(me, MagiError::Io(_)), "Should produce Io variant");
290    }
291}