Skip to main content

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