Skip to main content

whisper_macos_cli/
error.rs

1use std::process::ExitCode;
2
3#[derive(Debug, thiserror::Error)]
4pub enum Error {
5    #[error("no audio input provided")]
6    NoInput,
7
8    #[error("input not found: {path}")]
9    InputNotFound { path: String },
10
11    #[error("audio decode failed: {0}")]
12    AudioDecode(#[source] anyhow::Error),
13
14    #[error("unsupported audio format: {format}")]
15    UnsupportedFormat { format: String },
16
17    #[error("model not found: {name}")]
18    ModelNotFound { name: String },
19
20    #[error("model download failed: {0}")]
21    ModelDownload(#[source] anyhow::Error),
22
23    #[error("whisper inference failed: {0}")]
24    WhisperInference(String),
25
26    #[error("unsupported platform: macOS with Apple Silicon required")]
27    UnsupportedPlatform,
28
29    #[error("io error: {0}")]
30    Io(#[from] std::io::Error),
31
32    #[error("configuration error: {0}")]
33    Config(String),
34}
35
36impl Error {
37    pub fn exit_code(&self) -> u8 {
38        match self {
39            Self::NoInput => 64,
40            Self::InputNotFound { .. } => 66,
41            Self::AudioDecode(_) => 65,
42            Self::UnsupportedFormat { .. } => 65,
43            Self::ModelNotFound { .. } => 78,
44            Self::ModelDownload(_) => 69,
45            Self::WhisperInference(_) => 70,
46            Self::UnsupportedPlatform => 69,
47            Self::Io(_) => 74,
48            Self::Config(_) => 78,
49        }
50    }
51
52    pub fn to_exit_code(&self) -> ExitCode {
53        ExitCode::from(self.exit_code())
54    }
55
56    pub fn category(&self) -> &'static str {
57        match self {
58            Self::NoInput => "usage",
59            Self::InputNotFound { .. } => "input",
60            Self::AudioDecode(_) | Self::UnsupportedFormat { .. } => "data",
61            Self::ModelNotFound { .. } | Self::Config(_) => "config",
62            Self::ModelDownload(_) | Self::UnsupportedPlatform => "service",
63            Self::WhisperInference(_) => "internal",
64            Self::Io(_) => "io",
65        }
66    }
67
68    pub fn retryable(&self) -> bool {
69        matches!(self, Self::ModelDownload(_))
70    }
71
72    pub fn retry_after_ms(&self) -> Option<u64> {
73        match self {
74            Self::ModelDownload(_) => Some(2000),
75            _ => None,
76        }
77    }
78
79    pub fn hint(&self) -> Option<&'static str> {
80        match self {
81            Self::NoInput => Some("provide audio file(s) as arguments or pipe via stdin"),
82            Self::InputNotFound { .. } => Some("check the file path and try again"),
83            Self::AudioDecode(_) => {
84                Some("verify the file is a valid audio format (ogg, mp3, wav, flac)")
85            }
86            Self::UnsupportedFormat { .. } => Some("use --input-format to force a specific codec"),
87            Self::ModelNotFound { .. } => {
88                Some("run 'whisper-macos-cli models list' to see available models")
89            }
90            Self::ModelDownload(_) => Some("check network connectivity and retry"),
91            Self::WhisperInference(_) => Some("try a smaller model with --model base"),
92            Self::UnsupportedPlatform => Some("this CLI requires macOS with Apple Silicon (M1+)"),
93            Self::Io(_) => None,
94            Self::Config(_) => Some("run 'whisper-macos-cli doctor' to diagnose"),
95        }
96    }
97
98    pub fn docs_url(&self) -> &'static str {
99        match self {
100            Self::NoInput => {
101                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#contract"
102            }
103            Self::InputNotFound { .. } => {
104                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
105            }
106            Self::AudioDecode(_) => {
107                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#audio-decode"
108            }
109            Self::UnsupportedFormat { .. } => {
110                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#unsupported-format"
111            }
112            Self::ModelNotFound { .. } => {
113                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/AGENTS.md#model-management"
114            }
115            Self::ModelDownload(_) => {
116                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#model-download"
117            }
118            Self::WhisperInference(_) => {
119                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md#inference"
120            }
121            Self::UnsupportedPlatform => {
122                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/README.md#platform-requirements"
123            }
124            Self::Io(_) => {
125                "https://github.com/daniloaguiarbr/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
126            }
127            Self::Config(_) => {
128                "https://github.com/daniloteixeira/Dropbox/ai/dev/rust/macos/whisper-macos-cli/blob/main/docs/TROUBLESHOOTING.md"
129            }
130        }
131    }
132
133    pub fn to_json(&self, correlation_id: &str) -> serde_json::Value {
134        serde_json::json!({
135            "schema_version": env!("CARGO_PKG_VERSION"),
136            "error": true,
137            "code": self.exit_code(),
138            "message": self.to_string(),
139            "category": self.category(),
140            "retryable": self.retryable(),
141            "retry_after_ms": self.retry_after_ms(),
142            "hint": self.hint(),
143            "docs_url": self.docs_url(),
144            "correlation_id": correlation_id,
145        })
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn no_input_exit_code_is_64() {
155        assert_eq!(Error::NoInput.exit_code(), 64);
156    }
157
158    #[test]
159    fn input_not_found_exit_code_is_66() {
160        let err = Error::InputNotFound {
161            path: "test.mp3".to_string(),
162        };
163        assert_eq!(err.exit_code(), 66);
164    }
165
166    #[test]
167    fn model_not_found_exit_code_is_78() {
168        let err = Error::ModelNotFound {
169            name: "unknown".to_string(),
170        };
171        assert_eq!(err.exit_code(), 78);
172    }
173
174    #[test]
175    fn error_json_contains_all_required_fields() {
176        let err = Error::ModelDownload(anyhow::anyhow!("HTTP 503"));
177        let json = err.to_json("test-corr-id");
178        assert_eq!(json["error"], true);
179        assert!(json["code"].is_number());
180        assert!(json["message"].is_string());
181        assert!(json["category"].is_string());
182        assert!(json["retryable"].is_boolean());
183        assert!(json["retry_after_ms"].is_number());
184        assert!(json["docs_url"].is_string());
185        assert_eq!(json["correlation_id"], "test-corr-id");
186    }
187}