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    /// IO error
28    #[error("IO error: {0}")]
29    Io(#[from] std::io::Error),
30
31    /// Validation failed
32    #[error("Validation failed: {0}")]
33    ValidationFailed(String),
34
35    /// Aprender error
36    #[error("Aprender error: {0}")]
37    Aprender(String),
38
39    /// Model loading failed (used with inference feature)
40    #[error("Model load failed: {0}")]
41    #[allow(dead_code)]
42    ModelLoadFailed(String),
43
44    /// Inference failed (used with inference feature)
45    #[error("Inference failed: {0}")]
46    #[allow(dead_code)]
47    InferenceFailed(String),
48
49    /// Feature disabled (used when optional features are not compiled)
50    #[error("Feature not enabled: {0}")]
51    #[allow(dead_code)]
52    FeatureDisabled(String),
53
54    /// Network error
55    #[error("Network error: {0}")]
56    NetworkError(String),
57
58    /// HTTP 404 Not Found (GH-356: distinguish from other network errors)
59    #[error("HTTP 404 Not Found: {0}")]
60    HttpNotFound(String),
61}
62
63impl CliError {
64    /// Get exit code for this error
65    pub fn exit_code(&self) -> ExitCode {
66        match self {
67            Self::FileNotFound(_) | Self::NotAFile(_) => ExitCode::from(3),
68            Self::InvalidFormat(_) => ExitCode::from(4),
69            Self::Io(_) => ExitCode::from(7),
70            Self::ValidationFailed(_) => ExitCode::from(5),
71            Self::Aprender(_) => ExitCode::from(1),
72            Self::ModelLoadFailed(_) => ExitCode::from(6),
73            Self::InferenceFailed(_) => ExitCode::from(8),
74            Self::FeatureDisabled(_) => ExitCode::from(9),
75            Self::NetworkError(_) => ExitCode::from(10),
76            Self::HttpNotFound(_) => ExitCode::from(11),
77        }
78    }
79}
80
81impl From<aprender::error::AprenderError> for CliError {
82    fn from(e: aprender::error::AprenderError) -> Self {
83        Self::Aprender(e.to_string())
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use std::path::PathBuf;
91
92    // ==================== Exit Code Tests ====================
93
94    #[test]
95    fn test_file_not_found_exit_code() {
96        let err = CliError::FileNotFound(PathBuf::from("/test"));
97        assert_eq!(err.exit_code(), ExitCode::from(3));
98    }
99
100    #[test]
101    fn test_not_a_file_exit_code() {
102        let err = CliError::NotAFile(PathBuf::from("/test"));
103        assert_eq!(err.exit_code(), ExitCode::from(3));
104    }
105
106    #[test]
107    fn test_invalid_format_exit_code() {
108        let err = CliError::InvalidFormat("bad".to_string());
109        assert_eq!(err.exit_code(), ExitCode::from(4));
110    }
111
112    #[test]
113    fn test_io_error_exit_code() {
114        let err = CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, "test"));
115        assert_eq!(err.exit_code(), ExitCode::from(7));
116    }
117
118    #[test]
119    fn test_validation_failed_exit_code() {
120        let err = CliError::ValidationFailed("test".to_string());
121        assert_eq!(err.exit_code(), ExitCode::from(5));
122    }
123
124    #[test]
125    fn test_aprender_error_exit_code() {
126        let err = CliError::Aprender("test".to_string());
127        assert_eq!(err.exit_code(), ExitCode::from(1));
128    }
129
130    #[test]
131    fn test_model_load_failed_exit_code() {
132        let err = CliError::ModelLoadFailed("test".to_string());
133        assert_eq!(err.exit_code(), ExitCode::from(6));
134    }
135
136    #[test]
137    fn test_inference_failed_exit_code() {
138        let err = CliError::InferenceFailed("test".to_string());
139        assert_eq!(err.exit_code(), ExitCode::from(8));
140    }
141
142    #[test]
143    fn test_feature_disabled_exit_code() {
144        let err = CliError::FeatureDisabled("test".to_string());
145        assert_eq!(err.exit_code(), ExitCode::from(9));
146    }
147
148    #[test]
149    fn test_network_error_exit_code() {
150        let err = CliError::NetworkError("test".to_string());
151        assert_eq!(err.exit_code(), ExitCode::from(10));
152    }
153
154    #[test]
155    fn test_http_not_found_exit_code() {
156        let err = CliError::HttpNotFound("test".to_string());
157        assert_eq!(err.exit_code(), ExitCode::from(11));
158    }
159
160    // ==================== Display Tests ====================
161
162    #[test]
163    fn test_file_not_found_display() {
164        let err = CliError::FileNotFound(PathBuf::from("/model.apr"));
165        assert_eq!(err.to_string(), "File not found: /model.apr");
166    }
167
168    #[test]
169    fn test_not_a_file_display() {
170        let err = CliError::NotAFile(PathBuf::from("/dir"));
171        assert_eq!(err.to_string(), "Not a file: /dir");
172    }
173
174    #[test]
175    fn test_invalid_format_display() {
176        let err = CliError::InvalidFormat("bad magic".to_string());
177        assert_eq!(err.to_string(), "Invalid APR format: bad magic");
178    }
179
180    #[test]
181    fn test_validation_failed_display() {
182        let err = CliError::ValidationFailed("missing field".to_string());
183        assert_eq!(err.to_string(), "Validation failed: missing field");
184    }
185
186    #[test]
187    fn test_aprender_error_display() {
188        let err = CliError::Aprender("internal".to_string());
189        assert_eq!(err.to_string(), "Aprender error: internal");
190    }
191
192    #[test]
193    fn test_model_load_failed_display() {
194        let err = CliError::ModelLoadFailed("corrupt".to_string());
195        assert_eq!(err.to_string(), "Model load failed: corrupt");
196    }
197
198    #[test]
199    fn test_inference_failed_display() {
200        let err = CliError::InferenceFailed("OOM".to_string());
201        assert_eq!(err.to_string(), "Inference failed: OOM");
202    }
203
204    #[test]
205    fn test_feature_disabled_display() {
206        let err = CliError::FeatureDisabled("cuda".to_string());
207        assert_eq!(err.to_string(), "Feature not enabled: cuda");
208    }
209
210    #[test]
211    fn test_network_error_display() {
212        let err = CliError::NetworkError("timeout".to_string());
213        assert_eq!(err.to_string(), "Network error: timeout");
214    }
215
216    #[test]
217    fn test_http_not_found_display() {
218        let err = CliError::HttpNotFound("tokenizer.json".to_string());
219        assert_eq!(err.to_string(), "HTTP 404 Not Found: tokenizer.json");
220    }
221
222    // ==================== Conversion Tests ====================
223
224    #[test]
225    fn test_io_error_conversion() {
226        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
227        let cli_err: CliError = io_err.into();
228        assert!(cli_err.to_string().contains("file missing"));
229        assert_eq!(cli_err.exit_code(), ExitCode::from(7));
230    }
231
232    #[test]
233    fn test_debug_impl() {
234        let err = CliError::FileNotFound(PathBuf::from("/test"));
235        let debug = format!("{:?}", err);
236        assert!(debug.contains("FileNotFound"));
237    }
238
239    // ==================== Result Type Alias ====================
240
241    #[test]
242    fn test_result_type_ok() {
243        let result: Result<i32> = Ok(42);
244        assert_eq!(result.unwrap(), 42);
245    }
246
247    #[test]
248    fn test_result_type_err() {
249        let result: Result<i32> = Err(CliError::InvalidFormat("test".to_string()));
250        assert!(result.is_err());
251    }
252
253    // ==================== Exit Code Uniqueness ====================
254
255    #[test]
256    fn test_all_exit_codes_are_distinct_per_category() {
257        // Verify exit codes map to distinct categories
258        let codes = vec![
259            (
260                CliError::FileNotFound(PathBuf::from("a")).exit_code(),
261                "file",
262            ),
263            (
264                CliError::InvalidFormat("a".to_string()).exit_code(),
265                "format",
266            ),
267            (
268                CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, "")).exit_code(),
269                "io",
270            ),
271            (
272                CliError::ValidationFailed("a".to_string()).exit_code(),
273                "validation",
274            ),
275            (CliError::Aprender("a".to_string()).exit_code(), "aprender"),
276            (
277                CliError::ModelLoadFailed("a".to_string()).exit_code(),
278                "model_load",
279            ),
280            (
281                CliError::InferenceFailed("a".to_string()).exit_code(),
282                "inference",
283            ),
284            (
285                CliError::FeatureDisabled("a".to_string()).exit_code(),
286                "feature",
287            ),
288            (
289                CliError::NetworkError("a".to_string()).exit_code(),
290                "network",
291            ),
292            (
293                CliError::HttpNotFound("a".to_string()).exit_code(),
294                "http_not_found",
295            ),
296        ];
297        // FileNotFound and NotAFile intentionally share exit code 3
298        assert_eq!(codes[0].0, ExitCode::from(3));
299    }
300}