1use std::path::PathBuf;
6use std::process::ExitCode;
7use thiserror::Error;
8
9pub type Result<T> = std::result::Result<T, CliError>;
11
12#[derive(Error, Debug)]
14pub enum CliError {
15 #[error("File not found: {0}")]
17 FileNotFound(PathBuf),
18
19 #[error("Not a file: {0}")]
21 NotAFile(PathBuf),
22
23 #[error("Invalid APR format: {0}")]
25 InvalidFormat(String),
26
27 #[error("Invalid input: {0}")]
36 InvalidInput(String),
37
38 #[error("IO error: {0}")]
40 Io(#[from] std::io::Error),
41
42 #[error("Validation failed: {0}")]
44 ValidationFailed(String),
45
46 #[error("Aprender error: {0}")]
48 Aprender(String),
49
50 #[error("Model load failed: {0}")]
52 #[allow(dead_code)]
53 ModelLoadFailed(String),
54
55 #[error("Inference failed: {0}")]
57 #[allow(dead_code)]
58 InferenceFailed(String),
59
60 #[error("Feature not enabled: {0}")]
62 #[allow(dead_code)]
63 FeatureDisabled(String),
64
65 #[error("Network error: {0}")]
67 NetworkError(String),
68
69 #[error("HTTP 404 Not Found: {0}")]
71 HttpNotFound(String),
72
73 #[error("Not implemented: {0}")]
79 NotImplemented(String),
80}
81
82impl CliError {
83 pub fn exit_code(&self) -> ExitCode {
85 contract_pre_exit_code_semantics!();
86 contract_pre_error_mapping!();
87 contract_pre_exit_code_on_error!();
88 ExitCode::from(self.exit_code_value())
89 }
90
91 pub fn exit_code_value(&self) -> u8 {
98 match self {
99 Self::FileNotFound(_) | Self::NotAFile(_) => 3,
100 Self::InvalidFormat(_) | Self::InvalidInput(_) => 4,
101 Self::Io(_) => 7,
102 Self::ValidationFailed(_) => 5,
103 Self::Aprender(_) => 1,
104 Self::ModelLoadFailed(_) => 6,
105 Self::InferenceFailed(_) => 8,
106 Self::FeatureDisabled(_) => 9,
107 Self::NetworkError(_) => 10,
108 Self::HttpNotFound(_) => 11,
109 Self::NotImplemented(_) => 12,
113 }
114 }
115}
116
117impl From<aprender::error::AprenderError> for CliError {
118 fn from(e: aprender::error::AprenderError) -> Self {
119 Self::Aprender(e.to_string())
120 }
121}
122
123pub fn refuse_overwrite(path: &std::path::Path, force: bool) -> std::result::Result<(), CliError> {
139 if path.exists() && !force {
140 return Err(CliError::ValidationFailed(format!(
141 "Output file '{}' already exists. Use --force to overwrite.",
142 path.display()
143 )));
144 }
145 Ok(())
146}
147
148pub fn resolve_model_path(
154 path: &std::path::Path,
155) -> std::result::Result<std::path::PathBuf, CliError> {
156 if !path.exists() {
157 return Err(CliError::FileNotFound(path.to_path_buf()));
158 }
159 if path.is_file() {
160 return Ok(path.to_path_buf());
161 }
162 if path.is_dir() {
163 if let Some(parent) = path.parent() {
166 let depth = path.components().count();
167 if depth <= 2 {
169 return Err(CliError::NotAFile(path.to_path_buf()));
170 }
171 let _ = parent; }
173
174 let index = path.join("model.safetensors.index.json");
177 if index.is_file() {
178 return Ok(index);
179 }
180 let candidates = [
182 "model.safetensors",
183 "model-00001-of-00001.safetensors",
184 "model-00001-of-00002.safetensors",
185 "model-00001-of-00003.safetensors",
186 "model-00001-of-00004.safetensors",
187 ];
188 for candidate in &candidates {
189 let p = path.join(candidate);
190 if p.is_file() {
191 return Ok(p);
192 }
193 }
194 if let Ok(entries) = std::fs::read_dir(path) {
196 for entry in entries.flatten() {
197 let p = entry.path();
198 let is_temp = p
200 .file_name()
201 .is_some_and(|n| n.to_string_lossy().starts_with("rosetta_temp"));
202 if !is_temp && p.extension().is_some_and(|ext| ext == "gguf") && p.is_file() {
203 return Ok(p);
204 }
205 }
206 }
207 if let Ok(entries) = std::fs::read_dir(path) {
209 for entry in entries.flatten() {
210 let p = entry.path();
211 let is_temp = p
212 .file_name()
213 .is_some_and(|n| n.to_string_lossy().starts_with("rosetta_temp"));
214 if !is_temp && p.extension().is_some_and(|ext| ext == "apr") && p.is_file() {
215 return Ok(p);
216 }
217 }
218 }
219 Err(CliError::ValidationFailed(format!(
220 "Directory {} does not contain a model file (expected model.safetensors, *.gguf, or *.apr)",
221 path.display()
222 )))
223 } else {
224 Err(CliError::NotAFile(path.to_path_buf()))
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use std::path::PathBuf;
232
233 #[test]
236 fn test_file_not_found_exit_code() {
237 let err = CliError::FileNotFound(PathBuf::from("/test"));
238 assert_eq!(err.exit_code(), ExitCode::from(3));
239 }
240
241 #[test]
242 fn test_not_a_file_exit_code() {
243 let err = CliError::NotAFile(PathBuf::from("/test"));
244 assert_eq!(err.exit_code(), ExitCode::from(3));
245 }
246
247 #[test]
248 fn test_invalid_format_exit_code() {
249 let err = CliError::InvalidFormat("bad".to_string());
250 assert_eq!(err.exit_code(), ExitCode::from(4));
251 }
252
253 #[test]
254 fn test_io_error_exit_code() {
255 let err = CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, "test"));
256 assert_eq!(err.exit_code(), ExitCode::from(7));
257 }
258
259 #[test]
260 fn test_validation_failed_exit_code() {
261 let err = CliError::ValidationFailed("test".to_string());
262 assert_eq!(err.exit_code(), ExitCode::from(5));
263 }
264
265 #[test]
266 fn test_aprender_error_exit_code() {
267 let err = CliError::Aprender("test".to_string());
268 assert_eq!(err.exit_code(), ExitCode::from(1));
269 }
270
271 #[test]
272 fn test_model_load_failed_exit_code() {
273 let err = CliError::ModelLoadFailed("test".to_string());
274 assert_eq!(err.exit_code(), ExitCode::from(6));
275 }
276
277 #[test]
278 fn test_inference_failed_exit_code() {
279 let err = CliError::InferenceFailed("test".to_string());
280 assert_eq!(err.exit_code(), ExitCode::from(8));
281 }
282
283 #[test]
284 fn test_feature_disabled_exit_code() {
285 let err = CliError::FeatureDisabled("test".to_string());
286 assert_eq!(err.exit_code(), ExitCode::from(9));
287 }
288
289 #[test]
292 fn test_not_implemented_exit_code_is_nonzero() {
293 let err = CliError::NotImplemented("comparison".to_string());
294 assert_eq!(err.exit_code(), ExitCode::from(12));
295 assert_ne!(err.exit_code(), ExitCode::SUCCESS);
296 assert_eq!(err.to_string(), "Not implemented: comparison");
297 }
298
299 #[test]
300 fn test_network_error_exit_code() {
301 let err = CliError::NetworkError("test".to_string());
302 assert_eq!(err.exit_code(), ExitCode::from(10));
303 }
304
305 #[test]
306 fn test_http_not_found_exit_code() {
307 let err = CliError::HttpNotFound("test".to_string());
308 assert_eq!(err.exit_code(), ExitCode::from(11));
309 }
310
311 #[test]
314 fn test_file_not_found_display() {
315 let err = CliError::FileNotFound(PathBuf::from("/model.apr"));
316 assert_eq!(err.to_string(), "File not found: /model.apr");
317 }
318
319 #[test]
320 fn test_not_a_file_display() {
321 let err = CliError::NotAFile(PathBuf::from("/dir"));
322 assert_eq!(err.to_string(), "Not a file: /dir");
323 }
324
325 #[test]
326 fn test_invalid_format_display() {
327 let err = CliError::InvalidFormat("bad magic".to_string());
328 assert_eq!(err.to_string(), "Invalid APR format: bad magic");
329 }
330
331 #[test]
332 fn test_validation_failed_display() {
333 let err = CliError::ValidationFailed("missing field".to_string());
334 assert_eq!(err.to_string(), "Validation failed: missing field");
335 }
336
337 #[test]
338 fn test_aprender_error_display() {
339 let err = CliError::Aprender("internal".to_string());
340 assert_eq!(err.to_string(), "Aprender error: internal");
341 }
342
343 #[test]
344 fn test_model_load_failed_display() {
345 let err = CliError::ModelLoadFailed("corrupt".to_string());
346 assert_eq!(err.to_string(), "Model load failed: corrupt");
347 }
348
349 #[test]
350 fn test_inference_failed_display() {
351 let err = CliError::InferenceFailed("OOM".to_string());
352 assert_eq!(err.to_string(), "Inference failed: OOM");
353 }
354
355 #[test]
356 fn test_feature_disabled_display() {
357 let err = CliError::FeatureDisabled("cuda".to_string());
358 assert_eq!(err.to_string(), "Feature not enabled: cuda");
359 }
360
361 #[test]
362 fn test_network_error_display() {
363 let err = CliError::NetworkError("timeout".to_string());
364 assert_eq!(err.to_string(), "Network error: timeout");
365 }
366
367 #[test]
368 fn test_http_not_found_display() {
369 let err = CliError::HttpNotFound("tokenizer.json".to_string());
370 assert_eq!(err.to_string(), "HTTP 404 Not Found: tokenizer.json");
371 }
372
373 #[test]
376 fn test_io_error_conversion() {
377 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
378 let cli_err: CliError = io_err.into();
379 assert!(cli_err.to_string().contains("file missing"));
380 assert_eq!(cli_err.exit_code(), ExitCode::from(7));
381 }
382
383 #[test]
384 fn test_debug_impl() {
385 let err = CliError::FileNotFound(PathBuf::from("/test"));
386 let debug = format!("{:?}", err);
387 assert!(debug.contains("FileNotFound"));
388 }
389
390 #[test]
393 fn test_result_type_ok() {
394 let result: Result<i32> = Ok(42);
395 assert_eq!(result.expect("value"), 42);
396 }
397
398 #[test]
399 fn test_result_type_err() {
400 let result: Result<i32> = Err(CliError::InvalidFormat("test".to_string()));
401 assert!(result.is_err());
402 }
403
404 #[test]
407 fn test_all_exit_codes_are_distinct_per_category() {
408 let codes = vec![
410 (
411 CliError::FileNotFound(PathBuf::from("a")).exit_code(),
412 "file",
413 ),
414 (
415 CliError::InvalidFormat("a".to_string()).exit_code(),
416 "format",
417 ),
418 (
419 CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, "")).exit_code(),
420 "io",
421 ),
422 (
423 CliError::ValidationFailed("a".to_string()).exit_code(),
424 "validation",
425 ),
426 (CliError::Aprender("a".to_string()).exit_code(), "aprender"),
427 (
428 CliError::ModelLoadFailed("a".to_string()).exit_code(),
429 "model_load",
430 ),
431 (
432 CliError::InferenceFailed("a".to_string()).exit_code(),
433 "inference",
434 ),
435 (
436 CliError::FeatureDisabled("a".to_string()).exit_code(),
437 "feature",
438 ),
439 (
440 CliError::NetworkError("a".to_string()).exit_code(),
441 "network",
442 ),
443 (
444 CliError::HttpNotFound("a".to_string()).exit_code(),
445 "http_not_found",
446 ),
447 ];
448 assert_eq!(codes[0].0, ExitCode::from(3));
450 }
451
452 #[test]
455 fn test_resolve_model_path_nonexistent() {
456 let result = resolve_model_path(std::path::Path::new("/nonexistent/path/model.gguf"));
457 assert!(result.is_err());
458 assert!(matches!(result.unwrap_err(), CliError::FileNotFound(_)));
459 }
460
461 #[test]
462 fn test_resolve_model_path_regular_file() {
463 let tmp = std::env::temp_dir().join("apr-test-resolve.safetensors");
465 std::fs::write(&tmp, b"test").expect("write");
466 let result = resolve_model_path(&tmp);
467 assert!(result.is_ok());
468 assert_eq!(result.expect("value"), tmp);
469 std::fs::remove_file(&tmp).ok();
470 }
471
472 #[test]
473 fn test_resolve_model_path_dir_with_safetensors() {
474 let dir = std::env::temp_dir().join("apr-test-resolve-dir");
475 std::fs::create_dir_all(&dir).expect("mkdir");
476 let model_file = dir.join("model.safetensors");
477 std::fs::write(&model_file, b"test").expect("write");
478 let result = resolve_model_path(&dir);
479 assert!(result.is_ok());
480 assert_eq!(result.expect("value"), model_file);
481 std::fs::remove_file(&model_file).ok();
482 std::fs::remove_dir(&dir).ok();
483 }
484
485 #[test]
486 fn test_resolve_model_path_dir_with_gguf() {
487 let dir = std::env::temp_dir().join("apr-test-resolve-gguf");
488 std::fs::create_dir_all(&dir).expect("mkdir");
489 let model_file = dir.join("model-q4.gguf");
490 std::fs::write(&model_file, b"test").expect("write");
491 let result = resolve_model_path(&dir);
492 assert!(result.is_ok());
493 assert_eq!(result.expect("value"), model_file);
494 std::fs::remove_file(&model_file).ok();
495 std::fs::remove_dir(&dir).ok();
496 }
497
498 #[test]
499 fn test_resolve_model_path_dir_with_sharded_safetensors() {
500 let dir = std::env::temp_dir().join("apr-test-resolve-sharded");
503 std::fs::create_dir_all(&dir).expect("mkdir");
504 let index_file = dir.join("model.safetensors.index.json");
505 let shard_file = dir.join("model-00001-of-00002.safetensors");
506 std::fs::write(&index_file, b"{}").expect("write index");
507 std::fs::write(&shard_file, b"test").expect("write shard");
508 let result = resolve_model_path(&dir);
509 assert!(result.is_ok());
510 assert_eq!(
511 result.expect("value"),
512 index_file,
513 "index.json must take priority over shard files"
514 );
515 std::fs::remove_file(&shard_file).ok();
516 std::fs::remove_file(&index_file).ok();
517 std::fs::remove_dir(&dir).ok();
518 }
519
520 #[test]
521 fn test_resolve_model_path_empty_dir() {
522 let dir = std::env::temp_dir().join("apr-test-resolve-empty");
523 std::fs::create_dir_all(&dir).expect("mkdir");
524 let result = resolve_model_path(&dir);
525 assert!(result.is_err());
526 assert!(matches!(result.unwrap_err(), CliError::ValidationFailed(_)));
527 std::fs::remove_dir(&dir).ok();
528 }
529}