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    #[error("shell exited with code {exit_code}: {stderr}")]
22    Shell {
23        /// Process exit code, or `-1` if the process could not be spawned.
24        exit_code: i32,
25        /// Captured stderr, truncated to [`MAX_OUTPUT_SIZE`](crate::utils::MAX_OUTPUT_SIZE).
26        stderr: String,
27    },
28
29    /// An agent invocation failed.
30    ///
31    /// Wraps an [`AgentError`] with full detail about the failure.
32    #[error("agent error: {0}")]
33    Agent(#[from] AgentError),
34
35    /// An operation exceeded its configured timeout.
36    #[error("step '{step}' timed out after {limit:?}")]
37    Timeout {
38        /// Human-readable description of the timed-out step (usually the command string).
39        step: String,
40        /// The [`Duration`] that was exceeded.
41        limit: Duration,
42    },
43
44    /// An HTTP request failed at the transport layer or the response body
45    /// could not be read.
46    #[error("{}", match status {
47        Some(code) => format!("http error (status {code}): {message}"),
48        None => format!("http error: {message}"),
49    })]
50    Http {
51        /// HTTP status code, if a response was received.
52        status: Option<u16>,
53        /// Human-readable error description.
54        message: String,
55    },
56
57    /// Failed to deserialize a JSON response into the expected Rust type.
58    ///
59    /// Returned by [`AgentResult::json`](crate::operations::agent::AgentResult::json)
60    /// and [`HttpOutput::json`](crate::operations::http::HttpOutput::json).
61    #[error("failed to deserialize into {target_type}: {reason}")]
62    Deserialize {
63        /// The Rust type name that was expected.
64        target_type: String,
65        /// The underlying serde error message.
66        reason: String,
67    },
68}
69
70impl OperationError {
71    /// Build a [`Deserialize`](OperationError::Deserialize) error for type `T`.
72    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/// Error specific to agent (AI provider) invocations.
81///
82/// Returned by [`AgentProvider::invoke`](crate::provider::AgentProvider::invoke) and
83/// automatically wrapped into [`OperationError::Agent`] when propagated with `?`.
84#[derive(Debug, Error)]
85pub enum AgentError {
86    /// The agent process exited with a non-zero status code.
87    #[error("claude process exited with code {exit_code}: {stderr}")]
88    ProcessFailed {
89        /// Process exit code, or `-1` if spawning failed.
90        exit_code: i32,
91        /// Captured stderr.
92        stderr: String,
93    },
94
95    /// The agent output did not match the expected schema.
96    #[error("schema validation failed: expected {expected}, got {got}")]
97    SchemaValidation {
98        /// What was expected (e.g. `"structured_output field"`).
99        expected: String,
100        /// What was actually received.
101        got: String,
102    },
103
104    /// The prompt exceeds the model's context window.
105    ///
106    /// Returned before spawning the process when the estimated token count
107    /// exceeds the model's known limit.
108    ///
109    /// * `chars` - number of characters in the combined prompt (system + user).
110    /// * `estimated_tokens` - approximate token count (chars / 4).
111    /// * `model_limit` - the model's context window in tokens.
112    #[error(
113        "prompt too large: {chars} chars (~{estimated_tokens} tokens) exceeds model limit of {model_limit} tokens"
114    )]
115    PromptTooLarge {
116        /// Number of characters in the prompt.
117        chars: usize,
118        /// Estimated token count (chars / 4 heuristic).
119        estimated_tokens: usize,
120        /// Model's context window in tokens.
121        model_limit: usize,
122    },
123
124    /// The agent did not complete within the configured timeout.
125    #[error("agent timed out after {limit:?}")]
126    Timeout {
127        /// The [`Duration`] that was exceeded.
128        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}