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    /// Database error
52    #[error("Database error: {0}")]
53    Database(#[from] sqlx::Error),
54
55    /// Configuration error
56    #[error("Configuration error: {message}")]
57    Config {
58        /// What configuration is problematic
59        message: String,
60    },
61
62    /// Workflow not found
63    #[error("Workflow not found: {id}")]
64    WorkflowNotFound {
65        /// Workflow ID that was not found
66        id: String,
67    },
68
69    /// Step not found
70    #[error("Step not found: {id}")]
71    StepNotFound {
72        /// Step ID that was not found
73        id: String,
74    },
75}
76
77/// Convenience `Result` type alias for ECL operations.
78///
79/// This is the standard Result type used throughout the ECL codebase.
80pub type Result<T> = std::result::Result<T, Error>;
81
82impl Error {
83    /// Returns whether this error is retryable.
84    ///
85    /// Retryable errors include transient failures like rate limits,
86    /// network timeouts, and temporary service unavailability.
87    pub fn is_retryable(&self) -> bool {
88        match self {
89            Error::Llm { .. } => true, // LLM errors are generally retryable
90            Error::Io(_) => true,
91            Error::Database(_) => true, // Database errors may be transient
92            Error::Timeout { .. } => true,
93            Error::Validation { .. } => false, // Validation errors are permanent
94            Error::MaxRevisionsExceeded { .. } => false,
95            Error::Serialization(_) => false,
96            Error::Config { .. } => false,
97            Error::WorkflowNotFound { .. } => false,
98            Error::StepNotFound { .. } => false,
99        }
100    }
101
102    /// Creates a new LLM error with a message.
103    pub fn llm<S: Into<String>>(message: S) -> Self {
104        Error::Llm {
105            message: message.into(),
106            source: None,
107        }
108    }
109
110    /// Creates a new LLM error with a message and source error.
111    pub fn llm_with_source<S, E>(message: S, source: E) -> Self
112    where
113        S: Into<String>,
114        E: std::error::Error + Send + Sync + 'static,
115    {
116        Error::Llm {
117            message: message.into(),
118            source: Some(Box::new(source)),
119        }
120    }
121
122    /// Creates a new validation error.
123    pub fn validation<S: Into<String>>(message: S) -> Self {
124        Error::Validation {
125            field: None,
126            message: message.into(),
127        }
128    }
129
130    /// Creates a new validation error with a field name.
131    pub fn validation_field<F, M>(field: F, message: M) -> Self
132    where
133        F: Into<String>,
134        M: Into<String>,
135    {
136        Error::Validation {
137            field: Some(field.into()),
138            message: message.into(),
139        }
140    }
141
142    /// Creates a new configuration error.
143    pub fn config<S: Into<String>>(message: S) -> Self {
144        Error::Config {
145            message: message.into(),
146        }
147    }
148}
149
150#[cfg(test)]
151#[allow(clippy::unwrap_used)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_error_display() {
157        let err = Error::llm("Rate limit exceeded");
158        assert_eq!(err.to_string(), "LLM error: Rate limit exceeded");
159    }
160
161    #[test]
162    fn test_retryable_classification() {
163        assert!(Error::llm("test").is_retryable());
164        assert!(Error::Timeout { seconds: 30 }.is_retryable());
165        assert!(!Error::validation("test").is_retryable());
166        assert!(!Error::MaxRevisionsExceeded { attempts: 3 }.is_retryable());
167    }
168
169    #[test]
170    fn test_validation_error_with_field() {
171        let err = Error::validation_field("topic", "must not be empty");
172        let Error::Validation { field, message } = err else {
173            unreachable!("Expected Validation error variant");
174        };
175        assert_eq!(field, Some("topic".to_string()));
176        assert_eq!(message, "must not be empty");
177    }
178
179    #[test]
180    fn test_max_revisions_exceeded() {
181        let err = Error::MaxRevisionsExceeded { attempts: 5 };
182        assert_eq!(err.to_string(), "Maximum revisions exceeded: 5 attempts");
183    }
184
185    #[test]
186    fn test_error_implements_send_sync() {
187        fn assert_send_sync<T: Send + Sync>() {}
188        assert_send_sync::<Error>();
189    }
190
191    #[test]
192    fn test_config_error() {
193        let err = Error::config("Invalid API endpoint");
194        assert_eq!(err.to_string(), "Configuration error: Invalid API endpoint");
195        assert!(!err.is_retryable());
196    }
197
198    #[test]
199    fn test_workflow_not_found() {
200        let err = Error::WorkflowNotFound {
201            id: "wf-123".to_string(),
202        };
203        assert_eq!(err.to_string(), "Workflow not found: wf-123");
204        assert!(!err.is_retryable());
205    }
206
207    #[test]
208    fn test_step_not_found() {
209        let err = Error::StepNotFound {
210            id: "step-456".to_string(),
211        };
212        assert_eq!(err.to_string(), "Step not found: step-456");
213        assert!(!err.is_retryable());
214    }
215
216    #[test]
217    fn test_llm_error_with_source() {
218        let io_error = std::io::Error::other("network failure");
219        let err = Error::llm_with_source("API call failed", Box::new(io_error));
220        assert!(err.to_string().contains("API call failed"));
221        assert!(err.is_retryable());
222    }
223
224    #[test]
225    fn test_io_error_is_retryable() {
226        let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
227        let err: Error = io_error.into();
228        assert!(err.is_retryable());
229    }
230
231    #[test]
232    fn test_serde_error_not_retryable() {
233        let json = "{invalid json}";
234        let serde_err = serde_json::from_str::<serde_json::Value>(json).unwrap_err();
235        let err: Error = serde_err.into();
236        assert!(!err.is_retryable());
237    }
238
239    #[test]
240    fn test_timeout_error_display() {
241        let err = Error::Timeout { seconds: 60 };
242        assert_eq!(err.to_string(), "Step timed out after 60s");
243    }
244
245    #[test]
246    fn test_validation_without_field() {
247        let err = Error::validation("Generic validation failure");
248        let Error::Validation { field, message } = err else {
249            unreachable!("Expected Validation error");
250        };
251        assert_eq!(field, None);
252        assert_eq!(message, "Generic validation failure");
253    }
254}