1use std::any;
12use std::fmt;
13use std::time::Duration;
14
15use thiserror::Error;
16
17#[derive(Debug, Error)]
21pub enum OperationError {
22 #[error("shell exited with code {exit_code}: {stderr}")]
24 Shell {
25 exit_code: i32,
27 stderr: String,
29 },
30
31 #[error("agent error: {0}")]
35 Agent(#[from] AgentError),
36
37 #[error("step '{step}' timed out after {limit:?}")]
39 Timeout {
40 step: String,
42 limit: Duration,
44 },
45
46 #[error("{}", match status {
49 Some(code) => format!("http error (status {code}): {message}"),
50 None => format!("http error: {message}"),
51 })]
52 Http {
53 status: Option<u16>,
55 message: String,
57 },
58
59 #[error("failed to deserialize into {target_type}: {reason}")]
64 Deserialize {
65 target_type: String,
67 reason: String,
69 },
70}
71
72impl OperationError {
73 pub fn deserialize<T>(error: impl fmt::Display) -> Self {
75 Self::Deserialize {
76 target_type: any::type_name::<T>().to_string(),
77 reason: error.to_string(),
78 }
79 }
80}
81
82#[derive(Debug, Error)]
87pub enum AgentError {
88 #[error("claude process exited with code {exit_code}: {stderr}")]
90 ProcessFailed {
91 exit_code: i32,
93 stderr: String,
95 },
96
97 #[error("schema validation failed: expected {expected}, got {got}")]
99 SchemaValidation {
100 expected: String,
102 got: String,
104 },
105
106 #[error(
115 "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
116 )]
117 PromptTooLarge {
118 chars: usize,
120 estimated_tokens: usize,
122 model_limit: usize,
124 },
125
126 #[error("agent timed out after {limit:?}")]
128 Timeout {
129 limit: Duration,
131 },
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn shell_display_format() {
140 let err = OperationError::Shell {
141 exit_code: 127,
142 stderr: "command not found".to_string(),
143 };
144 assert_eq!(
145 err.to_string(),
146 "shell exited with code 127: command not found"
147 );
148 }
149
150 #[test]
151 fn agent_display_delegates_to_agent_error() {
152 let inner = AgentError::ProcessFailed {
153 exit_code: 1,
154 stderr: "boom".to_string(),
155 };
156 let err = OperationError::Agent(inner);
157 assert_eq!(
158 err.to_string(),
159 "agent error: claude process exited with code 1: boom"
160 );
161 }
162
163 #[test]
164 fn timeout_display_format() {
165 let err = OperationError::Timeout {
166 step: "build".to_string(),
167 limit: Duration::from_secs(30),
168 };
169 assert_eq!(err.to_string(), "step 'build' timed out after 30s");
170 }
171
172 #[test]
173 fn agent_error_process_failed_display_zero_exit_code() {
174 let err = AgentError::ProcessFailed {
175 exit_code: 0,
176 stderr: "unexpected".to_string(),
177 };
178 assert_eq!(
179 err.to_string(),
180 "claude process exited with code 0: unexpected"
181 );
182 }
183
184 #[test]
185 fn agent_error_process_failed_display_negative_exit_code() {
186 let err = AgentError::ProcessFailed {
187 exit_code: -1,
188 stderr: "killed".to_string(),
189 };
190 assert!(err.to_string().contains("-1"));
191 }
192
193 #[test]
194 fn agent_error_schema_validation_display() {
195 let err = AgentError::SchemaValidation {
196 expected: "object".to_string(),
197 got: "string".to_string(),
198 };
199 assert_eq!(
200 err.to_string(),
201 "schema validation failed: expected object, got string"
202 );
203 }
204
205 #[test]
206 fn agent_error_timeout_display() {
207 let err = AgentError::Timeout {
208 limit: Duration::from_secs(300),
209 };
210 assert_eq!(err.to_string(), "agent timed out after 300s");
211 }
212
213 #[test]
214 fn from_agent_error_process_failed() {
215 let agent_err = AgentError::ProcessFailed {
216 exit_code: 42,
217 stderr: "fail".to_string(),
218 };
219 let op_err: OperationError = agent_err.into();
220 assert!(matches!(
221 op_err,
222 OperationError::Agent(AgentError::ProcessFailed { exit_code: 42, .. })
223 ));
224 }
225
226 #[test]
227 fn from_agent_error_schema_validation() {
228 let agent_err = AgentError::SchemaValidation {
229 expected: "a".to_string(),
230 got: "b".to_string(),
231 };
232 let op_err: OperationError = agent_err.into();
233 assert!(matches!(
234 op_err,
235 OperationError::Agent(AgentError::SchemaValidation { .. })
236 ));
237 }
238
239 #[test]
240 fn from_agent_error_timeout() {
241 let agent_err = AgentError::Timeout {
242 limit: Duration::from_secs(60),
243 };
244 let op_err: OperationError = agent_err.into();
245 assert!(matches!(
246 op_err,
247 OperationError::Agent(AgentError::Timeout { .. })
248 ));
249 }
250
251 #[test]
252 fn operation_error_implements_std_error() {
253 use std::error::Error;
254 let err = OperationError::Shell {
255 exit_code: 1,
256 stderr: "x".to_string(),
257 };
258 let _: &dyn Error = &err;
259 }
260
261 #[test]
262 fn agent_error_implements_std_error() {
263 use std::error::Error;
264 let err = AgentError::Timeout {
265 limit: Duration::from_secs(60),
266 };
267 let _: &dyn Error = &err;
268 }
269
270 #[test]
271 fn empty_stderr_edge_case() {
272 let err = OperationError::Shell {
273 exit_code: 1,
274 stderr: String::new(),
275 };
276 assert_eq!(err.to_string(), "shell exited with code 1: ");
277 }
278
279 #[test]
280 fn multiline_stderr() {
281 let err = AgentError::ProcessFailed {
282 exit_code: 1,
283 stderr: "line1\nline2\nline3".to_string(),
284 };
285 assert!(err.to_string().contains("line1\nline2\nline3"));
286 }
287
288 #[test]
289 fn unicode_in_stderr() {
290 let err = OperationError::Shell {
291 exit_code: 1,
292 stderr: "erreur: fichier introuvable \u{1F4A5}".to_string(),
293 };
294 assert!(err.to_string().contains("\u{1F4A5}"));
295 }
296
297 #[test]
298 fn http_error_with_status_display() {
299 let err = OperationError::Http {
300 status: Some(500),
301 message: "internal server error".to_string(),
302 };
303 assert_eq!(
304 err.to_string(),
305 "http error (status 500): internal server error"
306 );
307 }
308
309 #[test]
310 fn http_error_without_status_display() {
311 let err = OperationError::Http {
312 status: None,
313 message: "connection refused".to_string(),
314 };
315 assert_eq!(err.to_string(), "http error: connection refused");
316 }
317
318 #[test]
319 fn http_error_empty_message() {
320 let err = OperationError::Http {
321 status: Some(404),
322 message: String::new(),
323 };
324 assert_eq!(err.to_string(), "http error (status 404): ");
325 }
326
327 #[test]
328 fn subsecond_duration_in_timeout_display() {
329 let err = OperationError::Timeout {
330 step: "fast".to_string(),
331 limit: Duration::from_millis(500),
332 };
333 assert_eq!(err.to_string(), "step 'fast' timed out after 500ms");
334 }
335
336 #[test]
337 fn source_chains_agent_error() {
338 use std::error::Error;
339 let err = OperationError::Agent(AgentError::Timeout {
340 limit: Duration::from_secs(60),
341 });
342 assert!(err.source().is_some());
343 }
344
345 #[test]
346 fn source_none_for_shell() {
347 use std::error::Error;
348 let err = OperationError::Shell {
349 exit_code: 1,
350 stderr: "x".to_string(),
351 };
352 assert!(err.source().is_none());
353 }
354
355 #[test]
356 fn deserialize_helper_formats_correctly() {
357 let err = OperationError::deserialize::<Vec<String>>(format_args!("missing field"));
358 match &err {
359 OperationError::Deserialize {
360 target_type,
361 reason,
362 } => {
363 assert!(target_type.contains("Vec"));
364 assert!(target_type.contains("String"));
365 assert_eq!(reason, "missing field");
366 }
367 _ => panic!("expected Deserialize variant"),
368 }
369 }
370
371 #[test]
372 fn deserialize_display_format() {
373 let err = OperationError::Deserialize {
374 target_type: "MyStruct".to_string(),
375 reason: "bad input".to_string(),
376 };
377 assert_eq!(
378 err.to_string(),
379 "failed to deserialize into MyStruct: bad input"
380 );
381 }
382
383 #[test]
384 fn agent_error_prompt_too_large_display() {
385 let err = AgentError::PromptTooLarge {
386 chars: 966_007,
387 estimated_tokens: 241_501,
388 model_limit: 200_000,
389 };
390 let msg = err.to_string();
391 assert!(msg.contains("966007 chars"));
392 assert!(msg.contains("241501 tokens"));
393 assert!(msg.contains("200000 tokens"));
394 }
395
396 #[test]
397 fn from_agent_error_prompt_too_large() {
398 let agent_err = AgentError::PromptTooLarge {
399 chars: 1_000_000,
400 estimated_tokens: 250_000,
401 model_limit: 200_000,
402 };
403 let op_err: OperationError = agent_err.into();
404 assert!(matches!(
405 op_err,
406 OperationError::Agent(AgentError::PromptTooLarge {
407 model_limit: 200_000,
408 ..
409 })
410 ));
411 }
412
413 #[test]
414 fn source_none_for_http_timeout_deserialize() {
415 use std::error::Error;
416 let http = OperationError::Http {
417 status: Some(500),
418 message: "x".to_string(),
419 };
420 assert!(http.source().is_none());
421
422 let timeout = OperationError::Timeout {
423 step: "x".to_string(),
424 limit: Duration::from_secs(1),
425 };
426 assert!(timeout.source().is_none());
427
428 let deser = OperationError::Deserialize {
429 target_type: "T".to_string(),
430 reason: "r".to_string(),
431 };
432 assert!(deser.source().is_none());
433 }
434}