subx_cli/
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_cli::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 an exit code via `SubXError::exit_code`.
32#[derive(Error, Debug)]
33pub enum SubXError {
34    /// I/O operation failed during file system access.
35    ///
36    /// This variant wraps `std::io::Error` and provides context about
37    /// file operations that failed.
38    ///
39    /// # Common Causes
40    /// - Permission issues
41    /// - Insufficient disk space
42    /// - Network filesystem errors
43    #[error("I/O error: {0}")]
44    Io(#[from] std::io::Error),
45
46    /// Configuration error due to invalid or missing settings.
47    ///
48    /// Contains a human-readable message describing the issue.
49    #[error("Configuration error: {message}")]
50    Config {
51        /// Description of the configuration error
52        message: String,
53    },
54
55    /// Subtitle format error indicating invalid timestamps or structure.
56    ///
57    /// Provides the subtitle format and detailed message.
58    #[error("Subtitle format error [{format}]: {message}")]
59    SubtitleFormat {
60        /// The subtitle format that caused the error (e.g., "SRT", "ASS")
61        format: String,
62        /// Detailed error message describing the issue
63        message: String,
64    },
65
66    /// AI service encountered an error.
67    ///
68    /// Captures the raw error message from the AI provider.
69    #[error("AI service error: {0}")]
70    AiService(String),
71
72    /// API request error with specified source.
73    ///
74    /// Represents errors that occur during API requests, providing both
75    /// the error message and the source of the API error.
76    #[error("API error [{source:?}]: {message}")]
77    Api {
78        /// Error message from the API
79        message: String,
80        /// Source of the API error
81        source: ApiErrorSource,
82    },
83
84    /// Audio processing error during analysis or format conversion.
85    ///
86    /// Provides a message describing the audio processing failure.
87    #[error("Audio processing error: {message}")]
88    AudioProcessing {
89        /// Description of the audio processing error
90        message: String,
91    },
92
93    /// Error during file matching or discovery.
94    ///
95    /// Contains details about path resolution or pattern matching failures.
96    #[error("File matching error: {message}")]
97    FileMatching {
98        /// Description of the file matching error
99        message: String,
100    },
101    /// Indicates that a file operation failed because the target exists.
102    #[error("File already exists: {0}")]
103    FileAlreadyExists(String),
104    /// Indicates that the specified file was not found.
105    #[error("File not found: {0}")]
106    FileNotFound(String),
107    /// Invalid file name encountered.
108    #[error("Invalid file name: {0}")]
109    InvalidFileName(String),
110    /// Generic file operation failure with message.
111    #[error("File operation failed: {0}")]
112    FileOperationFailed(String),
113    /// Generic command execution error.
114    #[error("{0}")]
115    CommandExecution(String),
116
117    /// No input path was specified for the operation.
118    #[error("No input path specified")]
119    NoInputSpecified,
120
121    /// The provided path is invalid or malformed.
122    #[error("Invalid path: {0}")]
123    InvalidPath(std::path::PathBuf),
124
125    /// The specified path does not exist on the filesystem.
126    #[error("Path not found: {0}")]
127    PathNotFound(std::path::PathBuf),
128
129    /// Unable to read the specified directory.
130    #[error("Unable to read directory: {path}")]
131    DirectoryReadError {
132        /// The directory path that could not be read
133        path: std::path::PathBuf,
134        /// The underlying I/O error
135        #[source]
136        source: std::io::Error,
137    },
138
139    /// Invalid synchronization configuration: please specify video and subtitle files, or use -i parameter for batch processing.
140    #[error(
141        "Invalid sync configuration: please specify video and subtitle files, or use -i parameter for batch processing"
142    )]
143    InvalidSyncConfiguration,
144
145    /// Unsupported file type encountered.
146    #[error("Unsupported file type: {0}")]
147    UnsupportedFileType(String),
148
149    /// Catch-all error variant wrapping any other failure.
150    #[error("Unknown error: {0}")]
151    Other(#[from] anyhow::Error),
152}
153
154// Unit test: SubXError error types and helper methods
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::io;
159
160    #[test]
161    fn test_config_error_creation() {
162        let error = SubXError::config("test config error");
163        assert!(matches!(error, SubXError::Config { .. }));
164        assert_eq!(error.to_string(), "Configuration error: test config error");
165    }
166
167    #[test]
168    fn test_subtitle_format_error_creation() {
169        let error = SubXError::subtitle_format("SRT", "invalid format");
170        assert!(matches!(error, SubXError::SubtitleFormat { .. }));
171        let msg = error.to_string();
172        assert!(msg.contains("SRT"));
173        assert!(msg.contains("invalid format"));
174    }
175
176    #[test]
177    fn test_audio_processing_error_creation() {
178        let error = SubXError::audio_processing("decode failed");
179        assert!(matches!(error, SubXError::AudioProcessing { .. }));
180        assert_eq!(error.to_string(), "Audio processing error: decode failed");
181    }
182
183    #[test]
184    fn test_file_matching_error_creation() {
185        let error = SubXError::file_matching("match failed");
186        assert!(matches!(error, SubXError::FileMatching { .. }));
187        assert_eq!(error.to_string(), "File matching error: match failed");
188    }
189
190    #[test]
191    fn test_io_error_conversion() {
192        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
193        let subx_error: SubXError = io_error.into();
194        assert!(matches!(subx_error, SubXError::Io(_)));
195    }
196
197    #[test]
198    fn test_exit_codes() {
199        assert_eq!(SubXError::config("test").exit_code(), 2);
200        assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
201        assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
202        assert_eq!(SubXError::file_matching("test").exit_code(), 6);
203    }
204
205    #[test]
206    fn test_user_friendly_messages() {
207        let config_error = SubXError::config("missing key");
208        let message = config_error.user_friendly_message();
209        assert!(message.contains("Configuration error:"));
210        assert!(message.contains("subx-cli config --help"));
211
212        let ai_error = SubXError::ai_service("network failure".to_string());
213        let message = ai_error.user_friendly_message();
214        assert!(message.contains("AI service error:"));
215        assert!(message.contains("check network connection"));
216    }
217}
218
219// Convert reqwest error to AI service error
220impl From<reqwest::Error> for SubXError {
221    fn from(err: reqwest::Error) -> Self {
222        SubXError::AiService(err.to_string())
223    }
224}
225
226// Convert file exploration error to file matching error
227impl From<walkdir::Error> for SubXError {
228    fn from(err: walkdir::Error) -> Self {
229        SubXError::FileMatching {
230            message: err.to_string(),
231        }
232    }
233}
234// Convert symphonia error to audio processing error
235impl From<symphonia::core::errors::Error> for SubXError {
236    fn from(err: symphonia::core::errors::Error) -> Self {
237        SubXError::audio_processing(err.to_string())
238    }
239}
240
241// Convert config crate error to configuration error
242impl From<config::ConfigError> for SubXError {
243    fn from(err: config::ConfigError) -> Self {
244        match err {
245            config::ConfigError::NotFound(path) => SubXError::Config {
246                message: format!("Configuration file not found: {}", path),
247            },
248            config::ConfigError::Message(msg) => SubXError::Config { message: msg },
249            _ => SubXError::Config {
250                message: format!("Configuration error: {}", err),
251            },
252        }
253    }
254}
255
256impl From<serde_json::Error> for SubXError {
257    fn from(err: serde_json::Error) -> Self {
258        SubXError::Config {
259            message: format!("JSON serialization/deserialization error: {}", err),
260        }
261    }
262}
263
264/// Specialized `Result` type for SubX operations.
265pub type SubXResult<T> = Result<T, SubXError>;
266
267impl SubXError {
268    /// Create a configuration error with the given message.
269    ///
270    /// # Examples
271    ///
272    /// ```rust
273    /// # use subx_cli::error::SubXError;
274    /// let err = SubXError::config("invalid setting");
275    /// assert_eq!(err.to_string(), "Configuration error: invalid setting");
276    /// ```
277    pub fn config<S: Into<String>>(message: S) -> Self {
278        SubXError::Config {
279            message: message.into(),
280        }
281    }
282
283    /// Create a subtitle format error for the given format and message.
284    ///
285    /// # Examples
286    ///
287    /// ```rust
288    /// # use subx_cli::error::SubXError;
289    /// let err = SubXError::subtitle_format("SRT", "invalid timestamp");
290    /// assert!(err.to_string().contains("SRT"));
291    /// ```
292    pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
293    where
294        S1: Into<String>,
295        S2: Into<String>,
296    {
297        SubXError::SubtitleFormat {
298            format: format.into(),
299            message: message.into(),
300        }
301    }
302
303    /// Create an audio processing error with the given message.
304    ///
305    /// # Examples
306    ///
307    /// ```rust
308    /// # use subx_cli::error::SubXError;
309    /// let err = SubXError::audio_processing("decode failed");
310    /// assert_eq!(err.to_string(), "Audio processing error: decode failed");
311    /// ```
312    pub fn audio_processing<S: Into<String>>(message: S) -> Self {
313        SubXError::AudioProcessing {
314            message: message.into(),
315        }
316    }
317
318    /// Create an AI service error with the given message.
319    ///
320    /// # Examples
321    ///
322    /// ```rust
323    /// # use subx_cli::error::SubXError;
324    /// let err = SubXError::ai_service("network failure");
325    /// assert_eq!(err.to_string(), "AI service error: network failure");
326    /// ```
327    pub fn ai_service<S: Into<String>>(message: S) -> Self {
328        SubXError::AiService(message.into())
329    }
330
331    /// Create a file matching error with the given message.
332    ///
333    /// # Examples
334    ///
335    /// ```rust
336    /// # use subx_cli::error::SubXError;
337    /// let err = SubXError::file_matching("not found");
338    /// assert_eq!(err.to_string(), "File matching error: not found");
339    /// ```
340    pub fn file_matching<S: Into<String>>(message: S) -> Self {
341        SubXError::FileMatching {
342            message: message.into(),
343        }
344    }
345    /// Create a parallel processing error with the given message.
346    pub fn parallel_processing(msg: String) -> Self {
347        SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
348    }
349    /// Create a task execution failure error with task ID and reason.
350    pub fn task_execution_failed(task_id: String, reason: String) -> Self {
351        SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
352    }
353    /// Create a worker pool exhausted error.
354    pub fn worker_pool_exhausted() -> Self {
355        SubXError::CommandExecution("Worker pool exhausted".to_string())
356    }
357    /// Create a task timeout error with task ID and duration.
358    pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
359        SubXError::CommandExecution(format!(
360            "Task {} timed out (limit: {:?})",
361            task_id, duration
362        ))
363    }
364    /// Create a dialogue detection failure error with the given message.
365    pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
366        SubXError::AudioProcessing {
367            message: format!("Dialogue detection failed: {}", msg.into()),
368        }
369    }
370    /// Create an invalid audio format error for the given format.
371    pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
372        SubXError::AudioProcessing {
373            message: format!("Unsupported audio format: {}", format.into()),
374        }
375    }
376    /// Create an invalid dialogue segment error with the given reason.
377    pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
378        SubXError::AudioProcessing {
379            message: format!("Invalid dialogue segment: {}", reason.into()),
380        }
381    }
382    /// Return the corresponding exit code for this error variant.
383    ///
384    /// # Examples
385    ///
386    /// ```rust
387    /// # use subx_cli::error::SubXError;
388    /// assert_eq!(SubXError::config("x").exit_code(), 2);
389    /// ```
390    pub fn exit_code(&self) -> i32 {
391        match self {
392            SubXError::Io(_) => 1,
393            SubXError::Config { .. } => 2,
394            SubXError::Api { .. } => 3,
395            SubXError::AiService(_) => 3,
396            SubXError::SubtitleFormat { .. } => 4,
397            SubXError::AudioProcessing { .. } => 5,
398            SubXError::FileMatching { .. } => 6,
399            _ => 1,
400        }
401    }
402
403    /// Return a user-friendly error message with suggested remedies.
404    ///
405    /// # Examples
406    ///
407    /// ```rust
408    /// # use subx_cli::error::SubXError;
409    /// let msg = SubXError::config("missing key").user_friendly_message();
410    /// assert!(msg.contains("Configuration error:"));
411    /// ```
412    pub fn user_friendly_message(&self) -> String {
413        match self {
414            SubXError::Io(e) => format!("File operation error: {}", e),
415            SubXError::Config { message } => format!(
416                "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
417                message
418            ),
419            SubXError::Api { message, source } => format!(
420                "API error ({:?}): {}\nHint: check network connection and API key settings",
421                source, message
422            ),
423            SubXError::AiService(msg) => format!(
424                "AI service error: {}\nHint: check network connection and API key settings",
425                msg
426            ),
427            SubXError::SubtitleFormat { message, .. } => format!(
428                "Subtitle processing error: {}\nHint: check file format and encoding",
429                message
430            ),
431            SubXError::AudioProcessing { message } => format!(
432                "Audio processing error: {}\nHint: ensure media file integrity and support",
433                message
434            ),
435            SubXError::FileMatching { message } => format!(
436                "File matching error: {}\nHint: verify file paths and patterns",
437                message
438            ),
439            SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
440            SubXError::FileNotFound(path) => format!("File not found: {}", path),
441            SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
442            SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
443            SubXError::CommandExecution(msg) => msg.clone(),
444            SubXError::Other(err) => {
445                format!("Unknown error: {}\nHint: please report this issue", err)
446            }
447            _ => format!("Error: {}", self),
448        }
449    }
450}
451
452/// Helper functions for Whisper API and audio processing related errors.
453impl SubXError {
454    /// Create a Whisper API error.
455    ///
456    /// # Arguments
457    ///
458    /// * `message` - The error message describing the Whisper API failure
459    ///
460    /// # Returns
461    ///
462    /// A new `SubXError::Api` variant with Whisper as the source
463    pub fn whisper_api<T: Into<String>>(message: T) -> Self {
464        Self::Api {
465            message: message.into(),
466            source: ApiErrorSource::Whisper,
467        }
468    }
469
470    /// Create an audio extraction/transcoding error.
471    ///
472    /// # Arguments
473    ///
474    /// * `message` - The error message describing the audio processing failure
475    ///
476    /// # Returns
477    ///
478    /// A new `SubXError::AudioProcessing` variant
479    pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
480        Self::AudioProcessing {
481            message: message.into(),
482        }
483    }
484}
485
486/// API error source enumeration.
487///
488/// Specifies the source of API-related errors to help with error diagnosis
489/// and handling.
490#[derive(Debug, thiserror::Error)]
491pub enum ApiErrorSource {
492    /// OpenAI Whisper API
493    #[error("OpenAI")]
494    OpenAI,
495    /// Whisper API
496    #[error("Whisper")]
497    Whisper,
498}
499
500// Support conversion from Box<dyn Error> to SubXError::AudioProcessing
501impl From<Box<dyn std::error::Error>> for SubXError {
502    fn from(err: Box<dyn std::error::Error>) -> Self {
503        SubXError::audio_processing(err.to_string())
504    }
505}