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    /// Load the configuration *from the file only* without applying
162    /// environment-variable overlays and without invoking the
163    /// cross-section validator.
164    ///
165    /// This is the "tolerant load" path used exclusively by the `config`
166    /// subcommand handlers (`set`, `get`, `list`) so that users can
167    /// inspect and repair an on-disk configuration that fails strict
168    /// cross-section validation. The pre-existing strict load
169    /// ([`ConfigService::get_config`]) is unchanged and continues to
170    /// drive every other code path.
171    ///
172    /// The returned [`Config`] reflects the file's view of the
173    /// configuration. Successful invocations of this method MUST NOT
174    /// populate the strict-config cache: only configurations that have
175    /// passed cross-section validation may enter the cache.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if:
180    /// - The file cannot be read.
181    /// - The file is not valid TOML.
182    /// - The file's contents cannot be deserialized into a [`Config`]
183    ///   (i.e. an individual field has the wrong type).
184    fn load_for_repair(&self) -> Result<Config>;
185}
186
187/// Production configuration service implementation.
188///
189/// This service loads configuration from multiple sources in order of priority:
190/// 1. Environment variables (highest priority)
191/// 2. User configuration file
192/// 3. Default configuration file (lowest priority)
193///
194/// Configuration is cached after first load for performance.
195pub struct ProductionConfigService {
196    config_builder: ConfigBuilder<DefaultState>,
197    cached_config: Arc<RwLock<Option<Config>>>,
198    env_provider: Arc<dyn EnvironmentProvider>,
199}
200
201impl ProductionConfigService {
202    /// Create a new production configuration service.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the configuration builder cannot be initialized.
207    /// Creates a configuration service using the default environment variable provider (maintains compatibility with existing methods).
208    pub fn new() -> Result<Self> {
209        Self::with_env_provider(Arc::new(SystemEnvironmentProvider::new()))
210    }
211
212    /// Create a configuration service using the specified environment variable provider.
213    ///
214    /// # Arguments
215    /// * `env_provider` - Environment variable provider
216    pub fn with_env_provider(env_provider: Arc<dyn EnvironmentProvider>) -> Result<Self> {
217        // Check if a custom config path is specified in the environment provider
218        let config_file_path = if let Some(custom_path) = env_provider.get_var("SUBX_CONFIG_PATH") {
219            PathBuf::from(custom_path)
220        } else {
221            Self::user_config_path()
222        };
223
224        let config_builder = ConfigCrate::builder()
225            .add_source(File::with_name("config/default").required(false))
226            .add_source(File::from(config_file_path).required(false))
227            .add_source(Environment::with_prefix("SUBX").separator("_"));
228
229        Ok(Self {
230            config_builder,
231            cached_config: Arc::new(RwLock::new(None)),
232            env_provider,
233        })
234    }
235
236    /// Create a configuration service with custom sources.
237    ///
238    /// This allows adding additional configuration sources for specific use cases.
239    ///
240    /// # Arguments
241    ///
242    /// * `sources` - Additional configuration sources to add
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the configuration builder cannot be updated.
247    pub fn with_custom_file(mut self, file_path: PathBuf) -> Result<Self> {
248        self.config_builder = self.config_builder.add_source(File::from(file_path));
249        Ok(self)
250    }
251
252    /// Get the user configuration file path.
253    ///
254    /// Returns the path to the user's configuration file, which is typically
255    /// located in the user's configuration directory.
256    fn user_config_path() -> PathBuf {
257        dirs::config_dir()
258            .unwrap_or_else(|| PathBuf::from("."))
259            .join("subx")
260            .join("config.toml")
261    }
262
263    /// Load and validate configuration from all sources.
264    ///
265    /// # Errors
266    ///
267    /// Returns an error if configuration loading or validation fails.
268    fn load_and_validate(&self) -> Result<Config> {
269        debug!("ProductionConfigService: Loading configuration from sources");
270
271        // Build configuration from all sources
272        let config_crate = self.config_builder.build_cloned().map_err(|e| {
273            debug!("ProductionConfigService: Config build failed: {e}");
274            SubXError::config(format!("Failed to build configuration: {e}"))
275        })?;
276
277        // Start with default configuration
278        let mut app_config = Config::default();
279
280        // Try to deserialize from config crate, but fall back to defaults if needed
281        if let Ok(config) = config_crate.clone().try_deserialize::<Config>() {
282            app_config = config;
283            debug!("ProductionConfigService: Full configuration loaded successfully");
284        } else {
285            debug!("ProductionConfigService: Full deserialization failed, attempting partial load");
286
287            // Try to load partial configurations from environment
288            if let Ok(raw_map) = config_crate
289                .try_deserialize::<std::collections::HashMap<String, serde_json::Value>>()
290            {
291                // Extract AI configuration if available
292                if let Some(ai_section) = raw_map.get("ai") {
293                    if let Some(ai_obj) = ai_section.as_object() {
294                        // Extract individual AI fields that are available
295                        if let Some(api_key) = ai_obj.get("apikey").and_then(|v| v.as_str()) {
296                            app_config.ai.api_key = Some(api_key.to_string());
297                            debug!(
298                                "ProductionConfigService: AI API key loaded from SUBX_AI_APIKEY"
299                            );
300                        }
301                        if let Some(provider) = ai_obj.get("provider").and_then(|v| v.as_str()) {
302                            app_config.ai.provider = provider.to_string();
303                            debug!(
304                                "ProductionConfigService: AI provider loaded from SUBX_AI_PROVIDER"
305                            );
306                        }
307                        if let Some(model) = ai_obj.get("model").and_then(|v| v.as_str()) {
308                            app_config.ai.model = model.to_string();
309                            debug!("ProductionConfigService: AI model loaded from SUBX_AI_MODEL");
310                        }
311                        if let Some(base_url) = ai_obj.get("base_url").and_then(|v| v.as_str()) {
312                            app_config.ai.base_url = base_url.to_string();
313                            debug!(
314                                "ProductionConfigService: AI base URL loaded from SUBX_AI_BASE_URL"
315                            );
316                        }
317                    }
318                }
319            }
320        }
321
322        // Apply SUBX_AI_* overrides directly through the injected
323        // EnvironmentProvider so tests using `TestEnvironmentProvider` can
324        // exercise the precedence rules below without touching real
325        // `std::env`. These mirror what the `config` crate's
326        // `Environment::with_prefix("SUBX")` source produces in production
327        // (the production path is preserved above) but go through the
328        // injectable provider so the carve-out below sees them too.
329        if let Some(provider) = self.env_provider.get_var("SUBX_AI_PROVIDER") {
330            app_config.ai.provider = provider;
331        }
332        if let Some(api_key) = self.env_provider.get_var("SUBX_AI_APIKEY") {
333            app_config.ai.api_key = Some(api_key);
334        }
335        if let Some(base_url) = self.env_provider.get_var("SUBX_AI_BASE_URL") {
336            app_config.ai.base_url = base_url;
337        }
338        if let Some(model) = self.env_provider.get_var("SUBX_AI_MODEL") {
339            app_config.ai.model = model;
340        }
341
342        // Canonicalize the resolved provider BEFORE any precedence or
343        // scoping decision (including the hosted-provider env-var carve-out
344        // below). `SUBX_AI_PROVIDER=ollama` therefore reaches the carve-out
345        // as `"local"` and the factory dispatch as `"local"`.
346        app_config.ai.provider =
347            crate::config::field_validator::normalize_ai_provider(&app_config.ai.provider);
348        let is_local = app_config.ai.provider == "local";
349
350        if is_local {
351            // Privacy posture (Decision 4): when the user has explicitly
352            // selected the local provider, hosted-provider env vars MUST
353            // NOT switch the provider away from `local` and MUST NOT
354            // populate any `ai.*` field. Skip the entire hosted env-var
355            // application path.
356            debug!(
357                "ProductionConfigService: ai.provider=local; skipping hosted-provider env vars \
358                 (OPENAI_API_KEY, OPENAI_BASE_URL, OPENROUTER_API_KEY, AZURE_OPENAI_*)"
359            );
360
361            // LOCAL_LLM_* overrides are honored only when provider is
362            // local, with LOWER precedence than SUBX_AI_BASE_URL /
363            // SUBX_AI_APIKEY (which were already applied above by the
364            // config crate's `Environment::with_prefix("SUBX")` source).
365            if self.env_provider.get_var("SUBX_AI_BASE_URL").is_none() {
366                if let Some(base_url) = self.env_provider.get_var("LOCAL_LLM_BASE_URL") {
367                    debug!(
368                        "ProductionConfigService: Found LOCAL_LLM_BASE_URL environment variable"
369                    );
370                    app_config.ai.base_url = base_url;
371                }
372            }
373            if self.env_provider.get_var("SUBX_AI_APIKEY").is_none() {
374                if let Some(api_key) = self.env_provider.get_var("LOCAL_LLM_API_KEY") {
375                    debug!("ProductionConfigService: Found LOCAL_LLM_API_KEY environment variable");
376                    app_config.ai.api_key = Some(api_key);
377                }
378            }
379        } else {
380            // Special handling for OPENROUTER_API_KEY environment variable
381            if let Some(api_key) = self.env_provider.get_var("OPENROUTER_API_KEY") {
382                debug!("ProductionConfigService: Found OPENROUTER_API_KEY environment variable");
383                app_config.ai.provider = "openrouter".to_string();
384                app_config.ai.api_key = Some(api_key);
385            }
386
387            // Special handling for OPENAI_API_KEY environment variable
388            // This provides backward compatibility with direct OPENAI_API_KEY usage
389            if app_config.ai.api_key.is_none() {
390                if let Some(api_key) = self.env_provider.get_var("OPENAI_API_KEY") {
391                    debug!("ProductionConfigService: Found OPENAI_API_KEY environment variable");
392                    app_config.ai.api_key = Some(api_key);
393                }
394            }
395
396            // Special handling for OPENAI_BASE_URL environment variable
397            if let Some(base_url) = self.env_provider.get_var("OPENAI_BASE_URL") {
398                debug!("ProductionConfigService: Found OPENAI_BASE_URL environment variable");
399                app_config.ai.base_url = base_url;
400            }
401
402            // Special handling for Azure OpenAI environment variables
403            if let Some(api_key) = self.env_provider.get_var("AZURE_OPENAI_API_KEY") {
404                debug!("ProductionConfigService: Found AZURE_OPENAI_API_KEY environment variable");
405                app_config.ai.provider = "azure-openai".to_string();
406                app_config.ai.api_key = Some(api_key);
407            }
408            if let Some(endpoint) = self.env_provider.get_var("AZURE_OPENAI_ENDPOINT") {
409                debug!("ProductionConfigService: Found AZURE_OPENAI_ENDPOINT environment variable");
410                app_config.ai.base_url = endpoint;
411            }
412            if let Some(version) = self.env_provider.get_var("AZURE_OPENAI_API_VERSION") {
413                debug!(
414                    "ProductionConfigService: Found AZURE_OPENAI_API_VERSION environment variable"
415                );
416                app_config.ai.api_version = Some(version);
417            }
418            // Special handling for Azure OpenAI deployment ID environment variable
419            if let Some(deployment) = self.env_provider.get_var("AZURE_OPENAI_DEPLOYMENT_ID") {
420                debug!(
421                    "ProductionConfigService: Found AZURE_OPENAI_DEPLOYMENT_ID environment variable"
422                );
423                app_config.ai.model = deployment;
424            }
425
426            // Re-canonicalize after hosted env-var application in case
427            // OPENROUTER_API_KEY or AZURE_OPENAI_API_KEY switched the
428            // provider above (those values are already canonical, but
429            // running the helper keeps every read site uniform).
430            app_config.ai.provider =
431                crate::config::field_validator::normalize_ai_provider(&app_config.ai.provider);
432        }
433
434        // Validate the configuration
435        crate::config::validator::validate_config(&app_config).map_err(|e| {
436            debug!("ProductionConfigService: Config validation failed: {e}");
437            SubXError::config(format!("Configuration validation failed: {e}"))
438        })?;
439
440        debug!("ProductionConfigService: Configuration loaded and validated successfully");
441        Ok(app_config)
442    }
443
444    /// Validate and set a configuration value.
445    ///
446    /// This method now delegates validation to the field_validator module.
447    fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
448        use crate::config::field_validator;
449
450        // Canonicalize on the write path so the persisted on-disk value is
451        // always the canonical form (e.g. `ollama` → `local`, `OPENAI` →
452        // `openai`). This must happen before validation so the alias passes
453        // the enum check.
454        let normalized;
455        let value: &str = if key == "ai.provider" {
456            normalized = field_validator::normalize_ai_provider(value);
457            normalized.as_str()
458        } else {
459            value
460        };
461
462        // Use the dedicated field validator
463        field_validator::validate_field(key, value)?;
464
465        // Set the value in the configuration
466        self.set_value_internal(config, key, value)?;
467
468        // Validate the entire configuration after the change
469        self.validate_configuration(config)?;
470
471        Ok(())
472    }
473
474    /// Internal method to set configuration values without validation.
475    fn set_value_internal(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
476        use crate::config::OverflowStrategy;
477        use crate::config::validation::*;
478        use crate::error::SubXError;
479
480        let parts: Vec<&str> = key.split('.').collect();
481        match parts.as_slice() {
482            ["ai", "provider"] => {
483                config.ai.provider = crate::config::field_validator::normalize_ai_provider(value);
484            }
485            ["ai", "api_key"] => {
486                if !value.is_empty() {
487                    config.ai.api_key = Some(value.to_string());
488                } else {
489                    config.ai.api_key = None;
490                }
491            }
492            ["ai", "model"] => {
493                config.ai.model = value.to_string();
494            }
495            ["ai", "base_url"] => {
496                config.ai.base_url = value.to_string();
497            }
498            ["ai", "max_sample_length"] => {
499                let v = value.parse().unwrap(); // Validation already done
500                config.ai.max_sample_length = v;
501            }
502            ["ai", "temperature"] => {
503                let v = value.parse().unwrap(); // Validation already done
504                config.ai.temperature = v;
505            }
506            ["ai", "max_tokens"] => {
507                let v = value.parse().unwrap(); // Validation already done
508                config.ai.max_tokens = v;
509            }
510            ["ai", "retry_attempts"] => {
511                let v = value.parse().unwrap(); // Validation already done
512                config.ai.retry_attempts = v;
513            }
514            ["ai", "retry_delay_ms"] => {
515                let v = value.parse().unwrap(); // Validation already done
516                config.ai.retry_delay_ms = v;
517            }
518            ["ai", "request_timeout_seconds"] => {
519                let v = value.parse().unwrap(); // Validation already done
520                config.ai.request_timeout_seconds = v;
521            }
522            ["ai", "api_version"] => {
523                if !value.is_empty() {
524                    config.ai.api_version = Some(value.to_string());
525                } else {
526                    config.ai.api_version = None;
527                }
528            }
529            ["formats", "default_output"] => {
530                config.formats.default_output = value.to_string();
531            }
532            ["formats", "preserve_styling"] => {
533                let v = parse_bool(value)?;
534                config.formats.preserve_styling = v;
535            }
536            ["formats", "default_encoding"] => {
537                config.formats.default_encoding = value.to_string();
538            }
539            ["formats", "encoding_detection_confidence"] => {
540                let v = value.parse().unwrap(); // Validation already done
541                config.formats.encoding_detection_confidence = v;
542            }
543            ["sync", "max_offset_seconds"] => {
544                let v = value.parse().unwrap(); // Validation already done
545                config.sync.max_offset_seconds = v;
546            }
547            ["sync", "default_method"] => {
548                config.sync.default_method = value.to_string();
549            }
550            ["sync", "vad", "enabled"] => {
551                let v = parse_bool(value)?;
552                config.sync.vad.enabled = v;
553            }
554            ["sync", "vad", "sensitivity"] => {
555                let v = value.parse().unwrap(); // Validation already done
556                config.sync.vad.sensitivity = v;
557            }
558            ["sync", "vad", "padding_chunks"] => {
559                let v = value.parse().unwrap(); // Validation already done
560                config.sync.vad.padding_chunks = v;
561            }
562            ["sync", "vad", "min_speech_duration_ms"] => {
563                let v = value.parse().unwrap(); // Validation already done
564                config.sync.vad.min_speech_duration_ms = v;
565            }
566            ["general", "backup_enabled"] => {
567                let v = parse_bool(value)?;
568                config.general.backup_enabled = v;
569            }
570            ["general", "max_concurrent_jobs"] => {
571                let v = value.parse().unwrap(); // Validation already done
572                config.general.max_concurrent_jobs = v;
573            }
574            ["general", "task_timeout_seconds"] => {
575                let v = value.parse().unwrap(); // Validation already done
576                config.general.task_timeout_seconds = v;
577            }
578            ["general", "enable_progress_bar"] => {
579                let v = parse_bool(value)?;
580                config.general.enable_progress_bar = v;
581            }
582            ["general", "worker_idle_timeout_seconds"] => {
583                let v = value.parse().unwrap(); // Validation already done
584                config.general.worker_idle_timeout_seconds = v;
585            }
586            ["general", "max_subtitle_bytes"] => {
587                let v = value.parse().unwrap(); // Validation already done
588                config.general.max_subtitle_bytes = v;
589            }
590            ["general", "max_audio_bytes"] => {
591                let v = value.parse().unwrap(); // Validation already done
592                config.general.max_audio_bytes = v;
593            }
594            ["parallel", "max_workers"] => {
595                let v = value.parse().unwrap(); // Validation already done
596                config.parallel.max_workers = v;
597            }
598            ["parallel", "task_queue_size"] => {
599                let v = value.parse().unwrap(); // Validation already done
600                config.parallel.task_queue_size = v;
601            }
602            ["parallel", "enable_task_priorities"] => {
603                let v = parse_bool(value)?;
604                config.parallel.enable_task_priorities = v;
605            }
606            ["parallel", "auto_balance_workers"] => {
607                let v = parse_bool(value)?;
608                config.parallel.auto_balance_workers = v;
609            }
610            ["parallel", "overflow_strategy"] => {
611                config.parallel.overflow_strategy = match value {
612                    "Block" => OverflowStrategy::Block,
613                    "Drop" => OverflowStrategy::Drop,
614                    "Expand" => OverflowStrategy::Expand,
615                    _ => unreachable!(), // Validation already done
616                };
617            }
618            ["translation", "batch_size"] => {
619                let v = value.parse().unwrap(); // Validation already done
620                config.translation.batch_size = v;
621            }
622            ["translation", "default_target_language"] => {
623                if value.is_empty() {
624                    config.translation.default_target_language = None;
625                } else {
626                    config.translation.default_target_language = Some(value.to_string());
627                }
628            }
629            _ => {
630                return Err(SubXError::config(format!(
631                    "Unknown configuration key: {key}"
632                )));
633            }
634        }
635        Ok(())
636    }
637
638    /// Validate the entire configuration.
639    fn validate_configuration(&self, config: &Config) -> Result<()> {
640        use crate::config::validator;
641        validator::validate_config(config)
642    }
643
644    /// Save configuration to file with specific config object.
645    fn save_config_to_file_with_config(
646        &self,
647        path: &std::path::Path,
648        config: &Config,
649    ) -> Result<()> {
650        let toml_content = toml::to_string_pretty(config)
651            .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
652        secure_write_config_file(path, &toml_content)
653            .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
654        Ok(())
655    }
656}
657
658/// Read a single dot-notation configuration value from a [`Config`]
659/// snapshot.
660///
661/// This is the shared key-lookup table used by both the strict and the
662/// tolerant `config get` paths. Returns the value as a string (numerics
663/// are stringified, missing optional values are returned as the empty
664/// string), or `Err` for an unknown key.
665pub(crate) fn read_config_value_from(config: &Config, key: &str) -> Result<String> {
666    let parts: Vec<&str> = key.split('.').collect();
667    match parts.as_slice() {
668        ["ai", "provider"] => Ok(config.ai.provider.clone()),
669        ["ai", "model"] => Ok(config.ai.model.clone()),
670        ["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
671        ["ai", "base_url"] => Ok(config.ai.base_url.clone()),
672        ["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
673        ["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
674        ["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
675        ["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
676        ["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
677        ["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
678
679        ["formats", "default_output"] => Ok(config.formats.default_output.clone()),
680        ["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
681        ["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
682        ["formats", "encoding_detection_confidence"] => {
683            Ok(config.formats.encoding_detection_confidence.to_string())
684        }
685
686        ["sync", "default_method"] => Ok(config.sync.default_method.clone()),
687        ["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
688        ["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
689        ["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
690        ["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
691        ["sync", "vad", "min_speech_duration_ms"] => {
692            Ok(config.sync.vad.min_speech_duration_ms.to_string())
693        }
694
695        ["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
696        ["general", "max_concurrent_jobs"] => Ok(config.general.max_concurrent_jobs.to_string()),
697        ["general", "task_timeout_seconds"] => Ok(config.general.task_timeout_seconds.to_string()),
698        ["general", "enable_progress_bar"] => Ok(config.general.enable_progress_bar.to_string()),
699        ["general", "worker_idle_timeout_seconds"] => {
700            Ok(config.general.worker_idle_timeout_seconds.to_string())
701        }
702        ["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
703        ["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
704
705        ["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
706        ["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
707        ["parallel", "enable_task_priorities"] => {
708            Ok(config.parallel.enable_task_priorities.to_string())
709        }
710        ["parallel", "auto_balance_workers"] => {
711            Ok(config.parallel.auto_balance_workers.to_string())
712        }
713        ["parallel", "overflow_strategy"] => Ok(format!("{:?}", config.parallel.overflow_strategy)),
714
715        ["translation", "batch_size"] => Ok(config.translation.batch_size.to_string()),
716        ["translation", "default_target_language"] => Ok(config
717            .translation
718            .default_target_language
719            .clone()
720            .unwrap_or_default()),
721
722        _ => Err(SubXError::config(format!(
723            "Unknown configuration key: {}",
724            key
725        ))),
726    }
727}
728
729impl ConfigService for ProductionConfigService {
730    fn get_config(&self) -> Result<Config> {
731        // Check cache first
732        {
733            let cache = self.cached_config.read().unwrap();
734            if let Some(config) = cache.as_ref() {
735                debug!("ProductionConfigService: Returning cached configuration");
736                return Ok(config.clone());
737            }
738        }
739
740        // Load configuration
741        let app_config = self.load_and_validate()?;
742
743        // Update cache
744        {
745            let mut cache = self.cached_config.write().unwrap();
746            *cache = Some(app_config.clone());
747        }
748
749        Ok(app_config)
750    }
751
752    fn reload(&self) -> Result<()> {
753        debug!("ProductionConfigService: Reloading configuration");
754
755        // Clear cache to force reload
756        {
757            let mut cache = self.cached_config.write().unwrap();
758            *cache = None;
759        }
760
761        // Trigger reload by calling get_config
762        self.get_config()?;
763
764        debug!("ProductionConfigService: Configuration reloaded successfully");
765        Ok(())
766    }
767
768    fn save_config(&self) -> Result<()> {
769        let _config = self.get_config()?;
770        let path = self.get_config_file_path()?;
771        self.save_config_to_file(&path)
772    }
773
774    fn save_config_to_file(&self, path: &Path) -> Result<()> {
775        let config = self.get_config()?;
776        let toml_content = toml::to_string_pretty(&config)
777            .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
778
779        secure_write_config_file(path, &toml_content)
780            .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
781
782        Ok(())
783    }
784
785    fn get_config_file_path(&self) -> Result<PathBuf> {
786        // Allow injection via EnvironmentProvider for testing
787        if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
788            return Ok(PathBuf::from(custom));
789        }
790
791        let config_dir = dirs::config_dir()
792            .ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
793        Ok(config_dir.join("subx").join("config.toml"))
794    }
795
796    fn get_config_value(&self, key: &str) -> Result<String> {
797        let config = self.get_config()?;
798        read_config_value_from(&config, key)
799    }
800
801    fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
802        // 1. Load current configuration *from the file only* (tolerant
803        //    load) so that an existing strict-invalid file does not
804        //    prevent the user from repairing it. Env-variable overlays
805        //    are deliberately omitted: `config set` writes file-derived
806        //    values back to disk and must not bake env-only secrets
807        //    (e.g. `OPENAI_API_KEY`) into the persisted file.
808        let mut config = self.load_for_repair()?;
809
810        // 2. Field-validate the new value, mutate `config`, and run
811        //    cross-section validation on the *post-mutation* config.
812        //    Both the field-level check and the cross-section check
813        //    happen inside `validate_and_set_value`; we MUST NOT
814        //    duplicate the cross-section call at this level.
815        self.validate_and_set_value(&mut config, key, value)?;
816
817        // 3. Save to file (only reached when step 2 succeeded, which
818        //    guarantees the on-disk file we are about to write passes
819        //    strict cross-section validation).
820        let path = self.get_config_file_path()?;
821        self.save_config_to_file_with_config(&path, &config)?;
822
823        // 4. Update cache. Only strict-valid configurations are allowed
824        //    to enter the cache, so this assignment is sound.
825        {
826            let mut cache = self.cached_config.write().unwrap();
827            *cache = Some(config);
828        }
829
830        Ok(())
831    }
832
833    fn reset_to_defaults(&self) -> Result<()> {
834        let default_config = Config::default();
835        let path = self.get_config_file_path()?;
836
837        let toml_content = toml::to_string_pretty(&default_config)
838            .map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
839
840        secure_write_config_file(&path, &toml_content)
841            .map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
842
843        self.reload()
844    }
845
846    fn load_for_repair(&self) -> Result<Config> {
847        // Tolerant load: read only the file (no env overlay), parse as
848        // TOML directly without falling back to defaults, canonicalize
849        // the AI provider, and return. Cross-section validation is
850        // deliberately skipped so users can repair an on-disk file that
851        // currently fails strict validation. This method MUST NOT
852        // populate the strict-config cache.
853        let path = self.get_config_file_path()?;
854
855        // A missing file means "the user has never written one"; in
856        // that case there is no on-disk state to repair, so fall back
857        // to defaults. (This matches the strict-load path's behavior
858        // when the file does not exist.)
859        if !path.exists() {
860            debug!(
861                "ProductionConfigService::load_for_repair: file {} does not exist, using defaults",
862                path.display()
863            );
864            return Ok(Config::default());
865        }
866
867        let content = std::fs::read_to_string(&path).map_err(|e| {
868            SubXError::config(format!(
869                "Failed to read configuration file {}: {}",
870                path.display(),
871                e
872            ))
873        })?;
874
875        let mut config = toml::from_str::<Config>(&content).map_err(|e| {
876            SubXError::config(format!(
877                "Failed to parse configuration file {}: {}",
878                path.display(),
879                e
880            ))
881        })?;
882
883        // Canonicalize the provider so downstream consumers see the
884        // canonical form (`ollama` → `local`, etc.).
885        config.ai.provider =
886            crate::config::field_validator::normalize_ai_provider(&config.ai.provider);
887
888        // Run per-field validation across every configuration section
889        // so that malformed individual values (out-of-range numbers,
890        // unknown enum variants, malformed URLs, etc.) are rejected
891        // here even though cross-section validation is skipped. This
892        // keeps `load_for_repair` strictly stronger than TOML parsing
893        // alone and prevents `config set/get/list` from silently
894        // accepting field-level garbage.
895        crate::config::field_validator::validate_all_fields(&config)?;
896
897        Ok(config)
898    }
899}
900
901impl Default for ProductionConfigService {
902    fn default() -> Self {
903        Self::new().expect("Failed to create default ProductionConfigService")
904    }
905}
906
907#[cfg(test)]
908mod tests {
909    use super::*;
910    use crate::config::TestConfigService;
911    use crate::config::TestEnvironmentProvider;
912    use std::sync::Arc;
913
914    /// Helper: create a ProductionConfigService whose config file lives in
915    /// the given TempDir so file-writing tests are isolated.
916    fn make_service_with_tmp_config(dir: &tempfile::TempDir) -> ProductionConfigService {
917        let config_path = dir.path().join("config.toml");
918        let mut env = TestEnvironmentProvider::new();
919        env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
920        ProductionConfigService::with_env_provider(Arc::new(env)).unwrap()
921    }
922
923    #[test]
924    fn test_production_config_service_creation() {
925        let service = ProductionConfigService::new();
926        assert!(service.is_ok());
927    }
928
929    #[test]
930    fn test_production_config_service_with_custom_file() {
931        let service = ProductionConfigService::new()
932            .unwrap()
933            .with_custom_file(PathBuf::from("test.toml"));
934        assert!(service.is_ok());
935    }
936
937    #[test]
938    fn test_production_service_implements_config_service_trait() {
939        // Use an isolated environment so the test does not depend on the
940        // developer's real `~/.config/subx/config.toml` (which may set
941        // `ai.base_url` to a non-HTTPS internal URL — a configuration that
942        // is now rejected by the hosted-provider HTTPS rule).
943        let dir = tempfile::tempdir().unwrap();
944        let service = make_service_with_tmp_config(&dir);
945
946        // Test trait methods
947        let config1 = service.get_config();
948        assert!(config1.is_ok());
949
950        let reload_result = service.reload();
951        assert!(reload_result.is_ok());
952
953        let config2 = service.get_config();
954        assert!(config2.is_ok());
955    }
956
957    #[test]
958    fn test_production_config_service_openrouter_api_key_loading() {
959        use crate::config::TestEnvironmentProvider;
960        use std::sync::Arc;
961
962        let mut env_provider = TestEnvironmentProvider::new();
963        env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
964        env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
965
966        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
967            .expect("Failed to create config service");
968
969        let config = service.get_config().expect("Failed to get config");
970
971        assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
972    }
973
974    #[test]
975    fn test_config_service_with_openai_api_key() {
976        // Test configuration with OpenAI API key using TestConfigService
977        let test_service = TestConfigService::with_ai_settings_and_key(
978            "openai",
979            "gpt-4.1-mini",
980            "sk-test-openai-key-123",
981        );
982
983        let config = test_service.get_config().unwrap();
984        assert_eq!(
985            config.ai.api_key,
986            Some("sk-test-openai-key-123".to_string())
987        );
988        assert_eq!(config.ai.provider, "openai");
989        assert_eq!(config.ai.model, "gpt-4.1-mini");
990    }
991
992    #[test]
993    fn test_config_service_with_custom_base_url() {
994        // Test configuration with custom base URL
995        let mut config = Config::default();
996        config.ai.base_url = "https://custom.openai.endpoint".to_string();
997
998        let test_service = TestConfigService::new(config);
999        let loaded_config = test_service.get_config().unwrap();
1000
1001        assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
1002    }
1003
1004    #[test]
1005    fn test_config_service_with_both_openai_settings() {
1006        // Test configuration with both API key and base URL
1007        let mut config = Config::default();
1008        config.ai.api_key = Some("sk-test-api-key-combined".to_string());
1009        config.ai.base_url = "https://api.custom-openai.com".to_string();
1010
1011        let test_service = TestConfigService::new(config);
1012        let loaded_config = test_service.get_config().unwrap();
1013
1014        assert_eq!(
1015            loaded_config.ai.api_key,
1016            Some("sk-test-api-key-combined".to_string())
1017        );
1018        assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
1019    }
1020
1021    #[test]
1022    fn test_config_service_provider_precedence() {
1023        // Test that manually configured values take precedence
1024        let test_service =
1025            TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
1026
1027        let config = test_service.get_config().unwrap();
1028        assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
1029        assert_eq!(config.ai.provider, "openai");
1030        assert_eq!(config.ai.model, "gpt-4.1");
1031    }
1032
1033    #[test]
1034    fn test_config_service_fallback_behavior() {
1035        // Test fallback to default values when no specific configuration provided
1036        let test_service = TestConfigService::with_defaults();
1037        let config = test_service.get_config().unwrap();
1038
1039        // Should use default values
1040        assert_eq!(config.ai.provider, "openai");
1041        assert_eq!(config.ai.model, "gpt-4.1-mini");
1042        assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
1043        assert_eq!(config.ai.api_key, None); // No API key by default
1044    }
1045
1046    #[test]
1047    fn test_config_service_reload_functionality() {
1048        // Test configuration reload capability
1049        let test_service = TestConfigService::with_defaults();
1050
1051        // First load
1052        let config1 = test_service.get_config().unwrap();
1053        assert_eq!(config1.ai.provider, "openai");
1054
1055        // Reload should always succeed for test service
1056        let reload_result = test_service.reload();
1057        assert!(reload_result.is_ok());
1058
1059        // Second load should still work
1060        let config2 = test_service.get_config().unwrap();
1061        assert_eq!(config2.ai.provider, "openai");
1062    }
1063
1064    #[test]
1065    fn test_config_service_custom_base_url_override() {
1066        // Test that custom base URL properly overrides default
1067        let mut config = Config::default();
1068        config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
1069
1070        let test_service = TestConfigService::new(config);
1071        let loaded_config = test_service.get_config().unwrap();
1072
1073        assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
1074    }
1075
1076    #[test]
1077    fn test_config_service_sync_settings() {
1078        // Test sync configuration settings
1079        let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
1080        let config = test_service.get_config().unwrap();
1081
1082        assert_eq!(config.sync.correlation_threshold, 0.8);
1083        assert_eq!(config.sync.max_offset_seconds, 45.0);
1084    }
1085
1086    #[test]
1087    fn test_config_service_parallel_settings() {
1088        // Test parallel processing configuration
1089        let test_service = TestConfigService::with_parallel_settings(8, 200);
1090        let config = test_service.get_config().unwrap();
1091
1092        assert_eq!(config.general.max_concurrent_jobs, 8);
1093        assert_eq!(config.parallel.task_queue_size, 200);
1094    }
1095
1096    #[test]
1097    fn test_config_size_limits_defaults() {
1098        let service = TestConfigService::with_defaults();
1099        let cfg = service.get_config().unwrap();
1100        assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
1101        assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
1102    }
1103
1104    #[test]
1105    fn test_config_size_limits_roundtrip() {
1106        let service = TestConfigService::with_defaults();
1107
1108        service
1109            .set_config_value("general.max_subtitle_bytes", "65536")
1110            .unwrap();
1111        service
1112            .set_config_value("general.max_audio_bytes", "1048576")
1113            .unwrap();
1114
1115        assert_eq!(
1116            service
1117                .get_config_value("general.max_subtitle_bytes")
1118                .unwrap(),
1119            "65536"
1120        );
1121        assert_eq!(
1122            service.get_config_value("general.max_audio_bytes").unwrap(),
1123            "1048576"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_config_size_limits_validation_reject() {
1129        let service = TestConfigService::with_defaults();
1130        // Below minimum (1024)
1131        assert!(
1132            service
1133                .set_config_value("general.max_subtitle_bytes", "100")
1134                .is_err()
1135        );
1136        // Above maximum (1 GiB)
1137        assert!(
1138            service
1139                .set_config_value("general.max_subtitle_bytes", "2147483648")
1140                .is_err()
1141        );
1142    }
1143
1144    #[test]
1145    fn test_config_service_direct_access() {
1146        // Test direct configuration access and mutation
1147        let test_service = TestConfigService::with_defaults();
1148
1149        // Test direct read access
1150        assert_eq!(test_service.config().ai.provider, "openai");
1151
1152        // Test mutable access
1153        test_service.config_mut().ai.provider = "modified".to_string();
1154        assert_eq!(test_service.config().ai.provider, "modified");
1155
1156        // Test that get_config reflects the changes
1157        let config = test_service.get_config().unwrap();
1158        assert_eq!(config.ai.provider, "modified");
1159    }
1160
1161    #[test]
1162    fn test_production_config_service_openai_api_key_loading() {
1163        // Test OPENAI_API_KEY environment variable loading
1164        let mut env_provider = TestEnvironmentProvider::new();
1165        env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
1166
1167        // Use a non-existent config path to avoid interference from existing config files
1168        env_provider.set_var(
1169            "SUBX_CONFIG_PATH",
1170            "/tmp/test_config_that_does_not_exist.toml",
1171        );
1172
1173        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1174            .expect("Failed to create config service");
1175
1176        let config = service.get_config().expect("Failed to get config");
1177
1178        assert_eq!(
1179            config.ai.api_key,
1180            Some("sk-test-openai-key-env".to_string())
1181        );
1182    }
1183
1184    #[test]
1185    fn test_production_config_service_openai_base_url_loading() {
1186        // Test OPENAI_BASE_URL environment variable loading
1187        let mut env_provider = TestEnvironmentProvider::new();
1188        env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
1189
1190        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1191            .expect("Failed to create config service");
1192
1193        let config = service.get_config().expect("Failed to get config");
1194
1195        assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
1196    }
1197
1198    #[test]
1199    fn test_production_config_service_both_openai_env_vars() {
1200        // Test setting both OPENAI environment variables simultaneously
1201        let mut env_provider = TestEnvironmentProvider::new();
1202        env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
1203        env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
1204
1205        // Use a non-existent config path to avoid interference from existing config files
1206        env_provider.set_var(
1207            "SUBX_CONFIG_PATH",
1208            "/tmp/test_config_both_that_does_not_exist.toml",
1209        );
1210
1211        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1212            .expect("Failed to create config service");
1213
1214        let config = service.get_config().expect("Failed to get config");
1215
1216        assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
1217        assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
1218    }
1219
1220    #[test]
1221    fn test_production_config_service_no_openai_env_vars() {
1222        // Test the case with no OPENAI environment variables
1223        let mut env_provider = TestEnvironmentProvider::new(); // Empty provider
1224
1225        // Use a non-existent config path to avoid interference from existing config files
1226        env_provider.set_var(
1227            "SUBX_CONFIG_PATH",
1228            "/tmp/test_config_no_openai_that_does_not_exist.toml",
1229        );
1230
1231        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1232            .expect("Failed to create config service");
1233
1234        let config = service.get_config().expect("Failed to get config");
1235
1236        // Should use default values
1237        assert_eq!(config.ai.api_key, None);
1238        assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); // Default value
1239    }
1240
1241    #[test]
1242    fn test_production_config_service_api_key_priority() {
1243        // Test API key priority: existing API key should not be overwritten
1244        let mut env_provider = TestEnvironmentProvider::new();
1245        env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1246        // Simulate API key loaded from other sources (e.g., configuration file)
1247        env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1248        // Isolate from the developer's real `~/.config/subx/config.toml`
1249        // (which may set a non-HTTPS `ai.base_url` that the new
1250        // hosted-provider HTTPS rule rejects).
1251        let dir = tempfile::tempdir().expect("tempdir");
1252        let cfg_path = dir.path().join("nonexistent.toml");
1253        env_provider.set_var("SUBX_CONFIG_PATH", cfg_path.to_str().unwrap());
1254
1255        let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1256            .expect("Failed to create config service");
1257
1258        let config = service.get_config().expect("Failed to get config");
1259
1260        // SUBX_AI_APIKEY should have higher priority (since it's processed first)
1261        // This test only verifies priority order, should at least have a value
1262        assert!(config.ai.api_key.is_some());
1263    }
1264
1265    #[cfg(unix)]
1266    #[test]
1267    fn test_secure_write_config_file_sets_0600_permissions() {
1268        use std::os::unix::fs::PermissionsExt;
1269
1270        let dir = tempfile::tempdir().expect("create tempdir");
1271        let nested = dir.path().join("subdir");
1272        let path = nested.join("config.toml");
1273
1274        super::secure_write_config_file(&path, "api_key = \"secret\"\n")
1275            .expect("secure write should succeed");
1276
1277        let meta = std::fs::metadata(&path).expect("file must exist");
1278        let mode = meta.permissions().mode() & 0o777;
1279        assert_eq!(
1280            mode, 0o600,
1281            "file permissions must be 0o600, got {:o}",
1282            mode
1283        );
1284
1285        let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
1286        let dir_mode = dir_meta.permissions().mode() & 0o777;
1287        assert_eq!(
1288            dir_mode, 0o700,
1289            "directory permissions must be 0o700, got {:o}",
1290            dir_mode
1291        );
1292
1293        let contents = std::fs::read_to_string(&path).unwrap();
1294        assert_eq!(contents, "api_key = \"secret\"\n");
1295    }
1296
1297    #[cfg(unix)]
1298    #[test]
1299    fn test_secure_write_config_file_truncates_existing_file() {
1300        use std::os::unix::fs::PermissionsExt;
1301
1302        let dir = tempfile::tempdir().expect("create tempdir");
1303        let path = dir.path().join("config.toml");
1304
1305        // Create an existing file with permissive mode and stale contents.
1306        std::fs::write(&path, "stale contents that should be replaced").unwrap();
1307        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1308
1309        super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
1310
1311        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1312        assert_eq!(mode, 0o600);
1313        assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
1314    }
1315
1316    // -----------------------------------------------------------------------
1317    // Caching behaviour
1318    // -----------------------------------------------------------------------
1319
1320    #[test]
1321    fn test_production_config_get_config_caches_result() {
1322        let dir = tempfile::tempdir().unwrap();
1323        let service = make_service_with_tmp_config(&dir);
1324        let config1 = service.get_config().unwrap();
1325        let config2 = service.get_config().unwrap();
1326        assert_eq!(config1.ai.provider, config2.ai.provider);
1327        assert_eq!(config1.ai.model, config2.ai.model);
1328    }
1329
1330    #[test]
1331    fn test_production_config_reload_clears_cache_and_reloads() {
1332        let dir = tempfile::tempdir().unwrap();
1333        let service = make_service_with_tmp_config(&dir);
1334        service.get_config().unwrap(); // populate cache
1335        service.reload().unwrap(); // must clear then reload
1336        let config = service.get_config().unwrap();
1337        assert_eq!(config.ai.provider, "openai");
1338    }
1339
1340    // -----------------------------------------------------------------------
1341    // Azure OpenAI environment variable handling
1342    // -----------------------------------------------------------------------
1343
1344    #[test]
1345    fn test_azure_openai_api_key_sets_provider_and_key() {
1346        let mut env = TestEnvironmentProvider::new();
1347        env.set_var("AZURE_OPENAI_API_KEY", "azure-api-key-test");
1348        env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_api_key_test.toml");
1349        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1350        let config = service.get_config().unwrap();
1351        assert_eq!(config.ai.provider, "azure-openai");
1352        assert_eq!(config.ai.api_key, Some("azure-api-key-test".to_string()));
1353    }
1354
1355    #[test]
1356    fn test_azure_openai_endpoint_sets_base_url() {
1357        let mut env = TestEnvironmentProvider::new();
1358        env.set_var(
1359            "AZURE_OPENAI_ENDPOINT",
1360            "https://my-instance.openai.azure.com",
1361        );
1362        env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_endpoint_test.toml");
1363        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1364        let config = service.get_config().unwrap();
1365        assert_eq!(config.ai.base_url, "https://my-instance.openai.azure.com");
1366    }
1367
1368    #[test]
1369    fn test_azure_openai_api_version_sets_api_version() {
1370        let mut env = TestEnvironmentProvider::new();
1371        env.set_var("AZURE_OPENAI_API_VERSION", "2024-02-01");
1372        env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_version_test.toml");
1373        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1374        let config = service.get_config().unwrap();
1375        assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1376    }
1377
1378    #[test]
1379    fn test_azure_openai_deployment_id_sets_model() {
1380        let mut env = TestEnvironmentProvider::new();
1381        env.set_var("AZURE_OPENAI_API_KEY", "azure-key-for-deploy");
1382        env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "my-gpt4-deployment");
1383        env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_deploy_test.toml");
1384        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1385        let config = service.get_config().unwrap();
1386        assert_eq!(config.ai.model, "my-gpt4-deployment");
1387    }
1388
1389    #[test]
1390    fn test_azure_openai_all_env_vars_together() {
1391        let mut env = TestEnvironmentProvider::new();
1392        env.set_var("AZURE_OPENAI_API_KEY", "full-azure-api-key");
1393        env.set_var("AZURE_OPENAI_ENDPOINT", "https://full.openai.azure.com");
1394        env.set_var("AZURE_OPENAI_API_VERSION", "2024-05-01");
1395        env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "full-deployment-name");
1396        env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_full_test.toml");
1397        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1398        let config = service.get_config().unwrap();
1399        assert_eq!(config.ai.provider, "azure-openai");
1400        assert_eq!(config.ai.api_key, Some("full-azure-api-key".to_string()));
1401        assert_eq!(config.ai.base_url, "https://full.openai.azure.com");
1402        assert_eq!(config.ai.api_version, Some("2024-05-01".to_string()));
1403        assert_eq!(config.ai.model, "full-deployment-name");
1404    }
1405
1406    // -----------------------------------------------------------------------
1407    // get_config_file_path
1408    // -----------------------------------------------------------------------
1409
1410    #[test]
1411    fn test_get_config_file_path_uses_subx_config_path_env() {
1412        let mut env = TestEnvironmentProvider::new();
1413        env.set_var("SUBX_CONFIG_PATH", "/custom/path/config.toml");
1414        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1415        let path = service.get_config_file_path().unwrap();
1416        assert_eq!(path, PathBuf::from("/custom/path/config.toml"));
1417    }
1418
1419    #[test]
1420    fn test_get_config_file_path_default_contains_subx() {
1421        let env = TestEnvironmentProvider::new(); // no SUBX_CONFIG_PATH
1422        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1423        let path = service.get_config_file_path().unwrap();
1424        let s = path.to_str().unwrap();
1425        assert!(s.contains("subx"), "expected 'subx' in path: {s}");
1426        assert!(
1427            s.ends_with("config.toml"),
1428            "expected 'config.toml' suffix: {s}"
1429        );
1430    }
1431
1432    // -----------------------------------------------------------------------
1433    // save_config_to_file / save_config
1434    // -----------------------------------------------------------------------
1435
1436    #[test]
1437    fn test_save_config_to_file_writes_valid_toml() {
1438        let dir = tempfile::tempdir().unwrap();
1439        let service = make_service_with_tmp_config(&dir);
1440        let save_path = dir.path().join("output.toml");
1441        service.save_config_to_file(&save_path).unwrap();
1442        let content = std::fs::read_to_string(&save_path).unwrap();
1443        assert!(content.contains("[ai]"), "missing [ai] section: {content}");
1444        assert!(
1445            content.contains("provider"),
1446            "missing 'provider': {content}"
1447        );
1448    }
1449
1450    #[test]
1451    fn test_save_config_writes_to_configured_path() {
1452        let dir = tempfile::tempdir().unwrap();
1453        let service = make_service_with_tmp_config(&dir);
1454        service.save_config().unwrap();
1455        let config_path = dir.path().join("config.toml");
1456        assert!(config_path.exists(), "config file was not created");
1457        let content = std::fs::read_to_string(&config_path).unwrap();
1458        assert!(content.contains("[ai]"));
1459    }
1460
1461    // -----------------------------------------------------------------------
1462    // reset_to_defaults
1463    // -----------------------------------------------------------------------
1464
1465    #[test]
1466    fn test_reset_to_defaults_restores_default_config() {
1467        let dir = tempfile::tempdir().unwrap();
1468        let service = make_service_with_tmp_config(&dir);
1469        // First write a file so reset has something to overwrite
1470        service.save_config().unwrap();
1471        service.reset_to_defaults().unwrap();
1472        let config = service.get_config().unwrap();
1473        assert_eq!(config.ai.provider, "openai");
1474        assert_eq!(config.ai.model, "gpt-4.1-mini");
1475        assert_eq!(config.formats.default_output, "srt");
1476    }
1477
1478    // -----------------------------------------------------------------------
1479    // get_config_value – all branches in ProductionConfigService
1480    // -----------------------------------------------------------------------
1481
1482    #[test]
1483    fn test_get_config_value_all_ai_keys() {
1484        let dir = tempfile::tempdir().unwrap();
1485        let service = make_service_with_tmp_config(&dir);
1486        for key in &[
1487            "ai.provider",
1488            "ai.model",
1489            "ai.api_key",
1490            "ai.base_url",
1491            "ai.max_sample_length",
1492            "ai.temperature",
1493            "ai.max_tokens",
1494            "ai.retry_attempts",
1495            "ai.retry_delay_ms",
1496            "ai.request_timeout_seconds",
1497        ] {
1498            assert!(
1499                service.get_config_value(key).is_ok(),
1500                "failed for key: {key}"
1501            );
1502        }
1503    }
1504
1505    #[test]
1506    fn test_get_config_value_all_formats_keys() {
1507        let dir = tempfile::tempdir().unwrap();
1508        let service = make_service_with_tmp_config(&dir);
1509        for key in &[
1510            "formats.default_output",
1511            "formats.default_encoding",
1512            "formats.preserve_styling",
1513            "formats.encoding_detection_confidence",
1514        ] {
1515            assert!(
1516                service.get_config_value(key).is_ok(),
1517                "failed for key: {key}"
1518            );
1519        }
1520    }
1521
1522    #[test]
1523    fn test_get_config_value_all_sync_keys() {
1524        let dir = tempfile::tempdir().unwrap();
1525        let service = make_service_with_tmp_config(&dir);
1526        for key in &[
1527            "sync.default_method",
1528            "sync.max_offset_seconds",
1529            "sync.vad.enabled",
1530            "sync.vad.sensitivity",
1531            "sync.vad.padding_chunks",
1532            "sync.vad.min_speech_duration_ms",
1533        ] {
1534            assert!(
1535                service.get_config_value(key).is_ok(),
1536                "failed for key: {key}"
1537            );
1538        }
1539    }
1540
1541    #[test]
1542    fn test_get_config_value_all_general_keys() {
1543        let dir = tempfile::tempdir().unwrap();
1544        let service = make_service_with_tmp_config(&dir);
1545        for key in &[
1546            "general.backup_enabled",
1547            "general.max_concurrent_jobs",
1548            "general.task_timeout_seconds",
1549            "general.enable_progress_bar",
1550            "general.worker_idle_timeout_seconds",
1551            "general.max_subtitle_bytes",
1552            "general.max_audio_bytes",
1553        ] {
1554            assert!(
1555                service.get_config_value(key).is_ok(),
1556                "failed for key: {key}"
1557            );
1558        }
1559    }
1560
1561    #[test]
1562    fn test_get_config_value_all_parallel_keys() {
1563        let dir = tempfile::tempdir().unwrap();
1564        let service = make_service_with_tmp_config(&dir);
1565        for key in &[
1566            "parallel.max_workers",
1567            "parallel.task_queue_size",
1568            "parallel.enable_task_priorities",
1569            "parallel.auto_balance_workers",
1570            "parallel.overflow_strategy",
1571        ] {
1572            assert!(
1573                service.get_config_value(key).is_ok(),
1574                "failed for key: {key}"
1575            );
1576        }
1577    }
1578
1579    #[test]
1580    fn test_get_config_value_unknown_key_returns_error() {
1581        let dir = tempfile::tempdir().unwrap();
1582        let service = make_service_with_tmp_config(&dir);
1583        assert!(service.get_config_value("nonexistent.key").is_err());
1584        assert!(service.get_config_value("ai").is_err());
1585    }
1586
1587    #[test]
1588    fn test_get_config_value_returns_correct_defaults() {
1589        let dir = tempfile::tempdir().unwrap();
1590        let service = make_service_with_tmp_config(&dir);
1591        assert_eq!(service.get_config_value("ai.provider").unwrap(), "openai");
1592        assert_eq!(
1593            service.get_config_value("ai.model").unwrap(),
1594            "gpt-4.1-mini"
1595        );
1596        assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1597        assert_eq!(
1598            service.get_config_value("formats.default_output").unwrap(),
1599            "srt"
1600        );
1601        assert_eq!(
1602            service.get_config_value("general.backup_enabled").unwrap(),
1603            "false"
1604        );
1605    }
1606
1607    // -----------------------------------------------------------------------
1608    // set_config_value – AI section
1609    // -----------------------------------------------------------------------
1610
1611    #[test]
1612    fn test_set_config_value_ai_provider() {
1613        let dir = tempfile::tempdir().unwrap();
1614        let service = make_service_with_tmp_config(&dir);
1615        service
1616            .set_config_value("ai.provider", "openrouter")
1617            .unwrap();
1618        assert_eq!(
1619            service.get_config_value("ai.provider").unwrap(),
1620            "openrouter"
1621        );
1622    }
1623
1624    /// Regression: `subx config set ai.provider <value>` MUST canonicalize
1625    /// the input via `normalize_ai_provider` BEFORE the allow-list check, so
1626    /// case variants and the `ollama` alias are all accepted and the
1627    /// persisted on-disk value is the canonical form.
1628    #[test]
1629    fn test_set_config_value_ai_provider_canonicalizes_alias_and_case() {
1630        let cases = [
1631            ("OLLAMA", "local"),
1632            ("ollama", "local"),
1633            (" ollama ", "local"),
1634            ("OPENAI", "openai"),
1635            (" Azure-OpenAI ", "azure-openai"),
1636        ];
1637        for (input, expected) in cases {
1638            let dir = tempfile::tempdir().unwrap();
1639            let service = make_service_with_tmp_config(&dir);
1640            service
1641                .set_config_value("ai.provider", input)
1642                .unwrap_or_else(|e| panic!("input {input:?} should be accepted: {e}"));
1643            assert_eq!(
1644                service.get_config_value("ai.provider").unwrap(),
1645                expected,
1646                "input {input:?} should canonicalize to {expected:?}"
1647            );
1648        }
1649    }
1650
1651    /// Unknown providers must still be rejected after normalization.
1652    #[test]
1653    fn test_set_config_value_ai_provider_rejects_unknown_after_normalization() {
1654        let dir = tempfile::tempdir().unwrap();
1655        let service = make_service_with_tmp_config(&dir);
1656        assert!(service.set_config_value("ai.provider", "GROK").is_err());
1657    }
1658
1659    #[test]
1660    fn test_set_config_value_ai_model() {
1661        let dir = tempfile::tempdir().unwrap();
1662        let service = make_service_with_tmp_config(&dir);
1663        service.set_config_value("ai.model", "gpt-4.1").unwrap();
1664        assert_eq!(service.get_config_value("ai.model").unwrap(), "gpt-4.1");
1665    }
1666
1667    #[test]
1668    fn test_set_config_value_ai_api_key_non_empty() {
1669        let dir = tempfile::tempdir().unwrap();
1670        let service = make_service_with_tmp_config(&dir);
1671        service
1672            .set_config_value("ai.api_key", "sk-test-apikey-12345")
1673            .unwrap();
1674        assert_eq!(
1675            service.get_config_value("ai.api_key").unwrap(),
1676            "sk-test-apikey-12345"
1677        );
1678    }
1679
1680    #[test]
1681    fn test_set_config_value_ai_api_key_empty_clears_key() {
1682        let dir = tempfile::tempdir().unwrap();
1683        let service = make_service_with_tmp_config(&dir);
1684        // Set a key first
1685        service
1686            .set_config_value("ai.api_key", "sk-test-apikey-12345")
1687            .unwrap();
1688        // Then clear it
1689        service.set_config_value("ai.api_key", "").unwrap();
1690        assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1691        let config = service.get_config().unwrap();
1692        assert!(config.ai.api_key.is_none());
1693    }
1694
1695    #[test]
1696    fn test_set_config_value_ai_base_url() {
1697        let dir = tempfile::tempdir().unwrap();
1698        let service = make_service_with_tmp_config(&dir);
1699        service
1700            .set_config_value("ai.base_url", "https://custom.example.com/v1")
1701            .unwrap();
1702        let config = service.get_config().unwrap();
1703        assert_eq!(config.ai.base_url, "https://custom.example.com/v1");
1704    }
1705
1706    #[test]
1707    fn test_set_config_value_ai_temperature() {
1708        let dir = tempfile::tempdir().unwrap();
1709        let service = make_service_with_tmp_config(&dir);
1710        service.set_config_value("ai.temperature", "0.7").unwrap();
1711        let config = service.get_config().unwrap();
1712        assert!((config.ai.temperature - 0.7).abs() < 0.001);
1713    }
1714
1715    #[test]
1716    fn test_set_config_value_ai_max_tokens() {
1717        let dir = tempfile::tempdir().unwrap();
1718        let service = make_service_with_tmp_config(&dir);
1719        service.set_config_value("ai.max_tokens", "5000").unwrap();
1720        assert_eq!(service.get_config_value("ai.max_tokens").unwrap(), "5000");
1721    }
1722
1723    #[test]
1724    fn test_set_config_value_ai_retry_attempts() {
1725        let dir = tempfile::tempdir().unwrap();
1726        let service = make_service_with_tmp_config(&dir);
1727        service.set_config_value("ai.retry_attempts", "5").unwrap();
1728        assert_eq!(service.get_config_value("ai.retry_attempts").unwrap(), "5");
1729    }
1730
1731    #[test]
1732    fn test_set_config_value_ai_retry_delay_ms() {
1733        let dir = tempfile::tempdir().unwrap();
1734        let service = make_service_with_tmp_config(&dir);
1735        service
1736            .set_config_value("ai.retry_delay_ms", "2000")
1737            .unwrap();
1738        assert_eq!(
1739            service.get_config_value("ai.retry_delay_ms").unwrap(),
1740            "2000"
1741        );
1742    }
1743
1744    #[test]
1745    fn test_set_config_value_ai_request_timeout_seconds() {
1746        let dir = tempfile::tempdir().unwrap();
1747        let service = make_service_with_tmp_config(&dir);
1748        service
1749            .set_config_value("ai.request_timeout_seconds", "60")
1750            .unwrap();
1751        assert_eq!(
1752            service
1753                .get_config_value("ai.request_timeout_seconds")
1754                .unwrap(),
1755            "60"
1756        );
1757    }
1758
1759    #[test]
1760    fn test_set_config_value_ai_max_sample_length() {
1761        let dir = tempfile::tempdir().unwrap();
1762        let service = make_service_with_tmp_config(&dir);
1763        service
1764            .set_config_value("ai.max_sample_length", "500")
1765            .unwrap();
1766        assert_eq!(
1767            service.get_config_value("ai.max_sample_length").unwrap(),
1768            "500"
1769        );
1770    }
1771
1772    #[test]
1773    fn test_set_config_value_ai_api_version_non_empty() {
1774        let dir = tempfile::tempdir().unwrap();
1775        let service = make_service_with_tmp_config(&dir);
1776        service
1777            .set_config_value("ai.api_version", "2024-02-01")
1778            .unwrap();
1779        let config = service.get_config().unwrap();
1780        assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1781    }
1782
1783    // -----------------------------------------------------------------------
1784    // set_config_value – formats section
1785    // -----------------------------------------------------------------------
1786
1787    #[test]
1788    fn test_set_config_value_formats_default_output() {
1789        let dir = tempfile::tempdir().unwrap();
1790        let service = make_service_with_tmp_config(&dir);
1791        service
1792            .set_config_value("formats.default_output", "ass")
1793            .unwrap();
1794        assert_eq!(
1795            service.get_config_value("formats.default_output").unwrap(),
1796            "ass"
1797        );
1798    }
1799
1800    #[test]
1801    fn test_set_config_value_formats_preserve_styling() {
1802        let dir = tempfile::tempdir().unwrap();
1803        let service = make_service_with_tmp_config(&dir);
1804        service
1805            .set_config_value("formats.preserve_styling", "true")
1806            .unwrap();
1807        let config = service.get_config().unwrap();
1808        assert!(config.formats.preserve_styling);
1809    }
1810
1811    #[test]
1812    fn test_set_config_value_formats_default_encoding() {
1813        let dir = tempfile::tempdir().unwrap();
1814        let service = make_service_with_tmp_config(&dir);
1815        service
1816            .set_config_value("formats.default_encoding", "utf-8")
1817            .unwrap();
1818        assert_eq!(
1819            service
1820                .get_config_value("formats.default_encoding")
1821                .unwrap(),
1822            "utf-8"
1823        );
1824    }
1825
1826    #[test]
1827    fn test_set_config_value_formats_encoding_detection_confidence() {
1828        let dir = tempfile::tempdir().unwrap();
1829        let service = make_service_with_tmp_config(&dir);
1830        service
1831            .set_config_value("formats.encoding_detection_confidence", "0.9")
1832            .unwrap();
1833        let config = service.get_config().unwrap();
1834        assert!((config.formats.encoding_detection_confidence - 0.9).abs() < 0.001);
1835    }
1836
1837    // -----------------------------------------------------------------------
1838    // set_config_value – sync section
1839    // -----------------------------------------------------------------------
1840
1841    #[test]
1842    fn test_set_config_value_sync_max_offset_seconds() {
1843        let dir = tempfile::tempdir().unwrap();
1844        let service = make_service_with_tmp_config(&dir);
1845        service
1846            .set_config_value("sync.max_offset_seconds", "30")
1847            .unwrap();
1848        let config = service.get_config().unwrap();
1849        assert!((config.sync.max_offset_seconds - 30.0).abs() < 0.001);
1850    }
1851
1852    #[test]
1853    fn test_set_config_value_sync_default_method() {
1854        let dir = tempfile::tempdir().unwrap();
1855        let service = make_service_with_tmp_config(&dir);
1856        service
1857            .set_config_value("sync.default_method", "vad")
1858            .unwrap();
1859        assert_eq!(
1860            service.get_config_value("sync.default_method").unwrap(),
1861            "vad"
1862        );
1863    }
1864
1865    #[test]
1866    fn test_set_config_value_sync_vad_enabled() {
1867        let dir = tempfile::tempdir().unwrap();
1868        let service = make_service_with_tmp_config(&dir);
1869        service
1870            .set_config_value("sync.vad.enabled", "false")
1871            .unwrap();
1872        let config = service.get_config().unwrap();
1873        assert!(!config.sync.vad.enabled);
1874    }
1875
1876    #[test]
1877    fn test_set_config_value_sync_vad_sensitivity() {
1878        let dir = tempfile::tempdir().unwrap();
1879        let service = make_service_with_tmp_config(&dir);
1880        service
1881            .set_config_value("sync.vad.sensitivity", "0.5")
1882            .unwrap();
1883        let config = service.get_config().unwrap();
1884        assert!((config.sync.vad.sensitivity - 0.5).abs() < 0.001);
1885    }
1886
1887    #[test]
1888    fn test_set_config_value_sync_vad_padding_chunks() {
1889        let dir = tempfile::tempdir().unwrap();
1890        let service = make_service_with_tmp_config(&dir);
1891        service
1892            .set_config_value("sync.vad.padding_chunks", "5")
1893            .unwrap();
1894        assert_eq!(
1895            service.get_config_value("sync.vad.padding_chunks").unwrap(),
1896            "5"
1897        );
1898    }
1899
1900    #[test]
1901    fn test_set_config_value_sync_vad_min_speech_duration_ms() {
1902        let dir = tempfile::tempdir().unwrap();
1903        let service = make_service_with_tmp_config(&dir);
1904        service
1905            .set_config_value("sync.vad.min_speech_duration_ms", "500")
1906            .unwrap();
1907        assert_eq!(
1908            service
1909                .get_config_value("sync.vad.min_speech_duration_ms")
1910                .unwrap(),
1911            "500"
1912        );
1913    }
1914
1915    // -----------------------------------------------------------------------
1916    // set_config_value – general section
1917    // -----------------------------------------------------------------------
1918
1919    #[test]
1920    fn test_set_config_value_general_backup_enabled() {
1921        let dir = tempfile::tempdir().unwrap();
1922        let service = make_service_with_tmp_config(&dir);
1923        service
1924            .set_config_value("general.backup_enabled", "true")
1925            .unwrap();
1926        let config = service.get_config().unwrap();
1927        assert!(config.general.backup_enabled);
1928    }
1929
1930    #[test]
1931    fn test_set_config_value_general_max_concurrent_jobs() {
1932        let dir = tempfile::tempdir().unwrap();
1933        let service = make_service_with_tmp_config(&dir);
1934        service
1935            .set_config_value("general.max_concurrent_jobs", "8")
1936            .unwrap();
1937        assert_eq!(
1938            service
1939                .get_config_value("general.max_concurrent_jobs")
1940                .unwrap(),
1941            "8"
1942        );
1943    }
1944
1945    #[test]
1946    fn test_set_config_value_general_task_timeout_seconds() {
1947        let dir = tempfile::tempdir().unwrap();
1948        let service = make_service_with_tmp_config(&dir);
1949        service
1950            .set_config_value("general.task_timeout_seconds", "120")
1951            .unwrap();
1952        assert_eq!(
1953            service
1954                .get_config_value("general.task_timeout_seconds")
1955                .unwrap(),
1956            "120"
1957        );
1958    }
1959
1960    #[test]
1961    fn test_set_config_value_general_enable_progress_bar() {
1962        let dir = tempfile::tempdir().unwrap();
1963        let service = make_service_with_tmp_config(&dir);
1964        service
1965            .set_config_value("general.enable_progress_bar", "false")
1966            .unwrap();
1967        let config = service.get_config().unwrap();
1968        assert!(!config.general.enable_progress_bar);
1969    }
1970
1971    #[test]
1972    fn test_set_config_value_general_worker_idle_timeout_seconds() {
1973        let dir = tempfile::tempdir().unwrap();
1974        let service = make_service_with_tmp_config(&dir);
1975        service
1976            .set_config_value("general.worker_idle_timeout_seconds", "60")
1977            .unwrap();
1978        assert_eq!(
1979            service
1980                .get_config_value("general.worker_idle_timeout_seconds")
1981                .unwrap(),
1982            "60"
1983        );
1984    }
1985
1986    // -----------------------------------------------------------------------
1987    // set_config_value – parallel section
1988    // -----------------------------------------------------------------------
1989
1990    #[test]
1991    fn test_set_config_value_parallel_max_workers() {
1992        let dir = tempfile::tempdir().unwrap();
1993        let service = make_service_with_tmp_config(&dir);
1994        service
1995            .set_config_value("parallel.max_workers", "4")
1996            .unwrap();
1997        assert_eq!(
1998            service.get_config_value("parallel.max_workers").unwrap(),
1999            "4"
2000        );
2001    }
2002
2003    #[test]
2004    fn test_set_config_value_parallel_task_queue_size() {
2005        let dir = tempfile::tempdir().unwrap();
2006        let service = make_service_with_tmp_config(&dir);
2007        service
2008            .set_config_value("parallel.task_queue_size", "200")
2009            .unwrap();
2010        assert_eq!(
2011            service
2012                .get_config_value("parallel.task_queue_size")
2013                .unwrap(),
2014            "200"
2015        );
2016    }
2017
2018    #[test]
2019    fn test_set_config_value_parallel_enable_task_priorities() {
2020        let dir = tempfile::tempdir().unwrap();
2021        let service = make_service_with_tmp_config(&dir);
2022        service
2023            .set_config_value("parallel.enable_task_priorities", "true")
2024            .unwrap();
2025        let config = service.get_config().unwrap();
2026        assert!(config.parallel.enable_task_priorities);
2027    }
2028
2029    #[test]
2030    fn test_set_config_value_parallel_auto_balance_workers() {
2031        let dir = tempfile::tempdir().unwrap();
2032        let service = make_service_with_tmp_config(&dir);
2033        service
2034            .set_config_value("parallel.auto_balance_workers", "false")
2035            .unwrap();
2036        let config = service.get_config().unwrap();
2037        assert!(!config.parallel.auto_balance_workers);
2038    }
2039
2040    #[test]
2041    fn test_set_config_value_parallel_overflow_strategy_block() {
2042        let dir = tempfile::tempdir().unwrap();
2043        let service = make_service_with_tmp_config(&dir);
2044        service
2045            .set_config_value("parallel.overflow_strategy", "Block")
2046            .unwrap();
2047        let config = service.get_config().unwrap();
2048        assert_eq!(
2049            config.parallel.overflow_strategy,
2050            crate::config::OverflowStrategy::Block
2051        );
2052    }
2053
2054    #[test]
2055    fn test_set_config_value_parallel_overflow_strategy_drop() {
2056        let dir = tempfile::tempdir().unwrap();
2057        let service = make_service_with_tmp_config(&dir);
2058        service
2059            .set_config_value("parallel.overflow_strategy", "Drop")
2060            .unwrap();
2061        let config = service.get_config().unwrap();
2062        assert_eq!(
2063            config.parallel.overflow_strategy,
2064            crate::config::OverflowStrategy::Drop
2065        );
2066    }
2067
2068    #[test]
2069    fn test_set_config_value_parallel_overflow_strategy_expand() {
2070        let dir = tempfile::tempdir().unwrap();
2071        let service = make_service_with_tmp_config(&dir);
2072        service
2073            .set_config_value("parallel.overflow_strategy", "Expand")
2074            .unwrap();
2075        let config = service.get_config().unwrap();
2076        assert_eq!(
2077            config.parallel.overflow_strategy,
2078            crate::config::OverflowStrategy::Expand
2079        );
2080    }
2081
2082    // -----------------------------------------------------------------------
2083    // set_config_value – error paths
2084    // -----------------------------------------------------------------------
2085
2086    #[test]
2087    fn test_set_config_value_unknown_key_returns_error() {
2088        let dir = tempfile::tempdir().unwrap();
2089        let service = make_service_with_tmp_config(&dir);
2090        assert!(
2091            service
2092                .set_config_value("nonexistent.key", "value")
2093                .is_err()
2094        );
2095    }
2096
2097    #[test]
2098    fn test_set_config_value_invalid_value_returns_error() {
2099        let dir = tempfile::tempdir().unwrap();
2100        let service = make_service_with_tmp_config(&dir);
2101        // temperature must be in [0.0, 2.0]
2102        assert!(service.set_config_value("ai.temperature", "99.9").is_err());
2103        // provider must be a known enum value
2104        assert!(
2105            service
2106                .set_config_value("ai.provider", "unknown-provider")
2107                .is_err()
2108        );
2109    }
2110
2111    // -----------------------------------------------------------------------
2112    // Default trait impl
2113    // -----------------------------------------------------------------------
2114
2115    #[test]
2116    fn test_production_config_service_default_trait_impl() {
2117        // Use an isolated environment so the test does not depend on the
2118        // developer's real `~/.config/subx/config.toml`. The intent of the
2119        // test is to verify the `Default` trait wiring, not to exercise
2120        // whatever the developer happens to have on disk.
2121        let dir = tempfile::tempdir().unwrap();
2122        let service = make_service_with_tmp_config(&dir);
2123        let config = service.get_config().unwrap();
2124        assert_eq!(config.ai.provider, "openai");
2125    }
2126
2127    // -----------------------------------------------------------------------
2128    // Loading config values from a TOML file
2129    // -----------------------------------------------------------------------
2130
2131    #[test]
2132    fn test_production_config_service_loads_values_from_toml_file() {
2133        let dir = tempfile::tempdir().unwrap();
2134        let config_path = dir.path().join("custom.toml");
2135
2136        // Write a serialised default config with one field overridden
2137        let mut cfg = crate::config::Config::default();
2138        cfg.ai.provider = "openrouter".to_string();
2139        cfg.ai.model = "toml-loaded-model".to_string();
2140        let toml_str = toml::to_string_pretty(&cfg).unwrap();
2141        std::fs::write(&config_path, toml_str).unwrap();
2142
2143        let mut env = TestEnvironmentProvider::new();
2144        env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2145        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2146        let loaded = service.get_config().unwrap();
2147        assert_eq!(loaded.ai.provider, "openrouter");
2148        assert_eq!(loaded.ai.model, "toml-loaded-model");
2149    }
2150
2151    // -----------------------------------------------------------------------
2152    // TestConfigService instance methods (set_ai_settings_and_key,
2153    // set_ai_settings_with_base_url) – covered here to keep service tests
2154    // complete and avoid cross-module duplication.
2155    // -----------------------------------------------------------------------
2156
2157    #[test]
2158    fn test_test_config_service_set_ai_settings_and_key_instance_method() {
2159        let service = TestConfigService::with_defaults();
2160        service.set_ai_settings_and_key("openrouter", "my-model", "test-key-1234567890");
2161        let config = service.get_config().unwrap();
2162        assert_eq!(config.ai.provider, "openrouter");
2163        assert_eq!(config.ai.model, "my-model");
2164        assert_eq!(config.ai.api_key, Some("test-key-1234567890".to_string()));
2165    }
2166
2167    #[test]
2168    fn test_test_config_service_set_ai_settings_and_key_empty_clears_key() {
2169        let service = TestConfigService::with_defaults();
2170        service.set_ai_settings_and_key("openai", "gpt-4", "");
2171        let config = service.get_config().unwrap();
2172        assert!(config.ai.api_key.is_none());
2173    }
2174
2175    #[test]
2176    fn test_test_config_service_set_ai_settings_with_base_url() {
2177        let service = TestConfigService::with_defaults();
2178        service.set_ai_settings_with_base_url(
2179            "openai",
2180            "gpt-4.1",
2181            "sk-test-key-12345",
2182            "https://proxy.example.com/v1",
2183        );
2184        let config = service.get_config().unwrap();
2185        assert_eq!(config.ai.provider, "openai");
2186        assert_eq!(config.ai.model, "gpt-4.1");
2187        assert_eq!(config.ai.api_key, Some("sk-test-key-12345".to_string()));
2188        assert_eq!(config.ai.base_url, "https://proxy.example.com/v1");
2189    }
2190
2191    // -----------------------------------------------------------------------
2192    // File persistence: set_config_value updates the file on disk
2193    // -----------------------------------------------------------------------
2194
2195    #[test]
2196    fn test_set_config_value_persists_to_disk() {
2197        let dir = tempfile::tempdir().unwrap();
2198        let config_path = dir.path().join("config.toml");
2199        let mut env = TestEnvironmentProvider::new();
2200        env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2201        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2202
2203        service.set_config_value("ai.model", "gpt-4.1").unwrap();
2204
2205        let file_content = std::fs::read_to_string(&config_path).unwrap();
2206        assert!(
2207            file_content.contains("gpt-4.1"),
2208            "model not persisted to disk: {file_content}"
2209        );
2210    }
2211
2212    // -----------------------------------------------------------------------
2213    // secure_write_config_file – parent dir already exists (no creation)
2214    // -----------------------------------------------------------------------
2215
2216    #[cfg(unix)]
2217    #[test]
2218    fn test_secure_write_config_file_existing_parent_dir() {
2219        use std::os::unix::fs::PermissionsExt;
2220
2221        let dir = tempfile::tempdir().unwrap();
2222        let path = dir.path().join("config.toml");
2223
2224        super::secure_write_config_file(&path, "key = \"value\"\n")
2225            .expect("write to existing dir should succeed");
2226
2227        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2228        assert_eq!(mode, 0o600);
2229        assert_eq!(std::fs::read_to_string(&path).unwrap(), "key = \"value\"\n");
2230    }
2231
2232    // ─────────────────────────────────────────────────────────────────────────
2233    // §1.8: env-var carve-out + LOCAL_LLM_* tests
2234    // ─────────────────────────────────────────────────────────────────────────
2235
2236    /// Build an env provider with `SUBX_CONFIG_PATH` pointing at a unique
2237    /// non-existent file inside a fresh `TempDir`, so the loader skips the
2238    /// real on-disk config file and only sees the explicitly seeded env
2239    /// variables.
2240    fn env_with_isolated_config() -> (TestEnvironmentProvider, tempfile::TempDir) {
2241        let dir = tempfile::tempdir().expect("create tempdir");
2242        let mut env = TestEnvironmentProvider::new();
2243        let p = dir.path().join("nonexistent_config.toml");
2244        env.set_var("SUBX_CONFIG_PATH", p.to_str().unwrap());
2245        (env, dir)
2246    }
2247
2248    #[test]
2249    fn test_local_llm_base_url_honored_when_provider_is_local() {
2250        let (mut env, _dir) = env_with_isolated_config();
2251        env.set_var("SUBX_AI_PROVIDER", "local");
2252        env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:8080/v1");
2253
2254        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2255        let config = service.get_config().expect("get_config");
2256
2257        assert_eq!(config.ai.provider, "local");
2258        assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2259    }
2260
2261    #[test]
2262    fn test_local_llm_api_key_honored_when_provider_is_local() {
2263        let (mut env, _dir) = env_with_isolated_config();
2264        env.set_var("SUBX_AI_PROVIDER", "local");
2265        env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2266        env.set_var("LOCAL_LLM_API_KEY", "local-secret-token");
2267
2268        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2269        let config = service.get_config().expect("get_config");
2270
2271        assert_eq!(config.ai.provider, "local");
2272        assert_eq!(config.ai.api_key.as_deref(), Some("local-secret-token"));
2273    }
2274
2275    #[test]
2276    fn test_local_llm_env_vars_ignored_for_non_local_provider() {
2277        let (mut env, _dir) = env_with_isolated_config();
2278        // Default provider is "openai"; do not set SUBX_AI_PROVIDER.
2279        env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2280        env.set_var("LOCAL_LLM_API_KEY", "leak-me");
2281
2282        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2283        let config = service.get_config().expect("get_config");
2284
2285        assert_eq!(config.ai.provider, "openai");
2286        // Default base_url stands; LOCAL_LLM_BASE_URL did not leak.
2287        assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
2288        // LOCAL_LLM_API_KEY did not populate the api_key field.
2289        assert_ne!(config.ai.api_key.as_deref(), Some("leak-me"));
2290    }
2291
2292    #[test]
2293    fn test_subx_ai_base_url_outranks_local_llm_base_url() {
2294        let (mut env, _dir) = env_with_isolated_config();
2295        env.set_var("SUBX_AI_PROVIDER", "local");
2296        env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2297        env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2298
2299        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2300        let config = service.get_config().expect("get_config");
2301
2302        assert_eq!(config.ai.provider, "local");
2303        assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2304    }
2305
2306    #[test]
2307    fn test_subx_ai_apikey_outranks_local_llm_api_key() {
2308        let (mut env, _dir) = env_with_isolated_config();
2309        env.set_var("SUBX_AI_PROVIDER", "local");
2310        env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2311        env.set_var("LOCAL_LLM_API_KEY", "local-loser");
2312        env.set_var("SUBX_AI_APIKEY", "subx-winner");
2313
2314        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2315        let config = service.get_config().expect("get_config");
2316
2317        assert_eq!(config.ai.provider, "local");
2318        assert_eq!(config.ai.api_key.as_deref(), Some("subx-winner"));
2319    }
2320
2321    #[test]
2322    fn test_openai_api_key_does_not_populate_api_key_when_provider_is_local() {
2323        let (mut env, _dir) = env_with_isolated_config();
2324        env.set_var("SUBX_AI_PROVIDER", "local");
2325        env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2326        env.set_var("OPENAI_API_KEY", "sk-leak-into-local");
2327
2328        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2329        let config = service.get_config().expect("get_config");
2330
2331        assert_eq!(config.ai.provider, "local");
2332        assert_eq!(config.ai.api_key, None);
2333    }
2334
2335    #[test]
2336    fn test_openrouter_api_key_does_not_switch_provider_away_from_local() {
2337        let (mut env, _dir) = env_with_isolated_config();
2338        env.set_var("SUBX_AI_PROVIDER", "local");
2339        env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2340        env.set_var("OPENROUTER_API_KEY", "or-leak-into-local");
2341
2342        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2343        let config = service.get_config().expect("get_config");
2344
2345        assert_eq!(config.ai.provider, "local");
2346        assert_eq!(config.ai.api_key, None);
2347    }
2348
2349    #[test]
2350    fn test_azure_openai_env_vars_do_not_populate_when_provider_is_local() {
2351        let (mut env, _dir) = env_with_isolated_config();
2352        env.set_var("SUBX_AI_PROVIDER", "local");
2353        env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2354        env.set_var("AZURE_OPENAI_API_KEY", "azure-leak");
2355        env.set_var("AZURE_OPENAI_ENDPOINT", "https://leak.openai.azure.com/");
2356        env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "leaked-deployment");
2357
2358        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2359        let config = service.get_config().expect("get_config");
2360
2361        assert_eq!(config.ai.provider, "local");
2362        assert_eq!(config.ai.api_key, None);
2363        assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2364        assert_ne!(config.ai.model, "leaked-deployment");
2365    }
2366
2367    #[test]
2368    fn test_subx_ai_provider_ollama_triggers_local_carve_out() {
2369        // SUBX_AI_PROVIDER=ollama MUST be normalized to `local` BEFORE the
2370        // hosted env-var carve-out is evaluated. Stray OPENAI_API_KEY /
2371        // OPENROUTER_API_KEY in the environment must NOT leak into the
2372        // resolved config.
2373        let (mut env, _dir) = env_with_isolated_config();
2374        env.set_var("SUBX_AI_PROVIDER", "ollama");
2375        env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2376        env.set_var("OPENAI_API_KEY", "sk-should-not-leak");
2377        env.set_var("OPENROUTER_API_KEY", "or-should-not-leak");
2378
2379        let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2380        let config = service.get_config().expect("get_config");
2381
2382        assert_eq!(config.ai.provider, "local");
2383        assert_eq!(config.ai.api_key, None);
2384        assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2385    }
2386
2387    #[test]
2388    fn test_set_config_value_normalizes_ollama_to_local() {
2389        // `subx config set ai.provider ollama` SHALL persist `local`.
2390        let dir = tempfile::tempdir().expect("tempdir");
2391        let service = make_service_with_tmp_config(&dir);
2392        service
2393            .set_config_value("ai.provider", "ollama")
2394            .expect("set ai.provider=ollama");
2395        // Set a base_url too so the post-write validation succeeds for the
2396        // local provider.
2397        service
2398            .set_config_value("ai.base_url", "http://localhost:11434/v1")
2399            .expect("set base_url");
2400
2401        assert_eq!(
2402            service.get_config_value("ai.provider").unwrap(),
2403            "local",
2404            "persisted ai.provider must be the canonical form"
2405        );
2406    }
2407}