Skip to main content

subx_cli/cli/
mod.rs

1//! Command-line interface for the SubX subtitle processing tool.
2//!
3//! This module provides the top-level CLI application structure and subcommands
4//! for AI-powered matching, subtitle format conversion, audio synchronization,
5//! encoding detection, configuration management, cache operations, and shell
6//! completion generation.
7//!
8//! # Architecture
9//!
10//! The CLI is built using `clap` and follows a subcommand pattern:
11//! - `match` - AI-powered subtitle file matching and renaming
12//! - `convert` - Subtitle format conversion between standards
13//! - `sync` - Audio-subtitle synchronization and timing adjustment
14//! - `detect-encoding` - Character encoding detection and conversion
15//! - `config` - Configuration management and inspection
16//! - `cache` - Cache inspection and dry-run management
17//! - `generate-completion` - Shell completion script generation
18//!
19//! # Examples
20//!
21//! ```bash
22//! # Basic subtitle matching
23//! subx match /path/to/videos /path/to/subtitles
24//!
25//! # Convert SRT to ASS format
26//! subx convert --input file.srt --output file.ass --format ass
27//!
28//! # Detect file encoding
29//! subx detect-encoding *.srt
30//! ```
31
32mod cache_args;
33mod config_args;
34mod convert_args;
35mod detect_encoding_args;
36mod generate_completion_args;
37mod input_handler;
38mod match_args;
39pub mod output;
40pub mod sync_args;
41pub mod table;
42mod translate_args;
43pub mod ui;
44
45pub use cache_args::{
46    ApplyArgs, CacheAction, CacheArgs, ClearArgs, ClearType, RollbackArgs, StatusArgs,
47};
48use clap::{Parser, Subcommand};
49pub use config_args::{ConfigAction, ConfigArgs};
50pub use convert_args::{ConvertArgs, OutputSubtitleFormat};
51pub use detect_encoding_args::DetectEncodingArgs;
52pub use generate_completion_args::GenerateCompletionArgs;
53pub use input_handler::{CollectedFiles, InputPathHandler};
54pub use match_args::MatchArgs;
55pub use output::{OutputMode, SCHEMA_VERSION};
56pub use sync_args::{SyncArgs, SyncMethod, SyncMethodArg, SyncMode};
57pub use translate_args::TranslateArgs;
58pub use ui::{
59    create_progress_bar, display_ai_usage, display_match_results, print_error, print_success,
60    print_warning,
61};
62
63/// Main CLI application structure defining the top-level interface.
64#[derive(Parser, Debug)]
65#[command(name = "subx-cli")]
66#[command(about = "Intelligent subtitle processing CLI tool")]
67#[command(version = env!("CARGO_PKG_VERSION"))]
68pub struct Cli {
69    /// Output mode for the entire invocation.
70    ///
71    /// Defaults to `text` (the existing human-friendly UI). Set to
72    /// `json` to receive a versioned, machine-readable envelope on
73    /// stdout. The flag is intentionally NOT `global(true)` so that it
74    /// must precede the subcommand token; this avoids colliding with
75    /// the per-subcommand `--output <PATH>` arguments on `convert`,
76    /// `sync`, and `translate`.
77    #[arg(long, value_enum, value_name = "MODE", global = false)]
78    pub output: Option<OutputMode>,
79
80    /// Suppress non-fatal status chatter.
81    ///
82    /// In text mode this silences `print_success`/`print_warning` and
83    /// progress bars. In JSON mode, free-form `eprintln!` / `println!`
84    /// chatter (matcher analysis blocks, conflict-resolution warnings,
85    /// AI candidate listings) is already suppressed unconditionally;
86    /// `--quiet` additionally silences the structured `tracing` / `log`
87    /// records that JSON mode would otherwise still allow on stderr.
88    /// Like `--output`, this flag must precede the subcommand token.
89    #[arg(long, global = false)]
90    pub quiet: bool,
91
92    /// The subcommand to execute
93    #[command(subcommand)]
94    pub command: Commands,
95}
96
97/// Available subcommands for the SubX CLI application.
98#[derive(Subcommand, Debug)]
99pub enum Commands {
100    /// AI-powered subtitle file matching and intelligent renaming
101    Match(MatchArgs),
102
103    /// Convert subtitle files between different formats
104    Convert(ConvertArgs),
105
106    /// Detect and convert character encoding of subtitle files
107    DetectEncoding(DetectEncodingArgs),
108
109    /// Synchronize subtitle timing with audio tracks
110    Sync(SyncArgs),
111
112    /// Manage and inspect application configuration
113    Config(ConfigArgs),
114
115    /// Generate shell completion scripts
116    GenerateCompletion(GenerateCompletionArgs),
117
118    /// Manage cache and inspect dry-run results
119    Cache(CacheArgs),
120
121    /// Translate subtitle cue text into a target language using the
122    /// configured AI provider.
123    Translate(TranslateArgs),
124}
125
126/// Outcome of a CLI invocation, surfaced to `main.rs` so it can render
127/// the final envelope without re-parsing argv.
128///
129/// The active [`OutputMode`] is resolved from `--output`, the
130/// `SUBX_OUTPUT` environment variable, and the built-in default in that
131/// order; `command` is the kebab-cased subcommand name (`"match"`,
132/// `"sync"`, …); `result` carries any [`crate::error::SubXError`]
133/// produced during dispatch.
134#[derive(Debug)]
135pub struct RunOutcome {
136    /// Active output mode for the invocation.
137    pub output_mode: OutputMode,
138    /// `--quiet` was set on the command line.
139    pub quiet: bool,
140    /// Stable subcommand identifier used as `envelope.command`.
141    pub command: &'static str,
142    /// Result of the dispatched subcommand.
143    pub result: crate::Result<()>,
144}
145
146/// Resolve the active output mode from a parsed [`Cli`] plus the
147/// `SUBX_OUTPUT` environment variable.
148///
149/// `--output` always wins over the environment fallback.
150pub fn resolve_output_mode(cli_flag: Option<OutputMode>) -> OutputMode {
151    if let Some(mode) = cli_flag {
152        return mode;
153    }
154    if let Ok(value) = std::env::var("SUBX_OUTPUT") {
155        if let Some(mode) = OutputMode::from_token(&value) {
156            return mode;
157        }
158    }
159    OutputMode::Text
160}
161
162/// Return the stable kebab-cased command name for a parsed subcommand.
163pub fn command_name(cmd: &Commands) -> &'static str {
164    match cmd {
165        Commands::Match(_) => "match",
166        Commands::Convert(_) => "convert",
167        Commands::DetectEncoding(_) => "detect-encoding",
168        Commands::Sync(_) => "sync",
169        Commands::Config(_) => "config",
170        Commands::GenerateCompletion(_) => "generate-completion",
171        Commands::Cache(_) => "cache",
172        Commands::Translate(_) => "translate",
173    }
174}
175
176/// Executes the SubX CLI application with the production configuration.
177///
178/// Backward-compatible shim returning `crate::Result<()>`. Prefer
179/// [`run_with_config`] (which returns a [`RunOutcome`]) for new
180/// integrations that need the resolved [`OutputMode`].
181pub async fn run() -> crate::Result<()> {
182    let config_service = std::sync::Arc::new(crate::config::ProductionConfigService::new()?);
183    run_with_config(config_service.as_ref()).await.result
184}
185
186/// Run the CLI with a provided configuration service.
187///
188/// Returns a structured [`RunOutcome`] so the caller (typically
189/// `main.rs`) can render the final JSON envelope without re-parsing
190/// argv. The output mode and `--quiet` flag are installed into the
191/// process-wide UI state via [`output::install_active_mode`] before
192/// dispatch, so all UI helpers and progress-bar construction sites
193/// observe the resolved mode.
194///
195/// # Arguments
196///
197/// * `config_service` - The configuration service to use
198pub async fn run_with_config(config_service: &dyn crate::config::ConfigService) -> RunOutcome {
199    let cli = match Cli::try_parse() {
200        Ok(cli) => cli,
201        Err(err) => {
202            // `main.rs` is responsible for rendering clap errors with
203            // mode-aware envelopes. When this function is invoked
204            // directly (tests, library callers), surface a generic
205            // CommandExecution error so the caller still gets a
206            // RunOutcome.
207            let mode = resolve_output_mode(None);
208            return RunOutcome {
209                output_mode: mode,
210                quiet: false,
211                command: "",
212                result: Err(crate::error::SubXError::CommandExecution(format!(
213                    "argument parsing failed: {err}"
214                ))),
215            };
216        }
217    };
218
219    let output_mode = resolve_output_mode(cli.output);
220    let quiet = cli.quiet;
221    output::install_active_mode(output_mode, quiet);
222    let command = command_name(&cli.command);
223
224    // Switch to workspace directory for file operations if specified via env or config
225    if let Some(ws_env) = std::env::var_os("SUBX_WORKSPACE") {
226        if let Err(e) = std::env::set_current_dir(&ws_env) {
227            return RunOutcome {
228                output_mode,
229                quiet,
230                command,
231                result: Err(crate::error::SubXError::CommandExecution(format!(
232                    "Failed to set workspace directory to {}: {}",
233                    std::path::PathBuf::from(&ws_env).display(),
234                    e
235                ))),
236            };
237        }
238    } else if let Ok(config) = config_service.get_config() {
239        let ws_dir = &config.general.workspace;
240        if !ws_dir.as_os_str().is_empty() {
241            if let Err(e) = std::env::set_current_dir(ws_dir) {
242                return RunOutcome {
243                    output_mode,
244                    quiet,
245                    command,
246                    result: Err(crate::error::SubXError::CommandExecution(format!(
247                        "Failed to set workspace directory to {}: {}",
248                        ws_dir.display(),
249                        e
250                    ))),
251                };
252            }
253        }
254    }
255
256    let result = crate::commands::dispatcher::dispatch_command_with_ref(
257        cli.command,
258        config_service,
259        output_mode,
260    )
261    .await;
262
263    RunOutcome {
264        output_mode,
265        quiet,
266        command,
267        result,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use clap::Parser;
275    use std::path::PathBuf;
276
277    // ─── Subcommand routing ──────────────────────────────────────────────────
278
279    #[test]
280    fn test_match_subcommand_routes_to_match_variant() {
281        let cli = Cli::try_parse_from(["subx-cli", "match", "."]).unwrap();
282        assert!(matches!(cli.command, Commands::Match(_)));
283    }
284
285    #[test]
286    fn test_convert_subcommand_routes_to_convert_variant() {
287        let cli = Cli::try_parse_from(["subx-cli", "convert", "file.srt"]).unwrap();
288        assert!(matches!(cli.command, Commands::Convert(_)));
289    }
290
291    #[test]
292    fn test_detect_encoding_subcommand_routes_to_detect_encoding_variant() {
293        let cli = Cli::try_parse_from(["subx-cli", "detect-encoding", "file.srt"]).unwrap();
294        assert!(matches!(cli.command, Commands::DetectEncoding(_)));
295    }
296
297    #[test]
298    fn test_sync_subcommand_routes_to_sync_variant() {
299        let cli = Cli::try_parse_from(["subx-cli", "sync", "video.mp4"]).unwrap();
300        assert!(matches!(cli.command, Commands::Sync(_)));
301    }
302
303    #[test]
304    fn test_config_subcommand_routes_to_config_variant() {
305        let cli = Cli::try_parse_from(["subx-cli", "config", "list"]).unwrap();
306        assert!(matches!(cli.command, Commands::Config(_)));
307    }
308
309    #[test]
310    fn test_generate_completion_subcommand_routes_to_generate_completion_variant() {
311        let cli = Cli::try_parse_from(["subx-cli", "generate-completion", "bash"]).unwrap();
312        assert!(matches!(cli.command, Commands::GenerateCompletion(_)));
313    }
314
315    #[test]
316    fn test_cache_subcommand_routes_to_cache_variant() {
317        let cli = Cli::try_parse_from(["subx-cli", "cache", "status"]).unwrap();
318        assert!(matches!(cli.command, Commands::Cache(_)));
319    }
320
321    // ─── Help and version flags ──────────────────────────────────────────────
322
323    #[test]
324    fn test_help_flag_exits_with_error() {
325        // --help causes clap to print and return an Err with kind DisplayHelp
326        let err = Cli::try_parse_from(["subx-cli", "--help"]).unwrap_err();
327        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
328    }
329
330    #[test]
331    fn test_version_flag_exits_with_error() {
332        let err = Cli::try_parse_from(["subx-cli", "--version"]).unwrap_err();
333        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayVersion);
334    }
335
336    #[test]
337    fn test_subcommand_help_flag() {
338        let err = Cli::try_parse_from(["subx-cli", "match", "--help"]).unwrap_err();
339        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
340    }
341
342    // ─── Invalid / missing arguments ────────────────────────────────────────
343
344    #[test]
345    fn test_no_subcommand_returns_error() {
346        let result = Cli::try_parse_from(["subx-cli"]);
347        assert!(result.is_err());
348    }
349
350    #[test]
351    fn test_unknown_subcommand_returns_error() {
352        let result = Cli::try_parse_from(["subx-cli", "nonexistent-command"]);
353        assert!(result.is_err());
354    }
355
356    #[test]
357    fn test_unknown_flag_returns_error() {
358        let result = Cli::try_parse_from(["subx-cli", "--unknown-flag"]);
359        assert!(result.is_err());
360    }
361
362    // ─── Default values propagated through Commands ──────────────────────────
363
364    #[test]
365    fn test_match_default_confidence_is_80() {
366        let cli = Cli::try_parse_from(["subx-cli", "match", "."]).unwrap();
367        if let Commands::Match(args) = cli.command {
368            assert_eq!(args.confidence, 80);
369        } else {
370            panic!("Expected Match command");
371        }
372    }
373
374    #[test]
375    fn test_match_default_flags_are_false() {
376        let cli = Cli::try_parse_from(["subx-cli", "match", "."]).unwrap();
377        if let Commands::Match(args) = cli.command {
378            assert!(!args.dry_run);
379            assert!(!args.recursive);
380            assert!(!args.backup);
381            assert!(!args.copy);
382            assert!(!args.move_files);
383            assert!(!args.no_extract);
384        } else {
385            panic!("Expected Match command");
386        }
387    }
388
389    #[test]
390    fn test_convert_default_encoding_is_utf8() {
391        let cli = Cli::try_parse_from(["subx-cli", "convert", "file.srt"]).unwrap();
392        if let Commands::Convert(args) = cli.command {
393            assert_eq!(args.encoding, "utf-8");
394            assert!(!args.keep_original);
395            assert!(!args.recursive);
396        } else {
397            panic!("Expected Convert command");
398        }
399    }
400
401    #[test]
402    fn test_cache_clear_default_type_is_all() {
403        let cli = Cli::try_parse_from(["subx-cli", "cache", "clear"]).unwrap();
404        if let Commands::Cache(cache_args) = cli.command {
405            if let CacheAction::Clear(clear_args) = cache_args.action {
406                assert_eq!(clear_args.r#type, ClearType::All);
407            } else {
408                panic!("Expected Clear action");
409            }
410        } else {
411            panic!("Expected Cache command");
412        }
413    }
414
415    // ─── Cache subcommand variants ───────────────────────────────────────────
416
417    #[test]
418    fn test_cache_status_parses_json_flag() {
419        let cli = Cli::try_parse_from(["subx-cli", "cache", "status", "--json"]).unwrap();
420        if let Commands::Cache(cache_args) = cli.command {
421            if let CacheAction::Status(status_args) = cache_args.action {
422                assert!(status_args.json);
423            } else {
424                panic!("Expected Status action");
425            }
426        } else {
427            panic!("Expected Cache command");
428        }
429    }
430
431    #[test]
432    fn test_cache_apply_parses_yes_and_force() {
433        let cli = Cli::try_parse_from(["subx-cli", "cache", "apply", "--yes", "--force"]).unwrap();
434        if let Commands::Cache(cache_args) = cli.command {
435            if let CacheAction::Apply(apply_args) = cache_args.action {
436                assert!(apply_args.yes);
437                assert!(apply_args.force);
438            } else {
439                panic!("Expected Apply action");
440            }
441        } else {
442            panic!("Expected Cache command");
443        }
444    }
445
446    #[test]
447    fn test_cache_rollback_parses_force() {
448        let cli = Cli::try_parse_from(["subx-cli", "cache", "rollback", "--force"]).unwrap();
449        if let Commands::Cache(cache_args) = cli.command {
450            if let CacheAction::Rollback(rollback_args) = cache_args.action {
451                assert!(rollback_args.force);
452            } else {
453                panic!("Expected Rollback action");
454            }
455        } else {
456            panic!("Expected Cache command");
457        }
458    }
459
460    #[test]
461    fn test_cache_clear_journal_type() {
462        let cli = Cli::try_parse_from(["subx-cli", "cache", "clear", "--type", "journal"]).unwrap();
463        if let Commands::Cache(cache_args) = cli.command {
464            if let CacheAction::Clear(clear_args) = cache_args.action {
465                assert_eq!(clear_args.r#type, ClearType::Journal);
466            } else {
467                panic!("Expected Clear action");
468            }
469        } else {
470            panic!("Expected Cache command");
471        }
472    }
473
474    // ─── Config subcommand variants ──────────────────────────────────────────
475
476    #[test]
477    fn test_config_set_parses_key_and_value() {
478        let cli =
479            Cli::try_parse_from(["subx-cli", "config", "set", "ai.provider", "openai"]).unwrap();
480        if let Commands::Config(config_args) = cli.command {
481            if let ConfigAction::Set { key, value } = config_args.action {
482                assert_eq!(key, "ai.provider");
483                assert_eq!(value, "openai");
484            } else {
485                panic!("Expected Set action");
486            }
487        } else {
488            panic!("Expected Config command");
489        }
490    }
491
492    #[test]
493    fn test_config_get_parses_key() {
494        let cli = Cli::try_parse_from(["subx-cli", "config", "get", "ai.model"]).unwrap();
495        if let Commands::Config(config_args) = cli.command {
496            if let ConfigAction::Get { key } = config_args.action {
497                assert_eq!(key, "ai.model");
498            } else {
499                panic!("Expected Get action");
500            }
501        } else {
502            panic!("Expected Config command");
503        }
504    }
505
506    #[test]
507    fn test_config_list_routes_to_list_action() {
508        let cli = Cli::try_parse_from(["subx-cli", "config", "list"]).unwrap();
509        if let Commands::Config(config_args) = cli.command {
510            assert!(matches!(config_args.action, ConfigAction::List));
511        } else {
512            panic!("Expected Config command");
513        }
514    }
515
516    #[test]
517    fn test_config_reset_routes_to_reset_action() {
518        let cli = Cli::try_parse_from(["subx-cli", "config", "reset"]).unwrap();
519        if let Commands::Config(config_args) = cli.command {
520            assert!(matches!(config_args.action, ConfigAction::Reset));
521        } else {
522            panic!("Expected Config command");
523        }
524    }
525
526    // ─── Generate-completion subcommand ──────────────────────────────────────
527
528    #[test]
529    fn test_generate_completion_bash() {
530        use clap_complete::Shell;
531        let cli = Cli::try_parse_from(["subx-cli", "generate-completion", "bash"]).unwrap();
532        if let Commands::GenerateCompletion(args) = cli.command {
533            assert_eq!(args.shell, Shell::Bash);
534        } else {
535            panic!("Expected GenerateCompletion command");
536        }
537    }
538
539    #[test]
540    fn test_generate_completion_zsh() {
541        use clap_complete::Shell;
542        let cli = Cli::try_parse_from(["subx-cli", "generate-completion", "zsh"]).unwrap();
543        if let Commands::GenerateCompletion(args) = cli.command {
544            assert_eq!(args.shell, Shell::Zsh);
545        } else {
546            panic!("Expected GenerateCompletion command");
547        }
548    }
549
550    #[test]
551    fn test_generate_completion_missing_shell_arg_returns_error() {
552        let result = Cli::try_parse_from(["subx-cli", "generate-completion"]);
553        assert!(result.is_err());
554    }
555
556    // ─── Sync subcommand ─────────────────────────────────────────────────────
557
558    #[test]
559    fn test_sync_video_and_subtitle_flags() {
560        let cli = Cli::try_parse_from([
561            "subx-cli",
562            "sync",
563            "--video",
564            "video.mp4",
565            "--subtitle",
566            "sub.srt",
567        ])
568        .unwrap();
569        if let Commands::Sync(args) = cli.command {
570            assert_eq!(args.video, Some(PathBuf::from("video.mp4")));
571            assert_eq!(args.subtitle, Some(PathBuf::from("sub.srt")));
572        } else {
573            panic!("Expected Sync command");
574        }
575    }
576
577    #[test]
578    fn test_sync_manual_offset_flag() {
579        let cli = Cli::try_parse_from([
580            "subx-cli", "sync", "--method", "manual", "--offset", "2.5", "sub.srt",
581        ])
582        .unwrap();
583        if let Commands::Sync(args) = cli.command {
584            assert_eq!(args.offset, Some(2.5));
585            assert_eq!(args.method, Some(SyncMethodArg::Manual));
586        } else {
587            panic!("Expected Sync command");
588        }
589    }
590
591    // ─── Detect-encoding subcommand ──────────────────────────────────────────
592
593    #[test]
594    fn test_detect_encoding_verbose_flag() {
595        let cli =
596            Cli::try_parse_from(["subx-cli", "detect-encoding", "--verbose", "file.srt"]).unwrap();
597        if let Commands::DetectEncoding(args) = cli.command {
598            assert!(args.verbose);
599            assert_eq!(args.file_paths, vec!["file.srt".to_string()]);
600        } else {
601            panic!("Expected DetectEncoding command");
602        }
603    }
604
605    #[test]
606    fn test_detect_encoding_missing_file_returns_error() {
607        let result = Cli::try_parse_from(["subx-cli", "detect-encoding"]);
608        assert!(result.is_err());
609    }
610
611    // ─── Debug formatting ────────────────────────────────────────────────────
612
613    #[test]
614    fn test_cli_debug_format() {
615        let cli = Cli::try_parse_from(["subx-cli", "match", "."]).unwrap();
616        let debug_str = format!("{cli:?}");
617        assert!(debug_str.contains("Cli"));
618    }
619
620    // ─── Top-level --output / --quiet placement ──────────────────────────────
621
622    #[test]
623    fn test_output_flag_before_subcommand_parses() {
624        // `subx --output json convert ...` must parse and set the
625        // top-level `Cli.output` to `Json` while leaving the
626        // subcommand-local `convert --output PATH` untouched.
627        let cli = Cli::try_parse_from([
628            "subx-cli", "--output", "json", "convert", "file.srt", "--output", "out.ass",
629            "--format", "ass",
630        ])
631        .expect("parses");
632        assert_eq!(cli.output, Some(OutputMode::Json));
633        if let Commands::Convert(args) = cli.command {
634            assert_eq!(
635                args.output.as_deref(),
636                Some(std::path::Path::new("out.ass"))
637            );
638        } else {
639            panic!("expected Convert");
640        }
641    }
642
643    #[test]
644    fn test_convert_local_output_path_does_not_set_output_mode() {
645        // `subx-cli convert --output a.ass --format ass` must parse
646        // (the convert-local --output is the file path), and
647        // `Cli.output` must default to None (i.e., text mode).
648        let cli = Cli::try_parse_from([
649            "subx-cli", "convert", "file.srt", "--output", "a.ass", "--format", "ass",
650        ])
651        .expect("parses");
652        assert_eq!(cli.output, None);
653    }
654
655    #[test]
656    fn test_output_flag_after_subcommand_does_not_apply_globally() {
657        // `subx-cli convert --output json --format ass` is the convert
658        // command's local file-path argument; clap routes the value
659        // `json` to ConvertArgs.output rather than the top-level mode.
660        // (We assert the local field captured the value; this guards
661        // against accidentally making the top-level flag global.)
662        let cli = Cli::try_parse_from([
663            "subx-cli", "convert", "file.srt", "--output", "json", "--format", "ass",
664        ])
665        .expect("parses");
666        assert_eq!(cli.output, None, "top-level mode must not flip");
667        if let Commands::Convert(args) = cli.command {
668            assert_eq!(args.output.as_deref(), Some(std::path::Path::new("json")));
669        } else {
670            panic!("expected Convert");
671        }
672    }
673
674    #[test]
675    fn test_quiet_flag_before_subcommand_parses() {
676        let cli = Cli::try_parse_from(["subx-cli", "--quiet", "match", "."]).expect("parses");
677        assert!(cli.quiet);
678    }
679
680    #[test]
681    fn test_quiet_flag_after_subcommand_is_rejected() {
682        // No subcommand currently defines a local `--quiet`; this
683        // guards against accidentally making the flag global.
684        let result = Cli::try_parse_from(["subx-cli", "match", ".", "--quiet"]);
685        assert!(
686            result.is_err(),
687            "--quiet must appear before the subcommand, got: {:?}",
688            result.map(|_| "unexpected ok")
689        );
690    }
691
692    #[test]
693    fn test_resolve_output_mode_prefers_flag_over_env() {
694        unsafe {
695            std::env::set_var("SUBX_OUTPUT", "json");
696        }
697        // Explicit flag wins.
698        assert_eq!(
699            super::resolve_output_mode(Some(OutputMode::Text)),
700            OutputMode::Text
701        );
702        // Env fallback.
703        assert_eq!(super::resolve_output_mode(None), OutputMode::Json);
704        unsafe {
705            std::env::remove_var("SUBX_OUTPUT");
706        }
707        assert_eq!(super::resolve_output_mode(None), OutputMode::Text);
708    }
709
710    #[test]
711    fn test_command_name_returns_kebab_case() {
712        let cli = Cli::try_parse_from(["subx-cli", "detect-encoding", "f.srt"]).unwrap();
713        assert_eq!(super::command_name(&cli.command), "detect-encoding");
714        let cli = Cli::try_parse_from(["subx-cli", "match", "."]).unwrap();
715        assert_eq!(super::command_name(&cli.command), "match");
716    }
717
718    #[test]
719    fn test_commands_debug_format_for_each_variant() {
720        let commands = [
721            Cli::try_parse_from(["subx-cli", "match", "."]),
722            Cli::try_parse_from(["subx-cli", "convert", "f.srt"]),
723            Cli::try_parse_from(["subx-cli", "detect-encoding", "f.srt"]),
724            Cli::try_parse_from(["subx-cli", "config", "list"]),
725            Cli::try_parse_from(["subx-cli", "cache", "status"]),
726            Cli::try_parse_from(["subx-cli", "generate-completion", "fish"]),
727        ];
728        for result in &commands {
729            let cli = result.as_ref().expect("parse should succeed");
730            let s = format!("{:?}", cli.command);
731            assert!(!s.is_empty());
732        }
733    }
734}