Skip to main content

subx_cli/commands/
dispatcher.rs

1use crate::{Result, cli::Commands, cli::OutputMode, config::ConfigService};
2use std::sync::Arc;
3
4/// Central command dispatcher to avoid code duplication.
5///
6/// This module provides a unified way to dispatch commands,
7/// eliminating duplication between CLI and library API paths.
8///
9/// # Design Principles
10///
11/// - **Single Responsibility**: Each command dispatcher handles exactly one command type
12/// - **Consistency**: Both CLI and App API use the same command execution logic
13/// - **Error Handling**: Unified error handling across all command paths
14/// - **Testability**: Easy to test individual command dispatch without full CLI setup
15///
16/// # Architecture
17///
18/// The dispatcher acts as a bridge between:
19/// - CLI argument parsing (from `clap`)
20/// - Command execution logic (in `commands` module)
21/// - Configuration dependency injection
22///
23/// This eliminates the previous duplication where both `cli::run_with_config()`
24/// and `App::handle_command()` had identical match statements.
25///
26/// # Examples
27///
28/// ```rust
29/// use subx_cli::commands::dispatcher::dispatch_command;
30/// use subx_cli::cli::{Commands, MatchArgs};
31/// use subx_cli::config::TestConfigService;
32/// use std::sync::Arc;
33///
34/// # async fn example() -> subx_cli::Result<()> {
35/// let config_service = Arc::new(TestConfigService::with_defaults());
36/// let match_args = MatchArgs {
37///     path: Some("/path/to/files".into()),
38///     input_paths: vec![],
39///     dry_run: true,
40///     confidence: 80,
41///     recursive: false,
42///     backup: false,
43///     copy: false,
44///     move_files: false,
45///     no_extract: false,
46/// };
47///
48/// dispatch_command(Commands::Match(match_args), config_service).await?;
49/// # Ok(())
50/// # }
51/// ```
52pub async fn dispatch_command(
53    command: Commands,
54    config_service: Arc<dyn ConfigService>,
55) -> Result<()> {
56    dispatch_command_with_mode(command, config_service, OutputMode::Text).await
57}
58
59/// Dispatch a command using an owned `Arc<dyn ConfigService>` and an
60/// explicit [`OutputMode`].
61///
62/// This is the entry point used by the CLI runtime. The `output_mode`
63/// parameter is currently used to thread the renderer through to each
64/// per-command handler (a follow-up task wires it into the per-command
65/// payload emission). Today, the dispatcher itself does not branch on
66/// the mode — the global mode installed via
67/// [`crate::cli::output::install_active_mode`] already governs UI
68/// helpers — but the parameter is plumbed for forward compatibility so
69/// per-command sub-agents have a stable contract to consume.
70pub async fn dispatch_command_with_mode(
71    command: Commands,
72    config_service: Arc<dyn ConfigService>,
73    output_mode: OutputMode,
74) -> Result<()> {
75    match command {
76        Commands::Match(args) => {
77            crate::commands::match_command::execute_with_config(args, config_service).await
78        }
79        Commands::Convert(args) => {
80            crate::commands::convert_command::execute_with_config(args, config_service).await
81        }
82        Commands::Sync(args) => {
83            crate::commands::sync_command::execute_with_config(args, config_service).await
84        }
85        Commands::Config(args) => {
86            crate::commands::config_command::execute_with_config(args, config_service).await
87        }
88        Commands::GenerateCompletion(args) => run_generate_completion(args, output_mode),
89        Commands::Cache(args) => {
90            crate::commands::cache_command::execute_with_config(args, config_service).await
91        }
92        Commands::Translate(args) => {
93            crate::commands::translate_command::execute_with_config(args, config_service).await
94        }
95        Commands::DetectEncoding(args) => {
96            crate::commands::detect_encoding_command::detect_encoding_command_with_config(
97                args,
98                config_service.as_ref(),
99            )?;
100            Ok(())
101        }
102    }
103}
104
105/// Dispatch command with borrowed config service reference.
106///
107/// This version is used by the CLI interface where we have a borrowed reference
108/// to the configuration service rather than an owned Arc. The
109/// `output_mode` parameter is plumbed for per-command renderer
110/// integration (see [`dispatch_command_with_mode`]).
111pub async fn dispatch_command_with_ref(
112    command: Commands,
113    config_service: &dyn ConfigService,
114    output_mode: OutputMode,
115) -> Result<()> {
116    match command {
117        Commands::Match(args) => {
118            args.validate()
119                .map_err(crate::error::SubXError::CommandExecution)?;
120            crate::commands::match_command::execute(args, config_service).await
121        }
122        Commands::Convert(args) => {
123            crate::commands::convert_command::execute(args, config_service).await
124        }
125        Commands::Sync(args) => crate::commands::sync_command::execute(args, config_service).await,
126        Commands::Config(args) => {
127            crate::commands::config_command::execute(args, config_service).await
128        }
129        Commands::GenerateCompletion(args) => run_generate_completion(args, output_mode),
130        Commands::Cache(args) => crate::commands::cache_command::execute(args).await,
131        Commands::Translate(args) => {
132            crate::commands::translate_command::execute(args, config_service).await
133        }
134        Commands::DetectEncoding(args) => {
135            crate::commands::detect_encoding_command::detect_encoding_command_with_config(
136                args,
137                config_service,
138            )?;
139            Ok(())
140        }
141    }
142}
143
144/// Execute the `generate-completion` subcommand, rejecting JSON output
145/// mode because the produced shell-completion script cannot be wrapped
146/// in the JSON envelope contract.
147///
148/// In [`OutputMode::Text`] this prints the script to stdout and returns
149/// `Ok(())`. In [`OutputMode::Json`] it returns
150/// [`crate::error::SubXError::OutputModeUnsupported`] so `main.rs` can
151/// emit the standard error envelope and exit with code 1.
152fn run_generate_completion(
153    args: crate::cli::GenerateCompletionArgs,
154    output_mode: OutputMode,
155) -> Result<()> {
156    if output_mode.is_json() {
157        return Err(crate::error::SubXError::OutputModeUnsupported {
158            command: "generate-completion".to_string(),
159        });
160    }
161    let mut cmd = <crate::cli::Cli as clap::CommandFactory>::command();
162    let cmd_name = cmd.get_name().to_string();
163    let mut stdout = std::io::stdout();
164    clap_complete::generate(args.shell, &mut cmd, cmd_name, &mut stdout);
165    Ok(())
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::cli::{
172        CacheAction, CacheArgs, ClearArgs, ClearType, ConfigAction, ConfigArgs, ConvertArgs,
173        DetectEncodingArgs, GenerateCompletionArgs, MatchArgs, OutputSubtitleFormat, StatusArgs,
174        SyncArgs,
175    };
176    use crate::config::TestConfigService;
177    use clap_complete::Shell;
178
179    // Helper to check that errors are expected filesystem/config errors in a test environment
180    fn is_expected_test_error(e: &crate::error::SubXError) -> bool {
181        let msg = format!("{e:?}");
182        msg.contains("NotFound")
183            || msg.contains("No subtitle files found")
184            || msg.contains("No video files found")
185            || msg.contains("Config")
186            || msg.contains("no such file")
187            || msg.contains("cannot find")
188            || msg.contains("No input")
189            || msg.contains("No files")
190            || msg.contains("FileNotFound")
191            || msg.contains("IoError")
192            || msg.contains("PathNotFound")
193            || msg.contains("InvalidInput")
194            || msg.contains("CommandExecution")
195            || msg.contains("NoInputSpecified")
196    }
197
198    fn make_match_args_dry_run() -> MatchArgs {
199        MatchArgs {
200            path: Some("/nonexistent_subx_test_path".into()),
201            input_paths: vec![],
202            dry_run: true,
203            confidence: 80,
204            recursive: false,
205            backup: false,
206            copy: false,
207            move_files: false,
208            no_extract: false,
209        }
210    }
211
212    fn make_convert_args() -> ConvertArgs {
213        ConvertArgs {
214            input: Some("/nonexistent_subx_test_path".into()),
215            input_paths: vec![],
216            recursive: false,
217            format: Some(OutputSubtitleFormat::Srt),
218            output: None,
219            keep_original: false,
220            encoding: "utf-8".to_string(),
221            no_extract: false,
222        }
223    }
224
225    fn make_sync_args() -> SyncArgs {
226        SyncArgs {
227            positional_paths: vec![],
228            video: None,
229            subtitle: None,
230            input_paths: vec![],
231            recursive: false,
232            offset: Some(1.0),
233            method: None,
234            window: 30,
235            vad_sensitivity: None,
236            output: None,
237            verbose: false,
238            dry_run: true,
239            force: false,
240            batch: None,
241            no_extract: false,
242        }
243    }
244
245    fn make_config_args_list() -> ConfigArgs {
246        ConfigArgs {
247            action: ConfigAction::List,
248        }
249    }
250
251    fn make_generate_completion_args() -> GenerateCompletionArgs {
252        GenerateCompletionArgs { shell: Shell::Bash }
253    }
254
255    fn make_cache_status_args() -> CacheArgs {
256        CacheArgs {
257            action: CacheAction::Status(StatusArgs { json: false }),
258        }
259    }
260
261    fn make_cache_clear_args() -> CacheArgs {
262        CacheArgs {
263            action: CacheAction::Clear(ClearArgs {
264                r#type: ClearType::All,
265            }),
266        }
267    }
268
269    fn make_detect_encoding_args() -> DetectEncodingArgs {
270        DetectEncodingArgs {
271            verbose: false,
272            input_paths: vec![],
273            recursive: false,
274            file_paths: vec![],
275            no_extract: false,
276        }
277    }
278
279    // ── dispatch_command (Arc<dyn ConfigService>) ─────────────────────────────
280
281    #[tokio::test]
282    async fn test_dispatch_match_command() {
283        let config_service = Arc::new(TestConfigService::with_ai_settings(
284            "test_provider",
285            "test_model",
286        ));
287        let result =
288            dispatch_command(Commands::Match(make_match_args_dry_run()), config_service).await;
289        match result {
290            Ok(_) => {}
291            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
292        }
293    }
294
295    #[tokio::test]
296    async fn test_dispatch_convert_command() {
297        let config_service = Arc::new(TestConfigService::with_defaults());
298        // The result may fail due to missing files; we only verify no panic occurs.
299        let _result =
300            dispatch_command(Commands::Convert(make_convert_args()), config_service).await;
301    }
302
303    #[tokio::test]
304    async fn test_dispatch_sync_command() {
305        let config_service = Arc::new(TestConfigService::with_defaults());
306        let result = dispatch_command(Commands::Sync(make_sync_args()), config_service).await;
307        match result {
308            Ok(_) => {}
309            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
310        }
311    }
312
313    #[tokio::test]
314    async fn test_dispatch_config_list_command() {
315        let config_service = Arc::new(TestConfigService::with_defaults());
316        let result =
317            dispatch_command(Commands::Config(make_config_args_list()), config_service).await;
318        // Config list should either succeed or produce an expected config error
319        match result {
320            Ok(_) => {}
321            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
322        }
323    }
324
325    #[tokio::test]
326    async fn test_dispatch_generate_completion_command() {
327        let config_service = Arc::new(TestConfigService::with_defaults());
328        // GenerateCompletion writes to stdout and returns Ok
329        let result = dispatch_command(
330            Commands::GenerateCompletion(make_generate_completion_args()),
331            config_service,
332        )
333        .await;
334        assert!(
335            result.is_ok(),
336            "GenerateCompletion should succeed: {result:?}"
337        );
338    }
339
340    #[tokio::test]
341    async fn test_dispatch_cache_status_command() {
342        let config_service = Arc::new(TestConfigService::with_defaults());
343        let _result =
344            dispatch_command(Commands::Cache(make_cache_status_args()), config_service).await;
345    }
346
347    #[tokio::test]
348    async fn test_dispatch_cache_clear_command() {
349        let config_service = Arc::new(TestConfigService::with_defaults());
350        let _result =
351            dispatch_command(Commands::Cache(make_cache_clear_args()), config_service).await;
352    }
353
354    #[tokio::test]
355    async fn test_dispatch_detect_encoding_command() {
356        let config_service = Arc::new(TestConfigService::with_defaults());
357        let result = dispatch_command(
358            Commands::DetectEncoding(make_detect_encoding_args()),
359            config_service,
360        )
361        .await;
362        match result {
363            Ok(_) => {}
364            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
365        }
366    }
367
368    // ── dispatch_command_with_ref (&dyn ConfigService) ────────────────────────
369
370    #[tokio::test]
371    async fn test_dispatch_with_ref_match_command() {
372        let config_service = TestConfigService::with_ai_settings("test_provider", "test_model");
373        let result = dispatch_command_with_ref(
374            Commands::Match(make_match_args_dry_run()),
375            &config_service,
376            OutputMode::Text,
377        )
378        .await;
379        match result {
380            Ok(_) => {}
381            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
382        }
383    }
384
385    #[tokio::test]
386    async fn test_dispatch_with_ref_match_validation_error() {
387        // copy + move together must fail with a CommandExecution validation error
388        let config_service = TestConfigService::with_defaults();
389        let args = MatchArgs {
390            path: Some("/nonexistent_subx_test_path".into()),
391            input_paths: vec![],
392            dry_run: true,
393            confidence: 80,
394            recursive: false,
395            backup: false,
396            copy: true,
397            move_files: true,
398            no_extract: false,
399        };
400        let result =
401            dispatch_command_with_ref(Commands::Match(args), &config_service, OutputMode::Text)
402                .await;
403        assert!(result.is_err(), "Expected validation error for copy+move");
404        let msg = format!("{:?}", result.unwrap_err());
405        assert!(
406            msg.contains("CommandExecution") || msg.contains("copy") || msg.contains("move"),
407            "Error should mention the conflicting flags: {msg}"
408        );
409    }
410
411    #[tokio::test]
412    async fn test_dispatch_with_ref_convert_command() {
413        let config_service = TestConfigService::with_defaults();
414        let _result = dispatch_command_with_ref(
415            Commands::Convert(make_convert_args()),
416            &config_service,
417            OutputMode::Text,
418        )
419        .await;
420    }
421
422    #[tokio::test]
423    async fn test_dispatch_with_ref_sync_command() {
424        let config_service = TestConfigService::with_defaults();
425        let result = dispatch_command_with_ref(
426            Commands::Sync(make_sync_args()),
427            &config_service,
428            OutputMode::Text,
429        )
430        .await;
431        match result {
432            Ok(_) => {}
433            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
434        }
435    }
436
437    #[tokio::test]
438    async fn test_dispatch_with_ref_config_list_command() {
439        let config_service = TestConfigService::with_defaults();
440        let result = dispatch_command_with_ref(
441            Commands::Config(make_config_args_list()),
442            &config_service,
443            OutputMode::Text,
444        )
445        .await;
446        match result {
447            Ok(_) => {}
448            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
449        }
450    }
451
452    #[tokio::test]
453    async fn test_dispatch_with_ref_generate_completion_command() {
454        let config_service = TestConfigService::with_defaults();
455        let result = dispatch_command_with_ref(
456            Commands::GenerateCompletion(make_generate_completion_args()),
457            &config_service,
458            OutputMode::Text,
459        )
460        .await;
461        assert!(
462            result.is_ok(),
463            "GenerateCompletion should succeed: {result:?}"
464        );
465    }
466
467    #[tokio::test]
468    async fn test_dispatch_with_ref_cache_status_command() {
469        let config_service = TestConfigService::with_defaults();
470        let _result = dispatch_command_with_ref(
471            Commands::Cache(make_cache_status_args()),
472            &config_service,
473            OutputMode::Text,
474        )
475        .await;
476    }
477
478    #[tokio::test]
479    async fn test_dispatch_with_ref_cache_clear_command() {
480        let config_service = TestConfigService::with_defaults();
481        let _result = dispatch_command_with_ref(
482            Commands::Cache(make_cache_clear_args()),
483            &config_service,
484            OutputMode::Text,
485        )
486        .await;
487    }
488
489    #[tokio::test]
490    async fn test_dispatch_with_ref_detect_encoding_command() {
491        let config_service = TestConfigService::with_defaults();
492        let result = dispatch_command_with_ref(
493            Commands::DetectEncoding(make_detect_encoding_args()),
494            &config_service,
495            OutputMode::Text,
496        )
497        .await;
498        match result {
499            Ok(_) => {}
500            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
501        }
502    }
503
504    // ── Configuration is forwarded to subcommands ─────────────────────────────
505
506    #[tokio::test]
507    async fn test_dispatch_config_get_command() {
508        let config_service = Arc::new(TestConfigService::with_defaults());
509        let args = ConfigArgs {
510            action: ConfigAction::Get {
511                key: "ai.provider".to_string(),
512            },
513        };
514        let result = dispatch_command(Commands::Config(args), config_service).await;
515        match result {
516            Ok(_) => {}
517            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
518        }
519    }
520
521    #[tokio::test]
522    async fn test_dispatch_config_set_command() {
523        let config_service = Arc::new(TestConfigService::with_defaults());
524        let args = ConfigArgs {
525            action: ConfigAction::Set {
526                key: "ai.provider".to_string(),
527                value: "openai".to_string(),
528            },
529        };
530        let result = dispatch_command(Commands::Config(args), config_service).await;
531        match result {
532            Ok(_) => {}
533            Err(e) => assert!(is_expected_test_error(&e), "Unexpected error: {e:?}"),
534        }
535    }
536
537    #[tokio::test]
538    async fn test_dispatch_generate_completion_zsh() {
539        let config_service = Arc::new(TestConfigService::with_defaults());
540        let result = dispatch_command(
541            Commands::GenerateCompletion(GenerateCompletionArgs { shell: Shell::Zsh }),
542            config_service,
543        )
544        .await;
545        assert!(result.is_ok(), "Zsh completion should succeed: {result:?}");
546    }
547
548    #[tokio::test]
549    async fn test_dispatch_generate_completion_fish() {
550        let config_service = Arc::new(TestConfigService::with_defaults());
551        let result = dispatch_command(
552            Commands::GenerateCompletion(GenerateCompletionArgs { shell: Shell::Fish }),
553            config_service,
554        )
555        .await;
556        assert!(result.is_ok(), "Fish completion should succeed: {result:?}");
557    }
558}