Skip to main content

ecl_core/
error.rs

1//! Error types for ECL core library.
2
3/// Errors that can occur during ECL workflow execution.
4///
5/// All error variants are marked with `#[non_exhaustive]` to allow
6/// adding new error types without breaking changes.
7#[derive(Debug, thiserror::Error)]
8#[non_exhaustive]
9pub enum Error {
10    /// LLM provider error (Claude API failures, rate limits, etc.)
11    #[error("LLM error: {message}")]
12    Llm {
13        /// Human-readable error message
14        message: String,
15        /// Source error if available
16        #[source]
17        source: Option<Box<dyn std::error::Error + Send + Sync>>,
18    },
19
20    /// Step validation error
21    #[error("Validation error: {message}")]
22    Validation {
23        /// Field or aspect that failed validation
24        field: Option<String>,
25        /// What went wrong
26        message: String,
27    },
28
29    /// I/O error (file operations, network, etc.)
30    #[error("I/O error: {0}")]
31    Io(#[from] std::io::Error),
32
33    /// JSON serialization/deserialization error
34    #[error("Serialization error: {0}")]
35    Serialization(#[from] serde_json::Error),
36
37    /// Step execution timeout
38    #[error("Step timed out after {seconds}s")]
39    Timeout {
40        /// Timeout duration in seconds
41        seconds: u64,
42    },
43
44    /// Maximum revision iterations exceeded
45    #[error("Maximum revisions exceeded: {attempts} attempts")]
46    MaxRevisionsExceeded {
47        /// Number of revision attempts made
48        attempts: u32,
49    },
50
51    /// Configuration error
52    #[error("Configuration error: {message}")]
53    Config {
54        /// What configuration is problematic
55        message: String,
56    },
57
58    /// Workflow not found
59    #[error("Workflow not found: {id}")]
60    WorkflowNotFound {
61        /// Workflow ID that was not found
62        id: String,
63    },
64
65    /// Step not found
66    #[error("Step not found: {id}")]
67    StepNotFound {
68        /// Step ID that was not found
69        id: String,
70    },
71}
72
73/// Convenience `Result` type alias for ECL operations.
74///
75/// This is the standard Result type used throughout the ECL codebase.
76pub type Result<T> = std::result::Result<T, Error>;
77
78impl Error {
79    /// Returns whether this error is retryable.
80    ///
81    /// Retryable errors include transient failures like rate limits,
82    /// network timeouts, and temporary service unavailability.
83    pub fn is_retryable(&self) -> bool {
84        match self {
85            Error::Llm { .. } => true, // LLM errors are generally retryable
86            Error::Io(_) => true,
87            Error::Timeout { .. } => true,
88            Error::Validation { .. } => false, // Validation errors are permanent
89            Error::MaxRevisionsExceeded { .. } => false,
90            Error::Serialization(_) => false,
91            Error::Config { .. } => false,
92            Error::WorkflowNotFound { .. } => false,
93            Error::StepNotFound { .. } => false,
94        }
95    }
96
97    /// Creates a new LLM error with a message.
98    pub fn llm<S: Into<String>>(message: S) -> Self {
99        Error::Llm {
100            message: message.into(),
101            source: None,
102        }
103    }
104
105    /// Creates a new LLM error with a message and source error.
106    pub fn llm_with_source<S, E>(message: S, source: E) -> Self
107    where
108        S: Into<String>,
109        E: std::error::Error + Send + Sync + 'static,
110    {
111        Error::Llm {
112            message: message.into(),
113            source: Some(Box::new(source)),
114        }
115    }
116
117    /// Creates a new validation error.
118    pub fn validation<S: Into<String>>(message: S) -> Self {
119        Error::Validation {
120            field: None,
121            message: message.into(),
122        }
123    }
124
125    /// Creates a new validation error with a field name.
126    pub fn validation_field<F, M>(field: F, message: M) -> Self
127    where
128        F: Into<String>,
129        M: Into<String>,
130    {
131        Error::Validation {
132            field: Some(field.into()),
133            message: message.into(),
134        }
135    }
136
137    /// Creates a new configuration error.
138    pub fn config<S: Into<String>>(message: S) -> Self {
139        Error::Config {
140            message: message.into(),
141        }
142    }
143}
144
145#[cfg(test)]
146#[allow(clippy::unwrap_used)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_error_display() {
152        let err = Error::llm("Rate limit exceeded");
153        assert_eq!(err.to_string(), "LLM error: Rate limit exceeded");
154    }
155
156    #[test]
157    fn test_retryable_classification() {
158        assert!(Error::llm("test").is_retryable());
159        assert!(Error::Timeout { seconds: 30 }.is_retryable());
160        assert!(!Error::validation("test").is_retryable());
161        assert!(!Error::MaxRevisionsExceeded { attempts: 3 }.is_retryable());
162    }
163
164    #[test]
165    fn test_validation_error_with_field() {
166        let err = Error::validation_field("topic", "must not be empty");
167        let Error::Validation { field, message } = err else {
168            unreachable!("Expected Validation error variant");
169        };
170        assert_eq!(field, Some("topic".to_string()));
171        assert_eq!(message, "must not be empty");
172    }
173
174    #[test]
175    fn test_max_revisions_exceeded() {
176        let err = Error::MaxRevisionsExceeded { attempts: 5 };
177        assert_eq!(err.to_string(), "Maximum revisions exceeded: 5 attempts");
178    }
179
180    #[test]
181    fn test_error_implements_send_sync() {
182        fn assert_send_sync<T: Send + Sync>() {}
183        assert_send_sync::<Error>();
184    }
185
186    #[test]
187    fn test_config_error() {
188        let err = Error::config("Invalid API endpoint");
189        assert_eq!(err.to_string(), "Configuration error: Invalid API endpoint");
190        assert!(!err.is_retryable());
191    }
192
193    #[test]
194    fn test_workflow_not_found() {
195        let err = Error::WorkflowNotFound {
196            id: "wf-123".to_string(),
197        };
198        assert_eq!(err.to_string(), "Workflow not found: wf-123");
199        assert!(!err.is_retryable());
200    }
201
202    #[test]
203    fn test_step_not_found() {
204        let err = Error::StepNotFound {
205            id: "step-456".to_string(),
206        };
207        assert_eq!(err.to_string(), "Step not found: step-456");
208        assert!(!err.is_retryable());
209    }
210
211    #[test]
212    fn test_llm_error_with_source() {
213        let io_error = std::io::Error::other("network failure");
214        let err = Error::llm_with_source("API call failed", Box::new(io_error));
215        assert!(err.to_string().contains("API call failed"));
216        assert!(err.is_retryable());
217    }
218
219    #[test]
220    fn test_io_error_is_retryable() {
221        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
222        let err: Error = io_error.into();
223        assert!(err.is_retryable());
224    }
225
226    #[test]
227    fn test_serde_error_not_retryable() {
228        let json = "{invalid json}";
229        let serde_err = serde_json::from_str::<serde_json::Value>(json).unwrap_err();
230        let err: Error = serde_err.into();
231        assert!(!err.is_retryable());
232    }
233
234    #[test]
235    fn test_timeout_error_display() {
236        let err = Error::Timeout { seconds: 60 };
237        assert_eq!(err.to_string(), "Step timed out after 60s");
238    }
239
240    #[test]
241    fn test_validation_without_field() {
242        let err = Error::validation("Generic validation failure");
243        let Error::Validation { field, message } = err else {
244            unreachable!("Expected Validation error");
245        };
246        assert_eq!(field, None);
247        assert_eq!(message, "Generic validation failure");
248    }
249}