Skip to main content

entrenar_common/
error.rs

1//! Error types with actionable diagnostics (Andon principle).
2//!
3//! All errors include contextual information to help users resolve issues
4//! without needing to consult external documentation.
5
6use std::path::PathBuf;
7use thiserror::Error;
8
9/// Result type alias for entrenar operations.
10pub type Result<T> = std::result::Result<T, EntrenarError>;
11
12/// Errors that can occur in entrenar CLI tools.
13///
14/// Each variant includes actionable context following the Andon principle
15/// of making problems immediately visible and actionable.
16#[derive(Error, Debug)]
17pub enum EntrenarError {
18    /// Configuration file not found at expected path.
19    #[error("Configuration file not found: {path}\n  → Create a config file or use --config to specify a different path")]
20    ConfigNotFound { path: PathBuf },
21
22    /// Configuration file has invalid syntax.
23    #[error("Invalid configuration syntax in {path}:\n  {message}\n  → Check YAML/JSON syntax at the indicated line")]
24    ConfigParsing { path: PathBuf, message: String },
25
26    /// Configuration value is invalid.
27    #[error("Invalid configuration value for '{field}': {message}\n  → {suggestion}")]
28    ConfigValue {
29        field: String,
30        message: String,
31        suggestion: String,
32    },
33
34    /// Model file not found.
35    #[error("Model file not found: {path}\n  → Download the model or check the path")]
36    ModelNotFound { path: PathBuf },
37
38    /// Model format is unsupported.
39    #[error("Unsupported model format: {format}\n  → Supported formats: SafeTensors, GGUF, APR")]
40    UnsupportedFormat { format: String },
41
42    /// HuggingFace API error.
43    #[error("HuggingFace API error: {message}\n  → Check your HF_TOKEN environment variable and network connection")]
44    HuggingFace { message: String },
45
46    /// Insufficient memory for operation.
47    #[error("Insufficient memory: need {required} GB, have {available} GB\n  → Try: reduce batch_size, enable gradient_accumulation, or use QLoRA")]
48    InsufficientMemory { required: f64, available: f64 },
49
50    /// Invalid tensor shape.
51    #[error("Tensor shape mismatch: expected {expected:?}, got {actual:?}\n  → Check model architecture compatibility")]
52    ShapeMismatch {
53        expected: Vec<usize>,
54        actual: Vec<usize>,
55    },
56
57    /// IO error with context.
58    #[error("IO error: {context}\n  Cause: {source}")]
59    Io {
60        context: String,
61        #[source]
62        source: std::io::Error,
63    },
64
65    /// Serialization/deserialization error.
66    #[error("Serialization error: {message}")]
67    Serialization { message: String },
68
69    /// User interrupted operation.
70    #[error("Operation cancelled by user")]
71    Cancelled,
72
73    /// Generic error for unexpected conditions.
74    #[error("Internal error: {message}\n  → Please report this bug at https://github.com/paiml/entrenar/issues")]
75    Internal { message: String },
76}
77
78impl EntrenarError {
79    /// Create an IO error with context.
80    pub fn io(context: impl Into<String>, source: std::io::Error) -> Self {
81        Self::Io {
82            context: context.into(),
83            source,
84        }
85    }
86
87    /// Check if this error is user-recoverable.
88    pub fn is_user_error(&self) -> bool {
89        matches!(
90            self,
91            Self::ConfigNotFound { .. }
92                | Self::ConfigParsing { .. }
93                | Self::ConfigValue { .. }
94                | Self::ModelNotFound { .. }
95                | Self::UnsupportedFormat { .. }
96                | Self::HuggingFace { .. }
97                | Self::InsufficientMemory { .. }
98                | Self::Cancelled
99        )
100    }
101
102    /// Get the error code for structured output.
103    pub fn code(&self) -> &'static str {
104        match self {
105            Self::ConfigNotFound { .. } => "E001",
106            Self::ConfigParsing { .. } => "E002",
107            Self::ConfigValue { .. } => "E003",
108            Self::ModelNotFound { .. } => "E010",
109            Self::UnsupportedFormat { .. } => "E011",
110            Self::HuggingFace { .. } => "E020",
111            Self::InsufficientMemory { .. } => "E030",
112            Self::ShapeMismatch { .. } => "E040",
113            Self::Io { .. } => "E050",
114            Self::Serialization { .. } => "E051",
115            Self::Cancelled => "E060",
116            Self::Internal { .. } => "E999",
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_error_codes_are_unique() {
127        let errors = vec![
128            EntrenarError::ConfigNotFound { path: "".into() },
129            EntrenarError::ConfigParsing {
130                path: "".into(),
131                message: String::new(),
132            },
133            EntrenarError::ConfigValue {
134                field: String::new(),
135                message: String::new(),
136                suggestion: String::new(),
137            },
138            EntrenarError::ModelNotFound { path: "".into() },
139            EntrenarError::UnsupportedFormat {
140                format: String::new(),
141            },
142            EntrenarError::HuggingFace {
143                message: String::new(),
144            },
145            EntrenarError::InsufficientMemory {
146                required: 0.0,
147                available: 0.0,
148            },
149            EntrenarError::ShapeMismatch {
150                expected: vec![],
151                actual: vec![],
152            },
153            EntrenarError::Serialization {
154                message: String::new(),
155            },
156            EntrenarError::Cancelled,
157            EntrenarError::Internal {
158                message: String::new(),
159            },
160        ];
161
162        let codes: Vec<_> = errors.iter().map(super::EntrenarError::code).collect();
163        let unique: std::collections::HashSet<_> = codes.iter().collect();
164
165        // All codes except E050 and E051 should be unique
166        // (Io and Serialization are in same category)
167        assert!(unique.len() >= codes.len() - 1);
168    }
169
170    #[test]
171    fn test_user_errors_are_recoverable() {
172        assert!(EntrenarError::ConfigNotFound { path: "".into() }.is_user_error());
173        assert!(EntrenarError::Cancelled.is_user_error());
174        assert!(!EntrenarError::Internal {
175            message: String::new()
176        }
177        .is_user_error());
178    }
179
180    #[test]
181    fn test_error_messages_are_actionable() {
182        let err = EntrenarError::InsufficientMemory {
183            required: 32.0,
184            available: 16.0,
185        };
186        let msg = err.to_string();
187
188        // Must mention the problem
189        assert!(msg.contains("32"));
190        assert!(msg.contains("16"));
191
192        // Must include actionable suggestions
193        assert!(msg.contains("batch_size") || msg.contains("QLoRA"));
194    }
195
196    #[test]
197    fn test_io_error_constructor() {
198        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
199        let err = EntrenarError::io("reading config", io_err);
200
201        assert!(matches!(err, EntrenarError::Io { .. }));
202        let msg = err.to_string();
203        assert!(msg.contains("reading config"));
204    }
205
206    #[test]
207    fn test_shape_mismatch_not_user_error() {
208        let err = EntrenarError::ShapeMismatch {
209            expected: vec![1, 2, 3],
210            actual: vec![1, 2, 4],
211        };
212        assert!(!err.is_user_error());
213    }
214
215    #[test]
216    fn test_serialization_error_display() {
217        let err = EntrenarError::Serialization {
218            message: "invalid JSON".into(),
219        };
220        let msg = err.to_string();
221        assert!(msg.contains("invalid JSON"));
222    }
223
224    #[test]
225    fn test_config_value_error_includes_suggestion() {
226        let err = EntrenarError::ConfigValue {
227            field: "learning_rate".into(),
228            message: "must be positive".into(),
229            suggestion: "Use a value like 0.001".into(),
230        };
231        let msg = err.to_string();
232        assert!(msg.contains("learning_rate"));
233        assert!(msg.contains("must be positive"));
234        assert!(msg.contains("Use a value like 0.001"));
235    }
236
237    #[test]
238    fn test_unsupported_format_lists_alternatives() {
239        let err = EntrenarError::UnsupportedFormat {
240            format: "pickle".into(),
241        };
242        let msg = err.to_string();
243        assert!(msg.contains("pickle"));
244        assert!(msg.contains("SafeTensors"));
245    }
246
247    #[test]
248    fn test_huggingface_error_mentions_token() {
249        let err = EntrenarError::HuggingFace {
250            message: "rate limited".into(),
251        };
252        let msg = err.to_string();
253        assert!(msg.contains("HF_TOKEN"));
254    }
255
256    #[test]
257    fn test_internal_error_mentions_bug_report() {
258        let err = EntrenarError::Internal {
259            message: "unexpected state".into(),
260        };
261        let msg = err.to_string();
262        assert!(msg.contains("github.com"));
263        assert!(msg.contains("issues"));
264    }
265
266    #[test]
267    fn test_all_error_codes_start_with_e() {
268        let errors: Vec<EntrenarError> = vec![
269            EntrenarError::ConfigNotFound { path: "".into() },
270            EntrenarError::Cancelled,
271            EntrenarError::Internal {
272                message: String::new(),
273            },
274        ];
275
276        for err in errors {
277            assert!(err.code().starts_with('E'));
278        }
279    }
280}