1use crate::permissions::DecisionReason;
7use thiserror::Error;
8
9pub type Result<T, E = Error> = std::result::Result<T, E>;
10
11#[derive(Debug, Error)]
12pub enum Error {
13 #[error("LLM error ({provider}): {message}")]
15 Llm { provider: String, message: String },
16
17 #[error("LLM rate limited ({provider}): retry after {retry_after_ms}ms")]
19 RateLimited {
20 provider: String,
21 retry_after_ms: u64,
22 },
23
24 #[error("tool error ({name}): {message}")]
26 Tool { name: String, message: String },
27
28 #[error("bad tool arguments ({name}): {message}")]
30 BadToolArgs { name: String, message: String },
31
32 #[error("tool `{0}` not found")]
34 UnknownTool(String),
35
36 #[error("permission denied: tool {name} ({reason:?})")]
38 PermissionDenied {
39 name: String,
40 reason: DecisionReason,
41 },
42
43 #[error("permission denial limit exceeded for tool {name}")]
45 PermissionDeniedLimit { name: String },
46
47 #[error("llm response truncated by provider (finish_reason = {0:?})")]
49 ProviderTruncated(String),
50
51 #[error("MCP error ({server}): {message}")]
53 Mcp { server: String, message: String },
54
55 #[error("config error: {message}")]
57 Config { message: String },
58
59 #[error("IO error: {0}")]
61 Io(#[from] std::io::Error),
62
63 #[error("HTTP error: {0}")]
65 Http(#[from] reqwest::Error),
66
67 #[error("JSON error: {0}")]
69 Json(#[from] serde_json::Error),
70
71 #[error("timeout after {duration_ms}ms")]
73 Timeout { duration_ms: u64 },
74
75 #[error("storage error: {message}")]
77 Storage { message: String },
78
79 #[error("{0}")]
81 Other(String),
82}
83
84impl Error {
85 pub fn is_retryable(&self) -> bool {
87 matches!(self, Error::RateLimited { .. } | Error::Timeout { .. })
88 }
89
90 pub fn is_transient(&self) -> bool {
93 matches!(
94 self,
95 Error::RateLimited { .. } | Error::Timeout { .. } | Error::Http(_) | Error::Io(_)
96 )
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn test_llm_error_format() {
106 let err = Error::Llm {
107 provider: "openai".into(),
108 message: "rate limit hit".into(),
109 };
110 let msg = err.to_string();
111 assert!(msg.contains("openai"));
112 assert!(msg.contains("rate limit"));
113 }
114
115 #[test]
116 fn test_rate_limited_format() {
117 let err = Error::RateLimited {
118 provider: "deepseek".into(),
119 retry_after_ms: 5000,
120 };
121 let msg = err.to_string();
122 assert!(msg.contains("deepseek"));
123 assert!(msg.contains("5000"));
124 }
125
126 #[test]
127 fn test_tool_error_format() {
128 let err = Error::Tool {
129 name: "run_shell".into(),
130 message: "command not found".into(),
131 };
132 let msg = err.to_string();
133 assert!(msg.contains("run_shell"));
134 assert!(msg.contains("command not found"));
135 }
136
137 #[test]
138 fn test_bad_tool_args_format() {
139 let err = Error::BadToolArgs {
140 name: "read_file".into(),
141 message: "missing path".into(),
142 };
143 let msg = err.to_string();
144 assert!(msg.contains("read_file"));
145 assert!(msg.contains("missing path"));
146 }
147
148 #[test]
149 fn test_permission_denied_format() {
150 let err = Error::PermissionDenied {
151 name: "run_shell".into(),
152 reason: crate::permissions::DecisionReason::Mode(
153 crate::permissions::PermissionMode::DontAsk,
154 ),
155 };
156 let msg = err.to_string();
157 assert!(msg.contains("run_shell"));
158 assert!(msg.contains("DontAsk"));
159 }
160
161 #[test]
162 fn test_mcp_error_format() {
163 let err = Error::Mcp {
164 server: "filesystem".into(),
165 message: "connection refused".into(),
166 };
167 let msg = err.to_string();
168 assert!(msg.contains("filesystem"));
169 assert!(msg.contains("connection refused"));
170 }
171
172 #[test]
173 fn test_config_error_format() {
174 let err = Error::Config {
175 message: "missing RECURSIVE_API_KEY".into(),
176 };
177 let msg = err.to_string();
178 assert!(msg.contains("missing RECURSIVE_API_KEY"));
179 }
180
181 #[test]
182 fn test_timeout_format() {
183 let err = Error::Timeout { duration_ms: 30000 };
184 let msg = err.to_string();
185 assert!(msg.contains("30000"));
186 }
187
188 #[test]
189 fn test_is_retryable() {
190 assert!(Error::RateLimited {
191 provider: "x".into(),
192 retry_after_ms: 1000
193 }
194 .is_retryable());
195 assert!(Error::Timeout { duration_ms: 5000 }.is_retryable());
196 assert!(!Error::Tool {
197 name: "x".into(),
198 message: "fail".into()
199 }
200 .is_retryable());
201 assert!(!Error::Config {
202 message: "bad".into()
203 }
204 .is_retryable());
205 }
206
207 #[test]
208 fn test_is_transient() {
209 assert!(Error::RateLimited {
210 provider: "x".into(),
211 retry_after_ms: 1000
212 }
213 .is_transient());
214 assert!(Error::Timeout { duration_ms: 5000 }.is_transient());
215 assert!(!Error::Tool {
218 name: "x".into(),
219 message: "fail".into()
220 }
221 .is_transient());
222 assert!(!Error::Config {
223 message: "bad".into()
224 }
225 .is_transient());
226 }
227
228 #[test]
229 fn test_from_io_error() {
230 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
231 let err: Error = io_err.into();
232 assert!(matches!(err, Error::Io(_)));
233 assert!(err.to_string().contains("file not found"));
234 }
235
236 #[test]
237 fn test_unknown_tool_format() {
238 let err = Error::UnknownTool("nonexistent".into());
239 let msg = err.to_string();
240 assert!(msg.contains("nonexistent"));
241 }
242
243 #[test]
244 fn test_provider_truncated_format() {
245 let err = Error::ProviderTruncated("length".into());
246 let msg = err.to_string();
247 assert!(msg.contains("length"));
248 }
249}