Skip to main content

subx_cli/cli/
error_ext.rs

1//! Binary-owned presentation extensions for [`SubXError`].
2//!
3//! The `SubXError` taxonomy itself lives in [`subx_core::error`] and ships with
4//! the library half of the split (`subx-core`): `category()`, `machine_code()`
5//! and `hint()` are inherent methods there because machine-readable front
6//! ends (e.g. the Tauri GUI) consume them directly.
7//!
8//! Process exit codes and multi-line terminal prose with `Hint:` lines,
9//! however, are properties of running the `subx-cli` binary — a library
10//! consumer embeds `SubXError` in its own presentation layer and has neither
11//! a process exit code nor a terminal. Those two operations therefore live
12//! here, as an extension trait owned by the binary:
13//!
14//! - [`SubXErrorExt::exit_code`] — the stable 1–6 process exit mapping.
15//! - [`SubXErrorExt::user_friendly_message`] — the multi-line, hinted,
16//!   terminal-facing message.
17//!
18//! Code under `src/core/` and `src/services/` SHALL NOT import this trait or
19//! call either method; core code that needs a rendered message uses
20//! `Display` (`to_string()`), optionally combined with `hint()`.
21
22use subx_core::error::SubXError;
23
24/// Presentation-layer extensions to [`SubXError`] owned by the binary.
25pub trait SubXErrorExt {
26    /// Return the corresponding exit code for this error variant.
27    ///
28    /// # Examples
29    ///
30    /// ```rust
31    /// # use subx_cli::error::SubXError;
32    /// # use subx_cli::cli::SubXErrorExt;
33    /// assert_eq!(SubXError::config("x").exit_code(), 2);
34    /// ```
35    fn exit_code(&self) -> i32;
36
37    /// Return a user-friendly error message with suggested remedies.
38    ///
39    /// # Examples
40    ///
41    /// ```rust
42    /// # use subx_cli::error::SubXError;
43    /// # use subx_cli::cli::SubXErrorExt;
44    /// let msg = SubXError::config("missing key").user_friendly_message();
45    /// assert!(msg.contains("Configuration error:"));
46    /// ```
47    fn user_friendly_message(&self) -> String;
48}
49
50impl SubXErrorExt for SubXError {
51    fn exit_code(&self) -> i32 {
52        match self {
53            SubXError::Io(_) => 1,
54            SubXError::Config { .. } => 2,
55            SubXError::Api { .. } => 3,
56            SubXError::AiService(_) => 3,
57            SubXError::SubtitleFormat { .. } => 4,
58            SubXError::AudioProcessing { .. } => 5,
59            SubXError::FileMatching { .. } => 6,
60            _ => 1,
61        }
62    }
63
64    fn user_friendly_message(&self) -> String {
65        match self {
66            SubXError::Io(e) => format!("File operation error: {}", e),
67            SubXError::Config { message } => format!(
68                "Configuration error: {}\nHint: run 'subx-cli config --help' for details",
69                message
70            ),
71            SubXError::Api { message, source } => format!(
72                "API error ({:?}): {}\nHint: check network connection and API key settings",
73                source, message
74            ),
75            SubXError::AiService(msg) => format!(
76                "AI service error: {}\nHint: check network connection and API key settings",
77                msg
78            ),
79            SubXError::SubtitleFormat { message, .. } => format!(
80                "Subtitle processing error: {}\nHint: check file format and encoding",
81                message
82            ),
83            SubXError::AudioProcessing { message } => format!(
84                "Audio processing error: {}\nHint: ensure media file integrity and support",
85                message
86            ),
87            SubXError::FileMatching { message } => format!(
88                "File matching error: {}\nHint: verify file paths and patterns",
89                message
90            ),
91            SubXError::FileAlreadyExists(path) => format!("File already exists: {}", path),
92            SubXError::FileNotFound(path) => format!("File not found: {}", path),
93            SubXError::InvalidFileName(name) => format!("Invalid file name: {}", name),
94            SubXError::FileOperationFailed(msg) => format!("File operation failed: {}", msg),
95            SubXError::CommandExecution(msg) => msg.clone(),
96            SubXError::OutputModeUnsupported { command } => format!(
97                "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)",
98                command
99            ),
100            SubXError::Other(err) => {
101                format!("Unknown error: {}\nHint: please report this issue", err)
102            }
103            _ => format!("Error: {}", self),
104        }
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::io;
112    use std::path::PathBuf;
113    use subx_core::error::ApiErrorSource;
114
115    // ── exit_code mapping ─────────────────────────────────────────────────────
116    //
117    // These tests moved here with the two presentation methods (change
118    // relocate-misplaced-core-modules, Decision 4): `exit_code()` and
119    // `user_friendly_message()` are trait methods now, so their coverage
120    // lives with the trait.
121
122    #[test]
123    fn test_exit_codes() {
124        assert_eq!(SubXError::config("test").exit_code(), 2);
125        assert_eq!(SubXError::subtitle_format("SRT", "test").exit_code(), 4);
126        assert_eq!(SubXError::audio_processing("test").exit_code(), 5);
127        assert_eq!(SubXError::file_matching("test").exit_code(), 6);
128    }
129
130    #[test]
131    fn test_user_friendly_messages() {
132        let config_error = SubXError::config("missing key");
133        let message = config_error.user_friendly_message();
134        assert!(message.contains("Configuration error:"));
135        assert!(message.contains("subx-cli config --help"));
136
137        let ai_error = SubXError::ai_service("network failure".to_string());
138        let message = ai_error.user_friendly_message();
139        assert!(message.contains("AI service error:"));
140        assert!(message.contains("check network connection"));
141    }
142
143    /// Audit: enumerates every `SubXError` variant and asserts that a
144    /// representative instance — built from non-sensitive dummy data —
145    /// never surfaces an OpenAI-style API key prefix (`sk-`) through
146    /// `Display`, `Debug`, or `user_friendly_message()`. If you add a
147    /// new variant, extend this list so the audit remains exhaustive.
148    ///
149    /// Separately, this test also exercises the sanitizing construction
150    /// paths (`From<reqwest::Error>`-style flows via the AI client's
151    /// `error_sanitizer` helpers) to confirm that when input *does*
152    /// contain an `sk-*` secret, it is stripped before wrapping it in
153    /// `SubXError::AiService`.
154    ///
155    /// The `Display`/`Debug` half of this audit — the same exhaustive
156    /// variant list — lives in the `subx-core` repository's `src/error.rs`
157    /// (`test_no_api_key_leaks_in_any_variant`), beside the code that
158    /// defines the variants. Keep the two variant lists in step; updating
159    /// one without the other half-defeats the `secrets-protection` audit.
160    #[test]
161    fn test_no_api_key_leaks_in_any_variant() {
162        use std::path::PathBuf;
163        use subx_core::services::ai::error_sanitizer::{
164            DEFAULT_ERROR_BODY_MAX_LEN, sanitize_url_in_error, truncate_error_body,
165        };
166
167        // 1. Canonical variant audit: benign dummy data must never yield
168        //    an `sk-` substring.
169        let variants: Vec<SubXError> = vec![
170            SubXError::Io(io::Error::other("disk error")),
171            SubXError::Config {
172                message: "missing key".to_string(),
173            },
174            SubXError::SubtitleFormat {
175                format: "SRT".to_string(),
176                message: "bad timestamp".to_string(),
177            },
178            SubXError::AiService("upstream service failed".to_string()),
179            SubXError::Api {
180                message: "auth failed".to_string(),
181                source: ApiErrorSource::OpenAI,
182            },
183            SubXError::AudioProcessing {
184                message: "codec failure".to_string(),
185            },
186            SubXError::FileMatching {
187                message: "pattern mismatch".to_string(),
188            },
189            SubXError::FileAlreadyExists("/tmp/example".to_string()),
190            SubXError::FileNotFound("/tmp/example".to_string()),
191            SubXError::InvalidFileName("bad?name".to_string()),
192            SubXError::FileOperationFailed("rename failed".to_string()),
193            SubXError::CommandExecution("exit 1".to_string()),
194            SubXError::NoInputSpecified,
195            SubXError::InvalidPath(PathBuf::from("/tmp/example")),
196            SubXError::PathNotFound(PathBuf::from("/tmp/example")),
197            SubXError::DirectoryReadError {
198                path: PathBuf::from("/tmp/example"),
199                source: io::Error::other("denied"),
200            },
201            SubXError::InvalidSyncConfiguration,
202            SubXError::UnsupportedFileType("xyz".to_string()),
203            SubXError::OutputModeUnsupported {
204                command: "generate-completion".to_string(),
205            },
206            SubXError::Other(anyhow::anyhow!("wrapped")),
207        ];
208
209        for err in &variants {
210            let display = format!("{}", err);
211            let debug = format!("{:?}", err);
212            let friendly = err.user_friendly_message();
213            for (label, text) in [
214                ("Display", &display),
215                ("Debug", &debug),
216                ("friendly", &friendly),
217            ] {
218                assert!(
219                    !text.contains("sk-"),
220                    "{} surface for variant {:?} contains `sk-` prefix: {}",
221                    label,
222                    err,
223                    text
224                );
225            }
226        }
227
228        // 2. Sanitizing construction paths: API keys injected via the
229        //    upstream response body or URL query string must be stripped
230        //    before being embedded into `SubXError::AiService`.
231        const SECRET: &str = "sk-test-key-12345";
232        let upstream_body = format!(
233            "{{\"error\": \"invalid\", \"echoed\": \"Bearer {}\"}}",
234            SECRET
235        );
236        let truncated = truncate_error_body(&upstream_body, DEFAULT_ERROR_BODY_MAX_LEN);
237        // Helper does not itself mask secrets shorter than the limit; this
238        // documents that short bodies pass through unchanged so upstream
239        // callers must continue to keep secrets out of request bodies.
240        assert!(truncated.contains(SECRET));
241
242        let url_leak = format!(
243            "request error: https://api.example.com/v1/chat?api-key={}",
244            SECRET
245        );
246        let cleaned = sanitize_url_in_error(&url_leak);
247        assert!(!cleaned.contains("sk-test-key"));
248        let wrapped = SubXError::AiService(cleaned);
249        assert!(!format!("{}", wrapped).contains("sk-test-key"));
250        assert!(!format!("{:?}", wrapped).contains("sk-test-key"));
251    }
252
253    // ── exit_code – remaining variants ───────────────────────────────────────
254
255    #[test]
256    fn test_exit_code_io() {
257        let err = SubXError::Io(io::Error::new(io::ErrorKind::NotFound, "x"));
258        assert_eq!(err.exit_code(), 1);
259    }
260
261    #[test]
262    fn test_exit_code_api() {
263        let err = SubXError::Api {
264            message: "x".to_string(),
265            source: ApiErrorSource::OpenAI,
266        };
267        assert_eq!(err.exit_code(), 3);
268    }
269
270    #[test]
271    fn test_exit_code_ai_service() {
272        let err = SubXError::AiService("x".to_string());
273        assert_eq!(err.exit_code(), 3);
274    }
275
276    #[test]
277    fn test_exit_code_catchall_variants() {
278        assert_eq!(SubXError::FileAlreadyExists("f".to_string()).exit_code(), 1);
279        assert_eq!(SubXError::FileNotFound("f".to_string()).exit_code(), 1);
280        assert_eq!(SubXError::InvalidFileName("f".to_string()).exit_code(), 1);
281        assert_eq!(
282            SubXError::FileOperationFailed("f".to_string()).exit_code(),
283            1
284        );
285        assert_eq!(SubXError::CommandExecution("f".to_string()).exit_code(), 1);
286        assert_eq!(SubXError::NoInputSpecified.exit_code(), 1);
287        assert_eq!(SubXError::InvalidPath(PathBuf::from("/x")).exit_code(), 1);
288        assert_eq!(SubXError::PathNotFound(PathBuf::from("/x")).exit_code(), 1);
289        assert_eq!(SubXError::InvalidSyncConfiguration.exit_code(), 1);
290        assert_eq!(
291            SubXError::UnsupportedFileType("xyz".to_string()).exit_code(),
292            1
293        );
294        assert_eq!(SubXError::Other(anyhow::anyhow!("other")).exit_code(), 1);
295    }
296
297    // ── category / machine_code / exit_code contract ────────────────────────
298
299    /// Exhaustive contract test for the closed `SubXError` mapping locked
300    /// by `specs/error-handling/spec.md`. If a new variant is added, this
301    /// test (and the exhaustive matches in `category()`/`machine_code()`)
302    /// SHALL be updated; the compiler-enforced exhaustive match guards
303    /// the source of truth. It lives here (not in `src/error.rs`) because
304    /// the `exit_code` column is a `SubXErrorExt` method.
305    #[test]
306    fn test_category_and_machine_code_contract() {
307        let cases: Vec<(SubXError, &'static str, &'static str, i32)> = vec![
308            (SubXError::Io(io::Error::other("x")), "io", "E_IO", 1),
309            (
310                SubXError::Config {
311                    message: "x".into(),
312                },
313                "config",
314                "E_CONFIG",
315                2,
316            ),
317            (
318                SubXError::SubtitleFormat {
319                    format: "SRT".into(),
320                    message: "x".into(),
321                },
322                "subtitle_format",
323                "E_SUBTITLE_FORMAT",
324                4,
325            ),
326            (
327                SubXError::AiService("x".into()),
328                "ai_service",
329                "E_AI_SERVICE",
330                3,
331            ),
332            (
333                SubXError::Api {
334                    message: "x".into(),
335                    source: ApiErrorSource::OpenAI,
336                },
337                "api",
338                "E_API",
339                3,
340            ),
341            (
342                SubXError::AudioProcessing {
343                    message: "x".into(),
344                },
345                "audio_processing",
346                "E_AUDIO_PROCESSING",
347                5,
348            ),
349            (
350                SubXError::FileMatching {
351                    message: "x".into(),
352                },
353                "file_matching",
354                "E_FILE_MATCHING",
355                6,
356            ),
357            (
358                SubXError::FileAlreadyExists("x".into()),
359                "file_already_exists",
360                "E_FILE_ALREADY_EXISTS",
361                1,
362            ),
363            (
364                SubXError::FileNotFound("x".into()),
365                "file_not_found",
366                "E_FILE_NOT_FOUND",
367                1,
368            ),
369            (
370                SubXError::InvalidFileName("x".into()),
371                "invalid_file_name",
372                "E_INVALID_FILE_NAME",
373                1,
374            ),
375            (
376                SubXError::FileOperationFailed("x".into()),
377                "file_operation_failed",
378                "E_FILE_OPERATION_FAILED",
379                1,
380            ),
381            (
382                SubXError::CommandExecution("x".into()),
383                "command_execution",
384                "E_COMMAND_EXECUTION",
385                1,
386            ),
387            (
388                SubXError::OutputModeUnsupported {
389                    command: "generate-completion".into(),
390                },
391                "command_execution",
392                "E_OUTPUT_MODE_UNSUPPORTED",
393                1,
394            ),
395            (
396                SubXError::NoInputSpecified,
397                "no_input_specified",
398                "E_NO_INPUT_SPECIFIED",
399                1,
400            ),
401            (
402                SubXError::InvalidPath(PathBuf::from("/x")),
403                "invalid_path",
404                "E_INVALID_PATH",
405                1,
406            ),
407            (
408                SubXError::PathNotFound(PathBuf::from("/x")),
409                "path_not_found",
410                "E_PATH_NOT_FOUND",
411                1,
412            ),
413            (
414                SubXError::DirectoryReadError {
415                    path: PathBuf::from("/x"),
416                    source: io::Error::other("denied"),
417                },
418                "directory_read_error",
419                "E_DIRECTORY_READ_ERROR",
420                1,
421            ),
422            (
423                SubXError::InvalidSyncConfiguration,
424                "invalid_sync_configuration",
425                "E_INVALID_SYNC_CONFIGURATION",
426                1,
427            ),
428            (
429                SubXError::UnsupportedFileType("xyz".into()),
430                "unsupported_file_type",
431                "E_UNSUPPORTED_FILE_TYPE",
432                1,
433            ),
434            (
435                SubXError::Other(anyhow::anyhow!("x")),
436                "other",
437                "E_OTHER",
438                1,
439            ),
440        ];
441
442        for (err, cat, code, exit) in &cases {
443            assert_eq!(err.category(), *cat, "category mismatch for {:?}", err);
444            assert_eq!(
445                err.machine_code(),
446                *code,
447                "machine_code mismatch for {:?}",
448                err
449            );
450            assert_eq!(err.exit_code(), *exit, "exit_code mismatch for {:?}", err);
451            assert!(!err.category().is_empty());
452            assert!(err.machine_code().starts_with("E_"));
453        }
454    }
455
456    // ── user_friendly_message – all variants ─────────────────────────────────
457
458    #[test]
459    fn test_user_friendly_message_io() {
460        let err = SubXError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "denied"));
461        let msg = err.user_friendly_message();
462        assert!(msg.contains("File operation error:"));
463        assert!(msg.contains("denied"));
464    }
465
466    #[test]
467    fn test_user_friendly_message_api() {
468        let err = SubXError::Api {
469            message: "forbidden".to_string(),
470            source: ApiErrorSource::OpenAI,
471        };
472        let msg = err.user_friendly_message();
473        assert!(msg.contains("API error"));
474        assert!(msg.contains("forbidden"));
475        assert!(msg.contains("check network connection"));
476    }
477
478    #[test]
479    fn test_user_friendly_message_subtitle_format() {
480        let err = SubXError::subtitle_format("ASS", "bad encoding");
481        let msg = err.user_friendly_message();
482        assert!(msg.contains("Subtitle processing error:"));
483        assert!(msg.contains("bad encoding"));
484        assert!(msg.contains("check file format"));
485    }
486
487    #[test]
488    fn test_user_friendly_message_audio_processing() {
489        let err = SubXError::audio_processing("corrupt frame");
490        let msg = err.user_friendly_message();
491        assert!(msg.contains("Audio processing error:"));
492        assert!(msg.contains("corrupt frame"));
493        assert!(msg.contains("media file integrity"));
494    }
495
496    #[test]
497    fn test_user_friendly_message_file_matching() {
498        let err = SubXError::file_matching("pattern mismatch");
499        let msg = err.user_friendly_message();
500        assert!(msg.contains("File matching error:"));
501        assert!(msg.contains("pattern mismatch"));
502        assert!(msg.contains("verify file paths"));
503    }
504
505    #[test]
506    fn test_user_friendly_message_file_already_exists() {
507        let err = SubXError::FileAlreadyExists("output.srt".to_string());
508        assert_eq!(
509            err.user_friendly_message(),
510            "File already exists: output.srt"
511        );
512    }
513
514    #[test]
515    fn test_user_friendly_message_file_not_found() {
516        let err = SubXError::FileNotFound("input.srt".to_string());
517        assert_eq!(err.user_friendly_message(), "File not found: input.srt");
518    }
519
520    #[test]
521    fn test_user_friendly_message_invalid_file_name() {
522        let err = SubXError::InvalidFileName("bad?name".to_string());
523        assert_eq!(err.user_friendly_message(), "Invalid file name: bad?name");
524    }
525
526    #[test]
527    fn test_user_friendly_message_file_operation_failed() {
528        let err = SubXError::FileOperationFailed("rename failed".to_string());
529        assert_eq!(
530            err.user_friendly_message(),
531            "File operation failed: rename failed"
532        );
533    }
534
535    #[test]
536    fn test_user_friendly_message_command_execution() {
537        let err = SubXError::CommandExecution("process died".to_string());
538        assert_eq!(err.user_friendly_message(), "process died");
539    }
540
541    #[test]
542    fn test_user_friendly_message_other() {
543        let err = SubXError::Other(anyhow::anyhow!("mystery"));
544        let msg = err.user_friendly_message();
545        assert!(msg.contains("Unknown error:"));
546        assert!(msg.contains("mystery"));
547        assert!(msg.contains("please report this issue"));
548    }
549
550    #[test]
551    fn test_user_friendly_message_catchall_variants() {
552        // Variants that fall through to the `_ => format!("Error: {}", self)` arm.
553        let cases: Vec<SubXError> = vec![
554            SubXError::NoInputSpecified,
555            SubXError::InvalidPath(PathBuf::from("/bad")),
556            SubXError::PathNotFound(PathBuf::from("/missing")),
557            SubXError::DirectoryReadError {
558                path: PathBuf::from("/locked"),
559                source: io::Error::new(io::ErrorKind::PermissionDenied, "denied"),
560            },
561            SubXError::InvalidSyncConfiguration,
562            SubXError::UnsupportedFileType("xyz".to_string()),
563        ];
564        for err in &cases {
565            let msg = err.user_friendly_message();
566            assert!(
567                msg.starts_with("Error:"),
568                "Expected 'Error:' prefix for {:?}, got: {}",
569                err,
570                msg
571            );
572        }
573    }
574
575    // ── Display == user_friendly_message lock (task 5.4) ────────────────────
576
577    /// Locks the invariant that `core::matcher::engine::operation_error_from`
578    /// relies on when it renders `OperationError::message` through `Display`
579    /// instead of calling the (binary-side) `user_friendly_message()`: the
580    /// only variant that reaches that function — `FileOperationFailed` —
581    /// renders identically through both paths, and carries no `Hint:` line
582    /// that `Display` could lose. Widening the set of variants reaching
583    /// `operation_error_from` without re-checking this equality silently
584    /// changes the JSON per-item `error.message` contract.
585    #[test]
586    fn file_operation_failed_display_equals_user_friendly_message() {
587        let err = SubXError::FileOperationFailed("could not rename".into());
588        assert_eq!(err.to_string(), err.user_friendly_message());
589        assert!(err.hint().is_none());
590    }
591}