1#![allow(deprecated)]
2use 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#[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 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
57pub trait ConfigService: Send + Sync {
62 fn get_config(&self) -> Result<Config>;
82
83 fn reload(&self) -> Result<()>;
92
93 fn save_config(&self) -> Result<()>;
102
103 fn save_config_to_file(&self, path: &Path) -> Result<()>;
116
117 fn get_config_file_path(&self) -> Result<PathBuf>;
124
125 fn get_config_value(&self, key: &str) -> Result<String>;
135
136 fn reset_to_defaults(&self) -> Result<()>;
145
146 fn set_config_value(&self, key: &str, value: &str) -> Result<()>;
160
161 fn load_for_repair(&self) -> Result<Config>;
185}
186
187pub 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 pub fn new() -> Result<Self> {
209 Self::with_env_provider(Arc::new(SystemEnvironmentProvider::new()))
210 }
211
212 pub fn with_env_provider(env_provider: Arc<dyn EnvironmentProvider>) -> Result<Self> {
217 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 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 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 fn load_and_validate(&self) -> Result<Config> {
269 debug!("ProductionConfigService: Loading configuration from sources");
270
271 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 let mut app_config = Config::default();
279
280 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 if let Ok(raw_map) = config_crate
289 .try_deserialize::<std::collections::HashMap<String, serde_json::Value>>()
290 {
291 if let Some(ai_section) = raw_map.get("ai") {
293 if let Some(ai_obj) = ai_section.as_object() {
294 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 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 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 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 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 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 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 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 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 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 app_config.ai.provider =
431 crate::config::field_validator::normalize_ai_provider(&app_config.ai.provider);
432 }
433
434 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 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 fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
493 use crate::config::field_validator;
494
495 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 field_validator::validate_field(key, value)?;
509
510 self.set_value_internal(config, key, value)?;
512
513 self.validate_configuration(config)?;
515
516 Ok(())
517 }
518
519 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(); config.ai.max_sample_length = v;
546 }
547 ["ai", "temperature"] => {
548 let v = value.parse().unwrap(); config.ai.temperature = v;
550 }
551 ["ai", "max_tokens"] => {
552 let v = value.parse().unwrap(); config.ai.max_tokens = v;
554 }
555 ["ai", "retry_attempts"] => {
556 let v = value.parse().unwrap(); config.ai.retry_attempts = v;
558 }
559 ["ai", "retry_delay_ms"] => {
560 let v = value.parse().unwrap(); config.ai.retry_delay_ms = v;
562 }
563 ["ai", "request_timeout_seconds"] => {
564 let v = value.parse().unwrap(); 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(); config.formats.encoding_detection_confidence = v;
587 }
588 ["sync", "max_offset_seconds"] => {
589 let v = value.parse().unwrap(); 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(); config.sync.vad.sensitivity = v;
602 }
603 ["sync", "vad", "padding_chunks"] => {
604 let v = value.parse().unwrap(); config.sync.vad.padding_chunks = v;
606 }
607 ["sync", "vad", "min_speech_duration_ms"] => {
608 let v = value.parse().unwrap(); 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(); config.general.max_concurrent_jobs = v;
618 }
619 ["general", "task_timeout_seconds"] => {
620 let v = value.parse().unwrap(); 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(); config.general.worker_idle_timeout_seconds = v;
630 }
631 ["general", "max_subtitle_bytes"] => {
632 let v = value.parse().unwrap(); config.general.max_subtitle_bytes = v;
634 }
635 ["general", "max_audio_bytes"] => {
636 let v = value.parse().unwrap(); config.general.max_audio_bytes = v;
638 }
639 ["parallel", "max_workers"] => {
640 let v = value.parse().unwrap(); config.parallel.max_workers = v;
642 }
643 ["parallel", "task_queue_size"] => {
644 let v = value.parse().unwrap(); 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!(), };
662 }
663 ["translation", "batch_size"] => {
664 let v = value.parse().unwrap(); 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 fn validate_configuration(&self, config: &Config) -> Result<()> {
685 use crate::config::validator;
686 validator::validate_config(config)
687 }
688
689 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
703pub(crate) fn read_config_value_from(config: &Config, key: &str) -> Result<String> {
711 let parts: Vec<&str> = key.split('.').collect();
712 match parts.as_slice() {
713 ["ai", "provider"] => Ok(config.ai.provider.clone()),
714 ["ai", "model"] => Ok(config.ai.model.clone()),
715 ["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
716 ["ai", "base_url"] => Ok(config.ai.base_url.clone()),
717 ["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
718 ["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
719 ["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
720 ["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
721 ["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
722 ["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
723
724 ["formats", "default_output"] => Ok(config.formats.default_output.clone()),
725 ["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
726 ["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
727 ["formats", "encoding_detection_confidence"] => {
728 Ok(config.formats.encoding_detection_confidence.to_string())
729 }
730
731 ["sync", "default_method"] => Ok(config.sync.default_method.clone()),
732 ["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
733 ["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
734 ["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
735 ["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
736 ["sync", "vad", "min_speech_duration_ms"] => {
737 Ok(config.sync.vad.min_speech_duration_ms.to_string())
738 }
739
740 ["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
741 ["general", "max_concurrent_jobs"] => Ok(config.general.max_concurrent_jobs.to_string()),
742 ["general", "task_timeout_seconds"] => Ok(config.general.task_timeout_seconds.to_string()),
743 ["general", "enable_progress_bar"] => Ok(config.general.enable_progress_bar.to_string()),
744 ["general", "worker_idle_timeout_seconds"] => {
745 Ok(config.general.worker_idle_timeout_seconds.to_string())
746 }
747 ["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
748 ["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
749
750 ["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
751 ["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
752 ["parallel", "enable_task_priorities"] => {
753 Ok(config.parallel.enable_task_priorities.to_string())
754 }
755 ["parallel", "auto_balance_workers"] => {
756 Ok(config.parallel.auto_balance_workers.to_string())
757 }
758 ["parallel", "overflow_strategy"] => Ok(format!("{:?}", config.parallel.overflow_strategy)),
759
760 ["translation", "batch_size"] => Ok(config.translation.batch_size.to_string()),
761 ["translation", "default_target_language"] => Ok(config
762 .translation
763 .default_target_language
764 .clone()
765 .unwrap_or_default()),
766
767 _ => Err(SubXError::config(format!(
768 "Unknown configuration key: {}",
769 key
770 ))),
771 }
772}
773
774impl ConfigService for ProductionConfigService {
775 fn get_config(&self) -> Result<Config> {
776 {
778 let cache = self.cached_config.read().unwrap();
779 if let Some(config) = cache.as_ref() {
780 debug!("ProductionConfigService: Returning cached configuration");
781 return Ok(config.clone());
782 }
783 }
784
785 let app_config = self.load_and_validate()?;
787
788 {
790 let mut cache = self.cached_config.write().unwrap();
791 *cache = Some(app_config.clone());
792 }
793
794 Ok(app_config)
795 }
796
797 fn reload(&self) -> Result<()> {
798 debug!("ProductionConfigService: Reloading configuration");
799
800 {
802 let mut cache = self.cached_config.write().unwrap();
803 *cache = None;
804 }
805
806 self.get_config()?;
808
809 debug!("ProductionConfigService: Configuration reloaded successfully");
810 Ok(())
811 }
812
813 fn save_config(&self) -> Result<()> {
814 let _config = self.get_config()?;
815 let path = self.get_config_file_path()?;
816 self.save_config_to_file(&path)
817 }
818
819 fn save_config_to_file(&self, path: &Path) -> Result<()> {
820 let config = self.get_config()?;
821 let toml_content = toml::to_string_pretty(&config)
822 .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
823
824 secure_write_config_file(path, &toml_content)
825 .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
826
827 Ok(())
828 }
829
830 fn get_config_file_path(&self) -> Result<PathBuf> {
831 if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
833 return Ok(PathBuf::from(custom));
834 }
835
836 let config_dir = dirs::config_dir()
837 .ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
838 Ok(config_dir.join("subx").join("config.toml"))
839 }
840
841 fn get_config_value(&self, key: &str) -> Result<String> {
842 let config = self.get_config()?;
843 read_config_value_from(&config, key)
844 }
845
846 fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
847 let mut config = self.load_for_repair()?;
854
855 self.validate_and_set_value(&mut config, key, value)?;
861
862 let path = self.get_config_file_path()?;
866 self.save_config_to_file_with_config(&path, &config)?;
867
868 {
871 let mut cache = self.cached_config.write().unwrap();
872 *cache = Some(config);
873 }
874
875 Ok(())
876 }
877
878 fn reset_to_defaults(&self) -> Result<()> {
879 let default_config = Config::default();
880 let path = self.get_config_file_path()?;
881
882 let toml_content = toml::to_string_pretty(&default_config)
883 .map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
884
885 secure_write_config_file(&path, &toml_content)
886 .map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
887
888 self.reload()
889 }
890
891 fn load_for_repair(&self) -> Result<Config> {
892 let path = self.get_config_file_path()?;
899
900 if !path.exists() {
905 debug!(
906 "ProductionConfigService::load_for_repair: file {} does not exist, using defaults",
907 path.display()
908 );
909 return Ok(Config::default());
910 }
911
912 let content = std::fs::read_to_string(&path).map_err(|e| {
913 SubXError::config(format!(
914 "Failed to read configuration file {}: {}",
915 path.display(),
916 e
917 ))
918 })?;
919
920 let mut config = toml::from_str::<Config>(&content).map_err(|e| {
921 SubXError::config(format!(
922 "Failed to parse configuration file {}: {}",
923 path.display(),
924 e
925 ))
926 })?;
927
928 config.ai.provider =
931 crate::config::field_validator::normalize_ai_provider(&config.ai.provider);
932
933 crate::config::field_validator::validate_all_fields(&config)?;
941
942 Ok(config)
943 }
944}
945
946impl Default for ProductionConfigService {
947 fn default() -> Self {
948 Self::new().expect("Failed to create default ProductionConfigService")
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955 use crate::config::TestConfigService;
956 use crate::config::TestEnvironmentProvider;
957 use std::sync::Arc;
958
959 fn make_service_with_tmp_config(dir: &tempfile::TempDir) -> ProductionConfigService {
962 let config_path = dir.path().join("config.toml");
963 let mut env = TestEnvironmentProvider::new();
964 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
965 ProductionConfigService::with_env_provider(Arc::new(env)).unwrap()
966 }
967
968 #[test]
969 fn test_production_config_service_creation() {
970 let service = ProductionConfigService::new();
971 assert!(service.is_ok());
972 }
973
974 #[test]
975 fn test_production_config_service_with_custom_file() {
976 let service = ProductionConfigService::new()
977 .unwrap()
978 .with_custom_file(PathBuf::from("test.toml"));
979 assert!(service.is_ok());
980 }
981
982 #[test]
983 fn test_production_service_implements_config_service_trait() {
984 let dir = tempfile::tempdir().unwrap();
989 let service = make_service_with_tmp_config(&dir);
990
991 let config1 = service.get_config();
993 assert!(config1.is_ok());
994
995 let reload_result = service.reload();
996 assert!(reload_result.is_ok());
997
998 let config2 = service.get_config();
999 assert!(config2.is_ok());
1000 }
1001
1002 #[test]
1003 fn test_production_config_service_openrouter_api_key_loading() {
1004 use crate::config::TestEnvironmentProvider;
1005 use std::sync::Arc;
1006
1007 let mut env_provider = TestEnvironmentProvider::new();
1008 env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
1009 env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
1010
1011 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1012 .expect("Failed to create config service");
1013
1014 let config = service.get_config().expect("Failed to get config");
1015
1016 assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
1017 }
1018
1019 #[test]
1020 fn test_config_service_with_openai_api_key() {
1021 let test_service = TestConfigService::with_ai_settings_and_key(
1023 "openai",
1024 "gpt-4.1-mini",
1025 "sk-test-openai-key-123",
1026 );
1027
1028 let config = test_service.get_config().unwrap();
1029 assert_eq!(
1030 config.ai.api_key,
1031 Some("sk-test-openai-key-123".to_string())
1032 );
1033 assert_eq!(config.ai.provider, "openai");
1034 assert_eq!(config.ai.model, "gpt-4.1-mini");
1035 }
1036
1037 #[test]
1038 fn test_config_service_with_custom_base_url() {
1039 let mut config = Config::default();
1041 config.ai.base_url = "https://custom.openai.endpoint".to_string();
1042
1043 let test_service = TestConfigService::new(config);
1044 let loaded_config = test_service.get_config().unwrap();
1045
1046 assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
1047 }
1048
1049 #[test]
1050 fn test_config_service_with_both_openai_settings() {
1051 let mut config = Config::default();
1053 config.ai.api_key = Some("sk-test-api-key-combined".to_string());
1054 config.ai.base_url = "https://api.custom-openai.com".to_string();
1055
1056 let test_service = TestConfigService::new(config);
1057 let loaded_config = test_service.get_config().unwrap();
1058
1059 assert_eq!(
1060 loaded_config.ai.api_key,
1061 Some("sk-test-api-key-combined".to_string())
1062 );
1063 assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
1064 }
1065
1066 #[test]
1067 fn test_config_service_provider_precedence() {
1068 let test_service =
1070 TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
1071
1072 let config = test_service.get_config().unwrap();
1073 assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
1074 assert_eq!(config.ai.provider, "openai");
1075 assert_eq!(config.ai.model, "gpt-4.1");
1076 }
1077
1078 #[test]
1079 fn test_config_service_fallback_behavior() {
1080 let test_service = TestConfigService::with_defaults();
1082 let config = test_service.get_config().unwrap();
1083
1084 assert_eq!(config.ai.provider, "openai");
1086 assert_eq!(config.ai.model, "gpt-4.1-mini");
1087 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
1088 assert_eq!(config.ai.api_key, None); }
1090
1091 #[test]
1092 fn test_config_service_reload_functionality() {
1093 let test_service = TestConfigService::with_defaults();
1095
1096 let config1 = test_service.get_config().unwrap();
1098 assert_eq!(config1.ai.provider, "openai");
1099
1100 let reload_result = test_service.reload();
1102 assert!(reload_result.is_ok());
1103
1104 let config2 = test_service.get_config().unwrap();
1106 assert_eq!(config2.ai.provider, "openai");
1107 }
1108
1109 #[test]
1110 fn test_config_service_custom_base_url_override() {
1111 let mut config = Config::default();
1113 config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
1114
1115 let test_service = TestConfigService::new(config);
1116 let loaded_config = test_service.get_config().unwrap();
1117
1118 assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
1119 }
1120
1121 #[test]
1122 fn test_config_service_sync_settings() {
1123 let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
1125 let config = test_service.get_config().unwrap();
1126
1127 assert_eq!(config.sync.correlation_threshold, 0.8);
1128 assert_eq!(config.sync.max_offset_seconds, 45.0);
1129 }
1130
1131 #[test]
1132 fn test_config_service_parallel_settings() {
1133 let test_service = TestConfigService::with_parallel_settings(8, 200);
1135 let config = test_service.get_config().unwrap();
1136
1137 assert_eq!(config.general.max_concurrent_jobs, 8);
1138 assert_eq!(config.parallel.task_queue_size, 200);
1139 }
1140
1141 #[test]
1142 fn test_config_size_limits_defaults() {
1143 let service = TestConfigService::with_defaults();
1144 let cfg = service.get_config().unwrap();
1145 assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
1146 assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
1147 }
1148
1149 #[test]
1150 fn test_config_size_limits_roundtrip() {
1151 let service = TestConfigService::with_defaults();
1152
1153 service
1154 .set_config_value("general.max_subtitle_bytes", "65536")
1155 .unwrap();
1156 service
1157 .set_config_value("general.max_audio_bytes", "1048576")
1158 .unwrap();
1159
1160 assert_eq!(
1161 service
1162 .get_config_value("general.max_subtitle_bytes")
1163 .unwrap(),
1164 "65536"
1165 );
1166 assert_eq!(
1167 service.get_config_value("general.max_audio_bytes").unwrap(),
1168 "1048576"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_config_size_limits_validation_reject() {
1174 let service = TestConfigService::with_defaults();
1175 assert!(
1177 service
1178 .set_config_value("general.max_subtitle_bytes", "100")
1179 .is_err()
1180 );
1181 assert!(
1183 service
1184 .set_config_value("general.max_subtitle_bytes", "2147483648")
1185 .is_err()
1186 );
1187 }
1188
1189 #[test]
1190 fn test_config_service_direct_access() {
1191 let test_service = TestConfigService::with_defaults();
1193
1194 assert_eq!(test_service.config().ai.provider, "openai");
1196
1197 test_service.config_mut().ai.provider = "modified".to_string();
1199 assert_eq!(test_service.config().ai.provider, "modified");
1200
1201 let config = test_service.get_config().unwrap();
1203 assert_eq!(config.ai.provider, "modified");
1204 }
1205
1206 #[test]
1207 fn test_production_config_service_openai_api_key_loading() {
1208 let mut env_provider = TestEnvironmentProvider::new();
1210 env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
1211
1212 env_provider.set_var(
1214 "SUBX_CONFIG_PATH",
1215 "/tmp/test_config_that_does_not_exist.toml",
1216 );
1217
1218 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1219 .expect("Failed to create config service");
1220
1221 let config = service.get_config().expect("Failed to get config");
1222
1223 assert_eq!(
1224 config.ai.api_key,
1225 Some("sk-test-openai-key-env".to_string())
1226 );
1227 }
1228
1229 #[test]
1230 fn test_production_config_service_openai_base_url_loading() {
1231 let mut env_provider = TestEnvironmentProvider::new();
1233 env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
1234 env_provider.set_var(
1237 "SUBX_CONFIG_PATH",
1238 "/tmp/test_config_base_url_that_does_not_exist.toml",
1239 );
1240
1241 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1242 .expect("Failed to create config service");
1243
1244 let config = service.get_config().expect("Failed to get config");
1245
1246 assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
1247 }
1248
1249 #[test]
1250 fn test_production_config_service_both_openai_env_vars() {
1251 let mut env_provider = TestEnvironmentProvider::new();
1253 env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
1254 env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
1255
1256 env_provider.set_var(
1258 "SUBX_CONFIG_PATH",
1259 "/tmp/test_config_both_that_does_not_exist.toml",
1260 );
1261
1262 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1263 .expect("Failed to create config service");
1264
1265 let config = service.get_config().expect("Failed to get config");
1266
1267 assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
1268 assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
1269 }
1270
1271 #[test]
1272 fn test_production_config_service_no_openai_env_vars() {
1273 let mut env_provider = TestEnvironmentProvider::new(); env_provider.set_var(
1278 "SUBX_CONFIG_PATH",
1279 "/tmp/test_config_no_openai_that_does_not_exist.toml",
1280 );
1281
1282 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1283 .expect("Failed to create config service");
1284
1285 let config = service.get_config().expect("Failed to get config");
1286
1287 assert_eq!(config.ai.api_key, None);
1289 assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); }
1291
1292 #[test]
1293 fn test_production_config_service_api_key_priority() {
1294 let mut env_provider = TestEnvironmentProvider::new();
1296 env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1297 env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1299 let dir = tempfile::tempdir().expect("tempdir");
1303 let cfg_path = dir.path().join("nonexistent.toml");
1304 env_provider.set_var("SUBX_CONFIG_PATH", cfg_path.to_str().unwrap());
1305
1306 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1307 .expect("Failed to create config service");
1308
1309 let config = service.get_config().expect("Failed to get config");
1310
1311 assert!(config.ai.api_key.is_some());
1314 }
1315
1316 #[cfg(unix)]
1317 #[test]
1318 fn test_secure_write_config_file_sets_0600_permissions() {
1319 use std::os::unix::fs::PermissionsExt;
1320
1321 let dir = tempfile::tempdir().expect("create tempdir");
1322 let nested = dir.path().join("subdir");
1323 let path = nested.join("config.toml");
1324
1325 super::secure_write_config_file(&path, "api_key = \"secret\"\n")
1326 .expect("secure write should succeed");
1327
1328 let meta = std::fs::metadata(&path).expect("file must exist");
1329 let mode = meta.permissions().mode() & 0o777;
1330 assert_eq!(
1331 mode, 0o600,
1332 "file permissions must be 0o600, got {:o}",
1333 mode
1334 );
1335
1336 let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
1337 let dir_mode = dir_meta.permissions().mode() & 0o777;
1338 assert_eq!(
1339 dir_mode, 0o700,
1340 "directory permissions must be 0o700, got {:o}",
1341 dir_mode
1342 );
1343
1344 let contents = std::fs::read_to_string(&path).unwrap();
1345 assert_eq!(contents, "api_key = \"secret\"\n");
1346 }
1347
1348 #[cfg(unix)]
1349 #[test]
1350 fn test_secure_write_config_file_truncates_existing_file() {
1351 use std::os::unix::fs::PermissionsExt;
1352
1353 let dir = tempfile::tempdir().expect("create tempdir");
1354 let path = dir.path().join("config.toml");
1355
1356 std::fs::write(&path, "stale contents that should be replaced").unwrap();
1358 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1359
1360 super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
1361
1362 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1363 assert_eq!(mode, 0o600);
1364 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
1365 }
1366
1367 #[test]
1372 fn test_production_config_get_config_caches_result() {
1373 let dir = tempfile::tempdir().unwrap();
1374 let service = make_service_with_tmp_config(&dir);
1375 let config1 = service.get_config().unwrap();
1376 let config2 = service.get_config().unwrap();
1377 assert_eq!(config1.ai.provider, config2.ai.provider);
1378 assert_eq!(config1.ai.model, config2.ai.model);
1379 }
1380
1381 #[test]
1382 fn test_production_config_reload_clears_cache_and_reloads() {
1383 let dir = tempfile::tempdir().unwrap();
1384 let service = make_service_with_tmp_config(&dir);
1385 service.get_config().unwrap(); service.reload().unwrap(); let config = service.get_config().unwrap();
1388 assert_eq!(config.ai.provider, "openai");
1389 }
1390
1391 #[test]
1396 fn test_azure_openai_api_key_sets_provider_and_key() {
1397 let mut env = TestEnvironmentProvider::new();
1398 env.set_var("AZURE_OPENAI_API_KEY", "azure-api-key-test");
1399 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_api_key_test.toml");
1400 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1401 let config = service.get_config().unwrap();
1402 assert_eq!(config.ai.provider, "azure-openai");
1403 assert_eq!(config.ai.api_key, Some("azure-api-key-test".to_string()));
1404 }
1405
1406 #[test]
1407 fn test_azure_openai_endpoint_sets_base_url() {
1408 let mut env = TestEnvironmentProvider::new();
1409 env.set_var(
1410 "AZURE_OPENAI_ENDPOINT",
1411 "https://my-instance.openai.azure.com",
1412 );
1413 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_endpoint_test.toml");
1414 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1415 let config = service.get_config().unwrap();
1416 assert_eq!(config.ai.base_url, "https://my-instance.openai.azure.com");
1417 }
1418
1419 #[test]
1420 fn test_azure_openai_api_version_sets_api_version() {
1421 let mut env = TestEnvironmentProvider::new();
1422 env.set_var("AZURE_OPENAI_API_VERSION", "2024-02-01");
1423 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_version_test.toml");
1424 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1425 let config = service.get_config().unwrap();
1426 assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1427 }
1428
1429 #[test]
1430 fn test_azure_openai_deployment_id_sets_model() {
1431 let mut env = TestEnvironmentProvider::new();
1432 env.set_var("AZURE_OPENAI_API_KEY", "azure-key-for-deploy");
1433 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "my-gpt4-deployment");
1434 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_deploy_test.toml");
1435 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1436 let config = service.get_config().unwrap();
1437 assert_eq!(config.ai.model, "my-gpt4-deployment");
1438 }
1439
1440 #[test]
1441 fn test_azure_openai_all_env_vars_together() {
1442 let mut env = TestEnvironmentProvider::new();
1443 env.set_var("AZURE_OPENAI_API_KEY", "full-azure-api-key");
1444 env.set_var("AZURE_OPENAI_ENDPOINT", "https://full.openai.azure.com");
1445 env.set_var("AZURE_OPENAI_API_VERSION", "2024-05-01");
1446 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "full-deployment-name");
1447 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_full_test.toml");
1448 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1449 let config = service.get_config().unwrap();
1450 assert_eq!(config.ai.provider, "azure-openai");
1451 assert_eq!(config.ai.api_key, Some("full-azure-api-key".to_string()));
1452 assert_eq!(config.ai.base_url, "https://full.openai.azure.com");
1453 assert_eq!(config.ai.api_version, Some("2024-05-01".to_string()));
1454 assert_eq!(config.ai.model, "full-deployment-name");
1455 }
1456
1457 #[test]
1462 fn test_get_config_file_path_uses_subx_config_path_env() {
1463 let mut env = TestEnvironmentProvider::new();
1464 env.set_var("SUBX_CONFIG_PATH", "/custom/path/config.toml");
1465 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1466 let path = service.get_config_file_path().unwrap();
1467 assert_eq!(path, PathBuf::from("/custom/path/config.toml"));
1468 }
1469
1470 #[test]
1471 fn test_get_config_file_path_default_contains_subx() {
1472 let env = TestEnvironmentProvider::new(); let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1474 let path = service.get_config_file_path().unwrap();
1475 let s = path.to_str().unwrap();
1476 assert!(s.contains("subx"), "expected 'subx' in path: {s}");
1477 assert!(
1478 s.ends_with("config.toml"),
1479 "expected 'config.toml' suffix: {s}"
1480 );
1481 }
1482
1483 #[test]
1488 fn test_save_config_to_file_writes_valid_toml() {
1489 let dir = tempfile::tempdir().unwrap();
1490 let service = make_service_with_tmp_config(&dir);
1491 let save_path = dir.path().join("output.toml");
1492 service.save_config_to_file(&save_path).unwrap();
1493 let content = std::fs::read_to_string(&save_path).unwrap();
1494 assert!(content.contains("[ai]"), "missing [ai] section: {content}");
1495 assert!(
1496 content.contains("provider"),
1497 "missing 'provider': {content}"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_save_config_writes_to_configured_path() {
1503 let dir = tempfile::tempdir().unwrap();
1504 let service = make_service_with_tmp_config(&dir);
1505 service.save_config().unwrap();
1506 let config_path = dir.path().join("config.toml");
1507 assert!(config_path.exists(), "config file was not created");
1508 let content = std::fs::read_to_string(&config_path).unwrap();
1509 assert!(content.contains("[ai]"));
1510 }
1511
1512 #[test]
1517 fn test_reset_to_defaults_restores_default_config() {
1518 let dir = tempfile::tempdir().unwrap();
1519 let service = make_service_with_tmp_config(&dir);
1520 service.save_config().unwrap();
1522 service.reset_to_defaults().unwrap();
1523 let config = service.get_config().unwrap();
1524 assert_eq!(config.ai.provider, "openai");
1525 assert_eq!(config.ai.model, "gpt-4.1-mini");
1526 assert_eq!(config.formats.default_output, "srt");
1527 }
1528
1529 #[test]
1534 fn test_get_config_value_all_ai_keys() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let service = make_service_with_tmp_config(&dir);
1537 for key in &[
1538 "ai.provider",
1539 "ai.model",
1540 "ai.api_key",
1541 "ai.base_url",
1542 "ai.max_sample_length",
1543 "ai.temperature",
1544 "ai.max_tokens",
1545 "ai.retry_attempts",
1546 "ai.retry_delay_ms",
1547 "ai.request_timeout_seconds",
1548 ] {
1549 assert!(
1550 service.get_config_value(key).is_ok(),
1551 "failed for key: {key}"
1552 );
1553 }
1554 }
1555
1556 #[test]
1557 fn test_get_config_value_all_formats_keys() {
1558 let dir = tempfile::tempdir().unwrap();
1559 let service = make_service_with_tmp_config(&dir);
1560 for key in &[
1561 "formats.default_output",
1562 "formats.default_encoding",
1563 "formats.preserve_styling",
1564 "formats.encoding_detection_confidence",
1565 ] {
1566 assert!(
1567 service.get_config_value(key).is_ok(),
1568 "failed for key: {key}"
1569 );
1570 }
1571 }
1572
1573 #[test]
1574 fn test_get_config_value_all_sync_keys() {
1575 let dir = tempfile::tempdir().unwrap();
1576 let service = make_service_with_tmp_config(&dir);
1577 for key in &[
1578 "sync.default_method",
1579 "sync.max_offset_seconds",
1580 "sync.vad.enabled",
1581 "sync.vad.sensitivity",
1582 "sync.vad.padding_chunks",
1583 "sync.vad.min_speech_duration_ms",
1584 ] {
1585 assert!(
1586 service.get_config_value(key).is_ok(),
1587 "failed for key: {key}"
1588 );
1589 }
1590 }
1591
1592 #[test]
1593 fn test_get_config_value_all_general_keys() {
1594 let dir = tempfile::tempdir().unwrap();
1595 let service = make_service_with_tmp_config(&dir);
1596 for key in &[
1597 "general.backup_enabled",
1598 "general.max_concurrent_jobs",
1599 "general.task_timeout_seconds",
1600 "general.enable_progress_bar",
1601 "general.worker_idle_timeout_seconds",
1602 "general.max_subtitle_bytes",
1603 "general.max_audio_bytes",
1604 ] {
1605 assert!(
1606 service.get_config_value(key).is_ok(),
1607 "failed for key: {key}"
1608 );
1609 }
1610 }
1611
1612 #[test]
1613 fn test_get_config_value_all_parallel_keys() {
1614 let dir = tempfile::tempdir().unwrap();
1615 let service = make_service_with_tmp_config(&dir);
1616 for key in &[
1617 "parallel.max_workers",
1618 "parallel.task_queue_size",
1619 "parallel.enable_task_priorities",
1620 "parallel.auto_balance_workers",
1621 "parallel.overflow_strategy",
1622 ] {
1623 assert!(
1624 service.get_config_value(key).is_ok(),
1625 "failed for key: {key}"
1626 );
1627 }
1628 }
1629
1630 #[test]
1631 fn test_get_config_value_unknown_key_returns_error() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let service = make_service_with_tmp_config(&dir);
1634 assert!(service.get_config_value("nonexistent.key").is_err());
1635 assert!(service.get_config_value("ai").is_err());
1636 }
1637
1638 #[test]
1639 fn test_get_config_value_returns_correct_defaults() {
1640 let dir = tempfile::tempdir().unwrap();
1641 let service = make_service_with_tmp_config(&dir);
1642 assert_eq!(service.get_config_value("ai.provider").unwrap(), "openai");
1643 assert_eq!(
1644 service.get_config_value("ai.model").unwrap(),
1645 "gpt-4.1-mini"
1646 );
1647 assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1648 assert_eq!(
1649 service.get_config_value("formats.default_output").unwrap(),
1650 "srt"
1651 );
1652 assert_eq!(
1653 service.get_config_value("general.backup_enabled").unwrap(),
1654 "false"
1655 );
1656 }
1657
1658 #[test]
1663 fn test_set_config_value_ai_provider() {
1664 let dir = tempfile::tempdir().unwrap();
1665 let service = make_service_with_tmp_config(&dir);
1666 service
1667 .set_config_value("ai.provider", "openrouter")
1668 .unwrap();
1669 assert_eq!(
1670 service.get_config_value("ai.provider").unwrap(),
1671 "openrouter"
1672 );
1673 }
1674
1675 #[test]
1680 fn test_set_config_value_ai_provider_canonicalizes_alias_and_case() {
1681 let cases = [
1682 ("OLLAMA", "local"),
1683 ("ollama", "local"),
1684 (" ollama ", "local"),
1685 ("OPENAI", "openai"),
1686 (" Azure-OpenAI ", "azure-openai"),
1687 ];
1688 for (input, expected) in cases {
1689 let dir = tempfile::tempdir().unwrap();
1690 let service = make_service_with_tmp_config(&dir);
1691 service
1692 .set_config_value("ai.provider", input)
1693 .unwrap_or_else(|e| panic!("input {input:?} should be accepted: {e}"));
1694 assert_eq!(
1695 service.get_config_value("ai.provider").unwrap(),
1696 expected,
1697 "input {input:?} should canonicalize to {expected:?}"
1698 );
1699 }
1700 }
1701
1702 #[test]
1704 fn test_set_config_value_ai_provider_rejects_unknown_after_normalization() {
1705 let dir = tempfile::tempdir().unwrap();
1706 let service = make_service_with_tmp_config(&dir);
1707 assert!(service.set_config_value("ai.provider", "GROK").is_err());
1708 }
1709
1710 #[test]
1711 fn test_set_config_value_ai_model() {
1712 let dir = tempfile::tempdir().unwrap();
1713 let service = make_service_with_tmp_config(&dir);
1714 service.set_config_value("ai.model", "gpt-4.1").unwrap();
1715 assert_eq!(service.get_config_value("ai.model").unwrap(), "gpt-4.1");
1716 }
1717
1718 #[test]
1719 fn test_set_config_value_ai_api_key_non_empty() {
1720 let dir = tempfile::tempdir().unwrap();
1721 let service = make_service_with_tmp_config(&dir);
1722 service
1723 .set_config_value("ai.api_key", "sk-test-apikey-12345")
1724 .unwrap();
1725 assert_eq!(
1726 service.get_config_value("ai.api_key").unwrap(),
1727 "sk-test-apikey-12345"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_set_config_value_ai_api_key_empty_clears_key() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let service = make_service_with_tmp_config(&dir);
1735 service
1737 .set_config_value("ai.api_key", "sk-test-apikey-12345")
1738 .unwrap();
1739 service.set_config_value("ai.api_key", "").unwrap();
1741 assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1742 let config = service.get_config().unwrap();
1743 assert!(config.ai.api_key.is_none());
1744 }
1745
1746 #[test]
1747 fn test_set_config_value_ai_base_url() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let service = make_service_with_tmp_config(&dir);
1750 service
1751 .set_config_value("ai.base_url", "https://custom.example.com/v1")
1752 .unwrap();
1753 let config = service.get_config().unwrap();
1754 assert_eq!(config.ai.base_url, "https://custom.example.com/v1");
1755 }
1756
1757 #[test]
1758 fn test_set_config_value_ai_temperature() {
1759 let dir = tempfile::tempdir().unwrap();
1760 let service = make_service_with_tmp_config(&dir);
1761 service.set_config_value("ai.temperature", "0.7").unwrap();
1762 let config = service.get_config().unwrap();
1763 assert!((config.ai.temperature - 0.7).abs() < 0.001);
1764 }
1765
1766 #[test]
1767 fn test_set_config_value_ai_max_tokens() {
1768 let dir = tempfile::tempdir().unwrap();
1769 let service = make_service_with_tmp_config(&dir);
1770 service.set_config_value("ai.max_tokens", "5000").unwrap();
1771 assert_eq!(service.get_config_value("ai.max_tokens").unwrap(), "5000");
1772 }
1773
1774 #[test]
1775 fn test_set_config_value_ai_retry_attempts() {
1776 let dir = tempfile::tempdir().unwrap();
1777 let service = make_service_with_tmp_config(&dir);
1778 service.set_config_value("ai.retry_attempts", "5").unwrap();
1779 assert_eq!(service.get_config_value("ai.retry_attempts").unwrap(), "5");
1780 }
1781
1782 #[test]
1783 fn test_set_config_value_ai_retry_delay_ms() {
1784 let dir = tempfile::tempdir().unwrap();
1785 let service = make_service_with_tmp_config(&dir);
1786 service
1787 .set_config_value("ai.retry_delay_ms", "2000")
1788 .unwrap();
1789 assert_eq!(
1790 service.get_config_value("ai.retry_delay_ms").unwrap(),
1791 "2000"
1792 );
1793 }
1794
1795 #[test]
1796 fn test_set_config_value_ai_request_timeout_seconds() {
1797 let dir = tempfile::tempdir().unwrap();
1798 let service = make_service_with_tmp_config(&dir);
1799 service
1800 .set_config_value("ai.request_timeout_seconds", "60")
1801 .unwrap();
1802 assert_eq!(
1803 service
1804 .get_config_value("ai.request_timeout_seconds")
1805 .unwrap(),
1806 "60"
1807 );
1808 }
1809
1810 #[test]
1811 fn test_set_config_value_ai_max_sample_length() {
1812 let dir = tempfile::tempdir().unwrap();
1813 let service = make_service_with_tmp_config(&dir);
1814 service
1815 .set_config_value("ai.max_sample_length", "500")
1816 .unwrap();
1817 assert_eq!(
1818 service.get_config_value("ai.max_sample_length").unwrap(),
1819 "500"
1820 );
1821 }
1822
1823 #[test]
1824 fn test_set_config_value_ai_api_version_non_empty() {
1825 let dir = tempfile::tempdir().unwrap();
1826 let service = make_service_with_tmp_config(&dir);
1827 service
1828 .set_config_value("ai.api_version", "2024-02-01")
1829 .unwrap();
1830 let config = service.get_config().unwrap();
1831 assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1832 }
1833
1834 #[test]
1839 fn test_set_config_value_formats_default_output() {
1840 let dir = tempfile::tempdir().unwrap();
1841 let service = make_service_with_tmp_config(&dir);
1842 service
1843 .set_config_value("formats.default_output", "ass")
1844 .unwrap();
1845 assert_eq!(
1846 service.get_config_value("formats.default_output").unwrap(),
1847 "ass"
1848 );
1849 }
1850
1851 #[test]
1852 fn test_set_config_value_formats_preserve_styling() {
1853 let dir = tempfile::tempdir().unwrap();
1854 let service = make_service_with_tmp_config(&dir);
1855 service
1856 .set_config_value("formats.preserve_styling", "true")
1857 .unwrap();
1858 let config = service.get_config().unwrap();
1859 assert!(config.formats.preserve_styling);
1860 }
1861
1862 #[test]
1863 fn test_set_config_value_formats_default_encoding() {
1864 let dir = tempfile::tempdir().unwrap();
1865 let service = make_service_with_tmp_config(&dir);
1866 service
1867 .set_config_value("formats.default_encoding", "utf-8")
1868 .unwrap();
1869 assert_eq!(
1870 service
1871 .get_config_value("formats.default_encoding")
1872 .unwrap(),
1873 "utf-8"
1874 );
1875 }
1876
1877 #[test]
1878 fn test_set_config_value_formats_encoding_detection_confidence() {
1879 let dir = tempfile::tempdir().unwrap();
1880 let service = make_service_with_tmp_config(&dir);
1881 service
1882 .set_config_value("formats.encoding_detection_confidence", "0.9")
1883 .unwrap();
1884 let config = service.get_config().unwrap();
1885 assert!((config.formats.encoding_detection_confidence - 0.9).abs() < 0.001);
1886 }
1887
1888 #[test]
1893 fn test_set_config_value_sync_max_offset_seconds() {
1894 let dir = tempfile::tempdir().unwrap();
1895 let service = make_service_with_tmp_config(&dir);
1896 service
1897 .set_config_value("sync.max_offset_seconds", "30")
1898 .unwrap();
1899 let config = service.get_config().unwrap();
1900 assert!((config.sync.max_offset_seconds - 30.0).abs() < 0.001);
1901 }
1902
1903 #[test]
1904 fn test_set_config_value_sync_default_method() {
1905 let dir = tempfile::tempdir().unwrap();
1906 let service = make_service_with_tmp_config(&dir);
1907 service
1908 .set_config_value("sync.default_method", "vad")
1909 .unwrap();
1910 assert_eq!(
1911 service.get_config_value("sync.default_method").unwrap(),
1912 "vad"
1913 );
1914 }
1915
1916 #[test]
1917 fn test_set_config_value_sync_vad_enabled() {
1918 let dir = tempfile::tempdir().unwrap();
1919 let service = make_service_with_tmp_config(&dir);
1920 service
1921 .set_config_value("sync.vad.enabled", "false")
1922 .unwrap();
1923 let config = service.get_config().unwrap();
1924 assert!(!config.sync.vad.enabled);
1925 }
1926
1927 #[test]
1928 fn test_set_config_value_sync_vad_sensitivity() {
1929 let dir = tempfile::tempdir().unwrap();
1930 let service = make_service_with_tmp_config(&dir);
1931 service
1932 .set_config_value("sync.vad.sensitivity", "0.5")
1933 .unwrap();
1934 let config = service.get_config().unwrap();
1935 assert!((config.sync.vad.sensitivity - 0.5).abs() < 0.001);
1936 }
1937
1938 #[test]
1939 fn test_set_config_value_sync_vad_padding_chunks() {
1940 let dir = tempfile::tempdir().unwrap();
1941 let service = make_service_with_tmp_config(&dir);
1942 service
1943 .set_config_value("sync.vad.padding_chunks", "5")
1944 .unwrap();
1945 assert_eq!(
1946 service.get_config_value("sync.vad.padding_chunks").unwrap(),
1947 "5"
1948 );
1949 }
1950
1951 #[test]
1952 fn test_set_config_value_sync_vad_min_speech_duration_ms() {
1953 let dir = tempfile::tempdir().unwrap();
1954 let service = make_service_with_tmp_config(&dir);
1955 service
1956 .set_config_value("sync.vad.min_speech_duration_ms", "500")
1957 .unwrap();
1958 assert_eq!(
1959 service
1960 .get_config_value("sync.vad.min_speech_duration_ms")
1961 .unwrap(),
1962 "500"
1963 );
1964 }
1965
1966 #[test]
1971 fn test_set_config_value_general_backup_enabled() {
1972 let dir = tempfile::tempdir().unwrap();
1973 let service = make_service_with_tmp_config(&dir);
1974 service
1975 .set_config_value("general.backup_enabled", "true")
1976 .unwrap();
1977 let config = service.get_config().unwrap();
1978 assert!(config.general.backup_enabled);
1979 }
1980
1981 #[test]
1982 fn test_set_config_value_general_max_concurrent_jobs() {
1983 let dir = tempfile::tempdir().unwrap();
1984 let service = make_service_with_tmp_config(&dir);
1985 service
1986 .set_config_value("general.max_concurrent_jobs", "8")
1987 .unwrap();
1988 assert_eq!(
1989 service
1990 .get_config_value("general.max_concurrent_jobs")
1991 .unwrap(),
1992 "8"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_set_config_value_general_task_timeout_seconds() {
1998 let dir = tempfile::tempdir().unwrap();
1999 let service = make_service_with_tmp_config(&dir);
2000 service
2001 .set_config_value("general.task_timeout_seconds", "120")
2002 .unwrap();
2003 assert_eq!(
2004 service
2005 .get_config_value("general.task_timeout_seconds")
2006 .unwrap(),
2007 "120"
2008 );
2009 }
2010
2011 #[test]
2012 fn test_set_config_value_general_enable_progress_bar() {
2013 let dir = tempfile::tempdir().unwrap();
2014 let service = make_service_with_tmp_config(&dir);
2015 service
2016 .set_config_value("general.enable_progress_bar", "false")
2017 .unwrap();
2018 let config = service.get_config().unwrap();
2019 assert!(!config.general.enable_progress_bar);
2020 }
2021
2022 #[test]
2023 fn test_set_config_value_general_worker_idle_timeout_seconds() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let service = make_service_with_tmp_config(&dir);
2026 service
2027 .set_config_value("general.worker_idle_timeout_seconds", "60")
2028 .unwrap();
2029 assert_eq!(
2030 service
2031 .get_config_value("general.worker_idle_timeout_seconds")
2032 .unwrap(),
2033 "60"
2034 );
2035 }
2036
2037 #[test]
2042 fn test_set_config_value_parallel_max_workers() {
2043 let dir = tempfile::tempdir().unwrap();
2044 let service = make_service_with_tmp_config(&dir);
2045 service
2046 .set_config_value("parallel.max_workers", "4")
2047 .unwrap();
2048 assert_eq!(
2049 service.get_config_value("parallel.max_workers").unwrap(),
2050 "4"
2051 );
2052 }
2053
2054 #[test]
2055 fn test_set_config_value_parallel_task_queue_size() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let service = make_service_with_tmp_config(&dir);
2058 service
2059 .set_config_value("parallel.task_queue_size", "200")
2060 .unwrap();
2061 assert_eq!(
2062 service
2063 .get_config_value("parallel.task_queue_size")
2064 .unwrap(),
2065 "200"
2066 );
2067 }
2068
2069 #[test]
2070 fn test_set_config_value_parallel_enable_task_priorities() {
2071 let dir = tempfile::tempdir().unwrap();
2072 let service = make_service_with_tmp_config(&dir);
2073 service
2074 .set_config_value("parallel.enable_task_priorities", "true")
2075 .unwrap();
2076 let config = service.get_config().unwrap();
2077 assert!(config.parallel.enable_task_priorities);
2078 }
2079
2080 #[test]
2081 fn test_set_config_value_parallel_auto_balance_workers() {
2082 let dir = tempfile::tempdir().unwrap();
2083 let service = make_service_with_tmp_config(&dir);
2084 service
2085 .set_config_value("parallel.auto_balance_workers", "false")
2086 .unwrap();
2087 let config = service.get_config().unwrap();
2088 assert!(!config.parallel.auto_balance_workers);
2089 }
2090
2091 #[test]
2092 fn test_set_config_value_parallel_overflow_strategy_block() {
2093 let dir = tempfile::tempdir().unwrap();
2094 let service = make_service_with_tmp_config(&dir);
2095 service
2096 .set_config_value("parallel.overflow_strategy", "Block")
2097 .unwrap();
2098 let config = service.get_config().unwrap();
2099 assert_eq!(
2100 config.parallel.overflow_strategy,
2101 crate::config::OverflowStrategy::Block
2102 );
2103 }
2104
2105 #[test]
2106 fn test_set_config_value_parallel_overflow_strategy_drop() {
2107 let dir = tempfile::tempdir().unwrap();
2108 let service = make_service_with_tmp_config(&dir);
2109 service
2110 .set_config_value("parallel.overflow_strategy", "Drop")
2111 .unwrap();
2112 let config = service.get_config().unwrap();
2113 assert_eq!(
2114 config.parallel.overflow_strategy,
2115 crate::config::OverflowStrategy::Drop
2116 );
2117 }
2118
2119 #[test]
2120 fn test_set_config_value_parallel_overflow_strategy_expand() {
2121 let dir = tempfile::tempdir().unwrap();
2122 let service = make_service_with_tmp_config(&dir);
2123 service
2124 .set_config_value("parallel.overflow_strategy", "Expand")
2125 .unwrap();
2126 let config = service.get_config().unwrap();
2127 assert_eq!(
2128 config.parallel.overflow_strategy,
2129 crate::config::OverflowStrategy::Expand
2130 );
2131 }
2132
2133 #[test]
2138 fn test_set_config_value_unknown_key_returns_error() {
2139 let dir = tempfile::tempdir().unwrap();
2140 let service = make_service_with_tmp_config(&dir);
2141 assert!(
2142 service
2143 .set_config_value("nonexistent.key", "value")
2144 .is_err()
2145 );
2146 }
2147
2148 #[test]
2149 fn test_set_config_value_invalid_value_returns_error() {
2150 let dir = tempfile::tempdir().unwrap();
2151 let service = make_service_with_tmp_config(&dir);
2152 assert!(service.set_config_value("ai.temperature", "99.9").is_err());
2154 assert!(
2156 service
2157 .set_config_value("ai.provider", "unknown-provider")
2158 .is_err()
2159 );
2160 }
2161
2162 #[test]
2167 fn test_production_config_service_default_trait_impl() {
2168 let dir = tempfile::tempdir().unwrap();
2173 let service = make_service_with_tmp_config(&dir);
2174 let config = service.get_config().unwrap();
2175 assert_eq!(config.ai.provider, "openai");
2176 }
2177
2178 #[test]
2183 fn test_production_config_service_loads_values_from_toml_file() {
2184 let dir = tempfile::tempdir().unwrap();
2185 let config_path = dir.path().join("custom.toml");
2186
2187 let mut cfg = crate::config::Config::default();
2189 cfg.ai.provider = "openrouter".to_string();
2190 cfg.ai.model = "toml-loaded-model".to_string();
2191 let toml_str = toml::to_string_pretty(&cfg).unwrap();
2192 std::fs::write(&config_path, toml_str).unwrap();
2193
2194 let mut env = TestEnvironmentProvider::new();
2195 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2196 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2197 let loaded = service.get_config().unwrap();
2198 assert_eq!(loaded.ai.provider, "openrouter");
2199 assert_eq!(loaded.ai.model, "toml-loaded-model");
2200 }
2201
2202 #[test]
2209 fn test_test_config_service_set_ai_settings_and_key_instance_method() {
2210 let service = TestConfigService::with_defaults();
2211 service.set_ai_settings_and_key("openrouter", "my-model", "test-key-1234567890");
2212 let config = service.get_config().unwrap();
2213 assert_eq!(config.ai.provider, "openrouter");
2214 assert_eq!(config.ai.model, "my-model");
2215 assert_eq!(config.ai.api_key, Some("test-key-1234567890".to_string()));
2216 }
2217
2218 #[test]
2219 fn test_test_config_service_set_ai_settings_and_key_empty_clears_key() {
2220 let service = TestConfigService::with_defaults();
2221 service.set_ai_settings_and_key("openai", "gpt-4", "");
2222 let config = service.get_config().unwrap();
2223 assert!(config.ai.api_key.is_none());
2224 }
2225
2226 #[test]
2227 fn test_test_config_service_set_ai_settings_with_base_url() {
2228 let service = TestConfigService::with_defaults();
2229 service.set_ai_settings_with_base_url(
2230 "openai",
2231 "gpt-4.1",
2232 "sk-test-key-12345",
2233 "https://proxy.example.com/v1",
2234 );
2235 let config = service.get_config().unwrap();
2236 assert_eq!(config.ai.provider, "openai");
2237 assert_eq!(config.ai.model, "gpt-4.1");
2238 assert_eq!(config.ai.api_key, Some("sk-test-key-12345".to_string()));
2239 assert_eq!(config.ai.base_url, "https://proxy.example.com/v1");
2240 }
2241
2242 #[test]
2247 fn test_set_config_value_persists_to_disk() {
2248 let dir = tempfile::tempdir().unwrap();
2249 let config_path = dir.path().join("config.toml");
2250 let mut env = TestEnvironmentProvider::new();
2251 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2252 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2253
2254 service.set_config_value("ai.model", "gpt-4.1").unwrap();
2255
2256 let file_content = std::fs::read_to_string(&config_path).unwrap();
2257 assert!(
2258 file_content.contains("gpt-4.1"),
2259 "model not persisted to disk: {file_content}"
2260 );
2261 }
2262
2263 #[cfg(unix)]
2268 #[test]
2269 fn test_secure_write_config_file_existing_parent_dir() {
2270 use std::os::unix::fs::PermissionsExt;
2271
2272 let dir = tempfile::tempdir().unwrap();
2273 let path = dir.path().join("config.toml");
2274
2275 super::secure_write_config_file(&path, "key = \"value\"\n")
2276 .expect("write to existing dir should succeed");
2277
2278 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2279 assert_eq!(mode, 0o600);
2280 assert_eq!(std::fs::read_to_string(&path).unwrap(), "key = \"value\"\n");
2281 }
2282
2283 fn env_with_isolated_config() -> (TestEnvironmentProvider, tempfile::TempDir) {
2292 let dir = tempfile::tempdir().expect("create tempdir");
2293 let mut env = TestEnvironmentProvider::new();
2294 let p = dir.path().join("nonexistent_config.toml");
2295 env.set_var("SUBX_CONFIG_PATH", p.to_str().unwrap());
2296 (env, dir)
2297 }
2298
2299 #[test]
2300 fn test_local_llm_base_url_honored_when_provider_is_local() {
2301 let (mut env, _dir) = env_with_isolated_config();
2302 env.set_var("SUBX_AI_PROVIDER", "local");
2303 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:8080/v1");
2304
2305 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2306 let config = service.get_config().expect("get_config");
2307
2308 assert_eq!(config.ai.provider, "local");
2309 assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2310 }
2311
2312 #[test]
2313 fn test_local_llm_api_key_honored_when_provider_is_local() {
2314 let (mut env, _dir) = env_with_isolated_config();
2315 env.set_var("SUBX_AI_PROVIDER", "local");
2316 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2317 env.set_var("LOCAL_LLM_API_KEY", "local-secret-token");
2318
2319 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2320 let config = service.get_config().expect("get_config");
2321
2322 assert_eq!(config.ai.provider, "local");
2323 assert_eq!(config.ai.api_key.as_deref(), Some("local-secret-token"));
2324 }
2325
2326 #[test]
2327 fn test_local_llm_env_vars_ignored_for_non_local_provider() {
2328 let (mut env, _dir) = env_with_isolated_config();
2329 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2331 env.set_var("LOCAL_LLM_API_KEY", "leak-me");
2332
2333 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2334 let config = service.get_config().expect("get_config");
2335
2336 assert_eq!(config.ai.provider, "openai");
2337 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
2339 assert_ne!(config.ai.api_key.as_deref(), Some("leak-me"));
2341 }
2342
2343 #[test]
2344 fn test_subx_ai_base_url_outranks_local_llm_base_url() {
2345 let (mut env, _dir) = env_with_isolated_config();
2346 env.set_var("SUBX_AI_PROVIDER", "local");
2347 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2348 env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2349
2350 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2351 let config = service.get_config().expect("get_config");
2352
2353 assert_eq!(config.ai.provider, "local");
2354 assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2355 }
2356
2357 #[test]
2358 fn test_subx_ai_apikey_outranks_local_llm_api_key() {
2359 let (mut env, _dir) = env_with_isolated_config();
2360 env.set_var("SUBX_AI_PROVIDER", "local");
2361 env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2362 env.set_var("LOCAL_LLM_API_KEY", "local-loser");
2363 env.set_var("SUBX_AI_APIKEY", "subx-winner");
2364
2365 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2366 let config = service.get_config().expect("get_config");
2367
2368 assert_eq!(config.ai.provider, "local");
2369 assert_eq!(config.ai.api_key.as_deref(), Some("subx-winner"));
2370 }
2371
2372 #[test]
2373 fn test_openai_api_key_does_not_populate_api_key_when_provider_is_local() {
2374 let (mut env, _dir) = env_with_isolated_config();
2375 env.set_var("SUBX_AI_PROVIDER", "local");
2376 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2377 env.set_var("OPENAI_API_KEY", "sk-leak-into-local");
2378
2379 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2380 let config = service.get_config().expect("get_config");
2381
2382 assert_eq!(config.ai.provider, "local");
2383 assert_eq!(config.ai.api_key, None);
2384 }
2385
2386 #[test]
2387 fn test_openrouter_api_key_does_not_switch_provider_away_from_local() {
2388 let (mut env, _dir) = env_with_isolated_config();
2389 env.set_var("SUBX_AI_PROVIDER", "local");
2390 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2391 env.set_var("OPENROUTER_API_KEY", "or-leak-into-local");
2392
2393 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2394 let config = service.get_config().expect("get_config");
2395
2396 assert_eq!(config.ai.provider, "local");
2397 assert_eq!(config.ai.api_key, None);
2398 }
2399
2400 #[test]
2401 fn test_azure_openai_env_vars_do_not_populate_when_provider_is_local() {
2402 let (mut env, _dir) = env_with_isolated_config();
2403 env.set_var("SUBX_AI_PROVIDER", "local");
2404 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2405 env.set_var("AZURE_OPENAI_API_KEY", "azure-leak");
2406 env.set_var("AZURE_OPENAI_ENDPOINT", "https://leak.openai.azure.com/");
2407 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "leaked-deployment");
2408
2409 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2410 let config = service.get_config().expect("get_config");
2411
2412 assert_eq!(config.ai.provider, "local");
2413 assert_eq!(config.ai.api_key, None);
2414 assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2415 assert_ne!(config.ai.model, "leaked-deployment");
2416 }
2417
2418 #[test]
2419 fn test_subx_ai_provider_ollama_triggers_local_carve_out() {
2420 let (mut env, _dir) = env_with_isolated_config();
2425 env.set_var("SUBX_AI_PROVIDER", "ollama");
2426 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2427 env.set_var("OPENAI_API_KEY", "sk-should-not-leak");
2428 env.set_var("OPENROUTER_API_KEY", "or-should-not-leak");
2429
2430 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2431 let config = service.get_config().expect("get_config");
2432
2433 assert_eq!(config.ai.provider, "local");
2434 assert_eq!(config.ai.api_key, None);
2435 assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2436 }
2437
2438 #[test]
2439 fn test_set_config_value_normalizes_ollama_to_local() {
2440 let dir = tempfile::tempdir().expect("tempdir");
2442 let service = make_service_with_tmp_config(&dir);
2443 service
2444 .set_config_value("ai.provider", "ollama")
2445 .expect("set ai.provider=ollama");
2446 service
2449 .set_config_value("ai.base_url", "http://localhost:11434/v1")
2450 .expect("set base_url");
2451
2452 assert_eq!(
2453 service.get_config_value("ai.provider").unwrap(),
2454 "local",
2455 "persisted ai.provider must be the canonical form"
2456 );
2457 }
2458
2459 struct LogCapture {
2464 lines: std::sync::Mutex<Vec<String>>,
2465 }
2466
2467 impl LogCapture {
2468 fn new() -> Self {
2469 Self {
2470 lines: std::sync::Mutex::new(Vec::new()),
2471 }
2472 }
2473
2474 fn text(&self) -> String {
2475 self.lines.lock().unwrap().join("\n")
2476 }
2477 }
2478
2479 impl log::Log for LogCapture {
2480 fn enabled(&self, _metadata: &log::Metadata) -> bool {
2481 true
2482 }
2483
2484 fn log(&self, record: &log::Record) {
2485 let mut line = String::new();
2486 use std::fmt::Write;
2487 let _ = write!(&mut line, "{}", record.args());
2488 self.lines
2489 .lock()
2490 .unwrap()
2491 .push(format!("[{}] {}", record.level(), line));
2492 }
2493
2494 fn flush(&self) {}
2495 }
2496
2497 struct ArcLog(Arc<LogCapture>);
2500
2501 impl log::Log for ArcLog {
2502 fn enabled(&self, _metadata: &log::Metadata) -> bool {
2503 self.0.enabled(_metadata)
2504 }
2505
2506 fn log(&self, record: &log::Record) {
2507 log::Log::log(&self.0, record);
2508 }
2509
2510 fn flush(&self) {
2511 self.0.flush();
2512 }
2513 }
2514
2515 #[test]
2521 fn a_successful_tolerant_read_does_not_populate_the_strict_cache() {
2522 let (mut env, _dir) = env_with_isolated_config();
2523 env.set_var("OPENAI_BASE_URL", "http://localhost:11434/v1");
2527 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2528
2529 service
2530 .load_for_repair()
2531 .expect("the tolerant read must succeed on a fresh install");
2532
2533 assert!(
2534 service.reload().is_err(),
2535 "a successful tolerant read must not satisfy the strict gate"
2536 );
2537 }
2538
2539 #[test]
2543 fn the_diagnostic_log_names_sources_but_never_the_raw_key() {
2544 let capture = Arc::new(LogCapture::new());
2545 let _ = log::set_boxed_logger(Box::new(ArcLog(capture.clone())));
2546 log::set_max_level(log::LevelFilter::Trace);
2549
2550 let (mut env, _dir) = env_with_isolated_config();
2551 env.set_var("SUBX_AI_APIKEY", "sk-super-secret-1234");
2552 env.set_var("OPENAI_BASE_URL", "http://localhost:11434/v1");
2553 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2554
2555 let _ = service.get_config();
2559
2560 let all = capture.text();
2561 assert!(
2562 !all.contains("sk-super-secret-1234"),
2563 "the raw API key must not be written to the logs: {all}"
2564 );
2565 assert!(
2566 all.contains("****1234"),
2567 "the masked key form should appear: {all}"
2568 );
2569 assert!(
2570 all.contains("OPENAI_BASE_URL"),
2571 "the contributing variable name should be named: {all}"
2572 );
2573 assert!(
2574 all.contains("SUBX_AI_APIKEY"),
2575 "the SUBX-prefixed key variable should be named: {all}"
2576 );
2577 }
2578}