1mod 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};
58pub use subx_core::core::input::{CollectedFiles, InputPathHandler};
63pub 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#[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 #[arg(long, value_enum, value_name = "MODE", global = false)]
90 pub output: Option<OutputMode>,
91
92 #[arg(long, global = false)]
102 pub quiet: bool,
103
104 #[command(subcommand)]
106 pub command: Commands,
107}
108
109#[derive(Subcommand, Debug)]
111pub enum Commands {
112 Match(MatchArgs),
114
115 Convert(ConvertArgs),
117
118 DetectEncoding(DetectEncodingArgs),
120
121 Sync(SyncArgs),
123
124 Config(ConfigArgs),
126
127 GenerateCompletion(GenerateCompletionArgs),
129
130 Cache(CacheArgs),
132
133 Translate(TranslateArgs),
136}
137
138#[derive(Debug)]
147pub struct RunOutcome {
148 pub output_mode: OutputMode,
150 pub quiet: bool,
152 pub command: &'static str,
154 pub result: crate::Result<()>,
156}
157
158pub 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
174pub 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
188pub 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
198pub 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 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 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 #[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 #[test]
336 fn test_help_flag_exits_with_error() {
337 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
635 fn test_output_flag_before_subcommand_parses() {
636 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 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 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 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 assert_eq!(
711 super::resolve_output_mode(Some(OutputMode::Text)),
712 OutputMode::Text
713 );
714 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}