Skip to main content

subx_core/
error.rs

1//! Comprehensive error types for the SubX CLI application operations.
2//!
3//! This module defines the `SubXError` enum covering all error conditions
4//! that can occur during subtitle processing, AI service integration,
5//! audio analysis, file matching, and general command execution.
6//!
7//! It also provides helper methods to construct errors and generate
8//! user-friendly messages.
9use thiserror::Error;
10
11/// Represents all possible errors in the SubX application.
12///
13/// Each variant provides specific context to facilitate debugging and
14/// user-friendly reporting.
15///
16/// # Examples
17///
18/// ```rust
19/// use subx_core::error::{SubXError, SubXResult};
20///
21/// fn example() -> SubXResult<()> {
22///     Err(SubXError::SubtitleFormat {
23///         format: "SRT".to_string(),
24///         message: "Invalid timestamp format".to_string(),
25///     })
26/// }
27/// ```
28///
29/// # Exit Codes
30///
31/// Each error variant maps to a stable process exit code (1–6). That
32/// mapping is a property of running the `subx-cli` binary and lives in
33/// that binary's `SubXErrorExt::exit_code` extension trait (rendered
34/// terminal prose likewise); this enum itself carries only the machine-readable
35/// contract — [`Self::category`], [`Self::machine_code`] and [`Self::hint`]
36/// — which library consumers can call without importing anything.
37#[derive(Error, Debug)]
38pub enum SubXError {
39    /// I/O operation failed during file system access.
40    ///
41    /// This variant wraps `std::io::Error` and provides context about
42    /// file operations that failed.
43    ///
44    /// # Common Causes
45    /// - Permission issues
46    /// - Insufficient disk space
47    /// - Network filesystem errors
48    #[error("I/O error: {0}")]
49    Io(#[from] std::io::Error),
50
51    /// Configuration error due to invalid or missing settings.
52    ///
53    /// Contains a human-readable message describing the issue.
54    #[error("Configuration error: {message}")]
55    Config {
56        /// Description of the configuration error
57        message: String,
58    },
59
60    /// Subtitle format error indicating invalid timestamps or structure.
61    ///
62    /// Provides the subtitle format and detailed message.
63    #[error("Subtitle format error [{format}]: {message}")]
64    SubtitleFormat {
65        /// The subtitle format that caused the error (e.g., "SRT", "ASS")
66        format: String,
67        /// Detailed error message describing the issue
68        message: String,
69    },
70
71    /// AI service encountered an error.
72    ///
73    /// Captures the raw error message from the AI provider.
74    #[error("AI service error: {0}")]
75    AiService(String),
76
77    /// API request error with specified source.
78    ///
79    /// Represents errors that occur during API requests, providing both
80    /// the error message and the source of the API error.
81    #[error("API error [{source:?}]: {message}")]
82    Api {
83        /// Error message from the API
84        message: String,
85        /// Source of the API error
86        source: ApiErrorSource,
87    },
88
89    /// Audio processing error during analysis or format conversion.
90    ///
91    /// Provides a message describing the audio processing failure.
92    #[error("Audio processing error: {message}")]
93    AudioProcessing {
94        /// Description of the audio processing error
95        message: String,
96    },
97
98    /// Error during file matching or discovery.
99    ///
100    /// Contains details about path resolution or pattern matching failures.
101    #[error("File matching error: {message}")]
102    FileMatching {
103        /// Description of the file matching error
104        message: String,
105    },
106    /// Indicates that a file operation failed because the target exists.
107    #[error("File already exists: {0}")]
108    FileAlreadyExists(String),
109    /// Indicates that the specified file was not found.
110    #[error("File not found: {0}")]
111    FileNotFound(String),
112    /// Invalid file name encountered.
113    #[error("Invalid file name: {0}")]
114    InvalidFileName(String),
115    /// Generic file operation failure with message.
116    #[error("File operation failed: {0}")]
117    FileOperationFailed(String),
118    /// Generic command execution error.
119    #[error("{0}")]
120    CommandExecution(String),
121
122    /// No input path was specified for the operation.
123    #[error("No input path specified")]
124    NoInputSpecified,
125
126    /// The provided path is invalid or malformed.
127    #[error("Invalid path: {0}")]
128    InvalidPath(std::path::PathBuf),
129
130    /// The specified path does not exist on the filesystem.
131    #[error("Path not found: {0}")]
132    PathNotFound(std::path::PathBuf),
133
134    /// Unable to read the specified directory.
135    #[error("Unable to read directory: {path}")]
136    DirectoryReadError {
137        /// The directory path that could not be read
138        path: std::path::PathBuf,
139        /// The underlying I/O error
140        #[source]
141        source: std::io::Error,
142    },
143
144    /// Invalid synchronization configuration: please specify video and subtitle files, or use -i parameter for batch processing.
145    #[error(
146        "Invalid sync configuration: please specify video and subtitle files, or use -i parameter for batch processing"
147    )]
148    InvalidSyncConfiguration,
149
150    /// Unsupported file type encountered.
151    #[error("Unsupported file type: {0}")]
152    UnsupportedFileType(String),
153
154    /// The active output mode (e.g. `--output json`) is incompatible
155    /// with the requested subcommand.
156    ///
157    /// Currently emitted by `generate-completion`, whose stdout is by
158    /// design a shell-completion script and cannot be wrapped in the
159    /// JSON envelope contract.
160    ///
161    /// Only the `subx-cli` binary ever constructs this variant — no code
162    /// under `src/core/` or `src/services/` produces it. It nevertheless
163    /// stays in the core enum so `category()` and `machine_code()` keep
164    /// their wildcard-free exhaustive matches (a new variant must be
165    /// mapped, not absorbed by a catch-all). The deliberate asymmetry
166    /// below — `category()` is the generic `"command_execution"` while
167    /// `machine_code()` is the more specific
168    /// `"E_OUTPUT_MODE_UNSUPPORTED"` — is spec-locked.
169    #[error(
170        "The '{command}' command does not support --output json; its stdout is a shell-completion script"
171    )]
172    OutputModeUnsupported {
173        /// The subcommand that rejected the output mode (e.g. `"generate-completion"`).
174        command: String,
175    },
176
177    /// Catch-all error variant wrapping any other failure.
178    #[error("Unknown error: {0}")]
179    Other(#[from] anyhow::Error),
180}
181
182// Unit test: SubXError error types and helper methods
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::io;
187    use std::path::PathBuf;
188
189    // ── Display messages ──────────────────────────────────────────────────────
190
191    #[test]
192    fn test_io_error_display() {
193        let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
194        assert!(err.to_string().starts_with("I/O error:"));
195        assert!(err.to_string().contains("denied"));
196    }
197
198    #[test]
199    fn test_ai_service_display() {
200        let err = SubXError::AiService("timeout".to_string());
201        assert_eq!(err.to_string(), "AI service error: timeout");
202    }
203
204    #[test]
205    fn test_api_display() {
206        let err = SubXError::Api {
207            message: "bad request".to_string(),
208            source: ApiErrorSource::OpenAI,
209        };
210        let s = err.to_string();
211        assert!(s.contains("API error"));
212        assert!(s.contains("bad request"));
213        assert!(s.contains("OpenAI"));
214    }
215
216    #[test]
217    fn test_file_already_exists_display() {
218        let err = SubXError::FileAlreadyExists("foo.srt".to_string());
219        assert_eq!(err.to_string(), "File already exists: foo.srt");
220    }
221
222    #[test]
223    fn test_file_not_found_display() {
224        let err = SubXError::FileNotFound("bar.srt".to_string());
225        assert_eq!(err.to_string(), "File not found: bar.srt");
226    }
227
228    #[test]
229    fn test_invalid_file_name_display() {
230        let err = SubXError::InvalidFileName("bad?name".to_string());
231        assert_eq!(err.to_string(), "Invalid file name: bad?name");
232    }
233
234    #[test]
235    fn test_file_operation_failed_display() {
236        let err = SubXError::FileOperationFailed("rename failed".to_string());
237        assert_eq!(err.to_string(), "File operation failed: rename failed");
238    }
239
240    #[test]
241    fn test_command_execution_display() {
242        let err = SubXError::CommandExecution("exit 1".to_string());
243        assert_eq!(err.to_string(), "exit 1");
244    }
245
246    #[test]
247    fn test_no_input_specified_display() {
248        let err = SubXError::NoInputSpecified;
249        assert_eq!(err.to_string(), "No input path specified");
250    }
251
252    #[test]
253    fn test_invalid_path_display() {
254        let err = SubXError::InvalidPath(PathBuf::from("/bad/path"));
255        assert!(err.to_string().contains("Invalid path:"));
256        assert!(err.to_string().contains("/bad/path"));
257    }
258
259    #[test]
260    fn test_path_not_found_display() {
261        let err = SubXError::PathNotFound(PathBuf::from("/missing"));
262        assert!(err.to_string().contains("Path not found:"));
263    }
264
265    #[test]
266    fn test_directory_read_error_display() {
267        let err = SubXError::DirectoryReadError {
268            path: PathBuf::from("/locked"),
269            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
270        };
271        assert!(err.to_string().contains("Unable to read directory:"));
272        assert!(err.to_string().contains("/locked"));
273    }
274
275    #[test]
276    fn test_invalid_sync_configuration_display() {
277        let err = SubXError::InvalidSyncConfiguration;
278        assert!(err.to_string().contains("Invalid sync configuration"));
279    }
280
281    #[test]
282    fn test_unsupported_file_type_display() {
283        let err = SubXError::UnsupportedFileType("xyz".to_string());
284        assert_eq!(err.to_string(), "Unsupported file type: xyz");
285    }
286
287    #[test]
288    fn test_other_error_display() {
289        let err = SubXError::Other(anyhow::anyhow!("wrapped error"));
290        assert!(err.to_string().contains("Unknown error:"));
291        assert!(err.to_string().contains("wrapped error"));
292    }
293
294    // ── ApiErrorSource ────────────────────────────────────────────────────────
295
296    #[test]
297    fn test_api_error_source_display() {
298        assert_eq!(ApiErrorSource::OpenAI.to_string(), "OpenAI");
299        assert_eq!(ApiErrorSource::Whisper.to_string(), "Whisper");
300    }
301
302    // ── exit_code mapping ─────────────────────────────────────────────────────
303
304    #[test]
305    fn test_config_error_creation() {
306        let error = SubXError::config("test config error");
307        assert!(matches!(error, SubXError::Config { .. }));
308        assert_eq!(error.to_string(), "Configuration error: test config error");
309    }
310
311    #[test]
312    fn test_subtitle_format_error_creation() {
313        let error = SubXError::subtitle_format("SRT", "invalid format");
314        assert!(matches!(error, SubXError::SubtitleFormat { .. }));
315        let msg = error.to_string();
316        assert!(msg.contains("SRT"));
317        assert!(msg.contains("invalid format"));
318    }
319
320    #[test]
321    fn test_audio_processing_error_creation() {
322        let error = SubXError::audio_processing("decode failed");
323        assert!(matches!(error, SubXError::AudioProcessing { .. }));
324        assert_eq!(error.to_string(), "Audio processing error: decode failed");
325    }
326
327    #[test]
328    fn test_file_matching_error_creation() {
329        let error = SubXError::file_matching("match failed");
330        assert!(matches!(error, SubXError::FileMatching { .. }));
331        assert_eq!(error.to_string(), "File matching error: match failed");
332    }
333
334    #[test]
335    fn test_io_error_conversion() {
336        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
337        let subx_error: SubXError = io_error.into();
338        assert!(matches!(subx_error, SubXError::Io(_)));
339    }
340
341    // ── Helper constructor methods ────────────────────────────────────────────
342
343    #[test]
344    fn test_ai_service_helper() {
345        let err = SubXError::ai_service("network failure");
346        assert!(matches!(err, SubXError::AiService(_)));
347        assert_eq!(err.to_string(), "AI service error: network failure");
348    }
349
350    #[test]
351    fn test_parallel_processing_helper() {
352        let err = SubXError::parallel_processing("channel closed".to_string());
353        assert!(matches!(err, SubXError::CommandExecution(_)));
354        assert!(err.to_string().contains("Parallel processing error:"));
355        assert!(err.to_string().contains("channel closed"));
356    }
357
358    #[test]
359    fn test_task_execution_failed_helper() {
360        let err = SubXError::task_execution_failed("task-42".to_string(), "panic".to_string());
361        assert!(matches!(err, SubXError::CommandExecution(_)));
362        assert!(err.to_string().contains("task-42"));
363        assert!(err.to_string().contains("panic"));
364    }
365
366    #[test]
367    fn test_worker_pool_exhausted_helper() {
368        let err = SubXError::worker_pool_exhausted();
369        assert!(matches!(err, SubXError::CommandExecution(_)));
370        assert_eq!(err.to_string(), "Worker pool exhausted");
371    }
372
373    #[test]
374    fn test_task_timeout_helper() {
375        let dur = std::time::Duration::from_secs(30);
376        let err = SubXError::task_timeout("task-7".to_string(), dur);
377        assert!(matches!(err, SubXError::CommandExecution(_)));
378        assert!(err.to_string().contains("task-7"));
379        assert!(err.to_string().contains("timed out"));
380    }
381
382    #[test]
383    fn test_dialogue_detection_failed_helper() {
384        let err = SubXError::dialogue_detection_failed("no speech found");
385        assert!(matches!(err, SubXError::AudioProcessing { .. }));
386        assert!(err.to_string().contains("Dialogue detection failed:"));
387        assert!(err.to_string().contains("no speech found"));
388    }
389
390    #[test]
391    fn test_invalid_audio_format_helper() {
392        let err = SubXError::invalid_audio_format("flac");
393        assert!(matches!(err, SubXError::AudioProcessing { .. }));
394        assert!(err.to_string().contains("Unsupported audio format:"));
395        assert!(err.to_string().contains("flac"));
396    }
397
398    #[test]
399    fn test_dialogue_segment_invalid_helper() {
400        let err = SubXError::dialogue_segment_invalid("negative duration");
401        assert!(matches!(err, SubXError::AudioProcessing { .. }));
402        assert!(err.to_string().contains("Invalid dialogue segment:"));
403        assert!(err.to_string().contains("negative duration"));
404    }
405
406    #[test]
407    fn test_whisper_api_helper() {
408        let err = SubXError::whisper_api("rate limited");
409        assert!(matches!(err, SubXError::Api { .. }));
410        let s = err.to_string();
411        assert!(s.contains("Whisper"));
412        assert!(s.contains("rate limited"));
413    }
414
415    #[test]
416    fn test_audio_extraction_helper() {
417        let err = SubXError::audio_extraction("ffmpeg missing");
418        assert!(matches!(err, SubXError::AudioProcessing { .. }));
419        assert!(err.to_string().contains("ffmpeg missing"));
420    }
421
422    // ── From conversions ─────────────────────────────────────────────────────
423
424    #[test]
425    fn test_from_anyhow_error() {
426        let anyhow_err = anyhow::anyhow!("some anyhow error");
427        let err: SubXError = anyhow_err.into();
428        assert!(matches!(err, SubXError::Other(_)));
429        assert!(err.to_string().contains("some anyhow error"));
430    }
431
432    #[test]
433    fn test_from_serde_json_error() {
434        let json_err: serde_json::Error =
435            serde_json::from_str::<serde_json::Value>("not json {{{").unwrap_err();
436        let err: SubXError = json_err.into();
437        assert!(matches!(err, SubXError::Config { .. }));
438        assert!(
439            err.to_string()
440                .contains("JSON serialization/deserialization error:")
441        );
442    }
443
444    #[test]
445    fn test_from_config_error_not_found() {
446        let config_err = config::ConfigError::NotFound("settings.toml".to_string());
447        let err: SubXError = config_err.into();
448        assert!(matches!(err, SubXError::Config { .. }));
449        assert!(err.to_string().contains("Configuration file not found:"));
450        assert!(err.to_string().contains("settings.toml"));
451    }
452
453    #[test]
454    fn test_from_config_error_message() {
455        let config_err = config::ConfigError::Message("bad value".to_string());
456        let err: SubXError = config_err.into();
457        assert!(matches!(err, SubXError::Config { .. }));
458        assert!(err.to_string().contains("bad value"));
459    }
460
461    #[test]
462    fn test_from_config_error_other() {
463        // Use a variant that falls through to the catch-all arm.
464        let config_err = config::ConfigError::Foreign(Box::new(io::Error::new(
465            io::ErrorKind::Other,
466            "foreign cfg error",
467        )));
468        let err: SubXError = config_err.into();
469        assert!(matches!(err, SubXError::Config { .. }));
470        assert!(err.to_string().contains("Configuration error:"));
471    }
472
473    #[test]
474    fn test_from_box_dyn_error() {
475        let boxed: Box<dyn std::error::Error> =
476            Box::new(io::Error::new(io::ErrorKind::Other, "boxed error"));
477        let err: SubXError = boxed.into();
478        assert!(matches!(err, SubXError::AudioProcessing { .. }));
479        assert!(err.to_string().contains("Audio processing error:"));
480        assert!(err.to_string().contains("boxed error"));
481    }
482
483    #[test]
484    fn test_from_walkdir_error() {
485        // Walk a non-existent path to generate a walkdir::Error.
486        let walk_err = walkdir::WalkDir::new("/nonexistent_subx_test_path_xyz")
487            .into_iter()
488            .filter_map(|e| e.err())
489            .next();
490        if let Some(we) = walk_err {
491            let err: SubXError = we.into();
492            assert!(matches!(err, SubXError::FileMatching { .. }));
493        }
494        // If no error was produced (unlikely), the From impl was never
495        // reached, but the test still passes: we cannot force the error.
496    }
497
498    #[test]
499    fn test_from_symphonia_error() {
500        use symphonia::core::errors::Error as SymphoniaError;
501        let sym_err = SymphoniaError::DecodeError("bad frame");
502        let err: SubXError = sym_err.into();
503        assert!(matches!(err, SubXError::AudioProcessing { .. }));
504        assert!(err.to_string().contains("Audio processing error:"));
505    }
506
507    // ── SubXResult type alias ─────────────────────────────────────────────────
508
509    #[test]
510    fn test_subx_result_ok() {
511        let result: SubXResult<i32> = Ok(42);
512        assert_eq!(result.unwrap(), 42);
513    }
514
515    #[test]
516    fn test_subx_result_err() {
517        let result: SubXResult<i32> = Err(SubXError::NoInputSpecified);
518        assert!(result.is_err());
519    }
520
521    /// Audit (core half): enumerates every `SubXError` variant and asserts
522    /// that a representative instance — built from non-sensitive dummy data —
523    /// never surfaces an OpenAI-style API key prefix (`sk-`) through
524    /// `Display` or `Debug`. If you add a new variant, extend this list so
525    /// the audit remains exhaustive.
526    ///
527    /// The `user_friendly_message` surface of the same audit lives in the
528    /// `subx-cli` repository's `src/cli/error_ext.rs`
529    /// (`test_no_api_key_leaks_in_any_variant`), beside the exit-code and
530    /// friendly-message assertions that are binary presentation and cannot
531    /// live here. Keep the two variant lists in step — updating one without
532    /// the other half-defeats the `secrets-protection` variant audit.
533    #[test]
534    fn test_no_api_key_leaks_in_any_variant() {
535        use std::path::PathBuf;
536
537        let variants: Vec<SubXError> = vec![
538            SubXError::Io(io::Error::other("disk error")),
539            SubXError::Config {
540                message: "missing key".to_string(),
541            },
542            SubXError::SubtitleFormat {
543                format: "SRT".to_string(),
544                message: "bad timestamp".to_string(),
545            },
546            SubXError::AiService("upstream service failed".to_string()),
547            SubXError::Api {
548                message: "auth failed".to_string(),
549                source: ApiErrorSource::OpenAI,
550            },
551            SubXError::AudioProcessing {
552                message: "codec failure".to_string(),
553            },
554            SubXError::FileMatching {
555                message: "pattern mismatch".to_string(),
556            },
557            SubXError::FileAlreadyExists("/tmp/example".to_string()),
558            SubXError::FileNotFound("/tmp/example".to_string()),
559            SubXError::InvalidFileName("bad?name".to_string()),
560            SubXError::FileOperationFailed("rename failed".to_string()),
561            SubXError::CommandExecution("exit 1".to_string()),
562            SubXError::NoInputSpecified,
563            SubXError::InvalidPath(PathBuf::from("/tmp/example")),
564            SubXError::PathNotFound(PathBuf::from("/tmp/example")),
565            SubXError::DirectoryReadError {
566                path: PathBuf::from("/tmp/example"),
567                source: io::Error::other("denied"),
568            },
569            SubXError::InvalidSyncConfiguration,
570            SubXError::UnsupportedFileType("xyz".to_string()),
571            SubXError::OutputModeUnsupported {
572                command: "generate-completion".to_string(),
573            },
574            SubXError::Other(anyhow::anyhow!("wrapped")),
575        ];
576
577        for err in &variants {
578            let display = format!("{err}");
579            let debug = format!("{err:?}");
580            for (label, text) in [("Display", &display), ("Debug", &debug)] {
581                assert!(
582                    !text.contains("sk-"),
583                    "{} surface for variant {err:?} contains `sk-` prefix: {text}",
584                    label,
585                );
586            }
587        }
588    }
589}
590
591// Convert reqwest error to AI service error
592impl From<reqwest::Error> for SubXError {
593    fn from(err: reqwest::Error) -> Self {
594        let raw = err.to_string();
595        // Strip query strings from any embedded URLs, since reqwest's Display
596        // implementation includes the full request URL which may carry
597        // sensitive credentials (e.g. `?api-key=...`).
598        let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
599        SubXError::AiService(sanitized)
600    }
601}
602
603// Convert file exploration error to file matching error
604impl From<walkdir::Error> for SubXError {
605    fn from(err: walkdir::Error) -> Self {
606        SubXError::FileMatching {
607            message: err.to_string(),
608        }
609    }
610}
611// Convert symphonia error to audio processing error
612impl From<symphonia::core::errors::Error> for SubXError {
613    fn from(err: symphonia::core::errors::Error) -> Self {
614        SubXError::audio_processing(err.to_string())
615    }
616}
617
618// Convert config crate error to configuration error
619impl From<config::ConfigError> for SubXError {
620    fn from(err: config::ConfigError) -> Self {
621        match err {
622            config::ConfigError::NotFound(path) => SubXError::Config {
623                message: format!("Configuration file not found: {}", path),
624            },
625            config::ConfigError::Message(msg) => SubXError::Config { message: msg },
626            _ => SubXError::Config {
627                message: format!("Configuration error: {}", err),
628            },
629        }
630    }
631}
632
633impl From<serde_json::Error> for SubXError {
634    fn from(err: serde_json::Error) -> Self {
635        SubXError::Config {
636            message: format!("JSON serialization/deserialization error: {}", err),
637        }
638    }
639}
640
641/// Specialized `Result` type for SubX operations.
642pub type SubXResult<T> = Result<T, SubXError>;
643
644impl SubXError {
645    /// Create a configuration error with the given message.
646    ///
647    /// # Examples
648    ///
649    /// ```rust
650    /// # use subx_core::error::SubXError;
651    /// let err = SubXError::config("invalid setting");
652    /// assert_eq!(err.to_string(), "Configuration error: invalid setting");
653    /// ```
654    pub fn config<S: Into<String>>(message: S) -> Self {
655        SubXError::Config {
656            message: message.into(),
657        }
658    }
659
660    /// Create a subtitle format error for the given format and message.
661    ///
662    /// # Examples
663    ///
664    /// ```rust
665    /// # use subx_core::error::SubXError;
666    /// let err = SubXError::subtitle_format("SRT", "invalid timestamp");
667    /// assert!(err.to_string().contains("SRT"));
668    /// ```
669    pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
670    where
671        S1: Into<String>,
672        S2: Into<String>,
673    {
674        SubXError::SubtitleFormat {
675            format: format.into(),
676            message: message.into(),
677        }
678    }
679
680    /// Create an audio processing error with the given message.
681    ///
682    /// # Examples
683    ///
684    /// ```rust
685    /// # use subx_core::error::SubXError;
686    /// let err = SubXError::audio_processing("decode failed");
687    /// assert_eq!(err.to_string(), "Audio processing error: decode failed");
688    /// ```
689    pub fn audio_processing<S: Into<String>>(message: S) -> Self {
690        SubXError::AudioProcessing {
691            message: message.into(),
692        }
693    }
694
695    /// Create an AI service error with the given message.
696    ///
697    /// # Examples
698    ///
699    /// ```rust
700    /// # use subx_core::error::SubXError;
701    /// let err = SubXError::ai_service("network failure");
702    /// assert_eq!(err.to_string(), "AI service error: network failure");
703    /// ```
704    pub fn ai_service<S: Into<String>>(message: S) -> Self {
705        SubXError::AiService(message.into())
706    }
707
708    /// Create a file matching error with the given message.
709    ///
710    /// # Examples
711    ///
712    /// ```rust
713    /// # use subx_core::error::SubXError;
714    /// let err = SubXError::file_matching("not found");
715    /// assert_eq!(err.to_string(), "File matching error: not found");
716    /// ```
717    pub fn file_matching<S: Into<String>>(message: S) -> Self {
718        SubXError::FileMatching {
719            message: message.into(),
720        }
721    }
722    /// Create a parallel processing error with the given message.
723    pub fn parallel_processing(msg: String) -> Self {
724        SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
725    }
726    /// Create a task execution failure error with task ID and reason.
727    pub fn task_execution_failed(task_id: String, reason: String) -> Self {
728        SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
729    }
730    /// Create a worker pool exhausted error.
731    pub fn worker_pool_exhausted() -> Self {
732        SubXError::CommandExecution("Worker pool exhausted".to_string())
733    }
734    /// Create a task timeout error with task ID and duration.
735    pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
736        SubXError::CommandExecution(format!(
737            "Task {} timed out (limit: {:?})",
738            task_id, duration
739        ))
740    }
741    /// Create a dialogue detection failure error with the given message.
742    pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
743        SubXError::AudioProcessing {
744            message: format!("Dialogue detection failed: {}", msg.into()),
745        }
746    }
747    /// Create an invalid audio format error for the given format.
748    pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
749        SubXError::AudioProcessing {
750            message: format!("Unsupported audio format: {}", format.into()),
751        }
752    }
753    /// Create an invalid dialogue segment error with the given reason.
754    pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
755        SubXError::AudioProcessing {
756            message: format!("Invalid dialogue segment: {}", reason.into()),
757        }
758    }
759    /// Stable snake_case machine-readable category for the JSON error
760    /// envelope. The mapping is closed and exhaustive (no wildcard arm)
761    /// so the compiler enforces updates whenever a new variant is added.
762    ///
763    /// This mapping is locked by the `error-handling` capability spec.
764    pub fn category(&self) -> &'static str {
765        match self {
766            // Mapped 1:1 from the closed set defined in the spec.
767            SubXError::Io(_) => "io",
768            SubXError::Config { .. } => "config",
769            SubXError::SubtitleFormat { .. } => "subtitle_format",
770            SubXError::AiService(_) => "ai_service",
771            SubXError::Api { .. } => "api",
772            SubXError::AudioProcessing { .. } => "audio_processing",
773            SubXError::FileMatching { .. } => "file_matching",
774            SubXError::FileAlreadyExists(_) => "file_already_exists",
775            SubXError::FileNotFound(_) => "file_not_found",
776            SubXError::InvalidFileName(_) => "invalid_file_name",
777            SubXError::FileOperationFailed(_) => "file_operation_failed",
778            SubXError::CommandExecution(_) => "command_execution",
779            // Spec locks `category == "command_execution"` for this variant
780            // even though the machine_code is the more specific
781            // `E_OUTPUT_MODE_UNSUPPORTED`.
782            SubXError::OutputModeUnsupported { .. } => "command_execution",
783            SubXError::NoInputSpecified => "no_input_specified",
784            SubXError::InvalidPath(_) => "invalid_path",
785            SubXError::PathNotFound(_) => "path_not_found",
786            SubXError::DirectoryReadError { .. } => "directory_read_error",
787            SubXError::InvalidSyncConfiguration => "invalid_sync_configuration",
788            SubXError::UnsupportedFileType(_) => "unsupported_file_type",
789            SubXError::Other(_) => "other",
790        }
791    }
792
793    /// Stable upper-snake-case machine code prefixed with `E_`.
794    /// Mirrors [`Self::category`] one-to-one and is similarly closed
795    /// against the addition of new variants.
796    pub fn machine_code(&self) -> &'static str {
797        match self {
798            SubXError::Io(_) => "E_IO",
799            SubXError::Config { .. } => "E_CONFIG",
800            SubXError::SubtitleFormat { .. } => "E_SUBTITLE_FORMAT",
801            SubXError::AiService(_) => "E_AI_SERVICE",
802            SubXError::Api { .. } => "E_API",
803            SubXError::AudioProcessing { .. } => "E_AUDIO_PROCESSING",
804            SubXError::FileMatching { .. } => "E_FILE_MATCHING",
805            SubXError::FileAlreadyExists(_) => "E_FILE_ALREADY_EXISTS",
806            SubXError::FileNotFound(_) => "E_FILE_NOT_FOUND",
807            SubXError::InvalidFileName(_) => "E_INVALID_FILE_NAME",
808            SubXError::FileOperationFailed(_) => "E_FILE_OPERATION_FAILED",
809            SubXError::CommandExecution(_) => "E_COMMAND_EXECUTION",
810            SubXError::OutputModeUnsupported { .. } => "E_OUTPUT_MODE_UNSUPPORTED",
811            SubXError::NoInputSpecified => "E_NO_INPUT_SPECIFIED",
812            SubXError::InvalidPath(_) => "E_INVALID_PATH",
813            SubXError::PathNotFound(_) => "E_PATH_NOT_FOUND",
814            SubXError::DirectoryReadError { .. } => "E_DIRECTORY_READ_ERROR",
815            SubXError::InvalidSyncConfiguration => "E_INVALID_SYNC_CONFIGURATION",
816            SubXError::UnsupportedFileType(_) => "E_UNSUPPORTED_FILE_TYPE",
817            SubXError::Other(_) => "E_OTHER",
818        }
819    }
820
821    /// Short user-facing remediation hint, or `None` when none applies.
822    ///
823    /// This is a separate, structured surface from the prose hints
824    /// already baked into the binary's `SubXErrorExt::user_friendly_message`
825    /// (`src/cli/error_ext.rs`); JSON callers receive it under
826    /// `error.hint`.
827    ///
828    /// The returned text is written for the `subx-cli` terminal and names
829    /// its binary and flags. Library consumers SHALL treat the return
830    /// value as an *availability* signal — branch on `Some`/`None` and
831    /// render their own localized copy — rather than as display copy.
832    /// Rewriting the prose is not a breaking change; changing *which*
833    /// variants return `Some` is: that set is the stable part of the
834    /// contract.
835    pub fn hint(&self) -> Option<&'static str> {
836        match self {
837            SubXError::Config { .. } => {
838                Some("Run 'subx-cli config --help' for configuration details.")
839            }
840            SubXError::Api { .. } | SubXError::AiService(_) => {
841                Some("Check network connectivity and the configured API key.")
842            }
843            SubXError::SubtitleFormat { .. } => {
844                Some("Check the subtitle file's format and encoding.")
845            }
846            SubXError::AudioProcessing { .. } => {
847                Some("Verify the media file's integrity and supported codecs.")
848            }
849            SubXError::FileMatching { .. } => Some("Verify file paths and patterns."),
850            SubXError::NoInputSpecified => Some("Pass an input path or use the -i/--input flag."),
851            SubXError::InvalidSyncConfiguration => {
852                Some("Specify both video and subtitle files, or use -i for batch processing.")
853            }
854            SubXError::PathNotFound(_) | SubXError::FileNotFound(_) => {
855                Some("Verify the path exists and is accessible.")
856            }
857            SubXError::OutputModeUnsupported { .. } => Some(
858                "Run the command without `--output json` (and without SUBX_OUTPUT=json) to receive the shell-completion script.",
859            ),
860            _ => None,
861        }
862    }
863}
864
865/// Helper functions for Whisper API and audio processing related errors.
866impl SubXError {
867    /// Create a Whisper API error.
868    ///
869    /// # Arguments
870    ///
871    /// * `message` - The error message describing the Whisper API failure
872    ///
873    /// # Returns
874    ///
875    /// A new `SubXError::Api` variant with Whisper as the source
876    pub fn whisper_api<T: Into<String>>(message: T) -> Self {
877        Self::Api {
878            message: message.into(),
879            source: ApiErrorSource::Whisper,
880        }
881    }
882
883    /// Create an audio extraction/transcoding error.
884    ///
885    /// # Arguments
886    ///
887    /// * `message` - The error message describing the audio processing failure
888    ///
889    /// # Returns
890    ///
891    /// A new `SubXError::AudioProcessing` variant
892    pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
893        Self::AudioProcessing {
894            message: message.into(),
895        }
896    }
897}
898
899/// API error source enumeration.
900///
901/// Specifies the source of API-related errors to help with error diagnosis
902/// and handling.
903#[derive(Debug, thiserror::Error)]
904pub enum ApiErrorSource {
905    /// OpenAI Whisper API
906    #[error("OpenAI")]
907    OpenAI,
908    /// Whisper API
909    #[error("Whisper")]
910    Whisper,
911}
912
913// Support conversion from Box<dyn Error> to SubXError::AudioProcessing
914impl From<Box<dyn std::error::Error>> for SubXError {
915    fn from(err: Box<dyn std::error::Error>) -> Self {
916        SubXError::audio_processing(err.to_string())
917    }
918}