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    /// The active output mode (e.g. `--output json`) is incompatible
150    /// with the requested subcommand.
151    ///
152    /// Currently emitted by `generate-completion`, whose stdout is by
153    /// design a shell-completion script and cannot be wrapped in the
154    /// JSON envelope contract.
155    #[error(
156        "The '{command}' command does not support --output json; its stdout is a shell-completion script"
157    )]
158    OutputModeUnsupported {
159        /// The subcommand that rejected the output mode (e.g. `"generate-completion"`).
160        command: String,
161    },
162
163    /// Catch-all error variant wrapping any other failure.
164    #[error("Unknown error: {0}")]
165    Other(#[from] anyhow::Error),
166}
167
168// Unit test: SubXError error types and helper methods
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::io;
173    use std::path::PathBuf;
174
175    // ── Display messages ──────────────────────────────────────────────────────
176
177    #[test]
178    fn test_io_error_display() {
179        let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
180        assert!(err.to_string().starts_with("I/O error:"));
181        assert!(err.to_string().contains("denied"));
182    }
183
184    #[test]
185    fn test_ai_service_display() {
186        let err = SubXError::AiService("timeout".to_string());
187        assert_eq!(err.to_string(), "AI service error: timeout");
188    }
189
190    #[test]
191    fn test_api_display() {
192        let err = SubXError::Api {
193            message: "bad request".to_string(),
194            source: ApiErrorSource::OpenAI,
195        };
196        let s = err.to_string();
197        assert!(s.contains("API error"));
198        assert!(s.contains("bad request"));
199        assert!(s.contains("OpenAI"));
200    }
201
202    #[test]
203    fn test_file_already_exists_display() {
204        let err = SubXError::FileAlreadyExists("foo.srt".to_string());
205        assert_eq!(err.to_string(), "File already exists: foo.srt");
206    }
207
208    #[test]
209    fn test_file_not_found_display() {
210        let err = SubXError::FileNotFound("bar.srt".to_string());
211        assert_eq!(err.to_string(), "File not found: bar.srt");
212    }
213
214    #[test]
215    fn test_invalid_file_name_display() {
216        let err = SubXError::InvalidFileName("bad?name".to_string());
217        assert_eq!(err.to_string(), "Invalid file name: bad?name");
218    }
219
220    #[test]
221    fn test_file_operation_failed_display() {
222        let err = SubXError::FileOperationFailed("rename failed".to_string());
223        assert_eq!(err.to_string(), "File operation failed: rename failed");
224    }
225
226    #[test]
227    fn test_command_execution_display() {
228        let err = SubXError::CommandExecution("exit 1".to_string());
229        assert_eq!(err.to_string(), "exit 1");
230    }
231
232    #[test]
233    fn test_no_input_specified_display() {
234        let err = SubXError::NoInputSpecified;
235        assert_eq!(err.to_string(), "No input path specified");
236    }
237
238    #[test]
239    fn test_invalid_path_display() {
240        let err = SubXError::InvalidPath(PathBuf::from("/bad/path"));
241        assert!(err.to_string().contains("Invalid path:"));
242        assert!(err.to_string().contains("/bad/path"));
243    }
244
245    #[test]
246    fn test_path_not_found_display() {
247        let err = SubXError::PathNotFound(PathBuf::from("/missing"));
248        assert!(err.to_string().contains("Path not found:"));
249    }
250
251    #[test]
252    fn test_directory_read_error_display() {
253        let err = SubXError::DirectoryReadError {
254            path: PathBuf::from("/locked"),
255            source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
256        };
257        assert!(err.to_string().contains("Unable to read directory:"));
258        assert!(err.to_string().contains("/locked"));
259    }
260
261    #[test]
262    fn test_invalid_sync_configuration_display() {
263        let err = SubXError::InvalidSyncConfiguration;
264        assert!(err.to_string().contains("Invalid sync configuration"));
265    }
266
267    #[test]
268    fn test_unsupported_file_type_display() {
269        let err = SubXError::UnsupportedFileType("xyz".to_string());
270        assert_eq!(err.to_string(), "Unsupported file type: xyz");
271    }
272
273    #[test]
274    fn test_other_error_display() {
275        let err = SubXError::Other(anyhow::anyhow!("wrapped error"));
276        assert!(err.to_string().contains("Unknown error:"));
277        assert!(err.to_string().contains("wrapped error"));
278    }
279
280    // ── ApiErrorSource ────────────────────────────────────────────────────────
281
282    #[test]
283    fn test_api_error_source_display() {
284        assert_eq!(ApiErrorSource::OpenAI.to_string(), "OpenAI");
285        assert_eq!(ApiErrorSource::Whisper.to_string(), "Whisper");
286    }
287
288    // ── exit_code mapping ─────────────────────────────────────────────────────
289
290    #[test]
291    fn test_config_error_creation() {
292        let error = SubXError::config("test config error");
293        assert!(matches!(error, SubXError::Config { .. }));
294        assert_eq!(error.to_string(), "Configuration error: test config error");
295    }
296
297    #[test]
298    fn test_subtitle_format_error_creation() {
299        let error = SubXError::subtitle_format("SRT", "invalid format");
300        assert!(matches!(error, SubXError::SubtitleFormat { .. }));
301        let msg = error.to_string();
302        assert!(msg.contains("SRT"));
303        assert!(msg.contains("invalid format"));
304    }
305
306    #[test]
307    fn test_audio_processing_error_creation() {
308        let error = SubXError::audio_processing("decode failed");
309        assert!(matches!(error, SubXError::AudioProcessing { .. }));
310        assert_eq!(error.to_string(), "Audio processing error: decode failed");
311    }
312
313    #[test]
314    fn test_file_matching_error_creation() {
315        let error = SubXError::file_matching("match failed");
316        assert!(matches!(error, SubXError::FileMatching { .. }));
317        assert_eq!(error.to_string(), "File matching error: match failed");
318    }
319
320    #[test]
321    fn test_io_error_conversion() {
322        let io_error = io::Error::new(io::ErrorKind::NotFound, "file not found");
323        let subx_error: SubXError = io_error.into();
324        assert!(matches!(subx_error, SubXError::Io(_)));
325    }
326
327    #[test]
328    fn test_exit_codes() {
329        assert_eq!(SubXError::config("test").exit_code(), 2);
330        assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
331        assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
332        assert_eq!(SubXError::file_matching("test").exit_code(), 6);
333    }
334
335    #[test]
336    fn test_user_friendly_messages() {
337        let config_error = SubXError::config("missing key");
338        let message = config_error.user_friendly_message();
339        assert!(message.contains("Configuration error:"));
340        assert!(message.contains("subx-cli config --help"));
341
342        let ai_error = SubXError::ai_service("network failure".to_string());
343        let message = ai_error.user_friendly_message();
344        assert!(message.contains("AI service error:"));
345        assert!(message.contains("check network connection"));
346    }
347
348    /// Audit: enumerates every `SubXError` variant and asserts that a
349    /// representative instance — built from non-sensitive dummy data —
350    /// never surfaces an OpenAI-style API key prefix (`sk-`) through
351    /// `Display`, `Debug`, or `user_friendly_message()`. If you add a
352    /// new variant, extend this list so the audit remains exhaustive.
353    ///
354    /// Separately, this test also exercises the sanitizing construction
355    /// paths (`From<reqwest::Error>`-style flows via the AI client's
356    /// `error_sanitizer` helpers) to confirm that when input *does*
357    /// contain an `sk-*` secret, it is stripped before wrapping it in
358    /// `SubXError::AiService`.
359    #[test]
360    fn test_no_api_key_leaks_in_any_variant() {
361        use crate::services::ai::error_sanitizer::{
362            DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
363        };
364        use std::path::PathBuf;
365
366        // 1. Canonical variant audit: benign dummy data must never yield
367        //    an `sk-` substring.
368        let variants: Vec<SubXError> = vec![
369            SubXError::Io(io::Error::other("disk error")),
370            SubXError::Config {
371                message: "missing key".to_string(),
372            },
373            SubXError::SubtitleFormat {
374                format: "SRT".to_string(),
375                message: "bad timestamp".to_string(),
376            },
377            SubXError::AiService("upstream service failed".to_string()),
378            SubXError::Api {
379                message: "auth failed".to_string(),
380                source: ApiErrorSource::OpenAI,
381            },
382            SubXError::AudioProcessing {
383                message: "codec failure".to_string(),
384            },
385            SubXError::FileMatching {
386                message: "pattern mismatch".to_string(),
387            },
388            SubXError::FileAlreadyExists("/tmp/example".to_string()),
389            SubXError::FileNotFound("/tmp/example".to_string()),
390            SubXError::InvalidFileName("bad?name".to_string()),
391            SubXError::FileOperationFailed("rename failed".to_string()),
392            SubXError::CommandExecution("exit 1".to_string()),
393            SubXError::NoInputSpecified,
394            SubXError::InvalidPath(PathBuf::from("/tmp/example")),
395            SubXError::PathNotFound(PathBuf::from("/tmp/example")),
396            SubXError::DirectoryReadError {
397                path: PathBuf::from("/tmp/example"),
398                source: io::Error::other("denied"),
399            },
400            SubXError::InvalidSyncConfiguration,
401            SubXError::UnsupportedFileType("xyz".to_string()),
402            SubXError::OutputModeUnsupported {
403                command: "generate-completion".to_string(),
404            },
405            SubXError::Other(anyhow::anyhow!("wrapped")),
406        ];
407
408        for err in &variants {
409            let display = format!("{}", err);
410            let debug = format!("{:?}", err);
411            let friendly = err.user_friendly_message();
412            for (label, text) in [
413                ("Display", &display),
414                ("Debug", &debug),
415                ("friendly", &friendly),
416            ] {
417                assert!(
418                    !text.contains("sk-"),
419                    "{} surface for variant {:?} contains `sk-` prefix: {}",
420                    label,
421                    err,
422                    text,
423                );
424            }
425        }
426
427        // 2. Sanitizing construction paths: API keys injected via the
428        //    upstream response body or URL query string must be stripped
429        //    before being embedded into `SubXError::AiService`.
430        const SECRET: &str = "sk-test-key-12345";
431        let upstream_body = format!(
432            "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
433            SECRET
434        );
435        let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
436        // Helper does not itself mask secrets shorter than the limit; this
437        // documents that short bodies pass through unchanged so upstream
438        // callers must continue to keep secrets out of request bodies.
439        assert!(truncated.contains(SECRET));
440
441        let url_leak = format!(
442            "request error: https://api.example.com/v1/chat?api-key={}",
443            SECRET
444        );
445        let cleaned = sanitize_url_in_error(&url_leak);
446        assert!(!cleaned.contains("sk-test-key"));
447        let wrapped = SubXError::AiService(cleaned);
448        assert!(!format!("{}", wrapped).contains("sk-test-key"));
449        assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
450    }
451
452    // ── exit_code – remaining variants ───────────────────────────────────────
453
454    #[test]
455    fn test_exit_code_io() {
456        let err = SubXError::Io(io::Error::new(io::ErrorKind::NotFound, "x"));
457        assert_eq!(err.exit_code(), 1);
458    }
459
460    #[test]
461    fn test_exit_code_api() {
462        let err = SubXError::Api {
463            message: "x".to_string(),
464            source: ApiErrorSource::OpenAI,
465        };
466        assert_eq!(err.exit_code(), 3);
467    }
468
469    #[test]
470    fn test_exit_code_ai_service() {
471        let err = SubXError::AiService("x".to_string());
472        assert_eq!(err.exit_code(), 3);
473    }
474
475    #[test]
476    fn test_exit_code_catchall_variants() {
477        assert_eq!(SubXError::FileAlreadyExists("f".to_string()).exit_code(), 1);
478        assert_eq!(SubXError::FileNotFound("f".to_string()).exit_code(), 1);
479        assert_eq!(SubXError::InvalidFileName("f".to_string()).exit_code(), 1);
480        assert_eq!(
481            SubXError::FileOperationFailed("f".to_string()).exit_code(),
482            1
483        );
484        assert_eq!(SubXError::CommandExecution("f".to_string()).exit_code(), 1);
485        assert_eq!(SubXError::NoInputSpecified.exit_code(), 1);
486        assert_eq!(SubXError::InvalidPath(PathBuf::from("/x")).exit_code(), 1);
487        assert_eq!(SubXError::PathNotFound(PathBuf::from("/x")).exit_code(), 1);
488        assert_eq!(SubXError::InvalidSyncConfiguration.exit_code(), 1);
489        assert_eq!(
490            SubXError::UnsupportedFileType("xyz".to_string()).exit_code(),
491            1
492        );
493        assert_eq!(SubXError::Other(anyhow::anyhow!("other")).exit_code(), 1);
494    }
495
496    // ── category / machine_code / exit_code contract ────────────────────────
497
498    /// Exhaustive contract test for the closed `SubXError` mapping locked
499    /// by `specs/error-handling/spec.md`. If a new variant is added, this
500    /// test (and the exhaustive matches in `category()`/`machine_code()`)
501    /// SHALL be updated; the compiler-enforced exhaustive match guards
502    /// the source of truth.
503    #[test]
504    fn test_category_and_machine_code_contract() {
505        let cases: Vec<(SubXError, &'static str, &'static str, i32)> = vec![
506            (SubXError::Io(io::Error::other("x")), "io", "E_IO", 1),
507            (
508                SubXError::Config {
509                    message: "x".into(),
510                },
511                "config",
512                "E_CONFIG",
513                2,
514            ),
515            (
516                SubXError::SubtitleFormat {
517                    format: "SRT".into(),
518                    message: "x".into(),
519                },
520                "subtitle_format",
521                "E_SUBTITLE_FORMAT",
522                4,
523            ),
524            (
525                SubXError::AiService("x".into()),
526                "ai_service",
527                "E_AI_SERVICE",
528                3,
529            ),
530            (
531                SubXError::Api {
532                    message: "x".into(),
533                    source: ApiErrorSource::OpenAI,
534                },
535                "api",
536                "E_API",
537                3,
538            ),
539            (
540                SubXError::AudioProcessing {
541                    message: "x".into(),
542                },
543                "audio_processing",
544                "E_AUDIO_PROCESSING",
545                5,
546            ),
547            (
548                SubXError::FileMatching {
549                    message: "x".into(),
550                },
551                "file_matching",
552                "E_FILE_MATCHING",
553                6,
554            ),
555            (
556                SubXError::FileAlreadyExists("x".into()),
557                "file_already_exists",
558                "E_FILE_ALREADY_EXISTS",
559                1,
560            ),
561            (
562                SubXError::FileNotFound("x".into()),
563                "file_not_found",
564                "E_FILE_NOT_FOUND",
565                1,
566            ),
567            (
568                SubXError::InvalidFileName("x".into()),
569                "invalid_file_name",
570                "E_INVALID_FILE_NAME",
571                1,
572            ),
573            (
574                SubXError::FileOperationFailed("x".into()),
575                "file_operation_failed",
576                "E_FILE_OPERATION_FAILED",
577                1,
578            ),
579            (
580                SubXError::CommandExecution("x".into()),
581                "command_execution",
582                "E_COMMAND_EXECUTION",
583                1,
584            ),
585            (
586                SubXError::NoInputSpecified,
587                "no_input_specified",
588                "E_NO_INPUT_SPECIFIED",
589                1,
590            ),
591            (
592                SubXError::InvalidPath(PathBuf::from("/x")),
593                "invalid_path",
594                "E_INVALID_PATH",
595                1,
596            ),
597            (
598                SubXError::PathNotFound(PathBuf::from("/x")),
599                "path_not_found",
600                "E_PATH_NOT_FOUND",
601                1,
602            ),
603            (
604                SubXError::DirectoryReadError {
605                    path: PathBuf::from("/x"),
606                    source: io::Error::other("denied"),
607                },
608                "directory_read_error",
609                "E_DIRECTORY_READ_ERROR",
610                1,
611            ),
612            (
613                SubXError::InvalidSyncConfiguration,
614                "invalid_sync_configuration",
615                "E_INVALID_SYNC_CONFIGURATION",
616                1,
617            ),
618            (
619                SubXError::UnsupportedFileType("xyz".into()),
620                "unsupported_file_type",
621                "E_UNSUPPORTED_FILE_TYPE",
622                1,
623            ),
624            (
625                SubXError::OutputModeUnsupported {
626                    command: "generate-completion".into(),
627                },
628                "command_execution",
629                "E_OUTPUT_MODE_UNSUPPORTED",
630                1,
631            ),
632            (
633                SubXError::Other(anyhow::anyhow!("x")),
634                "other",
635                "E_OTHER",
636                1,
637            ),
638        ];
639
640        for (err, cat, code, exit) in &cases {
641            assert_eq!(err.category(), *cat, "category mismatch for {:?}", err);
642            assert_eq!(
643                err.machine_code(),
644                *code,
645                "machine_code mismatch for {:?}",
646                err
647            );
648            assert_eq!(err.exit_code(), *exit, "exit_code mismatch for {:?}", err);
649            assert!(!err.category().is_empty());
650            assert!(err.machine_code().starts_with("E_"));
651        }
652    }
653
654    // ── user_friendly_message – all variants ─────────────────────────────────
655
656    #[test]
657    fn test_user_friendly_message_io() {
658        let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
659        let msg = err.user_friendly_message();
660        assert!(msg.contains("File operation error:"));
661        assert!(msg.contains("denied"));
662    }
663
664    #[test]
665    fn test_user_friendly_message_api() {
666        let err = SubXError::Api {
667            message: "forbidden".to_string(),
668            source: ApiErrorSource::OpenAI,
669        };
670        let msg = err.user_friendly_message();
671        assert!(msg.contains("API error"));
672        assert!(msg.contains("forbidden"));
673        assert!(msg.contains("check network connection"));
674    }
675
676    #[test]
677    fn test_user_friendly_message_subtitle_format() {
678        let err = SubXError::subtitle_format("ASS", "bad encoding");
679        let msg = err.user_friendly_message();
680        assert!(msg.contains("Subtitle processing error:"));
681        assert!(msg.contains("bad encoding"));
682        assert!(msg.contains("check file format"));
683    }
684
685    #[test]
686    fn test_user_friendly_message_audio_processing() {
687        let err = SubXError::audio_processing("corrupt frame");
688        let msg = err.user_friendly_message();
689        assert!(msg.contains("Audio processing error:"));
690        assert!(msg.contains("corrupt frame"));
691        assert!(msg.contains("media file integrity"));
692    }
693
694    #[test]
695    fn test_user_friendly_message_file_matching() {
696        let err = SubXError::file_matching("pattern mismatch");
697        let msg = err.user_friendly_message();
698        assert!(msg.contains("File matching error:"));
699        assert!(msg.contains("pattern mismatch"));
700        assert!(msg.contains("verify file paths"));
701    }
702
703    #[test]
704    fn test_user_friendly_message_file_already_exists() {
705        let err = SubXError::FileAlreadyExists("output.srt".to_string());
706        assert_eq!(
707            err.user_friendly_message(),
708            "File already exists: output.srt"
709        );
710    }
711
712    #[test]
713    fn test_user_friendly_message_file_not_found() {
714        let err = SubXError::FileNotFound("input.srt".to_string());
715        assert_eq!(err.user_friendly_message(), "File not found: input.srt");
716    }
717
718    #[test]
719    fn test_user_friendly_message_invalid_file_name() {
720        let err = SubXError::InvalidFileName("bad?name".to_string());
721        assert_eq!(err.user_friendly_message(), "Invalid file name: bad?name");
722    }
723
724    #[test]
725    fn test_user_friendly_message_file_operation_failed() {
726        let err = SubXError::FileOperationFailed("rename failed".to_string());
727        assert_eq!(
728            err.user_friendly_message(),
729            "File operation failed: rename failed"
730        );
731    }
732
733    #[test]
734    fn test_user_friendly_message_command_execution() {
735        let err = SubXError::CommandExecution("process died".to_string());
736        assert_eq!(err.user_friendly_message(), "process died");
737    }
738
739    #[test]
740    fn test_user_friendly_message_other() {
741        let err = SubXError::Other(anyhow::anyhow!("mystery"));
742        let msg = err.user_friendly_message();
743        assert!(msg.contains("Unknown error:"));
744        assert!(msg.contains("mystery"));
745        assert!(msg.contains("please report this issue"));
746    }
747
748    #[test]
749    fn test_user_friendly_message_catchall_variants() {
750        // Variants that fall through to the `_ => format!("Error: {}", self)` arm.
751        let cases: Vec<SubXError> = vec![
752            SubXError::NoInputSpecified,
753            SubXError::InvalidPath(PathBuf::from("/bad")),
754            SubXError::PathNotFound(PathBuf::from("/missing")),
755            SubXError::DirectoryReadError {
756                path: PathBuf::from("/locked"),
757                source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
758            },
759            SubXError::InvalidSyncConfiguration,
760            SubXError::UnsupportedFileType("xyz".to_string()),
761        ];
762        for err in &cases {
763            let msg = err.user_friendly_message();
764            assert!(
765                msg.starts_with("Error:"),
766                "Expected 'Error:' prefix for {:?}, got: {}",
767                err,
768                msg
769            );
770        }
771    }
772
773    // ── Helper constructor methods ────────────────────────────────────────────
774
775    #[test]
776    fn test_ai_service_helper() {
777        let err = SubXError::ai_service("network failure");
778        assert!(matches!(err, SubXError::AiService(_)));
779        assert_eq!(err.to_string(), "AI service error: network failure");
780    }
781
782    #[test]
783    fn test_parallel_processing_helper() {
784        let err = SubXError::parallel_processing("channel closed".to_string());
785        assert!(matches!(err, SubXError::CommandExecution(_)));
786        assert!(err.to_string().contains("Parallel processing error:"));
787        assert!(err.to_string().contains("channel closed"));
788    }
789
790    #[test]
791    fn test_task_execution_failed_helper() {
792        let err = SubXError::task_execution_failed("task-42".to_string(), "panic".to_string());
793        assert!(matches!(err, SubXError::CommandExecution(_)));
794        assert!(err.to_string().contains("task-42"));
795        assert!(err.to_string().contains("panic"));
796    }
797
798    #[test]
799    fn test_worker_pool_exhausted_helper() {
800        let err = SubXError::worker_pool_exhausted();
801        assert!(matches!(err, SubXError::CommandExecution(_)));
802        assert_eq!(err.to_string(), "Worker pool exhausted");
803    }
804
805    #[test]
806    fn test_task_timeout_helper() {
807        let dur = std::time::Duration::from_secs(30);
808        let err = SubXError::task_timeout("task-7".to_string(), dur);
809        assert!(matches!(err, SubXError::CommandExecution(_)));
810        assert!(err.to_string().contains("task-7"));
811        assert!(err.to_string().contains("timed out"));
812    }
813
814    #[test]
815    fn test_dialogue_detection_failed_helper() {
816        let err = SubXError::dialogue_detection_failed("no speech found");
817        assert!(matches!(err, SubXError::AudioProcessing { .. }));
818        assert!(err.to_string().contains("Dialogue detection failed:"));
819        assert!(err.to_string().contains("no speech found"));
820    }
821
822    #[test]
823    fn test_invalid_audio_format_helper() {
824        let err = SubXError::invalid_audio_format("flac");
825        assert!(matches!(err, SubXError::AudioProcessing { .. }));
826        assert!(err.to_string().contains("Unsupported audio format:"));
827        assert!(err.to_string().contains("flac"));
828    }
829
830    #[test]
831    fn test_dialogue_segment_invalid_helper() {
832        let err = SubXError::dialogue_segment_invalid("negative duration");
833        assert!(matches!(err, SubXError::AudioProcessing { .. }));
834        assert!(err.to_string().contains("Invalid dialogue segment:"));
835        assert!(err.to_string().contains("negative duration"));
836    }
837
838    #[test]
839    fn test_whisper_api_helper() {
840        let err = SubXError::whisper_api("rate limited");
841        assert!(matches!(err, SubXError::Api { .. }));
842        let s = err.to_string();
843        assert!(s.contains("Whisper"));
844        assert!(s.contains("rate limited"));
845    }
846
847    #[test]
848    fn test_audio_extraction_helper() {
849        let err = SubXError::audio_extraction("ffmpeg missing");
850        assert!(matches!(err, SubXError::AudioProcessing { .. }));
851        assert!(err.to_string().contains("ffmpeg missing"));
852    }
853
854    // ── From conversions ─────────────────────────────────────────────────────
855
856    #[test]
857    fn test_from_anyhow_error() {
858        let anyhow_err = anyhow::anyhow!("some anyhow error");
859        let err: SubXError = anyhow_err.into();
860        assert!(matches!(err, SubXError::Other(_)));
861        assert!(err.to_string().contains("some anyhow error"));
862    }
863
864    #[test]
865    fn test_from_serde_json_error() {
866        let json_err: serde_json::Error =
867            serde_json::from_str::<serde_json::Value>("not json {{{").unwrap_err();
868        let err: SubXError = json_err.into();
869        assert!(matches!(err, SubXError::Config { .. }));
870        assert!(
871            err.to_string()
872                .contains("JSON serialization/deserialization error:")
873        );
874    }
875
876    #[test]
877    fn test_from_config_error_not_found() {
878        let config_err = config::ConfigError::NotFound("settings.toml".to_string());
879        let err: SubXError = config_err.into();
880        assert!(matches!(err, SubXError::Config { .. }));
881        assert!(err.to_string().contains("Configuration file not found:"));
882        assert!(err.to_string().contains("settings.toml"));
883    }
884
885    #[test]
886    fn test_from_config_error_message() {
887        let config_err = config::ConfigError::Message("bad value".to_string());
888        let err: SubXError = config_err.into();
889        assert!(matches!(err, SubXError::Config { .. }));
890        assert!(err.to_string().contains("bad value"));
891    }
892
893    #[test]
894    fn test_from_config_error_other() {
895        // Use a variant that falls through to the catch-all arm.
896        let config_err = config::ConfigError::Foreign(Box::new(io::Error::new(
897            io::ErrorKind::Other,
898            "foreign cfg error",
899        )));
900        let err: SubXError = config_err.into();
901        assert!(matches!(err, SubXError::Config { .. }));
902        assert!(err.to_string().contains("Configuration error:"));
903    }
904
905    #[test]
906    fn test_from_box_dyn_error() {
907        let boxed: Box<dyn std::error::Error> =
908            Box::new(io::Error::new(io::ErrorKind::Other, "boxed error"));
909        let err: SubXError = boxed.into();
910        assert!(matches!(err, SubXError::AudioProcessing { .. }));
911        assert!(err.to_string().contains("Audio processing error:"));
912        assert!(err.to_string().contains("boxed error"));
913    }
914
915    #[test]
916    fn test_from_walkdir_error() {
917        // Walk a non-existent path to generate a walkdir::Error.
918        let walk_err = walkdir::WalkDir::new("/nonexistent_subx_test_path_xyz")
919            .into_iter()
920            .filter_map(|e| e.err())
921            .next();
922        if let Some(we) = walk_err {
923            let err: SubXError = we.into();
924            assert!(matches!(err, SubXError::FileMatching { .. }));
925        }
926        // If no error was produced (unlikely), the From impl was never
927        // reached, but the test still passes: we cannot force the error.
928    }
929
930    #[test]
931    fn test_from_symphonia_error() {
932        use symphonia::core::errors::Error as SymphoniaError;
933        let sym_err = SymphoniaError::DecodeError("bad frame");
934        let err: SubXError = sym_err.into();
935        assert!(matches!(err, SubXError::AudioProcessing { .. }));
936        assert!(err.to_string().contains("Audio processing error:"));
937    }
938
939    // ── SubXResult type alias ─────────────────────────────────────────────────
940
941    #[test]
942    fn test_subx_result_ok() {
943        let result: SubXResult<i32> = Ok(42);
944        assert_eq!(result.unwrap(), 42);
945    }
946
947    #[test]
948    fn test_subx_result_err() {
949        let result: SubXResult<i32> = Err(SubXError::NoInputSpecified);
950        assert!(result.is_err());
951    }
952}
953
954// Convert reqwest error to AI service error
955impl From<reqwest::Error> for SubXError {
956    fn from(err: reqwest::Error) -> Self {
957        let raw = err.to_string();
958        // Strip query strings from any embedded URLs, since reqwest's Display
959        // implementation includes the full request URL which may carry
960        // sensitive credentials (e.g. `?api-key=...`).
961        let sanitized = crate::services::ai::error_sanitizer::sanitize_url_in_error(&raw);
962        SubXError::AiService(sanitized)
963    }
964}
965
966// Convert file exploration error to file matching error
967impl From<walkdir::Error> for SubXError {
968    fn from(err: walkdir::Error) -> Self {
969        SubXError::FileMatching {
970            message: err.to_string(),
971        }
972    }
973}
974// Convert symphonia error to audio processing error
975impl From<symphonia::core::errors::Error> for SubXError {
976    fn from(err: symphonia::core::errors::Error) -> Self {
977        SubXError::audio_processing(err.to_string())
978    }
979}
980
981// Convert config crate error to configuration error
982impl From<config::ConfigError> for SubXError {
983    fn from(err: config::ConfigError) -> Self {
984        match err {
985            config::ConfigError::NotFound(path) => SubXError::Config {
986                message: format!("Configuration file not found: {}", path),
987            },
988            config::ConfigError::Message(msg) => SubXError::Config { message: msg },
989            _ => SubXError::Config {
990                message: format!("Configuration error: {}", err),
991            },
992        }
993    }
994}
995
996impl From<serde_json::Error> for SubXError {
997    fn from(err: serde_json::Error) -> Self {
998        SubXError::Config {
999            message: format!("JSON serialization/deserialization error: {}", err),
1000        }
1001    }
1002}
1003
1004/// Specialized `Result` type for SubX operations.
1005pub type SubXResult<T> = Result<T, SubXError>;
1006
1007impl SubXError {
1008    /// Create a configuration error with the given message.
1009    ///
1010    /// # Examples
1011    ///
1012    /// ```rust
1013    /// # use subx_cli::error::SubXError;
1014    /// let err = SubXError::config("invalid setting");
1015    /// assert_eq!(err.to_string(), "Configuration error: invalid setting");
1016    /// ```
1017    pub fn config<S: Into<String>>(message: S) -> Self {
1018        SubXError::Config {
1019            message: message.into(),
1020        }
1021    }
1022
1023    /// Create a subtitle format error for the given format and message.
1024    ///
1025    /// # Examples
1026    ///
1027    /// ```rust
1028    /// # use subx_cli::error::SubXError;
1029    /// let err = SubXError::subtitle_format("SRT", "invalid timestamp");
1030    /// assert!(err.to_string().contains("SRT"));
1031    /// ```
1032    pub fn subtitle_format<S1, S2>(format: S1, message: S2) -> Self
1033    where
1034        S1: Into<String>,
1035        S2: Into<String>,
1036    {
1037        SubXError::SubtitleFormat {
1038            format: format.into(),
1039            message: message.into(),
1040        }
1041    }
1042
1043    /// Create an audio processing error with the given message.
1044    ///
1045    /// # Examples
1046    ///
1047    /// ```rust
1048    /// # use subx_cli::error::SubXError;
1049    /// let err = SubXError::audio_processing("decode failed");
1050    /// assert_eq!(err.to_string(), "Audio processing error: decode failed");
1051    /// ```
1052    pub fn audio_processing<S: Into<String>>(message: S) -> Self {
1053        SubXError::AudioProcessing {
1054            message: message.into(),
1055        }
1056    }
1057
1058    /// Create an AI service error with the given message.
1059    ///
1060    /// # Examples
1061    ///
1062    /// ```rust
1063    /// # use subx_cli::error::SubXError;
1064    /// let err = SubXError::ai_service("network failure");
1065    /// assert_eq!(err.to_string(), "AI service error: network failure");
1066    /// ```
1067    pub fn ai_service<S: Into<String>>(message: S) -> Self {
1068        SubXError::AiService(message.into())
1069    }
1070
1071    /// Create a file matching error with the given message.
1072    ///
1073    /// # Examples
1074    ///
1075    /// ```rust
1076    /// # use subx_cli::error::SubXError;
1077    /// let err = SubXError::file_matching("not found");
1078    /// assert_eq!(err.to_string(), "File matching error: not found");
1079    /// ```
1080    pub fn file_matching<S: Into<String>>(message: S) -> Self {
1081        SubXError::FileMatching {
1082            message: message.into(),
1083        }
1084    }
1085    /// Create a parallel processing error with the given message.
1086    pub fn parallel_processing(msg: String) -> Self {
1087        SubXError::CommandExecution(format!("Parallel processing error: {}", msg))
1088    }
1089    /// Create a task execution failure error with task ID and reason.
1090    pub fn task_execution_failed(task_id: String, reason: String) -> Self {
1091        SubXError::CommandExecution(format!("Task {} execution failed: {}", task_id, reason))
1092    }
1093    /// Create a worker pool exhausted error.
1094    pub fn worker_pool_exhausted() -> Self {
1095        SubXError::CommandExecution("Worker pool exhausted".to_string())
1096    }
1097    /// Create a task timeout error with task ID and duration.
1098    pub fn task_timeout(task_id: String, duration: std::time::Duration) -> Self {
1099        SubXError::CommandExecution(format!(
1100            "Task {} timed out (limit: {:?})",
1101            task_id, duration
1102        ))
1103    }
1104    /// Create a dialogue detection failure error with the given message.
1105    pub fn dialogue_detection_failed<S: Into<String>>(msg: S) -> Self {
1106        SubXError::AudioProcessing {
1107            message: format!("Dialogue detection failed: {}", msg.into()),
1108        }
1109    }
1110    /// Create an invalid audio format error for the given format.
1111    pub fn invalid_audio_format<S: Into<String>>(format: S) -> Self {
1112        SubXError::AudioProcessing {
1113            message: format!("Unsupported audio format: {}", format.into()),
1114        }
1115    }
1116    /// Create an invalid dialogue segment error with the given reason.
1117    pub fn dialogue_segment_invalid<S: Into<String>>(reason: S) -> Self {
1118        SubXError::AudioProcessing {
1119            message: format!("Invalid dialogue segment: {}", reason.into()),
1120        }
1121    }
1122    /// Return the corresponding exit code for this error variant.
1123    ///
1124    /// # Examples
1125    ///
1126    /// ```rust
1127    /// # use subx_cli::error::SubXError;
1128    /// assert_eq!(SubXError::config("x").exit_code(), 2);
1129    /// ```
1130    pub fn exit_code(&self) -> i32 {
1131        match self {
1132            SubXError::Io(_) => 1,
1133            SubXError::Config { .. } => 2,
1134            SubXError::Api { .. } => 3,
1135            SubXError::AiService(_) => 3,
1136            SubXError::SubtitleFormat { .. } => 4,
1137            SubXError::AudioProcessing { .. } => 5,
1138            SubXError::FileMatching { .. } => 6,
1139            _ => 1,
1140        }
1141    }
1142
1143    /// Stable snake_case machine-readable category for the JSON error
1144    /// envelope. The mapping is closed and exhaustive (no wildcard arm)
1145    /// so the compiler enforces updates whenever a new variant is added.
1146    ///
1147    /// This mapping is locked by the `error-handling` capability spec.
1148    pub fn category(&self) -> &'static str {
1149        match self {
1150            // Mapped 1:1 from the closed set defined in the spec.
1151            SubXError::Io(_) => "io",
1152            SubXError::Config { .. } => "config",
1153            SubXError::SubtitleFormat { .. } => "subtitle_format",
1154            SubXError::AiService(_) => "ai_service",
1155            SubXError::Api { .. } => "api",
1156            SubXError::AudioProcessing { .. } => "audio_processing",
1157            SubXError::FileMatching { .. } => "file_matching",
1158            SubXError::FileAlreadyExists(_) => "file_already_exists",
1159            SubXError::FileNotFound(_) => "file_not_found",
1160            SubXError::InvalidFileName(_) => "invalid_file_name",
1161            SubXError::FileOperationFailed(_) => "file_operation_failed",
1162            SubXError::CommandExecution(_) => "command_execution",
1163            // Spec locks `category == "command_execution"` for this variant
1164            // even though the machine_code is the more specific
1165            // `E_OUTPUT_MODE_UNSUPPORTED`.
1166            SubXError::OutputModeUnsupported { .. } => "command_execution",
1167            SubXError::NoInputSpecified => "no_input_specified",
1168            SubXError::InvalidPath(_) => "invalid_path",
1169            SubXError::PathNotFound(_) => "path_not_found",
1170            SubXError::DirectoryReadError { .. } => "directory_read_error",
1171            SubXError::InvalidSyncConfiguration => "invalid_sync_configuration",
1172            SubXError::UnsupportedFileType(_) => "unsupported_file_type",
1173            SubXError::Other(_) => "other",
1174        }
1175    }
1176
1177    /// Stable upper-snake-case machine code prefixed with `E_`.
1178    /// Mirrors [`Self::category`] one-to-one and is similarly closed
1179    /// against the addition of new variants.
1180    pub fn machine_code(&self) -> &'static str {
1181        match self {
1182            SubXError::Io(_) => "E_IO",
1183            SubXError::Config { .. } => "E_CONFIG",
1184            SubXError::SubtitleFormat { .. } => "E_SUBTITLE_FORMAT",
1185            SubXError::AiService(_) => "E_AI_SERVICE",
1186            SubXError::Api { .. } => "E_API",
1187            SubXError::AudioProcessing { .. } => "E_AUDIO_PROCESSING",
1188            SubXError::FileMatching { .. } => "E_FILE_MATCHING",
1189            SubXError::FileAlreadyExists(_) => "E_FILE_ALREADY_EXISTS",
1190            SubXError::FileNotFound(_) => "E_FILE_NOT_FOUND",
1191            SubXError::InvalidFileName(_) => "E_INVALID_FILE_NAME",
1192            SubXError::FileOperationFailed(_) => "E_FILE_OPERATION_FAILED",
1193            SubXError::CommandExecution(_) => "E_COMMAND_EXECUTION",
1194            SubXError::OutputModeUnsupported { .. } => "E_OUTPUT_MODE_UNSUPPORTED",
1195            SubXError::NoInputSpecified => "E_NO_INPUT_SPECIFIED",
1196            SubXError::InvalidPath(_) => "E_INVALID_PATH",
1197            SubXError::PathNotFound(_) => "E_PATH_NOT_FOUND",
1198            SubXError::DirectoryReadError { .. } => "E_DIRECTORY_READ_ERROR",
1199            SubXError::InvalidSyncConfiguration => "E_INVALID_SYNC_CONFIGURATION",
1200            SubXError::UnsupportedFileType(_) => "E_UNSUPPORTED_FILE_TYPE",
1201            SubXError::Other(_) => "E_OTHER",
1202        }
1203    }
1204
1205    /// Short user-facing remediation hint, or `None` when none applies.
1206    ///
1207    /// This is a separate, structured surface from the prose hints
1208    /// already baked into [`Self::user_friendly_message`]; JSON callers
1209    /// receive it under `error.hint`.
1210    pub fn hint(&self) -> Option<&'static str> {
1211        match self {
1212            SubXError::Config { .. } => {
1213                Some("Run 'subx-cli config --help' for configuration details.")
1214            }
1215            SubXError::Api { .. } | SubXError::AiService(_) => {
1216                Some("Check network connectivity and the configured API key.")
1217            }
1218            SubXError::SubtitleFormat { .. } => {
1219                Some("Check the subtitle file's format and encoding.")
1220            }
1221            SubXError::AudioProcessing { .. } => {
1222                Some("Verify the media file's integrity and supported codecs.")
1223            }
1224            SubXError::FileMatching { .. } => Some("Verify file paths and patterns."),
1225            SubXError::NoInputSpecified => Some("Pass an input path or use the -i/--input flag."),
1226            SubXError::InvalidSyncConfiguration => {
1227                Some("Specify both video and subtitle files, or use -i for batch processing.")
1228            }
1229            SubXError::PathNotFound(_) | SubXError::FileNotFound(_) => {
1230                Some("Verify the path exists and is accessible.")
1231            }
1232            SubXError::OutputModeUnsupported { .. } => Some(
1233                "Run the command without `--output json` (and without SUBX_OUTPUT=json) to receive the shell-completion script.",
1234            ),
1235            _ => None,
1236        }
1237    }
1238
1239    /// Return a user-friendly error message with suggested remedies.
1240    ///
1241    /// # Examples
1242    ///
1243    /// ```rust
1244    /// # use subx_cli::error::SubXError;
1245    /// let msg = SubXError::config("missing key").user_friendly_message();
1246    /// assert!(msg.contains("Configuration error:"));
1247    /// ```
1248    pub fn user_friendly_message(&self) -> String {
1249        match self {
1250            SubXError::Io(e) => format!("File operation error: {}", e),
1251            SubXError::Config { message } => format!(
1252                "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
1253                message
1254            ),
1255            SubXError::Api { message, source } => format!(
1256                "API error ({:?}): {}\nHint: check network connection and API key settings",
1257                source, message
1258            ),
1259            SubXError::AiService(msg) => format!(
1260                "AI service error: {}\nHint: check network connection and API key settings",
1261                msg
1262            ),
1263            SubXError::SubtitleFormat { message, .. } => format!(
1264                "Subtitle processing error: {}\nHint: check file format and encoding",
1265                message
1266            ),
1267            SubXError::AudioProcessing { message } => format!(
1268                "Audio processing error: {}\nHint: ensure media file integrity and support",
1269                message
1270            ),
1271            SubXError::FileMatching { message } => format!(
1272                "File matching error: {}\nHint: verify file paths and patterns",
1273                message
1274            ),
1275            SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
1276            SubXError::FileNotFound(path) => format!("File not found: {}", path),
1277            SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
1278            SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
1279            SubXError::CommandExecution(msg) => msg.clone(),
1280            SubXError::OutputModeUnsupported { command } => format!(
1281                "The '{}' command does not support --output json; its stdout is a shell-completion script.\nHint: rerun without --output json (and ensure SUBX_OUTPUT is unset)",
1282                command
1283            ),
1284            SubXError::Other(err) => {
1285                format!("Unknown error: {}\nHint: please report this issue", err)
1286            }
1287            _ => format!("Error: {}", self),
1288        }
1289    }
1290}
1291
1292/// Helper functions for Whisper API and audio processing related errors.
1293impl SubXError {
1294    /// Create a Whisper API error.
1295    ///
1296    /// # Arguments
1297    ///
1298    /// * `message` - The error message describing the Whisper API failure
1299    ///
1300    /// # Returns
1301    ///
1302    /// A new `SubXError::Api` variant with Whisper as the source
1303    pub fn whisper_api<T: Into<String>>(message: T) -> Self {
1304        Self::Api {
1305            message: message.into(),
1306            source: ApiErrorSource::Whisper,
1307        }
1308    }
1309
1310    /// Create an audio extraction/transcoding error.
1311    ///
1312    /// # Arguments
1313    ///
1314    /// * `message` - The error message describing the audio processing failure
1315    ///
1316    /// # Returns
1317    ///
1318    /// A new `SubXError::AudioProcessing` variant
1319    pub fn audio_extraction<T: Into<String>>(message: T) -> Self {
1320        Self::AudioProcessing {
1321            message: message.into(),
1322        }
1323    }
1324}
1325
1326/// API error source enumeration.
1327///
1328/// Specifies the source of API-related errors to help with error diagnosis
1329/// and handling.
1330#[derive(Debug, thiserror::Error)]
1331pub enum ApiErrorSource {
1332    /// OpenAI Whisper API
1333    #[error("OpenAI")]
1334    OpenAI,
1335    /// Whisper API
1336    #[error("Whisper")]
1337    Whisper,
1338}
1339
1340// Support conversion from Box<dyn Error> to SubXError::AudioProcessing
1341impl From<Box<dyn std::error::Error>> for SubXError {
1342    fn from(err: Box<dyn std::error::Error>) -> Self {
1343        SubXError::audio_processing(err.to_string())
1344    }
1345}