Skip to main content

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