Skip to main content

subx_cli/config/
service.rs

1#![allow(deprecated)]
2//! Configuration service system for dependency injection and test isolation.
3//!
4//! This module provides a clean abstraction for configuration management
5//! that enables dependency injection and complete test isolation without
6//! requiring unsafe code or global state resets.
7
8use crate::config::{EnvironmentProvider, SystemEnvironmentProvider};
9use crate::{Result, config::Config, error::SubXError};
10use config::{Config as ConfigCrate, ConfigBuilder, Environment, File, builder::DefaultState};
11use log::debug;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, RwLock};
14
15/// Write configuration content to `path` with restrictive permissions.
16///
17/// On Unix the parent directory is created (if missing) with mode `0o700` and
18/// the file is created/truncated with mode `0o600`, ensuring only the current
19/// user can read the file containing sensitive values such as API keys.
20///
21/// On non-Unix platforms the file is written with the platform's default
22/// permissions because POSIX modes do not apply.
23#[cfg(unix)]
24fn secure_write_config_file(path: &Path, content: &str) -> std::io::Result<()> {
25    use std::io::Write;
26    use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
27
28    if let Some(parent) = path.parent() {
29        if !parent.as_os_str().is_empty() && !parent.exists() {
30            std::fs::create_dir_all(parent)?;
31            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
32        }
33    }
34
35    let mut file = std::fs::OpenOptions::new()
36        .write(true)
37        .create(true)
38        .truncate(true)
39        .mode(0o600)
40        .open(path)?;
41    file.write_all(content.as_bytes())?;
42    // Ensure an existing file's permissions are tightened as well.
43    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
44    Ok(())
45}
46
47#[cfg(not(unix))]
48fn secure_write_config_file(path: &Path, content: &str) -> std::io::Result<()> {
49    if let Some(parent) = path.parent() {
50        if !parent.as_os_str().is_empty() && !parent.exists() {
51            std::fs::create_dir_all(parent)?;
52        }
53    }
54    std::fs::write(path, content)
55}
56
57/// Configuration service trait for dependency injection.
58///
59/// This trait abstracts configuration loading and reloading operations,
60/// allowing different implementations for production and testing environments.
61pub trait ConfigService: Send + Sync {
62    /// Get the current configuration.
63    ///
64    /// Returns a clone of the current configuration state. This method
65    /// may use internal caching for performance.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if configuration loading or validation fails.
70    /// Get the current configuration.
71    ///
72    /// Returns the current [`Config`] instance loaded from files,
73    /// environment variables, and defaults.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if configuration loading fails due to:
78    /// - Invalid TOML format in configuration files
79    /// - Missing required configuration values
80    /// - File system access issues
81    fn get_config(&self) -> Result<Config>;
82
83    /// Reload configuration from sources.
84    ///
85    /// Forces a reload of configuration from all sources, discarding
86    /// any cached values.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if configuration reloading fails.
91    fn reload(&self) -> Result<()>;
92
93    /// Save current configuration to the default file location.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error if:
98    /// - Unable to determine config file path
99    /// - File system write permissions are insufficient
100    /// - TOML serialization fails
101    fn save_config(&self) -> Result<()>;
102
103    /// Save configuration to a specific file path.
104    ///
105    /// # Arguments
106    ///
107    /// - `path`: Target file path for the configuration
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if:
112    /// - TOML serialization fails
113    /// - Unable to create parent directories
114    /// - File write operation fails
115    fn save_config_to_file(&self, path: &Path) -> Result<()>;
116
117    /// Get the default configuration file path.
118    ///
119    /// # Returns
120    ///
121    /// Returns the path where configuration files are expected to be located,
122    /// typically `$CONFIG_DIR/subx/config.toml`.
123    fn get_config_file_path(&self) -> Result<PathBuf>;
124
125    /// Get a specific configuration value by key path.
126    ///
127    /// # Arguments
128    ///
129    /// - `key`: Dot-separated path to the configuration value (e.g., "ai.provider")
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if the key is not recognized.
134    fn get_config_value(&self, key: &str) -> Result<String>;
135
136    /// Reset configuration to default values.
137    ///
138    /// This will overwrite the current configuration file with default values
139    /// and reload the configuration.
140    ///
141    /// # Errors
142    ///
143    /// Returns an error if save or reload fails.
144    fn reset_to_defaults(&self) -> Result<()>;
145
146    /// Set a specific configuration value by key path.
147    ///
148    /// # Arguments
149    ///
150    /// - `key`: Dot-separated path to the configuration value
151    /// - `value`: New value as string (will be converted to appropriate type)
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if validation or persistence fails, including:
156    /// - Unknown configuration key
157    /// - Type conversion or validation error
158    /// - Failure to persist configuration
159    fn set_config_value(&self, key: &str, value: &str) -> Result<()>;
160}
161
162/// Production configuration service implementation.
163///
164/// This service loads configuration from multiple sources in order of priority:
165/// 1. Environment variables (highest priority)
166/// 2. User configuration file
167/// 3. Default configuration file (lowest priority)
168///
169/// Configuration is cached after first load for performance.
170pub struct ProductionConfigService {
171    config_builder: ConfigBuilder<DefaultState>,
172    cached_config: Arc<RwLock<Option<Config>>>,
173    env_provider: Arc<dyn EnvironmentProvider>,
174}
175
176impl ProductionConfigService {
177    /// Create a new production configuration service.
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the configuration builder cannot be initialized.
182    /// Creates a configuration service using the default environment variable provider (maintains compatibility with existing methods).
183    pub fn new() -> Result<Self> {
184        Self::with_env_provider(Arc::new(SystemEnvironmentProvider::new()))
185    }
186
187    /// Create a configuration service using the specified environment variable provider.
188    ///
189    /// # Arguments
190    /// * `env_provider` - Environment variable provider
191    pub fn with_env_provider(env_provider: Arc<dyn EnvironmentProvider>) -> Result<Self> {
192        // Check if a custom config path is specified in the environment provider
193        let config_file_path = if let Some(custom_path) = env_provider.get_var("SUBX_CONFIG_PATH") {
194            PathBuf::from(custom_path)
195        } else {
196            Self::user_config_path()
197        };
198
199        let config_builder = ConfigCrate::builder()
200            .add_source(File::with_name("config/default").required(false))
201            .add_source(File::from(config_file_path).required(false))
202            .add_source(Environment::with_prefix("SUBX").separator("_"));
203
204        Ok(Self {
205            config_builder,
206            cached_config: Arc::new(RwLock::new(None)),
207            env_provider,
208        })
209    }
210
211    /// Create a configuration service with custom sources.
212    ///
213    /// This allows adding additional configuration sources for specific use cases.
214    ///
215    /// # Arguments
216    ///
217    /// * `sources` - Additional configuration sources to add
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if the configuration builder cannot be updated.
222    pub fn with_custom_file(mut self, file_path: PathBuf) -> Result<Self> {
223        self.config_builder = self.config_builder.add_source(File::from(file_path));
224        Ok(self)
225    }
226
227    /// Get the user configuration file path.
228    ///
229    /// Returns the path to the user's configuration file, which is typically
230    /// located in the user's configuration directory.
231    fn user_config_path() -> PathBuf {
232        dirs::config_dir()
233            .unwrap_or_else(|| PathBuf::from("."))
234            .join("subx")
235            .join("config.toml")
236    }
237
238    /// Load and validate configuration from all sources.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error if configuration loading or validation fails.
243    fn load_and_validate(&self) -> Result<Config> {
244        debug!("ProductionConfigService: Loading configuration from sources");
245
246        // Build configuration from all sources
247        let config_crate = self.config_builder.build_cloned().map_err(|e| {
248            debug!("ProductionConfigService: Config build failed: {e}");
249            SubXError::config(format!("Failed to build configuration: {e}"))
250        })?;
251
252        // Start with default configuration
253        let mut app_config = Config::default();
254
255        // Try to deserialize from config crate, but fall back to defaults if needed
256        if let Ok(config) = config_crate.clone().try_deserialize::<Config>() {
257            app_config = config;
258            debug!("ProductionConfigService: Full configuration loaded successfully");
259        } else {
260            debug!("ProductionConfigService: Full deserialization failed, attempting partial load");
261
262            // Try to load partial configurations from environment
263            if let Ok(raw_map) = config_crate
264                .try_deserialize::<std::collections::HashMap<String, serde_json::Value>>()
265            {
266                // Extract AI configuration if available
267                if let Some(ai_section) = raw_map.get("ai") {
268                    if let Some(ai_obj) = ai_section.as_object() {
269                        // Extract individual AI fields that are available
270                        if let Some(api_key) = ai_obj.get("apikey").and_then(|v| v.as_str()) {
271                            app_config.ai.api_key = Some(api_key.to_string());
272                            debug!(
273                                "ProductionConfigService: AI API key loaded from SUBX_AI_APIKEY"
274                            );
275                        }
276                        if let Some(provider) = ai_obj.get("provider").and_then(|v| v.as_str()) {
277                            app_config.ai.provider = provider.to_string();
278                            debug!(
279                                "ProductionConfigService: AI provider loaded from SUBX_AI_PROVIDER"
280                            );
281                        }
282                        if let Some(model) = ai_obj.get("model").and_then(|v| v.as_str()) {
283                            app_config.ai.model = model.to_string();
284                            debug!("ProductionConfigService: AI model loaded from SUBX_AI_MODEL");
285                        }
286                        if let Some(base_url) = ai_obj.get("base_url").and_then(|v| v.as_str()) {
287                            app_config.ai.base_url = base_url.to_string();
288                            debug!(
289                                "ProductionConfigService: AI base URL loaded from SUBX_AI_BASE_URL"
290                            );
291                        }
292                    }
293                }
294            }
295        }
296
297        // Special handling for OPENROUTER_API_KEY environment variable
298        if let Some(api_key) = self.env_provider.get_var("OPENROUTER_API_KEY") {
299            debug!("ProductionConfigService: Found OPENROUTER_API_KEY environment variable");
300            app_config.ai.provider = "openrouter".to_string();
301            app_config.ai.api_key = Some(api_key);
302        }
303
304        // Special handling for OPENAI_API_KEY environment variable
305        // This provides backward compatibility with direct OPENAI_API_KEY usage
306        if app_config.ai.api_key.is_none() {
307            if let Some(api_key) = self.env_provider.get_var("OPENAI_API_KEY") {
308                debug!("ProductionConfigService: Found OPENAI_API_KEY environment variable");
309                app_config.ai.api_key = Some(api_key);
310            }
311        }
312
313        // Special handling for OPENAI_BASE_URL environment variable
314        if let Some(base_url) = self.env_provider.get_var("OPENAI_BASE_URL") {
315            debug!("ProductionConfigService: Found OPENAI_BASE_URL environment variable");
316            app_config.ai.base_url = base_url;
317        }
318
319        // Special handling for Azure OpenAI environment variables
320        if let Some(api_key) = self.env_provider.get_var("AZURE_OPENAI_API_KEY") {
321            debug!("ProductionConfigService: Found AZURE_OPENAI_API_KEY environment variable");
322            app_config.ai.provider = "azure-openai".to_string();
323            app_config.ai.api_key = Some(api_key);
324        }
325        if let Some(endpoint) = self.env_provider.get_var("AZURE_OPENAI_ENDPOINT") {
326            debug!("ProductionConfigService: Found AZURE_OPENAI_ENDPOINT environment variable");
327            app_config.ai.base_url = endpoint;
328        }
329        if let Some(version) = self.env_provider.get_var("AZURE_OPENAI_API_VERSION") {
330            debug!("ProductionConfigService: Found AZURE_OPENAI_API_VERSION environment variable");
331            app_config.ai.api_version = Some(version);
332        }
333        // Special handling for Azure OpenAI deployment ID environment variable
334        if let Some(deployment) = self.env_provider.get_var("AZURE_OPENAI_DEPLOYMENT_ID") {
335            debug!(
336                "ProductionConfigService: Found AZURE_OPENAI_DEPLOYMENT_ID environment variable"
337            );
338            app_config.ai.model = deployment;
339        }
340
341        // Validate the configuration
342        crate::config::validator::validate_config(&app_config).map_err(|e| {
343            debug!("ProductionConfigService: Config validation failed: {e}");
344            SubXError::config(format!("Configuration validation failed: {e}"))
345        })?;
346
347        debug!("ProductionConfigService: Configuration loaded and validated successfully");
348        Ok(app_config)
349    }
350
351    /// Validate and set a configuration value.
352    ///
353    /// This method now delegates validation to the field_validator module.
354    fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
355        use crate::config::field_validator;
356
357        // Use the dedicated field validator
358        field_validator::validate_field(key, value)?;
359
360        // Set the value in the configuration
361        self.set_value_internal(config, key, value)?;
362
363        // Validate the entire configuration after the change
364        self.validate_configuration(config)?;
365
366        Ok(())
367    }
368
369    /// Internal method to set configuration values without validation.
370    fn set_value_internal(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
371        use crate::config::OverflowStrategy;
372        use crate::config::validation::*;
373        use crate::error::SubXError;
374
375        let parts: Vec<&str> = key.split('.').collect();
376        match parts.as_slice() {
377            ["ai", "provider"] => {
378                config.ai.provider = value.to_string();
379            }
380            ["ai", "api_key"] => {
381                if !value.is_empty() {
382                    config.ai.api_key = Some(value.to_string());
383                } else {
384                    config.ai.api_key = None;
385                }
386            }
387            ["ai", "model"] => {
388                config.ai.model = value.to_string();
389            }
390            ["ai", "base_url"] => {
391                config.ai.base_url = value.to_string();
392            }
393            ["ai", "max_sample_length"] => {
394                let v = value.parse().unwrap(); // Validation already done
395                config.ai.max_sample_length = v;
396            }
397            ["ai", "temperature"] => {
398                let v = value.parse().unwrap(); // Validation already done
399                config.ai.temperature = v;
400            }
401            ["ai", "max_tokens"] => {
402                let v = value.parse().unwrap(); // Validation already done
403                config.ai.max_tokens = v;
404            }
405            ["ai", "retry_attempts"] => {
406                let v = value.parse().unwrap(); // Validation already done
407                config.ai.retry_attempts = v;
408            }
409            ["ai", "retry_delay_ms"] => {
410                let v = value.parse().unwrap(); // Validation already done
411                config.ai.retry_delay_ms = v;
412            }
413            ["ai", "request_timeout_seconds"] => {
414                let v = value.parse().unwrap(); // Validation already done
415                config.ai.request_timeout_seconds = v;
416            }
417            ["ai", "api_version"] => {
418                if !value.is_empty() {
419                    config.ai.api_version = Some(value.to_string());
420                } else {
421                    config.ai.api_version = None;
422                }
423            }
424            ["formats", "default_output"] => {
425                config.formats.default_output = value.to_string();
426            }
427            ["formats", "preserve_styling"] => {
428                let v = parse_bool(value)?;
429                config.formats.preserve_styling = v;
430            }
431            ["formats", "default_encoding"] => {
432                config.formats.default_encoding = value.to_string();
433            }
434            ["formats", "encoding_detection_confidence"] => {
435                let v = value.parse().unwrap(); // Validation already done
436                config.formats.encoding_detection_confidence = v;
437            }
438            ["sync", "max_offset_seconds"] => {
439                let v = value.parse().unwrap(); // Validation already done
440                config.sync.max_offset_seconds = v;
441            }
442            ["sync", "default_method"] => {
443                config.sync.default_method = value.to_string();
444            }
445            ["sync", "vad", "enabled"] => {
446                let v = parse_bool(value)?;
447                config.sync.vad.enabled = v;
448            }
449            ["sync", "vad", "sensitivity"] => {
450                let v = value.parse().unwrap(); // Validation already done
451                config.sync.vad.sensitivity = v;
452            }
453            ["sync", "vad", "padding_chunks"] => {
454                let v = value.parse().unwrap(); // Validation already done
455                config.sync.vad.padding_chunks = v;
456            }
457            ["sync", "vad", "min_speech_duration_ms"] => {
458                let v = value.parse().unwrap(); // Validation already done
459                config.sync.vad.min_speech_duration_ms = v;
460            }
461            ["general", "backup_enabled"] => {
462                let v = parse_bool(value)?;
463                config.general.backup_enabled = v;
464            }
465            ["general", "max_concurrent_jobs"] => {
466                let v = value.parse().unwrap(); // Validation already done
467                config.general.max_concurrent_jobs = v;
468            }
469            ["general", "task_timeout_seconds"] => {
470                let v = value.parse().unwrap(); // Validation already done
471                config.general.task_timeout_seconds = v;
472            }
473            ["general", "enable_progress_bar"] => {
474                let v = parse_bool(value)?;
475                config.general.enable_progress_bar = v;
476            }
477            ["general", "worker_idle_timeout_seconds"] => {
478                let v = value.parse().unwrap(); // Validation already done
479                config.general.worker_idle_timeout_seconds = v;
480            }
481            ["general", "max_subtitle_bytes"] => {
482                let v = value.parse().unwrap(); // Validation already done
483                config.general.max_subtitle_bytes = v;
484            }
485            ["general", "max_audio_bytes"] => {
486                let v = value.parse().unwrap(); // Validation already done
487                config.general.max_audio_bytes = v;
488            }
489            ["parallel", "max_workers"] => {
490                let v = value.parse().unwrap(); // Validation already done
491                config.parallel.max_workers = v;
492            }
493            ["parallel", "task_queue_size"] => {
494                let v = value.parse().unwrap(); // Validation already done
495                config.parallel.task_queue_size = v;
496            }
497            ["parallel", "enable_task_priorities"] => {
498                let v = parse_bool(value)?;
499                config.parallel.enable_task_priorities = v;
500            }
501            ["parallel", "auto_balance_workers"] => {
502                let v = parse_bool(value)?;
503                config.parallel.auto_balance_workers = v;
504            }
505            ["parallel", "overflow_strategy"] => {
506                config.parallel.overflow_strategy = match value {
507                    "Block" => OverflowStrategy::Block,
508                    "Drop" => OverflowStrategy::Drop,
509                    "Expand" => OverflowStrategy::Expand,
510                    _ => unreachable!(), // Validation already done
511                };
512            }
513            _ => {
514                return Err(SubXError::config(format!(
515                    "Unknown configuration key: {key}"
516                )));
517            }
518        }
519        Ok(())
520    }
521
522    /// Validate the entire configuration.
523    fn validate_configuration(&self, config: &Config) -> Result<()> {
524        use crate::config::validator;
525        validator::validate_config(config)
526    }
527
528    /// Save configuration to file with specific config object.
529    fn save_config_to_file_with_config(
530        &self,
531        path: &std::path::Path,
532        config: &Config,
533    ) -> Result<()> {
534        let toml_content = toml::to_string_pretty(config)
535            .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
536        secure_write_config_file(path, &toml_content)
537            .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
538        Ok(())
539    }
540}
541
542impl ConfigService for ProductionConfigService {
543    fn get_config(&self) -> Result<Config> {
544        // Check cache first
545        {
546            let cache = self.cached_config.read().unwrap();
547            if let Some(config) = cache.as_ref() {
548                debug!("ProductionConfigService: Returning cached configuration");
549                return Ok(config.clone());
550            }
551        }
552
553        // Load configuration
554        let app_config = self.load_and_validate()?;
555
556        // Update cache
557        {
558            let mut cache = self.cached_config.write().unwrap();
559            *cache = Some(app_config.clone());
560        }
561
562        Ok(app_config)
563    }
564
565    fn reload(&self) -> Result<()> {
566        debug!("ProductionConfigService: Reloading configuration");
567
568        // Clear cache to force reload
569        {
570            let mut cache = self.cached_config.write().unwrap();
571            *cache = None;
572        }
573
574        // Trigger reload by calling get_config
575        self.get_config()?;
576
577        debug!("ProductionConfigService: Configuration reloaded successfully");
578        Ok(())
579    }
580
581    fn save_config(&self) -> Result<()> {
582        let _config = self.get_config()?;
583        let path = self.get_config_file_path()?;
584        self.save_config_to_file(&path)
585    }
586
587    fn save_config_to_file(&self, path: &Path) -> Result<()> {
588        let config = self.get_config()?;
589        let toml_content = toml::to_string_pretty(&config)
590            .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
591
592        secure_write_config_file(path, &toml_content)
593            .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
594
595        Ok(())
596    }
597
598    fn get_config_file_path(&self) -> Result<PathBuf> {
599        // Allow injection via EnvironmentProvider for testing
600        if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
601            return Ok(PathBuf::from(custom));
602        }
603
604        let config_dir = dirs::config_dir()
605            .ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
606        Ok(config_dir.join("subx").join("config.toml"))
607    }
608
609    fn get_config_value(&self, key: &str) -> Result<String> {
610        let config = self.get_config()?;
611        let parts: Vec<&str> = key.split('.').collect();
612        match parts.as_slice() {
613            ["ai", "provider"] => Ok(config.ai.provider.clone()),
614            ["ai", "model"] => Ok(config.ai.model.clone()),
615            ["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
616            ["ai", "base_url"] => Ok(config.ai.base_url.clone()),
617            ["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
618            ["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
619            ["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
620            ["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
621            ["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
622            ["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
623
624            ["formats", "default_output"] => Ok(config.formats.default_output.clone()),
625            ["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
626            ["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
627            ["formats", "encoding_detection_confidence"] => {
628                Ok(config.formats.encoding_detection_confidence.to_string())
629            }
630
631            ["sync", "default_method"] => Ok(config.sync.default_method.clone()),
632            ["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
633            ["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
634            ["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
635            ["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
636            ["sync", "vad", "min_speech_duration_ms"] => {
637                Ok(config.sync.vad.min_speech_duration_ms.to_string())
638            }
639
640            ["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
641            ["general", "max_concurrent_jobs"] => {
642                Ok(config.general.max_concurrent_jobs.to_string())
643            }
644            ["general", "task_timeout_seconds"] => {
645                Ok(config.general.task_timeout_seconds.to_string())
646            }
647            ["general", "enable_progress_bar"] => {
648                Ok(config.general.enable_progress_bar.to_string())
649            }
650            ["general", "worker_idle_timeout_seconds"] => {
651                Ok(config.general.worker_idle_timeout_seconds.to_string())
652            }
653            ["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
654            ["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
655
656            ["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
657            ["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
658            ["parallel", "enable_task_priorities"] => {
659                Ok(config.parallel.enable_task_priorities.to_string())
660            }
661            ["parallel", "auto_balance_workers"] => {
662                Ok(config.parallel.auto_balance_workers.to_string())
663            }
664            ["parallel", "overflow_strategy"] => {
665                Ok(format!("{:?}", config.parallel.overflow_strategy))
666            }
667
668            _ => Err(SubXError::config(format!(
669                "Unknown configuration key: {}",
670                key
671            ))),
672        }
673    }
674
675    fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
676        // 1. Load current configuration
677        let mut config = self.get_config()?;
678
679        // 2. Validate and set the value
680        self.validate_and_set_value(&mut config, key, value)?;
681
682        // 3. Validate the entire configuration
683        crate::config::validator::validate_config(&config)?;
684
685        // 4. Save to file
686        let path = self.get_config_file_path()?;
687        self.save_config_to_file_with_config(&path, &config)?;
688
689        // 5. Update cache
690        {
691            let mut cache = self.cached_config.write().unwrap();
692            *cache = Some(config);
693        }
694
695        Ok(())
696    }
697
698    fn reset_to_defaults(&self) -> Result<()> {
699        let default_config = Config::default();
700        let path = self.get_config_file_path()?;
701
702        let toml_content = toml::to_string_pretty(&default_config)
703            .map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
704
705        secure_write_config_file(&path, &toml_content)
706            .map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
707
708        self.reload()
709    }
710}
711
712impl Default for ProductionConfigService {
713    fn default() -> Self {
714        Self::new().expect("Failed to create default ProductionConfigService")
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::config::TestConfigService;
722    use crate::config::TestEnvironmentProvider;
723    use std::sync::Arc;
724
725    #[test]
726    fn test_production_config_service_creation() {
727        let service = ProductionConfigService::new();
728        assert!(service.is_ok());
729    }
730
731    #[test]
732    fn test_production_config_service_with_custom_file() {
733        let service = ProductionConfigService::new()
734            .unwrap()
735            .with_custom_file(PathBuf::from("test.toml"));
736        assert!(service.is_ok());
737    }
738
739    #[test]
740    fn test_production_service_implements_config_service_trait() {
741        let service = ProductionConfigService::new().unwrap();
742
743        // Test trait methods
744        let config1 = service.get_config();
745        assert!(config1.is_ok());
746
747        let reload_result = service.reload();
748        assert!(reload_result.is_ok());
749
750        let config2 = service.get_config();
751        assert!(config2.is_ok());
752    }
753
754    #[test]
755    fn test_production_config_service_openrouter_api_key_loading() {
756        use crate::config::TestEnvironmentProvider;
757        use std::sync::Arc;
758
759        let mut env_provider = TestEnvironmentProvider::new();
760        env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
761        env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
762
763        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
764            .expect("Failed to create config service");
765
766        let config = service.get_config().expect("Failed to get config");
767
768        assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
769    }
770
771    #[test]
772    fn test_config_service_with_openai_api_key() {
773        // Test configuration with OpenAI API key using TestConfigService
774        let test_service = TestConfigService::with_ai_settings_and_key(
775            "openai",
776            "gpt-4.1-mini",
777            "sk-test-openai-key-123",
778        );
779
780        let config = test_service.get_config().unwrap();
781        assert_eq!(
782            config.ai.api_key,
783            Some("sk-test-openai-key-123".to_string())
784        );
785        assert_eq!(config.ai.provider, "openai");
786        assert_eq!(config.ai.model, "gpt-4.1-mini");
787    }
788
789    #[test]
790    fn test_config_service_with_custom_base_url() {
791        // Test configuration with custom base URL
792        let mut config = Config::default();
793        config.ai.base_url = "https://custom.openai.endpoint".to_string();
794
795        let test_service = TestConfigService::new(config);
796        let loaded_config = test_service.get_config().unwrap();
797
798        assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
799    }
800
801    #[test]
802    fn test_config_service_with_both_openai_settings() {
803        // Test configuration with both API key and base URL
804        let mut config = Config::default();
805        config.ai.api_key = Some("sk-test-api-key-combined".to_string());
806        config.ai.base_url = "https://api.custom-openai.com".to_string();
807
808        let test_service = TestConfigService::new(config);
809        let loaded_config = test_service.get_config().unwrap();
810
811        assert_eq!(
812            loaded_config.ai.api_key,
813            Some("sk-test-api-key-combined".to_string())
814        );
815        assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
816    }
817
818    #[test]
819    fn test_config_service_provider_precedence() {
820        // Test that manually configured values take precedence
821        let test_service =
822            TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
823
824        let config = test_service.get_config().unwrap();
825        assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
826        assert_eq!(config.ai.provider, "openai");
827        assert_eq!(config.ai.model, "gpt-4.1");
828    }
829
830    #[test]
831    fn test_config_service_fallback_behavior() {
832        // Test fallback to default values when no specific configuration provided
833        let test_service = TestConfigService::with_defaults();
834        let config = test_service.get_config().unwrap();
835
836        // Should use default values
837        assert_eq!(config.ai.provider, "openai");
838        assert_eq!(config.ai.model, "gpt-4.1-mini");
839        assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
840        assert_eq!(config.ai.api_key, None); // No API key by default
841    }
842
843    #[test]
844    fn test_config_service_reload_functionality() {
845        // Test configuration reload capability
846        let test_service = TestConfigService::with_defaults();
847
848        // First load
849        let config1 = test_service.get_config().unwrap();
850        assert_eq!(config1.ai.provider, "openai");
851
852        // Reload should always succeed for test service
853        let reload_result = test_service.reload();
854        assert!(reload_result.is_ok());
855
856        // Second load should still work
857        let config2 = test_service.get_config().unwrap();
858        assert_eq!(config2.ai.provider, "openai");
859    }
860
861    #[test]
862    fn test_config_service_custom_base_url_override() {
863        // Test that custom base URL properly overrides default
864        let mut config = Config::default();
865        config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
866
867        let test_service = TestConfigService::new(config);
868        let loaded_config = test_service.get_config().unwrap();
869
870        assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
871    }
872
873    #[test]
874    fn test_config_service_sync_settings() {
875        // Test sync configuration settings
876        let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
877        let config = test_service.get_config().unwrap();
878
879        assert_eq!(config.sync.correlation_threshold, 0.8);
880        assert_eq!(config.sync.max_offset_seconds, 45.0);
881    }
882
883    #[test]
884    fn test_config_service_parallel_settings() {
885        // Test parallel processing configuration
886        let test_service = TestConfigService::with_parallel_settings(8, 200);
887        let config = test_service.get_config().unwrap();
888
889        assert_eq!(config.general.max_concurrent_jobs, 8);
890        assert_eq!(config.parallel.task_queue_size, 200);
891    }
892
893    #[test]
894    fn test_config_size_limits_defaults() {
895        let service = TestConfigService::with_defaults();
896        let cfg = service.get_config().unwrap();
897        assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
898        assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
899    }
900
901    #[test]
902    fn test_config_size_limits_roundtrip() {
903        let service = TestConfigService::with_defaults();
904
905        service
906            .set_config_value("general.max_subtitle_bytes", "65536")
907            .unwrap();
908        service
909            .set_config_value("general.max_audio_bytes", "1048576")
910            .unwrap();
911
912        assert_eq!(
913            service
914                .get_config_value("general.max_subtitle_bytes")
915                .unwrap(),
916            "65536"
917        );
918        assert_eq!(
919            service.get_config_value("general.max_audio_bytes").unwrap(),
920            "1048576"
921        );
922    }
923
924    #[test]
925    fn test_config_size_limits_validation_reject() {
926        let service = TestConfigService::with_defaults();
927        // Below minimum (1024)
928        assert!(
929            service
930                .set_config_value("general.max_subtitle_bytes", "100")
931                .is_err()
932        );
933        // Above maximum (1 GiB)
934        assert!(
935            service
936                .set_config_value("general.max_subtitle_bytes", "2147483648")
937                .is_err()
938        );
939    }
940
941    #[test]
942    fn test_config_service_direct_access() {
943        // Test direct configuration access and mutation
944        let test_service = TestConfigService::with_defaults();
945
946        // Test direct read access
947        assert_eq!(test_service.config().ai.provider, "openai");
948
949        // Test mutable access
950        test_service.config_mut().ai.provider = "modified".to_string();
951        assert_eq!(test_service.config().ai.provider, "modified");
952
953        // Test that get_config reflects the changes
954        let config = test_service.get_config().unwrap();
955        assert_eq!(config.ai.provider, "modified");
956    }
957
958    #[test]
959    fn test_production_config_service_openai_api_key_loading() {
960        // Test OPENAI_API_KEY environment variable loading
961        let mut env_provider = TestEnvironmentProvider::new();
962        env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
963
964        // Use a non-existent config path to avoid interference from existing config files
965        env_provider.set_var(
966            "SUBX_CONFIG_PATH",
967            "/tmp/test_config_that_does_not_exist.toml",
968        );
969
970        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
971            .expect("Failed to create config service");
972
973        let config = service.get_config().expect("Failed to get config");
974
975        assert_eq!(
976            config.ai.api_key,
977            Some("sk-test-openai-key-env".to_string())
978        );
979    }
980
981    #[test]
982    fn test_production_config_service_openai_base_url_loading() {
983        // Test OPENAI_BASE_URL environment variable loading
984        let mut env_provider = TestEnvironmentProvider::new();
985        env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
986
987        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
988            .expect("Failed to create config service");
989
990        let config = service.get_config().expect("Failed to get config");
991
992        assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
993    }
994
995    #[test]
996    fn test_production_config_service_both_openai_env_vars() {
997        // Test setting both OPENAI environment variables simultaneously
998        let mut env_provider = TestEnvironmentProvider::new();
999        env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
1000        env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
1001
1002        // Use a non-existent config path to avoid interference from existing config files
1003        env_provider.set_var(
1004            "SUBX_CONFIG_PATH",
1005            "/tmp/test_config_both_that_does_not_exist.toml",
1006        );
1007
1008        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1009            .expect("Failed to create config service");
1010
1011        let config = service.get_config().expect("Failed to get config");
1012
1013        assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
1014        assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
1015    }
1016
1017    #[test]
1018    fn test_production_config_service_no_openai_env_vars() {
1019        // Test the case with no OPENAI environment variables
1020        let mut env_provider = TestEnvironmentProvider::new(); // Empty provider
1021
1022        // Use a non-existent config path to avoid interference from existing config files
1023        env_provider.set_var(
1024            "SUBX_CONFIG_PATH",
1025            "/tmp/test_config_no_openai_that_does_not_exist.toml",
1026        );
1027
1028        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1029            .expect("Failed to create config service");
1030
1031        let config = service.get_config().expect("Failed to get config");
1032
1033        // Should use default values
1034        assert_eq!(config.ai.api_key, None);
1035        assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); // Default value
1036    }
1037
1038    #[test]
1039    fn test_production_config_service_api_key_priority() {
1040        // Test API key priority: existing API key should not be overwritten
1041        let mut env_provider = TestEnvironmentProvider::new();
1042        env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1043        // Simulate API key loaded from other sources (e.g., configuration file)
1044        env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1045
1046        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1047            .expect("Failed to create config service");
1048
1049        let config = service.get_config().expect("Failed to get config");
1050
1051        // SUBX_AI_APIKEY should have higher priority (since it's processed first)
1052        // This test only verifies priority order, should at least have a value
1053        assert!(config.ai.api_key.is_some());
1054    }
1055
1056    #[cfg(unix)]
1057    #[test]
1058    fn test_secure_write_config_file_sets_0600_permissions() {
1059        use std::os::unix::fs::PermissionsExt;
1060
1061        let dir = tempfile::tempdir().expect("create tempdir");
1062        let nested = dir.path().join("subdir");
1063        let path = nested.join("config.toml");
1064
1065        super::secure_write_config_file(&path, "api_key = \"secret\"\n")
1066            .expect("secure write should succeed");
1067
1068        let meta = std::fs::metadata(&path).expect("file must exist");
1069        let mode = meta.permissions().mode() & 0o777;
1070        assert_eq!(
1071            mode, 0o600,
1072            "file permissions must be 0o600, got {:o}",
1073            mode
1074        );
1075
1076        let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
1077        let dir_mode = dir_meta.permissions().mode() & 0o777;
1078        assert_eq!(
1079            dir_mode, 0o700,
1080            "directory permissions must be 0o700, got {:o}",
1081            dir_mode
1082        );
1083
1084        let contents = std::fs::read_to_string(&path).unwrap();
1085        assert_eq!(contents, "api_key = \"secret\"\n");
1086    }
1087
1088    #[cfg(unix)]
1089    #[test]
1090    fn test_secure_write_config_file_truncates_existing_file() {
1091        use std::os::unix::fs::PermissionsExt;
1092
1093        let dir = tempfile::tempdir().expect("create tempdir");
1094        let path = dir.path().join("config.toml");
1095
1096        // Create an existing file with permissive mode and stale contents.
1097        std::fs::write(&path, "stale contents that should be replaced").unwrap();
1098        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1099
1100        super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
1101
1102        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1103        assert_eq!(mode, 0o600);
1104        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
1105    }
1106}