Skip to main content

subx_cli/config/
test_service.rs

1//! Test configuration service for isolated testing.
2//!
3//! This module provides a configuration service implementation specifically
4//! designed for testing environments, offering complete isolation and
5//! predictable configuration states.
6
7use crate::config::service::ConfigService;
8use crate::{Result, config::Config};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11
12/// Test configuration service implementation.
13///
14/// This service provides a fixed configuration for testing purposes,
15/// ensuring complete isolation between tests and predictable behavior.
16/// It does not load from external sources or cache.
17pub struct TestConfigService {
18    config: Mutex<Config>,
19}
20
21impl TestConfigService {
22    /// Set AI provider, model, and API key for testing.
23    pub fn set_ai_settings_and_key(&self, provider: &str, model: &str, api_key: &str) {
24        let mut cfg = self.config.lock().unwrap();
25        cfg.ai.provider = provider.to_string();
26        cfg.ai.model = model.to_string();
27        cfg.ai.api_key = if api_key.is_empty() {
28            None
29        } else {
30            Some(api_key.to_string())
31        };
32    }
33
34    /// Set AI provider, model, API key, and custom base URL for testing.
35    pub fn set_ai_settings_with_base_url(
36        &self,
37        provider: &str,
38        model: &str,
39        api_key: &str,
40        base_url: &str,
41    ) {
42        self.set_ai_settings_and_key(provider, model, api_key);
43        let mut cfg = self.config.lock().unwrap();
44        cfg.ai.base_url = base_url.to_string();
45    }
46    /// Create a new test configuration service with the provided configuration.
47    ///
48    /// # Arguments
49    ///
50    /// * `config` - The fixed configuration to use
51    pub fn new(config: Config) -> Self {
52        Self {
53            config: Mutex::new(config),
54        }
55    }
56
57    /// Create a test configuration service with default settings.
58    ///
59    /// This is useful for tests that don't need specific configuration values.
60    pub fn with_defaults() -> Self {
61        Self::new(Config::default())
62    }
63
64    /// Create a test configuration service with specific AI settings.
65    ///
66    /// # Arguments
67    ///
68    /// * `provider` - AI provider name
69    /// * `model` - AI model name
70    pub fn with_ai_settings(provider: &str, model: &str) -> Self {
71        let mut config = Config::default();
72        config.ai.provider = provider.to_string();
73        config.ai.model = model.to_string();
74        Self::new(config)
75    }
76
77    /// Create a test configuration service with specific AI settings including API key.
78    ///
79    /// # Arguments
80    ///
81    /// * `provider` - AI provider name
82    /// * `model` - AI model name
83    /// * `api_key` - API key for the provider
84    pub fn with_ai_settings_and_key(provider: &str, model: &str, api_key: &str) -> Self {
85        let mut config = Config::default();
86        config.ai.provider = provider.to_string();
87        config.ai.model = model.to_string();
88        config.ai.api_key = Some(api_key.to_string());
89        Self::new(config)
90    }
91
92    /// Create a test configuration service with specific sync settings.
93    ///
94    /// # Arguments
95    ///
96    /// * `correlation_threshold` - Correlation threshold for synchronization
97    /// * `max_offset` - Maximum time offset in seconds
98    pub fn with_sync_settings(correlation_threshold: f32, max_offset: f32) -> Self {
99        let mut config = Config::default();
100        config.sync.correlation_threshold = correlation_threshold;
101        config.sync.max_offset_seconds = max_offset;
102        Self::new(config)
103    }
104
105    /// Create a test configuration service with specific parallel processing settings.
106    ///
107    /// # Arguments
108    ///
109    /// * `max_workers` - Maximum number of parallel workers
110    /// * `queue_size` - Task queue size
111    pub fn with_parallel_settings(max_workers: usize, queue_size: usize) -> Self {
112        let mut config = Config::default();
113        config.general.max_concurrent_jobs = max_workers;
114        config.parallel.task_queue_size = queue_size;
115        Self::new(config)
116    }
117
118    /// Get the underlying configuration.
119    ///
120    /// This is useful for tests that need direct access to the configuration object.
121    pub fn config(&self) -> std::sync::MutexGuard<'_, Config> {
122        self.config.lock().unwrap()
123    }
124
125    /// Get a mutable reference to the underlying configuration.
126    ///
127    /// This allows tests to modify the configuration after creation.
128    pub fn config_mut(&self) -> std::sync::MutexGuard<'_, Config> {
129        self.config.lock().unwrap()
130    }
131}
132
133impl ConfigService for TestConfigService {
134    fn get_config(&self) -> Result<Config> {
135        Ok(self.config.lock().unwrap().clone())
136    }
137
138    fn reload(&self) -> Result<()> {
139        // Test configuration doesn't need reloading since it's fixed
140        Ok(())
141    }
142
143    fn save_config(&self) -> Result<()> {
144        // Test environment does not perform actual file I/O
145        Ok(())
146    }
147
148    fn save_config_to_file(&self, _path: &Path) -> Result<()> {
149        // Test environment does not perform actual file I/O
150        Ok(())
151    }
152
153    fn get_config_file_path(&self) -> Result<PathBuf> {
154        // Return a dummy path to avoid conflicts in test environment
155        Ok(PathBuf::from("/tmp/subx_test_config.toml"))
156    }
157
158    fn get_config_value(&self, key: &str) -> Result<String> {
159        let config = self.config.lock().unwrap();
160        crate::config::service::read_config_value_from(&config, key)
161    }
162
163    fn reset_to_defaults(&self) -> Result<()> {
164        // Reset the configuration to default values
165        *self.config.lock().unwrap() = Config::default();
166        Ok(())
167    }
168
169    fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
170        // Load current configuration via the tolerant path (which for
171        // tests is equivalent to the strict path because in-memory
172        // test configs are validated by construction).
173        let mut cfg = self.load_for_repair()?;
174        // Validate and set the value using the same logic as
175        // ProductionConfigService. `validate_and_set_value` already
176        // runs cross-section validation on the post-mutation config,
177        // so we MUST NOT duplicate that call here.
178        self.validate_and_set_value(&mut cfg, key, value)?;
179        // Update the internal configuration
180        *self.config.lock().unwrap() = cfg;
181        Ok(())
182    }
183
184    fn load_for_repair(&self) -> Result<Config> {
185        // Test configurations live in memory and are validated by
186        // construction; tolerant load is equivalent to strict load.
187        Ok(self.config.lock().unwrap().clone())
188    }
189}
190
191impl TestConfigService {
192    /// Validate and set a configuration value (same logic as ProductionConfigService).
193    fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
194        use crate::config::OverflowStrategy;
195        use crate::config::validation::*;
196        use crate::error::SubXError;
197
198        let parts: Vec<&str> = key.split('.').collect();
199        match parts.as_slice() {
200            ["ai", "provider"] => {
201                validate_enum(
202                    value,
203                    &[
204                        "openai",
205                        "anthropic",
206                        "local",
207                        "ollama",
208                        "openrouter",
209                        "azure-openai",
210                    ],
211                )?;
212                config.ai.provider = crate::config::field_validator::normalize_ai_provider(value);
213            }
214            ["ai", "api_key"] => {
215                if !value.is_empty() {
216                    validate_api_key(value)?;
217                    config.ai.api_key = Some(value.to_string());
218                } else {
219                    config.ai.api_key = None;
220                }
221            }
222            ["ai", "model"] => {
223                config.ai.model = value.to_string();
224            }
225            ["ai", "base_url"] => {
226                validate_url(value)?;
227                config.ai.base_url = value.to_string();
228            }
229            ["ai", "max_sample_length"] => {
230                let v = validate_usize_range(value, 100, 10000)?;
231                config.ai.max_sample_length = v;
232            }
233            ["ai", "temperature"] => {
234                let v = validate_float_range(value, 0.0, 1.0)?;
235                config.ai.temperature = v;
236            }
237            ["ai", "max_tokens"] => {
238                let v = validate_uint_range(value, 1, 100_000)?;
239                config.ai.max_tokens = v;
240            }
241            ["ai", "retry_attempts"] => {
242                let v = validate_uint_range(value, 1, 10)?;
243                config.ai.retry_attempts = v;
244            }
245            ["ai", "retry_delay_ms"] => {
246                let v = validate_u64_range(value, 100, 30000)?;
247                config.ai.retry_delay_ms = v;
248            }
249            ["ai", "request_timeout_seconds"] => {
250                let v = validate_u64_range(value, 10, 600)?;
251                config.ai.request_timeout_seconds = v;
252            }
253            ["formats", "default_output"] => {
254                validate_enum(value, &["srt", "ass", "vtt", "webvtt"])?;
255                config.formats.default_output = value.to_string();
256            }
257            ["formats", "preserve_styling"] => {
258                let v = parse_bool(value)?;
259                config.formats.preserve_styling = v;
260            }
261            ["formats", "default_encoding"] => {
262                validate_enum(value, &["utf-8", "gbk", "big5", "shift_jis"])?;
263                config.formats.default_encoding = value.to_string();
264            }
265            ["formats", "encoding_detection_confidence"] => {
266                let v = validate_float_range(value, 0.0, 1.0)?;
267                config.formats.encoding_detection_confidence = v;
268            }
269            ["sync", "max_offset_seconds"] => {
270                let v = validate_float_range(value, 0.0, 300.0)?;
271                config.sync.max_offset_seconds = v;
272            }
273            ["sync", "default_method"] => {
274                validate_enum(value, &["auto", "vad"])?;
275                config.sync.default_method = value.to_string();
276            }
277            ["sync", "vad", "enabled"] => {
278                let v = parse_bool(value)?;
279                config.sync.vad.enabled = v;
280            }
281            ["sync", "vad", "sensitivity"] => {
282                let v = validate_float_range(value, 0.0, 1.0)?;
283                config.sync.vad.sensitivity = v;
284            }
285            ["sync", "vad", "padding_chunks"] => {
286                let v = validate_uint_range(value, 0, u32::MAX)?;
287                config.sync.vad.padding_chunks = v;
288            }
289            ["sync", "vad", "min_speech_duration_ms"] => {
290                let v = validate_uint_range(value, 0, u32::MAX)?;
291                config.sync.vad.min_speech_duration_ms = v;
292            }
293            ["sync", "correlation_threshold"] => {
294                let v = validate_float_range(value, 0.0, 1.0)?;
295                config.sync.correlation_threshold = v;
296            }
297            ["sync", "dialogue_detection_threshold"] => {
298                let v = validate_float_range(value, 0.0, 1.0)?;
299                config.sync.dialogue_detection_threshold = v;
300            }
301            ["sync", "min_dialogue_duration_ms"] => {
302                let v = validate_uint_range(value, 100, 5000)?;
303                config.sync.min_dialogue_duration_ms = v;
304            }
305            ["sync", "dialogue_merge_gap_ms"] => {
306                let v = validate_uint_range(value, 50, 2000)?;
307                config.sync.dialogue_merge_gap_ms = v;
308            }
309            ["sync", "enable_dialogue_detection"] => {
310                let v = parse_bool(value)?;
311                config.sync.enable_dialogue_detection = v;
312            }
313            ["sync", "audio_sample_rate"] => {
314                let v = validate_uint_range(value, 8000, 192000)?;
315                config.sync.audio_sample_rate = v;
316            }
317            ["sync", "auto_detect_sample_rate"] => {
318                let v = parse_bool(value)?;
319                config.sync.auto_detect_sample_rate = v;
320            }
321            ["general", "backup_enabled"] => {
322                let v = parse_bool(value)?;
323                config.general.backup_enabled = v;
324            }
325            ["general", "max_concurrent_jobs"] => {
326                let v = validate_usize_range(value, 1, 64)?;
327                config.general.max_concurrent_jobs = v;
328            }
329            ["general", "task_timeout_seconds"] => {
330                let v = validate_u64_range(value, 30, 3600)?;
331                config.general.task_timeout_seconds = v;
332            }
333            ["general", "enable_progress_bar"] => {
334                let v = parse_bool(value)?;
335                config.general.enable_progress_bar = v;
336            }
337            ["general", "worker_idle_timeout_seconds"] => {
338                let v = validate_u64_range(value, 10, 3600)?;
339                config.general.worker_idle_timeout_seconds = v;
340            }
341            ["general", "max_subtitle_bytes"] => {
342                let v = validate_u64_range(value, 1024, 1_073_741_824)?;
343                config.general.max_subtitle_bytes = v;
344            }
345            ["general", "max_audio_bytes"] => {
346                let v = validate_u64_range(value, 1024, 10_737_418_240)?;
347                config.general.max_audio_bytes = v;
348            }
349            ["parallel", "max_workers"] => {
350                let v = validate_usize_range(value, 1, 64)?;
351                config.parallel.max_workers = v;
352            }
353            ["parallel", "task_queue_size"] => {
354                let v = validate_usize_range(value, 100, 10000)?;
355                config.parallel.task_queue_size = v;
356            }
357            ["parallel", "enable_task_priorities"] => {
358                let v = parse_bool(value)?;
359                config.parallel.enable_task_priorities = v;
360            }
361            ["parallel", "auto_balance_workers"] => {
362                let v = parse_bool(value)?;
363                config.parallel.auto_balance_workers = v;
364            }
365            ["parallel", "overflow_strategy"] => {
366                validate_enum(value, &["Block", "Drop", "Expand"])?;
367                config.parallel.overflow_strategy = match value {
368                    "Block" => OverflowStrategy::Block,
369                    "Drop" => OverflowStrategy::Drop,
370                    "Expand" => OverflowStrategy::Expand,
371                    _ => unreachable!(),
372                };
373            }
374            _ => {
375                return Err(SubXError::config(format!(
376                    "Unknown configuration key: {key}"
377                )));
378            }
379        }
380        Ok(())
381    }
382}
383
384impl Default for TestConfigService {
385    fn default() -> Self {
386        Self::with_defaults()
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_config_service_with_defaults() {
396        let service = TestConfigService::with_defaults();
397        let config = service.get_config().unwrap();
398
399        assert_eq!(config.ai.provider, "openai");
400        assert_eq!(config.ai.model, "gpt-4.1-mini");
401    }
402
403    #[test]
404    fn test_config_service_with_ai_settings() {
405        let service = TestConfigService::with_ai_settings("anthropic", "claude-3");
406        let config = service.get_config().unwrap();
407
408        assert_eq!(config.ai.provider, "anthropic");
409        assert_eq!(config.ai.model, "claude-3");
410    }
411
412    #[test]
413    fn test_config_service_with_ai_settings_and_key() {
414        let service =
415            TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "test-api-key");
416        let config = service.get_config().unwrap();
417
418        assert_eq!(config.ai.provider, "openai");
419        assert_eq!(config.ai.model, "gpt-4.1");
420        assert_eq!(config.ai.api_key, Some("test-api-key".to_string()));
421    }
422
423    #[test]
424    fn test_config_service_with_ai_settings_and_key_openrouter() {
425        let service = TestConfigService::with_ai_settings_and_key(
426            "openrouter",
427            "deepseek/deepseek-r1-0528:free",
428            "test-openrouter-key",
429        );
430        let config = service.get_config().unwrap();
431        assert_eq!(config.ai.provider, "openrouter");
432        assert_eq!(config.ai.model, "deepseek/deepseek-r1-0528:free");
433        assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
434    }
435
436    #[test]
437    fn test_config_service_with_sync_settings() {
438        let service = TestConfigService::with_sync_settings(0.8, 45.0);
439        let config = service.get_config().unwrap();
440
441        assert_eq!(config.sync.correlation_threshold, 0.8);
442        assert_eq!(config.sync.max_offset_seconds, 45.0);
443    }
444
445    #[test]
446    fn test_config_service_with_parallel_settings() {
447        let service = TestConfigService::with_parallel_settings(8, 200);
448        let config = service.get_config().unwrap();
449
450        assert_eq!(config.general.max_concurrent_jobs, 8);
451        assert_eq!(config.parallel.task_queue_size, 200);
452    }
453
454    #[test]
455    fn test_config_service_reload() {
456        let service = TestConfigService::with_defaults();
457
458        // Reload should always succeed for test service
459        assert!(service.reload().is_ok());
460    }
461
462    #[test]
463    fn test_config_service_direct_access() {
464        let service = TestConfigService::with_defaults();
465
466        // Test direct read access
467        assert_eq!(service.config().ai.provider, "openai");
468
469        // Test mutable access
470        service.config_mut().ai.provider = "modified".to_string();
471        assert_eq!(service.config().ai.provider, "modified");
472
473        let config = service.get_config().unwrap();
474        assert_eq!(config.ai.provider, "modified");
475    }
476}