Skip to main content

ironflow_core/
error.rs

1//! Error types for ironflow operations.
2//!
3//! This module defines two error enums:
4//!
5//! * [`OperationError`] - top-level error returned by both shell and agent operations.
6//! * [`AgentError`] - agent-specific error returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke).
7//!
8//! [`AgentError`] converts into [`OperationError`] via the [`From`] trait, so agent
9//! errors propagate naturally through the `?` operator.
10
11use std::time::Duration;
12
13use thiserror::Error;
14
15/// Top-level error for any workflow operation (shell or agent).
16///
17/// Every public operation in ironflow returns `Result<T, OperationError>`.
18#[derive(Debug, Error)]
19pub enum OperationError {
20    /// A shell command exited with a non-zero status code.
21    ///
22    /// * `exit_code` - the process exit code (or `-1` if the process could not be spawned).
23    /// * `stderr` - the captured standard-error output, truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
24    #[error("shell exited with code {exit_code}: {stderr}")]
25    Shell { exit_code: i32, stderr: String },
26
27    /// An agent invocation failed.
28    ///
29    /// Wraps an [`AgentError`] with full detail about the failure.
30    #[error("agent error: {0}")]
31    Agent(#[from] AgentError),
32
33    /// An operation exceeded its configured timeout.
34    ///
35    /// * `step` - a human-readable description of the timed-out step (usually the command string).
36    /// * `limit` - the [`Duration`] that was exceeded.
37    #[error("step '{step}' timed out after {limit:?}")]
38    Timeout { step: String, limit: Duration },
39
40    /// An HTTP request failed at the transport layer or the response body
41    /// could not be read.
42    ///
43    /// * `status` - the HTTP status code, if a response was received.
44    /// * `message` - human-readable error description.
45    #[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    /// Failed to deserialize a JSON response into the expected Rust type.
55    ///
56    /// Returned by [`AgentResult::json`](crate::operations::agent::AgentResult::json)
57    /// and [`HttpOutput::json`](crate::operations::http::HttpOutput::json).
58    #[error("failed to deserialize into {target_type}: {reason}")]
59    Deserialize {
60        /// The Rust type name that was expected.
61        target_type: String,
62        /// The underlying serde error message.
63        reason: String,
64    },
65}
66
67impl OperationError {
68    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
69    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/// Error specific to agent (AI provider) invocations.
78///
79/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
80/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
81#[derive(Debug, Error)]
82pub enum AgentError {
83    /// The agent process exited with a non-zero status code.
84    ///
85    /// * `exit_code` - the process exit code (or `-1` if spawning failed).
86    /// * `stderr` - the captured standard-error output.
87    #[error("claude process exited with code {exit_code}: {stderr}")]
88    ProcessFailed { exit_code: i32, stderr: String },
89
90    /// The agent output did not match the expected schema.
91    ///
92    /// * `expected` - a description of what was expected (e.g. `"structured_output field"`).
93    /// * `got` - a description of what was actually received.
94    #[error("schema validation failed: expected {expected}, got {got}")]
95    SchemaValidation { expected: String, got: String },
96
97    /// The prompt exceeds the model's context window.
98    ///
99    /// Returned before spawning the process when the estimated token count
100    /// exceeds the model's known limit.
101    ///
102    /// * `chars` - number of characters in the combined prompt (system + user).
103    /// * `estimated_tokens` - approximate token count (chars / 4).
104    /// * `model_limit` - the model's context window in tokens.
105    #[error(
106        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
107    )]
108    PromptTooLarge {
109        /// Number of characters in the prompt.
110        chars: usize,
111        /// Estimated token count (chars / 4 heuristic).
112        estimated_tokens: usize,
113        /// Model's context window in tokens.
114        model_limit: usize,
115    },
116
117    /// The agent did not complete within the configured timeout.
118    #[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}