subx_cli/commands/
dispatcher.rs

1use crate::{Result, cli::Commands, 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/// };
46///
47/// dispatch_command(Commands::Match(match_args), config_service).await?;
48/// # Ok(())
49/// # }
50/// ```
51pub async fn dispatch_command(
52    command: Commands,
53    config_service: Arc<dyn ConfigService>,
54) -> Result<()> {
55    match command {
56        Commands::Match(args) => {
57            crate::commands::match_command::execute_with_config(args, config_service).await
58        }
59        Commands::Convert(args) => {
60            crate::commands::convert_command::execute_with_config(args, config_service).await
61        }
62        Commands::Sync(args) => {
63            crate::commands::sync_command::execute_with_config(args, config_service).await
64        }
65        Commands::Config(args) => {
66            crate::commands::config_command::execute_with_config(args, config_service).await
67        }
68        Commands::GenerateCompletion(args) => {
69            let mut cmd = <crate::cli::Cli as clap::CommandFactory>::command();
70            let cmd_name = cmd.get_name().to_string();
71            let mut stdout = std::io::stdout();
72            clap_complete::generate(args.shell, &mut cmd, cmd_name, &mut stdout);
73            Ok(())
74        }
75        Commands::Cache(args) => {
76            crate::commands::cache_command::execute_with_config(args, config_service).await
77        }
78        Commands::DetectEncoding(args) => {
79            crate::commands::detect_encoding_command::detect_encoding_command_with_config(
80                args,
81                config_service.as_ref(),
82            )?;
83            Ok(())
84        }
85    }
86}
87
88/// Dispatch command with borrowed config service reference.
89///
90/// This version is used by the CLI interface where we have a borrowed reference
91/// to the configuration service rather than an owned Arc.
92pub async fn dispatch_command_with_ref(
93    command: Commands,
94    config_service: &dyn ConfigService,
95) -> Result<()> {
96    match command {
97        Commands::Match(args) => {
98            args.validate()
99                .map_err(crate::error::SubXError::CommandExecution)?;
100            crate::commands::match_command::execute(args, config_service).await
101        }
102        Commands::Convert(args) => {
103            crate::commands::convert_command::execute(args, config_service).await
104        }
105        Commands::Sync(args) => crate::commands::sync_command::execute(args, config_service).await,
106        Commands::Config(args) => {
107            crate::commands::config_command::execute(args, config_service).await
108        }
109        Commands::GenerateCompletion(args) => {
110            let mut cmd = <crate::cli::Cli as clap::CommandFactory>::command();
111            let cmd_name = cmd.get_name().to_string();
112            let mut stdout = std::io::stdout();
113            clap_complete::generate(args.shell, &mut cmd, cmd_name, &mut stdout);
114            Ok(())
115        }
116        Commands::Cache(args) => crate::commands::cache_command::execute(args).await,
117        Commands::DetectEncoding(args) => {
118            crate::commands::detect_encoding_command::detect_encoding_command_with_config(
119                args,
120                config_service,
121            )?;
122            Ok(())
123        }
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::cli::{ConvertArgs, MatchArgs, OutputSubtitleFormat};
131    use crate::config::TestConfigService;
132
133    #[tokio::test]
134    async fn test_dispatch_match_command() {
135        let config_service = Arc::new(TestConfigService::with_ai_settings(
136            "test_provider",
137            "test_model",
138        ));
139        let args = MatchArgs {
140            path: Some("/tmp/test".into()),
141            input_paths: vec![],
142            dry_run: true,
143            confidence: 80,
144            recursive: false,
145            backup: false,
146            copy: false,
147            move_files: false,
148        };
149
150        // Should not panic and should handle the command
151        let result = dispatch_command(Commands::Match(args), config_service).await;
152
153        // The actual result depends on the test setup, but it should not panic
154        // In a dry run mode, it should generally succeed
155        match result {
156            Ok(_) => {} // Success case
157            Err(e) => {
158                // Allow certain expected errors like missing files in test environment
159                let error_msg = format!("{:?}", e);
160                assert!(
161                    error_msg.contains("NotFound")
162                        || error_msg.contains("No subtitle files found")
163                        || error_msg.contains("No video files found")
164                        || error_msg.contains("Config"),
165                    "Unexpected error: {:?}",
166                    e
167                );
168            }
169        }
170    }
171
172    #[tokio::test]
173    async fn test_dispatch_convert_command() {
174        let config_service = Arc::new(TestConfigService::with_defaults());
175        let args = ConvertArgs {
176            input: Some("/tmp/nonexistent".into()),
177            input_paths: vec![],
178            recursive: false,
179            format: Some(OutputSubtitleFormat::Srt),
180            output: None,
181            keep_original: false,
182            encoding: "utf-8".to_string(),
183        };
184
185        // Should handle the command (even if it fails due to missing files)
186        let _result = dispatch_command(Commands::Convert(args), config_service).await;
187        // Just verify it doesn't panic - actual success depends on file existence
188    }
189
190    #[tokio::test]
191    async fn test_dispatch_with_ref() {
192        let config_service = TestConfigService::with_ai_settings("test_provider", "test_model");
193        let args = MatchArgs {
194            path: Some("/tmp/test".into()),
195            input_paths: vec![],
196            dry_run: true,
197            confidence: 80,
198            recursive: false,
199            backup: false,
200            copy: false,
201            move_files: false,
202        };
203
204        // Test the reference version
205        let result = dispatch_command_with_ref(Commands::Match(args), &config_service).await;
206
207        match result {
208            Ok(_) => {} // Success case
209            Err(e) => {
210                // Allow certain expected errors like missing files in test environment
211                let error_msg = format!("{:?}", e);
212                assert!(
213                    error_msg.contains("NotFound")
214                        || error_msg.contains("No subtitle files found")
215                        || error_msg.contains("No video files found")
216                        || error_msg.contains("Config"),
217                    "Unexpected error: {:?}",
218                    e
219                );
220            }
221        }
222    }
223}