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