Skip to main content

agent_code_lib/
error.rs

1//! Unified error types for the application.
2//!
3//! Each subsystem defines specific error variants that compose into
4//! the top-level `Error` enum. Tool errors and API errors are recoverable
5//! within the agent loop; other errors propagate to the caller.
6
7use thiserror::Error;
8
9/// Top-level error type.
10#[derive(Debug, Error)]
11pub enum Error {
12    #[error(transparent)]
13    Llm(#[from] LlmError),
14
15    #[error(transparent)]
16    Tool(#[from] ToolError),
17
18    #[error(transparent)]
19    Permission(#[from] PermissionError),
20
21    #[error(transparent)]
22    Config(#[from] ConfigError),
23
24    #[error(transparent)]
25    Io(#[from] std::io::Error),
26
27    #[error("{0}")]
28    Other(String),
29}
30
31/// LLM API errors.
32#[derive(Debug, Error)]
33pub enum LlmError {
34    #[error("HTTP request failed: {0}")]
35    Http(String),
36
37    #[error("API error (status {status}): {body}")]
38    Api { status: u16, body: String },
39
40    #[error("Rate limited, retry after {retry_after_ms}ms")]
41    RateLimited { retry_after_ms: u64 },
42
43    #[error("Stream interrupted")]
44    StreamInterrupted,
45
46    #[error("Invalid response: {0}")]
47    InvalidResponse(String),
48
49    #[error("Authentication failed: {0}")]
50    AuthError(String),
51
52    #[error("Context window exceeded ({tokens} tokens)")]
53    ContextOverflow { tokens: usize },
54}
55
56/// Tool execution errors.
57#[derive(Debug, Error)]
58pub enum ToolError {
59    #[error("Permission denied: {0}")]
60    PermissionDenied(String),
61
62    #[error("Tool execution failed: {0}")]
63    ExecutionFailed(String),
64
65    #[error("Invalid input: {0}")]
66    InvalidInput(String),
67
68    #[error("Tool not found: {0}")]
69    NotFound(String),
70
71    #[error("IO error: {0}")]
72    Io(#[from] std::io::Error),
73
74    #[error("Operation cancelled")]
75    Cancelled,
76
77    #[error("Timeout after {0}ms")]
78    Timeout(u64),
79}
80
81/// Permission system errors.
82#[derive(Debug, Error)]
83pub enum PermissionError {
84    #[error("Permission denied by rule: {0}")]
85    DeniedByRule(String),
86
87    #[error("User denied permission for {tool}: {reason}")]
88    UserDenied { tool: String, reason: String },
89}
90
91/// Configuration errors.
92#[derive(Debug, Error)]
93pub enum ConfigError {
94    #[error("Config file error: {0}")]
95    FileError(String),
96
97    #[error("Invalid config value: {0}")]
98    InvalidValue(String),
99
100    #[error("TOML parse error: {0}")]
101    ParseError(#[from] toml::de::Error),
102}
103
104/// Convenience alias.
105pub type Result<T> = std::result::Result<T, Error>;
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    // ---- Error Display formatting ----
112
113    #[test]
114    fn error_display_llm_variant_delegates_to_llm_error() {
115        let err: Error = LlmError::StreamInterrupted.into();
116        assert_eq!(err.to_string(), "Stream interrupted");
117    }
118
119    #[test]
120    fn error_display_tool_variant_delegates_to_tool_error() {
121        let err: Error = ToolError::Cancelled.into();
122        assert_eq!(err.to_string(), "Operation cancelled");
123    }
124
125    #[test]
126    fn error_display_permission_variant_delegates_to_permission_error() {
127        let err: Error = PermissionError::DeniedByRule("no writes".into()).into();
128        assert_eq!(err.to_string(), "Permission denied by rule: no writes");
129    }
130
131    #[test]
132    fn error_display_config_variant_delegates_to_config_error() {
133        let err: Error = ConfigError::InvalidValue("bad timeout".into()).into();
134        assert_eq!(err.to_string(), "Invalid config value: bad timeout");
135    }
136
137    #[test]
138    fn error_display_io_variant() {
139        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
140        let err: Error = io_err.into();
141        assert_eq!(err.to_string(), "file missing");
142    }
143
144    #[test]
145    fn error_display_other_variant() {
146        let err = Error::Other("something went wrong".into());
147        assert_eq!(err.to_string(), "something went wrong");
148    }
149
150    // ---- From conversions ----
151
152    #[test]
153    fn from_llm_error_to_error() {
154        let llm = LlmError::Http("connection reset".into());
155        let err: Error = llm.into();
156        assert!(matches!(err, Error::Llm(_)));
157    }
158
159    #[test]
160    fn from_tool_error_to_error() {
161        let tool = ToolError::NotFound("bash".into());
162        let err: Error = tool.into();
163        assert!(matches!(err, Error::Tool(_)));
164    }
165
166    #[test]
167    fn from_permission_error_to_error() {
168        let perm = PermissionError::DeniedByRule("rule_1".into());
169        let err: Error = perm.into();
170        assert!(matches!(err, Error::Permission(_)));
171    }
172
173    #[test]
174    fn from_config_error_to_error() {
175        let cfg = ConfigError::FileError("not found".into());
176        let err: Error = cfg.into();
177        assert!(matches!(err, Error::Config(_)));
178    }
179
180    #[test]
181    fn from_io_error_to_error() {
182        let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
183        let err: Error = io.into();
184        assert!(matches!(err, Error::Io(_)));
185    }
186
187    // ---- LlmError Display ----
188
189    #[test]
190    fn llm_error_display_http() {
191        let err = LlmError::Http("timeout".into());
192        assert_eq!(err.to_string(), "HTTP request failed: timeout");
193    }
194
195    #[test]
196    fn llm_error_display_api() {
197        let err = LlmError::Api {
198            status: 429,
199            body: "too many requests".into(),
200        };
201        assert_eq!(err.to_string(), "API error (status 429): too many requests");
202    }
203
204    #[test]
205    fn llm_error_display_rate_limited() {
206        let err = LlmError::RateLimited {
207            retry_after_ms: 5000,
208        };
209        assert_eq!(err.to_string(), "Rate limited, retry after 5000ms");
210    }
211
212    #[test]
213    fn llm_error_display_stream_interrupted() {
214        let err = LlmError::StreamInterrupted;
215        assert_eq!(err.to_string(), "Stream interrupted");
216    }
217
218    #[test]
219    fn llm_error_display_invalid_response() {
220        let err = LlmError::InvalidResponse("missing field".into());
221        assert_eq!(err.to_string(), "Invalid response: missing field");
222    }
223
224    #[test]
225    fn llm_error_display_auth_error() {
226        let err = LlmError::AuthError("invalid key".into());
227        assert_eq!(err.to_string(), "Authentication failed: invalid key");
228    }
229
230    #[test]
231    fn llm_error_display_context_overflow() {
232        let err = LlmError::ContextOverflow { tokens: 200000 };
233        assert_eq!(err.to_string(), "Context window exceeded (200000 tokens)");
234    }
235
236    // ---- ToolError Display ----
237
238    #[test]
239    fn tool_error_display_permission_denied() {
240        let err = ToolError::PermissionDenied("read /etc/shadow".into());
241        assert_eq!(err.to_string(), "Permission denied: read /etc/shadow");
242    }
243
244    #[test]
245    fn tool_error_display_execution_failed() {
246        let err = ToolError::ExecutionFailed("exit code 1".into());
247        assert_eq!(err.to_string(), "Tool execution failed: exit code 1");
248    }
249
250    #[test]
251    fn tool_error_display_invalid_input() {
252        let err = ToolError::InvalidInput("expected JSON".into());
253        assert_eq!(err.to_string(), "Invalid input: expected JSON");
254    }
255
256    #[test]
257    fn tool_error_display_not_found() {
258        let err = ToolError::NotFound("custom_tool".into());
259        assert_eq!(err.to_string(), "Tool not found: custom_tool");
260    }
261
262    #[test]
263    fn tool_error_display_io() {
264        let io = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "broken pipe");
265        let err = ToolError::Io(io);
266        assert_eq!(err.to_string(), "IO error: broken pipe");
267    }
268
269    #[test]
270    fn tool_error_display_cancelled() {
271        let err = ToolError::Cancelled;
272        assert_eq!(err.to_string(), "Operation cancelled");
273    }
274
275    #[test]
276    fn tool_error_display_timeout() {
277        let err = ToolError::Timeout(30000);
278        assert_eq!(err.to_string(), "Timeout after 30000ms");
279    }
280
281    #[test]
282    fn tool_error_from_io_error() {
283        let io = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
284        let err: ToolError = io.into();
285        assert!(matches!(err, ToolError::Io(_)));
286    }
287
288    // ---- PermissionError Display ----
289
290    #[test]
291    fn permission_error_display_denied_by_rule() {
292        let err = PermissionError::DeniedByRule("no shell access".into());
293        assert_eq!(
294            err.to_string(),
295            "Permission denied by rule: no shell access"
296        );
297    }
298
299    #[test]
300    fn permission_error_display_user_denied() {
301        let err = PermissionError::UserDenied {
302            tool: "Bash".into(),
303            reason: "looks dangerous".into(),
304        };
305        assert_eq!(
306            err.to_string(),
307            "User denied permission for Bash: looks dangerous"
308        );
309    }
310
311    // ---- ConfigError Display ----
312
313    #[test]
314    fn config_error_display_file_error() {
315        let err = ConfigError::FileError("config.toml not found".into());
316        assert_eq!(err.to_string(), "Config file error: config.toml not found");
317    }
318
319    #[test]
320    fn config_error_display_invalid_value() {
321        let err = ConfigError::InvalidValue("timeout must be positive".into());
322        assert_eq!(
323            err.to_string(),
324            "Invalid config value: timeout must be positive"
325        );
326    }
327
328    #[test]
329    fn config_error_from_toml_de_error() {
330        // Trigger a real TOML parse error.
331        let bad_toml = "key = [unclosed";
332        let toml_err = toml::from_str::<toml::Value>(bad_toml).unwrap_err();
333        let err: ConfigError = toml_err.into();
334        assert!(matches!(err, ConfigError::ParseError(_)));
335        let display = err.to_string();
336        assert!(display.starts_with("TOML parse error:"));
337    }
338
339    #[test]
340    fn config_error_parse_error_propagates_to_top_level() {
341        let bad_toml = "= missing key";
342        let toml_err = toml::from_str::<toml::Value>(bad_toml).unwrap_err();
343        let config_err: ConfigError = toml_err.into();
344        let top_err: Error = config_err.into();
345        assert!(matches!(top_err, Error::Config(ConfigError::ParseError(_))));
346    }
347
348    // ---- Result alias ----
349
350    #[test]
351    fn result_alias_ok() {
352        let r: Result<i32> = Ok(42);
353        assert!(r.is_ok());
354    }
355
356    #[test]
357    fn result_alias_err() {
358        let r: Result<i32> = Err(Error::Other("oops".into()));
359        assert!(r.is_err());
360    }
361}