Skip to main content

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    /// Audit: enumerates every `SubXError` variant and asserts that a
219    /// representative instance — built from non-sensitive dummy data —
220    /// never surfaces an OpenAI-style API key prefix (`sk-`) through
221    /// `Display`, `Debug`, or `user_friendly_message()`. If you add a
222    /// new variant, extend this list so the audit remains exhaustive.
223    ///
224    /// Separately, this test also exercises the sanitizing construction
225    /// paths (`From<reqwest::Error>`-style flows via the AI client's
226    /// `error_sanitizer` helpers) to confirm that when input *does*
227    /// contain an `sk-*` secret, it is stripped before wrapping it in
228    /// `SubXError::AiService`.
229    #[test]
230    fn test_no_api_key_leaks_in_any_variant() {
231        use crate::services::ai::error_sanitizer::{
232            DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
233        };
234        use std::path::PathBuf;
235
236        // 1. Canonical variant audit: benign dummy data must never yield
237        //    an `sk-` substring.
238        let variants: Vec<SubXError> = vec![
239            SubXError::Io(io::Error::other("disk error")),
240            SubXError::Config {
241                message: "missing key".to_string(),
242            },
243            SubXError::SubtitleFormat {
244                format: "SRT".to_string(),
245                message: "bad timestamp".to_string(),
246            },
247            SubXError::AiService("upstream service failed".to_string()),
248            SubXError::Api {
249                message: "auth failed".to_string(),
250                source: ApiErrorSource::OpenAI,
251            },
252            SubXError::AudioProcessing {
253                message: "codec failure".to_string(),
254            },
255            SubXError::FileMatching {
256                message: "pattern mismatch".to_string(),
257            },
258            SubXError::FileAlreadyExists("/tmp/example".to_string()),
259            SubXError::FileNotFound("/tmp/example".to_string()),
260            SubXError::InvalidFileName("bad?name".to_string()),
261            SubXError::FileOperationFailed("rename failed".to_string()),
262            SubXError::CommandExecution("exit 1".to_string()),
263            SubXError::NoInputSpecified,
264            SubXError::InvalidPath(PathBuf::from("/tmp/example")),
265            SubXError::PathNotFound(PathBuf::from("/tmp/example")),
266            SubXError::DirectoryReadError {
267                path: PathBuf::from("/tmp/example"),
268                source: io::Error::other("denied"),
269            },
270            SubXError::InvalidSyncConfiguration,
271            SubXError::UnsupportedFileType("xyz".to_string()),
272            SubXError::Other(anyhow::anyhow!("wrapped")),
273        ];
274
275        for err in &variants {
276            let display = format!("{}", err);
277            let debug = format!("{:?}", err);
278            let friendly = err.user_friendly_message();
279            for (label, text) in [
280                ("Display", &display),
281                ("Debug", &debug),
282                ("friendly", &friendly),
283            ] {
284                assert!(
285                    !text.contains("sk-"),
286                    "{} surface for variant {:?} contains `sk-` prefix: {}",
287                    label,
288                    err,
289                    text,
290                );
291            }
292        }
293
294        // 2. Sanitizing construction paths: API keys injected via the
295        //    upstream response body or URL query string must be stripped
296        //    before being embedded into `SubXError::AiService`.
297        const SECRET: &str = "sk-test-key-12345";
298        let upstream_body = format!(
299            "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
300            SECRET
301        );
302        let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
303        // Helper does not itself mask secrets shorter than the limit; this
304        // documents that short bodies pass through unchanged so upstream
305        // callers must continue to keep secrets out of request bodies.
306        assert!(truncated.contains(SECRET));
307
308        let url_leak = format!(
309            "request error: https://api.example.com/v1/chat?api-key={}",
310            SECRET
311        );
312        let cleaned = sanitize_url_in_error(&url_leak);
313        assert!(!cleaned.contains("sk-test-key"));
314        let wrapped = SubXError::AiService(cleaned);
315        assert!(!format!("{}", wrapped).contains("sk-test-key"));
316        assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
317    }
318}
319
320// Convert reqwest error to AI service error
321impl From<reqwest::Error> for SubXError {
322    fn from(err: reqwest::Error) -> Self {
323        let raw = err.to_string();
324        // Strip query strings from any embedded URLs, since reqwest's Display
325        // implementation includes the full request URL which may carry
326        // sensitive credentials (e.g. `?api-key=...`).
327        let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
328        SubXError::AiService(sanitized)
329    }
330}
331
332// Convert file exploration error to file matching error
333impl From<walkdir::Error> for SubXError {
334    fn from(err: walkdir::Error) -> Self {
335        SubXError::FileMatching {
336            message: err.to_string(),
337        }
338    }
339}
340// Convert symphonia error to audio processing error
341impl From<symphonia::core::errors::Error> for SubXError {
342    fn from(err: symphonia::core::errors::Error) -> Self {
343        SubXError::audio_processing(err.to_string())
344    }
345}
346
347// Convert config crate error to configuration error
348impl From<config::ConfigError> for SubXError {
349    fn from(err: config::ConfigError) -> Self {
350        match err {
351            config::ConfigError::NotFound(path) => SubXError::Config {
352                message: format!("Configuration file not found: {}", path),
353            },
354            config::ConfigError::Message(msg) => SubXError::Config { message: msg },
355            _ => SubXError::Config {
356                message: format!("Configuration error: {}", err),
357            },
358        }
359    }
360}
361
362impl From<serde_json::Error> for SubXError {
363    fn from(err: serde_json::Error) -> Self {
364        SubXError::Config {
365            message: format!("JSON serialization/deserialization error: {}", err),
366        }
367    }
368}
369
370/// Specialized `Result` type for SubX operations.
371pub type SubXResult<T> = Result<T, SubXError>;
372
373impl SubXError {
374    /// Create a configuration error with the given message.
375    ///
376    /// # Examples
377    ///
378    /// ```rust
379    /// # use subx_cli::error::SubXError;
380    /// let err = SubXError::config("invalid setting");
381    /// assert_eq!(err.to_string(), "Configuration error: invalid setting");
382    /// ```
383    pub fn config<S: Into<String>>(message: S) -> Self {
384        SubXError::Config {
385            message: message.into(),
386        }
387    }
388
389    /// Create a subtitle format error for the given format and message.
390    ///
391    /// # Examples
392    ///
393    /// ```rust
394    /// # use subx_cli::error::SubXError;
395    /// let err = SubXError::subtitle_format("SRT", "invalid timestamp");
396    /// assert!(err.to_string().contains("SRT"));
397    /// ```
398    pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
399    where
400        S1: Into<String>,
401        S2: Into<String>,
402    {
403        SubXError::SubtitleFormat {
404            format: format.into(),
405            message: message.into(),
406        }
407    }
408
409    /// Create an audio processing error with the given message.
410    ///
411    /// # Examples
412    ///
413    /// ```rust
414    /// # use subx_cli::error::SubXError;
415    /// let err = SubXError::audio_processing("decode failed");
416    /// assert_eq!(err.to_string(), "Audio processing error: decode failed");
417    /// ```
418    pub fn audio_processing<S: Into<String>>(message: S) -> Self {
419        SubXError::AudioProcessing {
420            message: message.into(),
421        }
422    }
423
424    /// Create an AI service error with the given message.
425    ///
426    /// # Examples
427    ///
428    /// ```rust
429    /// # use subx_cli::error::SubXError;
430    /// let err = SubXError::ai_service("network failure");
431    /// assert_eq!(err.to_string(), "AI service error: network failure");
432    /// ```
433    pub fn ai_service<S: Into<String>>(message: S) -> Self {
434        SubXError::AiService(message.into())
435    }
436
437    /// Create a file matching error with the given message.
438    ///
439    /// # Examples
440    ///
441    /// ```rust
442    /// # use subx_cli::error::SubXError;
443    /// let err = SubXError::file_matching("not found");
444    /// assert_eq!(err.to_string(), "File matching error: not found");
445    /// ```
446    pub fn file_matching<S: Into<String>>(message: S) -> Self {
447        SubXError::FileMatching {
448            message: message.into(),
449        }
450    }
451    /// Create a parallel processing error with the given message.
452    pub fn parallel_processing(msg: String) -> Self {
453        SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
454    }
455    /// Create a task execution failure error with task ID and reason.
456    pub fn task_execution_failed(task_id: String, reason: String) -> Self {
457        SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
458    }
459    /// Create a worker pool exhausted error.
460    pub fn worker_pool_exhausted() -> Self {
461        SubXError::CommandExecution("Worker pool exhausted".to_string())
462    }
463    /// Create a task timeout error with task ID and duration.
464    pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
465        SubXError::CommandExecution(format!(
466            "Task {} timed out (limit: {:?})",
467            task_id, duration
468        ))
469    }
470    /// Create a dialogue detection failure error with the given message.
471    pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
472        SubXError::AudioProcessing {
473            message: format!("Dialogue detection failed: {}", msg.into()),
474        }
475    }
476    /// Create an invalid audio format error for the given format.
477    pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
478        SubXError::AudioProcessing {
479            message: format!("Unsupported audio format: {}", format.into()),
480        }
481    }
482    /// Create an invalid dialogue segment error with the given reason.
483    pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
484        SubXError::AudioProcessing {
485            message: format!("Invalid dialogue segment: {}", reason.into()),
486        }
487    }
488    /// Return the corresponding exit code for this error variant.
489    ///
490    /// # Examples
491    ///
492    /// ```rust
493    /// # use subx_cli::error::SubXError;
494    /// assert_eq!(SubXError::config("x").exit_code(), 2);
495    /// ```
496    pub fn exit_code(&self) -> i32 {
497        match self {
498            SubXError::Io(_) => 1,
499            SubXError::Config { .. } => 2,
500            SubXError::Api { .. } => 3,
501            SubXError::AiService(_) => 3,
502            SubXError::SubtitleFormat { .. } => 4,
503            SubXError::AudioProcessing { .. } => 5,
504            SubXError::FileMatching { .. } => 6,
505            _ => 1,
506        }
507    }
508
509    /// Return a user-friendly error message with suggested remedies.
510    ///
511    /// # Examples
512    ///
513    /// ```rust
514    /// # use subx_cli::error::SubXError;
515    /// let msg = SubXError::config("missing key").user_friendly_message();
516    /// assert!(msg.contains("Configuration error:"));
517    /// ```
518    pub fn user_friendly_message(&self) -> String {
519        match self {
520            SubXError::Io(e) => format!("File operation error: {}", e),
521            SubXError::Config { message } => format!(
522                "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
523                message
524            ),
525            SubXError::Api { message, source } => format!(
526                "API error ({:?}): {}\nHint: check network connection and API key settings",
527                source, message
528            ),
529            SubXError::AiService(msg) => format!(
530                "AI service error: {}\nHint: check network connection and API key settings",
531                msg
532            ),
533            SubXError::SubtitleFormat { message, .. } => format!(
534                "Subtitle processing error: {}\nHint: check file format and encoding",
535                message
536            ),
537            SubXError::AudioProcessing { message } => format!(
538                "Audio processing error: {}\nHint: ensure media file integrity and support",
539                message
540            ),
541            SubXError::FileMatching { message } => format!(
542                "File matching error: {}\nHint: verify file paths and patterns",
543                message
544            ),
545            SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
546            SubXError::FileNotFound(path) => format!("File not found: {}", path),
547            SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
548            SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
549            SubXError::CommandExecution(msg) => msg.clone(),
550            SubXError::Other(err) => {
551                format!("Unknown error: {}\nHint: please report this issue", err)
552            }
553            _ => format!("Error: {}", self),
554        }
555    }
556}
557
558/// Helper functions for Whisper API and audio processing related errors.
559impl SubXError {
560    /// Create a Whisper API error.
561    ///
562    /// # Arguments
563    ///
564    /// * `message` - The error message describing the Whisper API failure
565    ///
566    /// # Returns
567    ///
568    /// A new `SubXError::Api` variant with Whisper as the source
569    pub fn whisper_api<T: Into<String>>(message: T) -> Self {
570        Self::Api {
571            message: message.into(),
572            source: ApiErrorSource::Whisper,
573        }
574    }
575
576    /// Create an audio extraction/transcoding error.
577    ///
578    /// # Arguments
579    ///
580    /// * `message` - The error message describing the audio processing failure
581    ///
582    /// # Returns
583    ///
584    /// A new `SubXError::AudioProcessing` variant
585    pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
586        Self::AudioProcessing {
587            message: message.into(),
588        }
589    }
590}
591
592/// API error source enumeration.
593///
594/// Specifies the source of API-related errors to help with error diagnosis
595/// and handling.
596#[derive(Debug, thiserror::Error)]
597pub enum ApiErrorSource {
598    /// OpenAI Whisper API
599    #[error("OpenAI")]
600    OpenAI,
601    /// Whisper API
602    #[error("Whisper")]
603    Whisper,
604}
605
606// Support conversion from Box<dyn Error> to SubXError::AudioProcessing
607impl From<Box<dyn std::error::Error>> for SubXError {
608    fn from(err: Box<dyn std::error::Error>) -> Self {
609        SubXError::audio_processing(err.to_string())
610    }
611}