1use std::path::PathBuf;
7use thiserror::Error;
8
9pub type Result<T> = std::result::Result<T, EntrenarError>;
11
12#[derive(Error, Debug)]
17pub enum EntrenarError {
18 #[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 #[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 #[error("Invalid configuration value for '{field}': {message}\n → {suggestion}")]
28 ConfigValue {
29 field: String,
30 message: String,
31 suggestion: String,
32 },
33
34 #[error("Model file not found: {path}\n → Download the model or check the path")]
36 ModelNotFound { path: PathBuf },
37
38 #[error("Unsupported model format: {format}\n → Supported formats: SafeTensors, GGUF, APR")]
40 UnsupportedFormat { format: String },
41
42 #[error("HuggingFace API error: {message}\n → Check your HF_TOKEN environment variable and network connection")]
44 HuggingFace { message: String },
45
46 #[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 #[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 #[error("IO error: {context}\n Cause: {source}")]
59 Io {
60 context: String,
61 #[source]
62 source: std::io::Error,
63 },
64
65 #[error("Serialization error: {message}")]
67 Serialization { message: String },
68
69 #[error("Operation cancelled by user")]
71 Cancelled,
72
73 #[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 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 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 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 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 assert!(msg.contains("32"));
190 assert!(msg.contains("16"));
191
192 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}