1mod 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#[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 #[arg(long, value_enum, value_name = "MODE", global = false)]
78 pub output: Option<OutputMode>,
79
80 #[arg(long, global = false)]
90 pub quiet: bool,
91
92 #[command(subcommand)]
94 pub command: Commands,
95}
96
97#[derive(Subcommand, Debug)]
99pub enum Commands {
100 Match(MatchArgs),
102
103 Convert(ConvertArgs),
105
106 DetectEncoding(DetectEncodingArgs),
108
109 Sync(SyncArgs),
111
112 Config(ConfigArgs),
114
115 GenerateCompletion(GenerateCompletionArgs),
117
118 Cache(CacheArgs),
120
121 Translate(TranslateArgs),
124}
125
126#[derive(Debug)]
135pub struct RunOutcome {
136 pub output_mode: OutputMode,
138 pub quiet: bool,
140 pub command: &'static str,
142 pub result: crate::Result<()>,
144}
145
146pub 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
162pub 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
176pub 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
186pub 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 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 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 #[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 #[test]
324 fn test_help_flag_exits_with_error() {
325 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
623 fn test_output_flag_before_subcommand_parses() {
624 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 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 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 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 assert_eq!(
699 super::resolve_output_mode(Some(OutputMode::Text)),
700 OutputMode::Text
701 );
702 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}