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