Skip to main content

apr_cli/
error.rs

1//! Error types for apr-cli
2//!
3//! Toyota Way: Jidoka - Stop and highlight problems immediately.
4
5use std::path::PathBuf;
6use std::process::ExitCode;
7use thiserror::Error;
8
9/// Result type alias for CLI operations
10pub type Result<T> = std::result::Result<T, CliError>;
11
12/// CLI error types
13#[derive(Error, Debug)]
14pub enum CliError {
15    /// File not found
16    #[error("File not found: {0}")]
17    FileNotFound(PathBuf),
18
19    /// Not a file (e.g., directory)
20    #[error("Not a file: {0}")]
21    NotAFile(PathBuf),
22
23    /// Invalid APR format
24    #[error("Invalid APR format: {0}")]
25    InvalidFormat(String),
26
27    /// Malformed non-model input (a JSON report, a YAML config, a CSV…).
28    ///
29    /// Distinct from [`CliError::InvalidFormat`], whose Display hardcodes
30    /// "Invalid APR format" and therefore sends a user hunting for a corrupt
31    /// model file when what actually failed to parse was, say, a grad-norm
32    /// telemetry history. Shares exit code 4 — the class of failure is the
33    /// same ("the input could not be parsed"), only the artifact named is
34    /// different, exactly as `FileNotFound`/`NotAFile` already share 3.
35    #[error("Invalid input: {0}")]
36    InvalidInput(String),
37
38    /// IO error
39    #[error("IO error: {0}")]
40    Io(#[from] std::io::Error),
41
42    /// Validation failed
43    #[error("Validation failed: {0}")]
44    ValidationFailed(String),
45
46    /// Aprender error
47    #[error("Aprender error: {0}")]
48    Aprender(String),
49
50    /// Model loading failed (used with inference feature)
51    #[error("Model load failed: {0}")]
52    #[allow(dead_code)]
53    ModelLoadFailed(String),
54
55    /// Inference failed (used with inference feature)
56    #[error("Inference failed: {0}")]
57    #[allow(dead_code)]
58    InferenceFailed(String),
59
60    /// Feature disabled (used when optional features are not compiled)
61    #[error("Feature not enabled: {0}")]
62    #[allow(dead_code)]
63    FeatureDisabled(String),
64
65    /// Network error
66    #[error("Network error: {0}")]
67    NetworkError(String),
68
69    /// HTTP 404 Not Found (GH-356: distinguish from other network errors)
70    #[error("HTTP 404 Not Found: {0}")]
71    HttpNotFound(String),
72
73    /// An advertised option exists but its implementation does not.
74    ///
75    /// #2407: `apr trace --reference` printed a stub string and exited 0, so
76    /// every caller — including the MCP wrapper — read "did nothing" as
77    /// "succeeded". An unimplemented option must fail, not report success.
78    #[error("Not implemented: {0}")]
79    NotImplemented(String),
80}
81
82impl CliError {
83    /// Get exit code for this error
84    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    /// The numeric exit code this error maps to.
92    ///
93    /// `exit_code()` returns a `std::process::ExitCode`, which is opaque — it
94    /// has no accessor and no `PartialEq`, so a test cannot assert on it. This
95    /// is the same mapping as a plain `u8` so the exit-code convention is
96    /// testable in-process.
97    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            // #2407: an advertised option whose implementation is a stub must
110            // FAIL, not print something and exit 0. Distinct code so a caller
111            // can tell "this build cannot do that" from "your input was wrong".
112            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
123/// Refuse to clobber an existing output artifact unless `--force` was given.
124///
125/// #2392 (dogfood 0.63.0, finding 4): `apr convert` and `apr quantize` already
126/// refused with exit 5 and "Use --force to overwrite", but `apr export`,
127/// `apr merge`, `apr shard` and `apr unshard` wrote straight over whatever was
128/// at the output path and exited 0 — and none of them even *had* a `--force`
129/// flag, so the guarded behaviour was unreachable. A 9-byte file handed to
130/// `apr export -o precious.safetensors` came back as 9717255 bytes of model with
131/// no warning. Every write-a-file command now routes its overwrite decision
132/// through this one function so the policy cannot drift again.
133///
134/// # Errors
135///
136/// Returns [`CliError::ValidationFailed`] (exit 5) when `path` exists and
137/// `force` is false.
138pub 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
148/// Resolve a model path: if given a directory, look for common model files inside.
149///
150/// HuggingFace models are stored as directories containing `model.safetensors`,
151/// `model-00001-of-NNNNN.safetensors`, or `*.gguf`. This function resolves such
152/// directories to the actual model file, avoiding "Not a file" errors.
153pub 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        // GH-668: Reject common system/temp directories. Only resolve dirs that
164        // are plausible HuggingFace model checkpoints (not /, /tmp, /home, etc.).
165        if let Some(parent) = path.parent() {
166            let depth = path.components().count();
167            // Directories at filesystem root level (depth <= 2) are never model dirs
168            if depth <= 2 {
169                return Err(CliError::NotAFile(path.to_path_buf()));
170            }
171            let _ = parent; // suppress unused warning
172        }
173
174        // PMAT-314: Check sharded SafeTensors index FIRST — individual shard files
175        // only contain a subset of tensors and will fail the architecture gate.
176        let index = path.join("model.safetensors.index.json");
177        if index.is_file() {
178            return Ok(index);
179        }
180        // Try common model file names in priority order
181        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        // Try first .gguf file
195        if let Ok(entries) = std::fs::read_dir(path) {
196            for entry in entries.flatten() {
197                let p = entry.path();
198                // GH-668: Skip temp files (rosetta_temp.apr, etc.) to avoid inspecting stale artifacts
199                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        // Try first .apr file
208        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    // ==================== Exit Code Tests ====================
234
235    #[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    /// #2407: an unimplemented option must exit non-zero — a stub that exits
290    /// 0 is read by every caller as "it worked".
291    #[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    // ==================== Display Tests ====================
312
313    #[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    // ==================== Conversion Tests ====================
374
375    #[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    // ==================== Result Type Alias ====================
391
392    #[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    // ==================== Exit Code Uniqueness ====================
405
406    #[test]
407    fn test_all_exit_codes_are_distinct_per_category() {
408        // Verify exit codes map to distinct categories
409        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        // FileNotFound and NotAFile intentionally share exit code 3
449        assert_eq!(codes[0].0, ExitCode::from(3));
450    }
451
452    // ==================== resolve_model_path Tests ====================
453
454    #[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        // Create a temp file and resolve it
464        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        // PMAT-314: Sharded models have index.json that MUST take priority
501        // over individual shard files (model-00001-of-00002.safetensors)
502        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}