Skip to main content

whisper_macos_cli/
error.rs

1use std::process::ExitCode;
2
3#[derive(Debug, thiserror::Error)]
4#[non_exhaustive]
5pub enum Error {
6    #[error("no audio input provided")]
7    NoInput,
8
9    #[error("input not found: {path}")]
10    InputNotFound { path: String },
11
12    #[error("audio decode failed: {0}")]
13    AudioDecode(#[source] anyhow::Error),
14
15    #[error("unsupported audio format: {format}")]
16    UnsupportedFormat { format: String },
17
18    #[error("video extraction failed: {path}: {ffmpeg_stderr}")]
19    VideoExtractionFailed { path: String, ffmpeg_stderr: String },
20
21    #[error("ffmpeg not found in PATH: install via `brew install ffmpeg` or set --ffmpeg-binary")]
22    FfmpegNotFound,
23
24    #[error("unsupported video format: {format}")]
25    UnsupportedVideoFormat { format: String },
26
27    #[error("model not found: {name}")]
28    ModelNotFound { name: String },
29
30    #[error("model download failed: {0}")]
31    ModelDownload(#[source] anyhow::Error),
32
33    #[error("whisper inference failed: {0}")]
34    WhisperInference(String),
35
36    #[error("unsupported platform: macOS with Apple Silicon required")]
37    UnsupportedPlatform,
38
39    #[error("io error: {0}")]
40    Io(#[from] std::io::Error),
41
42    #[error("configuration error: {0}")]
43    Config(String),
44}
45
46impl Error {
47    pub fn exit_code(&self) -> u8 {
48        match self {
49            Self::NoInput => 64,
50            Self::InputNotFound { .. } => 66,
51            Self::AudioDecode(_) => 65,
52            Self::UnsupportedFormat { .. } => 65,
53            Self::VideoExtractionFailed { .. } => 65,
54            Self::FfmpegNotFound => 69,
55            Self::UnsupportedVideoFormat { .. } => 65,
56            Self::ModelNotFound { .. } => 78,
57            Self::ModelDownload(_) => 69,
58            Self::WhisperInference(_) => 70,
59            Self::UnsupportedPlatform => 69,
60            Self::Io(_) => 74,
61            Self::Config(_) => 78,
62        }
63    }
64
65    pub fn to_exit_code(&self) -> ExitCode {
66        ExitCode::from(self.exit_code())
67    }
68
69    pub fn category(&self) -> &'static str {
70        match self {
71            Self::NoInput => "usage",
72            Self::InputNotFound { .. } => "input",
73            Self::AudioDecode(_)
74            | Self::UnsupportedFormat { .. }
75            | Self::VideoExtractionFailed { .. }
76            | Self::UnsupportedVideoFormat { .. } => "data",
77            Self::FfmpegNotFound => "service",
78            Self::ModelNotFound { .. } | Self::Config(_) => "config",
79            Self::ModelDownload(_) | Self::UnsupportedPlatform => "service",
80            Self::WhisperInference(_) => "internal",
81            Self::Io(_) => "io",
82        }
83    }
84
85    pub fn retryable(&self) -> bool {
86        matches!(self, Self::ModelDownload(_))
87    }
88
89    pub fn retry_after_ms(&self) -> Option<u64> {
90        match self {
91            Self::ModelDownload(_) => Some(2000),
92            _ => None,
93        }
94    }
95
96    pub fn hint(&self) -> Option<&'static str> {
97        match self {
98            Self::NoInput => Some("provide audio file(s) as arguments or pipe via stdin"),
99            Self::InputNotFound { .. } => Some("check the file path and try again"),
100            Self::AudioDecode(_) => {
101                Some("verify the file is a valid audio format (ogg, mp3, wav, flac)")
102            }
103            Self::UnsupportedFormat { .. } => Some("use --input-format to force a specific codec"),
104            Self::VideoExtractionFailed { .. } => {
105                Some("ffmpeg failed to extract audio; check codec and --ffmpeg-binary")
106            }
107            Self::FfmpegNotFound => {
108                Some("install ffmpeg via `brew install ffmpeg` or set --ffmpeg-binary")
109            }
110            Self::UnsupportedVideoFormat { .. } => Some("supported: mp4, mov, m4v, mkv, webm, avi"),
111            Self::ModelNotFound { .. } => {
112                Some("run 'whisper-macos-cli models list' to see available models")
113            }
114            Self::ModelDownload(_) => Some("check network connectivity and retry"),
115            Self::WhisperInference(_) => Some("try a smaller model with --model base"),
116            Self::UnsupportedPlatform => Some("this CLI requires macOS with Apple Silicon (M1+)"),
117            Self::Io(_) => None,
118            Self::Config(_) => Some("run 'whisper-macos-cli doctor' to diagnose"),
119        }
120    }
121
122    pub fn docs_url(&self) -> &'static str {
123        match self {
124            Self::NoInput => {
125                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#contract"
126            }
127            Self::InputNotFound { .. } => {
128                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
129            }
130            Self::AudioDecode(_) => {
131                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#audio-decode"
132            }
133            Self::UnsupportedFormat { .. } => {
134                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#unsupported-format"
135            }
136            Self::VideoExtractionFailed { .. } => {
137                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/VIDEO-EXTRACTION.md"
138            }
139            Self::FfmpegNotFound => {
140                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/VIDEO-EXTRACTION.md#ffmpeg-not-found"
141            }
142            Self::UnsupportedVideoFormat { .. } => {
143                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/VIDEO-EXTRACTION.md#supported-formats"
144            }
145            Self::ModelNotFound { .. } => {
146                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#model-management"
147            }
148            Self::ModelDownload(_) => {
149                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#model-download"
150            }
151            Self::WhisperInference(_) => {
152                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#inference"
153            }
154            Self::UnsupportedPlatform => {
155                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/README.md#platform-requirements"
156            }
157            Self::Io(_) => {
158                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
159            }
160            Self::Config(_) => {
161                "https://github.com/daniloteixeira/Dropbox/ai/dev/rust/macos/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
162            }
163        }
164    }
165
166    pub fn to_json(&self, correlation_id: &str) -> serde_json::Value {
167        serde_json::json!({
168            "schema_version": env!("CARGO_PKG_VERSION"),
169            "error": true,
170            "code": self.exit_code(),
171            "message": self.to_string(),
172            "category": self.category(),
173            "retryable": self.retryable(),
174            "retry_after_ms": self.retry_after_ms(),
175            "hint": self.hint(),
176            "docs_url": self.docs_url(),
177            "correlation_id": correlation_id,
178        })
179    }
180}
181
182/// Build a stable JSON error envelope from any [`Error`].
183///
184/// # Example
185///
186/// ```
187/// use whisper_macos_cli::error::Error;
188///
189/// let err = Error::InputNotFound { path: "missing.ogg".to_string() };
190/// let envelope = err.to_json("test-correlation-id");
191/// assert_eq!(envelope["error"], true);
192/// assert_eq!(envelope["code"], 66);
193/// assert_eq!(envelope["category"], "input");
194/// assert_eq!(envelope["correlation_id"], "test-correlation-id");
195/// assert!(envelope["docs_url"].as_str().unwrap().starts_with("https://"));
196/// ```
197pub type ErrorEnvelope = serde_json::Value;
198
199#[doc(hidden)]
200pub fn _doc_test_compiles() {}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn no_input_exit_code_is_64() {
208        assert_eq!(Error::NoInput.exit_code(), 64);
209    }
210
211    #[test]
212    fn input_not_found_exit_code_is_66() {
213        let err = Error::InputNotFound {
214            path: "test.mp3".to_string(),
215        };
216        assert_eq!(err.exit_code(), 66);
217    }
218
219    #[test]
220    fn model_not_found_exit_code_is_78() {
221        let err = Error::ModelNotFound {
222            name: "unknown".to_string(),
223        };
224        assert_eq!(err.exit_code(), 78);
225    }
226
227    #[test]
228    fn error_json_contains_all_required_fields() {
229        let err = Error::ModelDownload(anyhow::anyhow!("HTTP 503"));
230        let json = err.to_json("test-corr-id");
231        assert_eq!(json["error"], true);
232        assert!(json["code"].is_number());
233        assert!(json["message"].is_string());
234        assert!(json["category"].is_string());
235        assert!(json["retryable"].is_boolean());
236        assert!(json["retry_after_ms"].is_number());
237        assert!(json["docs_url"].is_string());
238        assert_eq!(json["correlation_id"], "test-corr-id");
239    }
240
241    #[test]
242    fn error_json_uses_pkg_version_for_schema_version() {
243        let err = Error::NoInput;
244        let json = err.to_json("any");
245        assert_eq!(json["schema_version"], env!("CARGO_PKG_VERSION"));
246    }
247
248    #[test]
249    fn category_assignments_are_correct() {
250        assert_eq!(Error::NoInput.category(), "usage");
251        assert_eq!(
252            Error::InputNotFound { path: "x".into() }.category(),
253            "input"
254        );
255        assert_eq!(
256            Error::UnsupportedFormat { format: "x".into() }.category(),
257            "data"
258        );
259        assert_eq!(
260            Error::ModelNotFound { name: "x".into() }.category(),
261            "config"
262        );
263        assert_eq!(
264            Error::ModelDownload(anyhow::anyhow!("x")).category(),
265            "service"
266        );
267        assert_eq!(Error::UnsupportedPlatform.category(), "service");
268        assert_eq!(Error::WhisperInference("x".into()).category(), "internal");
269        assert_eq!(Error::Config("x".into()).category(), "config");
270    }
271
272    #[test]
273    fn retryable_only_model_download() {
274        assert!(Error::ModelDownload(anyhow::anyhow!("x")).retryable());
275        assert!(!Error::NoInput.retryable());
276        assert!(!Error::InputNotFound { path: "x".into() }.retryable());
277        assert!(!Error::UnsupportedFormat { format: "x".into() }.retryable());
278        assert!(!Error::ModelNotFound { name: "x".into() }.retryable());
279        assert!(!Error::WhisperInference("x".into()).retryable());
280        assert!(!Error::Config("x".into()).retryable());
281    }
282
283    #[test]
284    fn retry_after_ms_only_for_model_download() {
285        assert_eq!(
286            Error::ModelDownload(anyhow::anyhow!("x")).retry_after_ms(),
287            Some(2000)
288        );
289        assert_eq!(Error::NoInput.retry_after_ms(), None);
290        assert_eq!(Error::Config("x".into()).retry_after_ms(), None);
291    }
292
293    #[test]
294    fn hint_present_for_recoverable_errors() {
295        assert!(Error::NoInput.hint().is_some());
296        assert!(Error::InputNotFound { path: "x".into() }.hint().is_some());
297        assert!(Error::ModelNotFound { name: "x".into() }.hint().is_some());
298    }
299
300    #[test]
301    fn docs_url_is_full_github_url() {
302        for err in [
303            Error::NoInput,
304            Error::InputNotFound { path: "x".into() },
305            Error::ModelNotFound { name: "x".into() },
306            Error::UnsupportedPlatform,
307            Error::Config("x".into()),
308        ] {
309            let url = err.docs_url();
310            assert!(url.starts_with("https://"), "{url} should be https");
311        }
312    }
313
314    #[test]
315    fn error_display_messages_are_lowercase() {
316        let msgs = [
317            Error::NoInput.to_string(),
318            Error::InputNotFound { path: "x".into() }.to_string(),
319            Error::UnsupportedFormat { format: "x".into() }.to_string(),
320            Error::ModelNotFound { name: "x".into() }.to_string(),
321            Error::WhisperInference("x".into()).to_string(),
322            Error::UnsupportedPlatform.to_string(),
323        ];
324        for msg in msgs {
325            assert!(
326                !msg.ends_with('.'),
327                "msg `{msg}` should not end with period"
328            );
329        }
330    }
331
332    #[test]
333    fn exit_codes_match_sysexits_h() {
334        assert_eq!(Error::NoInput.exit_code(), 64);
335        assert_eq!(Error::InputNotFound { path: "x".into() }.exit_code(), 66);
336        assert_eq!(Error::AudioDecode(anyhow::anyhow!("x")).exit_code(), 65);
337        assert_eq!(
338            Error::UnsupportedFormat { format: "x".into() }.exit_code(),
339            65
340        );
341        assert_eq!(Error::ModelNotFound { name: "x".into() }.exit_code(), 78);
342        assert_eq!(Error::ModelDownload(anyhow::anyhow!("x")).exit_code(), 69);
343        assert_eq!(Error::WhisperInference("x".into()).exit_code(), 70);
344        assert_eq!(Error::UnsupportedPlatform.exit_code(), 69);
345        assert_eq!(Error::Config("x".into()).exit_code(), 78);
346    }
347
348    #[test]
349    fn video_extraction_failed_exit_code_is_65() {
350        let err = Error::VideoExtractionFailed {
351            path: "video.mp4".into(),
352            ffmpeg_stderr: "Invalid data found".into(),
353        };
354        assert_eq!(err.exit_code(), 65);
355        assert_eq!(err.category(), "data");
356        assert!(!err.retryable());
357    }
358
359    #[test]
360    fn ffmpeg_not_found_exit_code_is_69() {
361        assert_eq!(Error::FfmpegNotFound.exit_code(), 69);
362        assert_eq!(Error::FfmpegNotFound.category(), "service");
363        assert!(!Error::FfmpegNotFound.retryable());
364        assert!(
365            Error::FfmpegNotFound
366                .hint()
367                .unwrap()
368                .contains("brew install ffmpeg")
369        );
370    }
371
372    #[test]
373    fn unsupported_video_format_exit_code_is_65() {
374        let err = Error::UnsupportedVideoFormat {
375            format: "wmv".into(),
376        };
377        assert_eq!(err.exit_code(), 65);
378        assert_eq!(err.category(), "data");
379        assert!(err.hint().unwrap().contains("mp4"));
380    }
381
382    #[test]
383    fn video_errors_have_video_docs_url() {
384        let err = Error::VideoExtractionFailed {
385            path: "x".into(),
386            ffmpeg_stderr: "y".into(),
387        };
388        assert!(err.docs_url().contains("VIDEO-EXTRACTION"));
389        assert!(
390            Error::FfmpegNotFound
391                .docs_url()
392                .contains("VIDEO-EXTRACTION")
393        );
394        assert!(
395            Error::UnsupportedVideoFormat { format: "x".into() }
396                .docs_url()
397                .contains("VIDEO-EXTRACTION")
398        );
399    }
400}