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