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