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