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 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 {
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 let app_config = self.load_and_validate()?;
791
792 {
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 {
806 let mut cache = self.cached_config.write().unwrap();
807 *cache = None;
808 }
809
810 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 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 let mut config = self.load_for_repair()?;
858
859 self.validate_and_set_value(&mut config, key, value)?;
865
866 let path = self.get_config_file_path()?;
870 self.save_config_to_file_with_config(&path, &config)?;
871
872 {
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 let path = self.get_config_file_path()?;
903
904 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 config.ai.provider =
935 crate::config::field_validator::normalize_ai_provider(&config.ai.provider);
936
937 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 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 let dir = tempfile::tempdir().unwrap();
993 let service = make_service_with_tmp_config(&dir);
994
995 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 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 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 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 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 let test_service = TestConfigService::with_defaults();
1086 let config = test_service.get_config().unwrap();
1087
1088 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); }
1094
1095 #[test]
1096 fn test_config_service_reload_functionality() {
1097 let test_service = TestConfigService::with_defaults();
1099
1100 let config1 = test_service.get_config().unwrap();
1102 assert_eq!(config1.ai.provider, "openai");
1103
1104 let reload_result = test_service.reload();
1106 assert!(reload_result.is_ok());
1107
1108 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 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 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 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 assert!(
1181 service
1182 .set_config_value("general.max_subtitle_bytes", "100")
1183 .is_err()
1184 );
1185 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 let test_service = TestConfigService::with_defaults();
1197
1198 assert_eq!(test_service.config().ai.provider, "openai");
1200
1201 test_service.config_mut().ai.provider = "modified".to_string();
1203 assert_eq!(test_service.config().ai.provider, "modified");
1204
1205 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 let mut env_provider = TestEnvironmentProvider::new();
1214 env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
1215
1216 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 let mut env_provider = TestEnvironmentProvider::new();
1237 env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
1238 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 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 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 let mut env_provider = TestEnvironmentProvider::new(); 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 assert_eq!(config.ai.api_key, None);
1293 assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); }
1295
1296 #[test]
1297 fn test_production_config_service_api_key_priority() {
1298 let mut env_provider = TestEnvironmentProvider::new();
1300 env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1301 env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1303 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 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 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 #[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(); service.reload().unwrap(); let config = service.get_config().unwrap();
1392 assert_eq!(config.ai.provider, "openai");
1393 }
1394
1395 #[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 #[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(); 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 #[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 #[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 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 #[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 #[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 #[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 #[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 service
1741 .set_config_value("ai.api_key", "sk-test-apikey-12345")
1742 .unwrap();
1743 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 #[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 #[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 #[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 #[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 #[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 assert!(service.set_config_value("ai.temperature", "99.9").is_err());
2158 assert!(
2160 service
2161 .set_config_value("ai.provider", "unknown-provider")
2162 .is_err()
2163 );
2164 }
2165
2166 #[test]
2171 fn test_production_config_service_default_trait_impl() {
2172 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 #[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 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 #[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 #[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 #[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 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 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 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
2343 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 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 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 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 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 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 #[test]
2525 fn a_successful_tolerant_read_does_not_populate_the_strict_cache() {
2526 let (mut env, _dir) = env_with_isolated_config();
2527 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 #[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 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 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}