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