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
182pub 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}