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    /// Filesystem I/O error.
94    #[error(transparent)]
95    Io(#[from] std::io::Error),
96}
97
98impl From<serde_json::Error> for MagiError {
99    fn from(err: serde_json::Error) -> Self {
100        MagiError::Deserialization(err.to_string())
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    // -- ProviderError Display tests --
109
110    /// ProviderError::Http contains status code and body in Display output.
111    #[test]
112    fn test_provider_error_http_display_contains_status_and_body() {
113        let err = ProviderError::Http {
114            status: 500,
115            body: "Internal Server Error".to_string(),
116        };
117        let display = format!("{err}");
118        assert!(
119            display.contains("500"),
120            "Display should contain status code"
121        );
122        assert!(
123            display.contains("Internal Server Error"),
124            "Display should contain body"
125        );
126    }
127
128    /// ProviderError::Network includes message in Display.
129    #[test]
130    fn test_provider_error_network_display_contains_message() {
131        let err = ProviderError::Network {
132            message: "connection refused".to_string(),
133        };
134        let display = format!("{err}");
135        assert!(
136            display.contains("connection refused"),
137            "Display should contain message"
138        );
139    }
140
141    /// ProviderError::Timeout includes message in Display.
142    #[test]
143    fn test_provider_error_timeout_display_contains_message() {
144        let err = ProviderError::Timeout {
145            message: "exceeded 30s".to_string(),
146        };
147        let display = format!("{err}");
148        assert!(
149            display.contains("exceeded 30s"),
150            "Display should contain message"
151        );
152    }
153
154    /// ProviderError::Auth includes message in Display.
155    #[test]
156    fn test_provider_error_auth_display_contains_message() {
157        let err = ProviderError::Auth {
158            message: "invalid api key".to_string(),
159        };
160        let display = format!("{err}");
161        assert!(
162            display.contains("invalid api key"),
163            "Display should contain message"
164        );
165    }
166
167    /// ProviderError::Process includes exit_code and stderr in Display.
168    #[test]
169    fn test_provider_error_process_display_includes_exit_code_and_stderr() {
170        let err = ProviderError::Process {
171            exit_code: Some(1),
172            stderr: "segfault".to_string(),
173        };
174        let display = format!("{err}");
175        assert!(display.contains("1"), "Display should contain exit code");
176        assert!(
177            display.contains("segfault"),
178            "Display should contain stderr"
179        );
180    }
181
182    /// ProviderError::Process with no exit code still displays stderr.
183    #[test]
184    fn test_provider_error_process_display_none_exit_code() {
185        let err = ProviderError::Process {
186            exit_code: None,
187            stderr: "killed".to_string(),
188        };
189        let display = format!("{err}");
190        assert!(display.contains("killed"), "Display should contain stderr");
191    }
192
193    /// ProviderError::NestedSession has a meaningful Display.
194    #[test]
195    fn test_provider_error_nested_session_display() {
196        let err = ProviderError::NestedSession;
197        let display = format!("{err}");
198        assert!(!display.is_empty(), "Display should not be empty");
199    }
200
201    // -- MagiError Display tests --
202
203    /// MagiError::Validation contains descriptive message.
204    #[test]
205    fn test_magi_error_validation_contains_message() {
206        let err = MagiError::Validation("confidence out of range".to_string());
207        let display = format!("{err}");
208        assert!(
209            display.contains("confidence out of range"),
210            "Display should contain validation message"
211        );
212    }
213
214    /// MagiError::InsufficientAgents formats succeeded and required in Display.
215    #[test]
216    fn test_magi_error_insufficient_agents_formats_counts() {
217        let err = MagiError::InsufficientAgents {
218            succeeded: 1,
219            required: 2,
220        };
221        let display = format!("{err}");
222        assert!(
223            display.contains("1"),
224            "Display should contain succeeded count"
225        );
226        assert!(
227            display.contains("2"),
228            "Display should contain required count"
229        );
230    }
231
232    /// MagiError::InputTooLarge formats size and max in Display.
233    #[test]
234    fn test_magi_error_input_too_large_formats_size_and_max() {
235        let err = MagiError::InputTooLarge {
236            size: 2_000_000,
237            max: 1_048_576,
238        };
239        let display = format!("{err}");
240        assert!(
241            display.contains("2000000"),
242            "Display should contain actual size"
243        );
244        assert!(
245            display.contains("1048576"),
246            "Display should contain max size"
247        );
248    }
249
250    // -- From impls --
251
252    /// From<ProviderError> for MagiError wraps correctly into Provider variant.
253    #[test]
254    fn test_from_provider_error_wraps_into_magi_error_provider() {
255        let pe = ProviderError::Timeout {
256            message: "timed out".to_string(),
257        };
258        let me: MagiError = pe.into();
259        assert!(
260            matches!(me, MagiError::Provider(_)),
261            "Should wrap into Provider variant"
262        );
263    }
264
265    /// From<serde_json::Error> for MagiError produces Deserialization variant.
266    #[test]
267    fn test_from_serde_json_error_produces_deserialization_variant() {
268        let result: Result<String, _> = serde_json::from_str("not json");
269        let serde_err = result.unwrap_err();
270        let me: MagiError = serde_err.into();
271        assert!(
272            matches!(me, MagiError::Deserialization(_)),
273            "Should produce Deserialization variant"
274        );
275    }
276
277    /// From<std::io::Error> for MagiError produces Io variant.
278    #[test]
279    fn test_from_io_error_produces_io_variant() {
280        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
281        let me: MagiError = io_err.into();
282        assert!(matches!(me, MagiError::Io(_)), "Should produce Io variant");
283    }
284}