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