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 crate::config::validator::validate_config(&app_config).map_err(|e| {
436 debug!("ProductionConfigService: Config validation failed: {e}");
437 SubXError::config(format!("Configuration validation failed: {e}"))
438 })?;
439
440 debug!("ProductionConfigService: Configuration loaded and validated successfully");
441 Ok(app_config)
442 }
443
444 fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
448 use crate::config::field_validator;
449
450 let normalized;
455 let value: &str = if key == "ai.provider" {
456 normalized = field_validator::normalize_ai_provider(value);
457 normalized.as_str()
458 } else {
459 value
460 };
461
462 field_validator::validate_field(key, value)?;
464
465 self.set_value_internal(config, key, value)?;
467
468 self.validate_configuration(config)?;
470
471 Ok(())
472 }
473
474 fn set_value_internal(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
476 use crate::config::OverflowStrategy;
477 use crate::config::validation::*;
478 use crate::error::SubXError;
479
480 let parts: Vec<&str> = key.split('.').collect();
481 match parts.as_slice() {
482 ["ai", "provider"] => {
483 config.ai.provider = crate::config::field_validator::normalize_ai_provider(value);
484 }
485 ["ai", "api_key"] => {
486 if !value.is_empty() {
487 config.ai.api_key = Some(value.to_string());
488 } else {
489 config.ai.api_key = None;
490 }
491 }
492 ["ai", "model"] => {
493 config.ai.model = value.to_string();
494 }
495 ["ai", "base_url"] => {
496 config.ai.base_url = value.to_string();
497 }
498 ["ai", "max_sample_length"] => {
499 let v = value.parse().unwrap(); config.ai.max_sample_length = v;
501 }
502 ["ai", "temperature"] => {
503 let v = value.parse().unwrap(); config.ai.temperature = v;
505 }
506 ["ai", "max_tokens"] => {
507 let v = value.parse().unwrap(); config.ai.max_tokens = v;
509 }
510 ["ai", "retry_attempts"] => {
511 let v = value.parse().unwrap(); config.ai.retry_attempts = v;
513 }
514 ["ai", "retry_delay_ms"] => {
515 let v = value.parse().unwrap(); config.ai.retry_delay_ms = v;
517 }
518 ["ai", "request_timeout_seconds"] => {
519 let v = value.parse().unwrap(); config.ai.request_timeout_seconds = v;
521 }
522 ["ai", "api_version"] => {
523 if !value.is_empty() {
524 config.ai.api_version = Some(value.to_string());
525 } else {
526 config.ai.api_version = None;
527 }
528 }
529 ["formats", "default_output"] => {
530 config.formats.default_output = value.to_string();
531 }
532 ["formats", "preserve_styling"] => {
533 let v = parse_bool(value)?;
534 config.formats.preserve_styling = v;
535 }
536 ["formats", "default_encoding"] => {
537 config.formats.default_encoding = value.to_string();
538 }
539 ["formats", "encoding_detection_confidence"] => {
540 let v = value.parse().unwrap(); config.formats.encoding_detection_confidence = v;
542 }
543 ["sync", "max_offset_seconds"] => {
544 let v = value.parse().unwrap(); config.sync.max_offset_seconds = v;
546 }
547 ["sync", "default_method"] => {
548 config.sync.default_method = value.to_string();
549 }
550 ["sync", "vad", "enabled"] => {
551 let v = parse_bool(value)?;
552 config.sync.vad.enabled = v;
553 }
554 ["sync", "vad", "sensitivity"] => {
555 let v = value.parse().unwrap(); config.sync.vad.sensitivity = v;
557 }
558 ["sync", "vad", "padding_chunks"] => {
559 let v = value.parse().unwrap(); config.sync.vad.padding_chunks = v;
561 }
562 ["sync", "vad", "min_speech_duration_ms"] => {
563 let v = value.parse().unwrap(); config.sync.vad.min_speech_duration_ms = v;
565 }
566 ["general", "backup_enabled"] => {
567 let v = parse_bool(value)?;
568 config.general.backup_enabled = v;
569 }
570 ["general", "max_concurrent_jobs"] => {
571 let v = value.parse().unwrap(); config.general.max_concurrent_jobs = v;
573 }
574 ["general", "task_timeout_seconds"] => {
575 let v = value.parse().unwrap(); config.general.task_timeout_seconds = v;
577 }
578 ["general", "enable_progress_bar"] => {
579 let v = parse_bool(value)?;
580 config.general.enable_progress_bar = v;
581 }
582 ["general", "worker_idle_timeout_seconds"] => {
583 let v = value.parse().unwrap(); config.general.worker_idle_timeout_seconds = v;
585 }
586 ["general", "max_subtitle_bytes"] => {
587 let v = value.parse().unwrap(); config.general.max_subtitle_bytes = v;
589 }
590 ["general", "max_audio_bytes"] => {
591 let v = value.parse().unwrap(); config.general.max_audio_bytes = v;
593 }
594 ["parallel", "max_workers"] => {
595 let v = value.parse().unwrap(); config.parallel.max_workers = v;
597 }
598 ["parallel", "task_queue_size"] => {
599 let v = value.parse().unwrap(); config.parallel.task_queue_size = v;
601 }
602 ["parallel", "enable_task_priorities"] => {
603 let v = parse_bool(value)?;
604 config.parallel.enable_task_priorities = v;
605 }
606 ["parallel", "auto_balance_workers"] => {
607 let v = parse_bool(value)?;
608 config.parallel.auto_balance_workers = v;
609 }
610 ["parallel", "overflow_strategy"] => {
611 config.parallel.overflow_strategy = match value {
612 "Block" => OverflowStrategy::Block,
613 "Drop" => OverflowStrategy::Drop,
614 "Expand" => OverflowStrategy::Expand,
615 _ => unreachable!(), };
617 }
618 ["translation", "batch_size"] => {
619 let v = value.parse().unwrap(); config.translation.batch_size = v;
621 }
622 ["translation", "default_target_language"] => {
623 if value.is_empty() {
624 config.translation.default_target_language = None;
625 } else {
626 config.translation.default_target_language = Some(value.to_string());
627 }
628 }
629 _ => {
630 return Err(SubXError::config(format!(
631 "Unknown configuration key: {key}"
632 )));
633 }
634 }
635 Ok(())
636 }
637
638 fn validate_configuration(&self, config: &Config) -> Result<()> {
640 use crate::config::validator;
641 validator::validate_config(config)
642 }
643
644 fn save_config_to_file_with_config(
646 &self,
647 path: &std::path::Path,
648 config: &Config,
649 ) -> Result<()> {
650 let toml_content = toml::to_string_pretty(config)
651 .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
652 secure_write_config_file(path, &toml_content)
653 .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
654 Ok(())
655 }
656}
657
658pub(crate) fn read_config_value_from(config: &Config, key: &str) -> Result<String> {
666 let parts: Vec<&str> = key.split('.').collect();
667 match parts.as_slice() {
668 ["ai", "provider"] => Ok(config.ai.provider.clone()),
669 ["ai", "model"] => Ok(config.ai.model.clone()),
670 ["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
671 ["ai", "base_url"] => Ok(config.ai.base_url.clone()),
672 ["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
673 ["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
674 ["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
675 ["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
676 ["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
677 ["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
678
679 ["formats", "default_output"] => Ok(config.formats.default_output.clone()),
680 ["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
681 ["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
682 ["formats", "encoding_detection_confidence"] => {
683 Ok(config.formats.encoding_detection_confidence.to_string())
684 }
685
686 ["sync", "default_method"] => Ok(config.sync.default_method.clone()),
687 ["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
688 ["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
689 ["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
690 ["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
691 ["sync", "vad", "min_speech_duration_ms"] => {
692 Ok(config.sync.vad.min_speech_duration_ms.to_string())
693 }
694
695 ["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
696 ["general", "max_concurrent_jobs"] => Ok(config.general.max_concurrent_jobs.to_string()),
697 ["general", "task_timeout_seconds"] => Ok(config.general.task_timeout_seconds.to_string()),
698 ["general", "enable_progress_bar"] => Ok(config.general.enable_progress_bar.to_string()),
699 ["general", "worker_idle_timeout_seconds"] => {
700 Ok(config.general.worker_idle_timeout_seconds.to_string())
701 }
702 ["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
703 ["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
704
705 ["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
706 ["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
707 ["parallel", "enable_task_priorities"] => {
708 Ok(config.parallel.enable_task_priorities.to_string())
709 }
710 ["parallel", "auto_balance_workers"] => {
711 Ok(config.parallel.auto_balance_workers.to_string())
712 }
713 ["parallel", "overflow_strategy"] => Ok(format!("{:?}", config.parallel.overflow_strategy)),
714
715 ["translation", "batch_size"] => Ok(config.translation.batch_size.to_string()),
716 ["translation", "default_target_language"] => Ok(config
717 .translation
718 .default_target_language
719 .clone()
720 .unwrap_or_default()),
721
722 _ => Err(SubXError::config(format!(
723 "Unknown configuration key: {}",
724 key
725 ))),
726 }
727}
728
729impl ConfigService for ProductionConfigService {
730 fn get_config(&self) -> Result<Config> {
731 {
733 let cache = self.cached_config.read().unwrap();
734 if let Some(config) = cache.as_ref() {
735 debug!("ProductionConfigService: Returning cached configuration");
736 return Ok(config.clone());
737 }
738 }
739
740 let app_config = self.load_and_validate()?;
742
743 {
745 let mut cache = self.cached_config.write().unwrap();
746 *cache = Some(app_config.clone());
747 }
748
749 Ok(app_config)
750 }
751
752 fn reload(&self) -> Result<()> {
753 debug!("ProductionConfigService: Reloading configuration");
754
755 {
757 let mut cache = self.cached_config.write().unwrap();
758 *cache = None;
759 }
760
761 self.get_config()?;
763
764 debug!("ProductionConfigService: Configuration reloaded successfully");
765 Ok(())
766 }
767
768 fn save_config(&self) -> Result<()> {
769 let _config = self.get_config()?;
770 let path = self.get_config_file_path()?;
771 self.save_config_to_file(&path)
772 }
773
774 fn save_config_to_file(&self, path: &Path) -> Result<()> {
775 let config = self.get_config()?;
776 let toml_content = toml::to_string_pretty(&config)
777 .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
778
779 secure_write_config_file(path, &toml_content)
780 .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
781
782 Ok(())
783 }
784
785 fn get_config_file_path(&self) -> Result<PathBuf> {
786 if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
788 return Ok(PathBuf::from(custom));
789 }
790
791 let config_dir = dirs::config_dir()
792 .ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
793 Ok(config_dir.join("subx").join("config.toml"))
794 }
795
796 fn get_config_value(&self, key: &str) -> Result<String> {
797 let config = self.get_config()?;
798 read_config_value_from(&config, key)
799 }
800
801 fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
802 let mut config = self.load_for_repair()?;
809
810 self.validate_and_set_value(&mut config, key, value)?;
816
817 let path = self.get_config_file_path()?;
821 self.save_config_to_file_with_config(&path, &config)?;
822
823 {
826 let mut cache = self.cached_config.write().unwrap();
827 *cache = Some(config);
828 }
829
830 Ok(())
831 }
832
833 fn reset_to_defaults(&self) -> Result<()> {
834 let default_config = Config::default();
835 let path = self.get_config_file_path()?;
836
837 let toml_content = toml::to_string_pretty(&default_config)
838 .map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
839
840 secure_write_config_file(&path, &toml_content)
841 .map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
842
843 self.reload()
844 }
845
846 fn load_for_repair(&self) -> Result<Config> {
847 let path = self.get_config_file_path()?;
854
855 if !path.exists() {
860 debug!(
861 "ProductionConfigService::load_for_repair: file {} does not exist, using defaults",
862 path.display()
863 );
864 return Ok(Config::default());
865 }
866
867 let content = std::fs::read_to_string(&path).map_err(|e| {
868 SubXError::config(format!(
869 "Failed to read configuration file {}: {}",
870 path.display(),
871 e
872 ))
873 })?;
874
875 let mut config = toml::from_str::<Config>(&content).map_err(|e| {
876 SubXError::config(format!(
877 "Failed to parse configuration file {}: {}",
878 path.display(),
879 e
880 ))
881 })?;
882
883 config.ai.provider =
886 crate::config::field_validator::normalize_ai_provider(&config.ai.provider);
887
888 crate::config::field_validator::validate_all_fields(&config)?;
896
897 Ok(config)
898 }
899}
900
901impl Default for ProductionConfigService {
902 fn default() -> Self {
903 Self::new().expect("Failed to create default ProductionConfigService")
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use super::*;
910 use crate::config::TestConfigService;
911 use crate::config::TestEnvironmentProvider;
912 use std::sync::Arc;
913
914 fn make_service_with_tmp_config(dir: &tempfile::TempDir) -> ProductionConfigService {
917 let config_path = dir.path().join("config.toml");
918 let mut env = TestEnvironmentProvider::new();
919 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
920 ProductionConfigService::with_env_provider(Arc::new(env)).unwrap()
921 }
922
923 #[test]
924 fn test_production_config_service_creation() {
925 let service = ProductionConfigService::new();
926 assert!(service.is_ok());
927 }
928
929 #[test]
930 fn test_production_config_service_with_custom_file() {
931 let service = ProductionConfigService::new()
932 .unwrap()
933 .with_custom_file(PathBuf::from("test.toml"));
934 assert!(service.is_ok());
935 }
936
937 #[test]
938 fn test_production_service_implements_config_service_trait() {
939 let dir = tempfile::tempdir().unwrap();
944 let service = make_service_with_tmp_config(&dir);
945
946 let config1 = service.get_config();
948 assert!(config1.is_ok());
949
950 let reload_result = service.reload();
951 assert!(reload_result.is_ok());
952
953 let config2 = service.get_config();
954 assert!(config2.is_ok());
955 }
956
957 #[test]
958 fn test_production_config_service_openrouter_api_key_loading() {
959 use crate::config::TestEnvironmentProvider;
960 use std::sync::Arc;
961
962 let mut env_provider = TestEnvironmentProvider::new();
963 env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
964 env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
965
966 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
967 .expect("Failed to create config service");
968
969 let config = service.get_config().expect("Failed to get config");
970
971 assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
972 }
973
974 #[test]
975 fn test_config_service_with_openai_api_key() {
976 let test_service = TestConfigService::with_ai_settings_and_key(
978 "openai",
979 "gpt-4.1-mini",
980 "sk-test-openai-key-123",
981 );
982
983 let config = test_service.get_config().unwrap();
984 assert_eq!(
985 config.ai.api_key,
986 Some("sk-test-openai-key-123".to_string())
987 );
988 assert_eq!(config.ai.provider, "openai");
989 assert_eq!(config.ai.model, "gpt-4.1-mini");
990 }
991
992 #[test]
993 fn test_config_service_with_custom_base_url() {
994 let mut config = Config::default();
996 config.ai.base_url = "https://custom.openai.endpoint".to_string();
997
998 let test_service = TestConfigService::new(config);
999 let loaded_config = test_service.get_config().unwrap();
1000
1001 assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
1002 }
1003
1004 #[test]
1005 fn test_config_service_with_both_openai_settings() {
1006 let mut config = Config::default();
1008 config.ai.api_key = Some("sk-test-api-key-combined".to_string());
1009 config.ai.base_url = "https://api.custom-openai.com".to_string();
1010
1011 let test_service = TestConfigService::new(config);
1012 let loaded_config = test_service.get_config().unwrap();
1013
1014 assert_eq!(
1015 loaded_config.ai.api_key,
1016 Some("sk-test-api-key-combined".to_string())
1017 );
1018 assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
1019 }
1020
1021 #[test]
1022 fn test_config_service_provider_precedence() {
1023 let test_service =
1025 TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
1026
1027 let config = test_service.get_config().unwrap();
1028 assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
1029 assert_eq!(config.ai.provider, "openai");
1030 assert_eq!(config.ai.model, "gpt-4.1");
1031 }
1032
1033 #[test]
1034 fn test_config_service_fallback_behavior() {
1035 let test_service = TestConfigService::with_defaults();
1037 let config = test_service.get_config().unwrap();
1038
1039 assert_eq!(config.ai.provider, "openai");
1041 assert_eq!(config.ai.model, "gpt-4.1-mini");
1042 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
1043 assert_eq!(config.ai.api_key, None); }
1045
1046 #[test]
1047 fn test_config_service_reload_functionality() {
1048 let test_service = TestConfigService::with_defaults();
1050
1051 let config1 = test_service.get_config().unwrap();
1053 assert_eq!(config1.ai.provider, "openai");
1054
1055 let reload_result = test_service.reload();
1057 assert!(reload_result.is_ok());
1058
1059 let config2 = test_service.get_config().unwrap();
1061 assert_eq!(config2.ai.provider, "openai");
1062 }
1063
1064 #[test]
1065 fn test_config_service_custom_base_url_override() {
1066 let mut config = Config::default();
1068 config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
1069
1070 let test_service = TestConfigService::new(config);
1071 let loaded_config = test_service.get_config().unwrap();
1072
1073 assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
1074 }
1075
1076 #[test]
1077 fn test_config_service_sync_settings() {
1078 let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
1080 let config = test_service.get_config().unwrap();
1081
1082 assert_eq!(config.sync.correlation_threshold, 0.8);
1083 assert_eq!(config.sync.max_offset_seconds, 45.0);
1084 }
1085
1086 #[test]
1087 fn test_config_service_parallel_settings() {
1088 let test_service = TestConfigService::with_parallel_settings(8, 200);
1090 let config = test_service.get_config().unwrap();
1091
1092 assert_eq!(config.general.max_concurrent_jobs, 8);
1093 assert_eq!(config.parallel.task_queue_size, 200);
1094 }
1095
1096 #[test]
1097 fn test_config_size_limits_defaults() {
1098 let service = TestConfigService::with_defaults();
1099 let cfg = service.get_config().unwrap();
1100 assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
1101 assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
1102 }
1103
1104 #[test]
1105 fn test_config_size_limits_roundtrip() {
1106 let service = TestConfigService::with_defaults();
1107
1108 service
1109 .set_config_value("general.max_subtitle_bytes", "65536")
1110 .unwrap();
1111 service
1112 .set_config_value("general.max_audio_bytes", "1048576")
1113 .unwrap();
1114
1115 assert_eq!(
1116 service
1117 .get_config_value("general.max_subtitle_bytes")
1118 .unwrap(),
1119 "65536"
1120 );
1121 assert_eq!(
1122 service.get_config_value("general.max_audio_bytes").unwrap(),
1123 "1048576"
1124 );
1125 }
1126
1127 #[test]
1128 fn test_config_size_limits_validation_reject() {
1129 let service = TestConfigService::with_defaults();
1130 assert!(
1132 service
1133 .set_config_value("general.max_subtitle_bytes", "100")
1134 .is_err()
1135 );
1136 assert!(
1138 service
1139 .set_config_value("general.max_subtitle_bytes", "2147483648")
1140 .is_err()
1141 );
1142 }
1143
1144 #[test]
1145 fn test_config_service_direct_access() {
1146 let test_service = TestConfigService::with_defaults();
1148
1149 assert_eq!(test_service.config().ai.provider, "openai");
1151
1152 test_service.config_mut().ai.provider = "modified".to_string();
1154 assert_eq!(test_service.config().ai.provider, "modified");
1155
1156 let config = test_service.get_config().unwrap();
1158 assert_eq!(config.ai.provider, "modified");
1159 }
1160
1161 #[test]
1162 fn test_production_config_service_openai_api_key_loading() {
1163 let mut env_provider = TestEnvironmentProvider::new();
1165 env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
1166
1167 env_provider.set_var(
1169 "SUBX_CONFIG_PATH",
1170 "/tmp/test_config_that_does_not_exist.toml",
1171 );
1172
1173 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1174 .expect("Failed to create config service");
1175
1176 let config = service.get_config().expect("Failed to get config");
1177
1178 assert_eq!(
1179 config.ai.api_key,
1180 Some("sk-test-openai-key-env".to_string())
1181 );
1182 }
1183
1184 #[test]
1185 fn test_production_config_service_openai_base_url_loading() {
1186 let mut env_provider = TestEnvironmentProvider::new();
1188 env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
1189
1190 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1191 .expect("Failed to create config service");
1192
1193 let config = service.get_config().expect("Failed to get config");
1194
1195 assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
1196 }
1197
1198 #[test]
1199 fn test_production_config_service_both_openai_env_vars() {
1200 let mut env_provider = TestEnvironmentProvider::new();
1202 env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
1203 env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
1204
1205 env_provider.set_var(
1207 "SUBX_CONFIG_PATH",
1208 "/tmp/test_config_both_that_does_not_exist.toml",
1209 );
1210
1211 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1212 .expect("Failed to create config service");
1213
1214 let config = service.get_config().expect("Failed to get config");
1215
1216 assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
1217 assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
1218 }
1219
1220 #[test]
1221 fn test_production_config_service_no_openai_env_vars() {
1222 let mut env_provider = TestEnvironmentProvider::new(); env_provider.set_var(
1227 "SUBX_CONFIG_PATH",
1228 "/tmp/test_config_no_openai_that_does_not_exist.toml",
1229 );
1230
1231 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1232 .expect("Failed to create config service");
1233
1234 let config = service.get_config().expect("Failed to get config");
1235
1236 assert_eq!(config.ai.api_key, None);
1238 assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); }
1240
1241 #[test]
1242 fn test_production_config_service_api_key_priority() {
1243 let mut env_provider = TestEnvironmentProvider::new();
1245 env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1246 env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1248 let dir = tempfile::tempdir().expect("tempdir");
1252 let cfg_path = dir.path().join("nonexistent.toml");
1253 env_provider.set_var("SUBX_CONFIG_PATH", cfg_path.to_str().unwrap());
1254
1255 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1256 .expect("Failed to create config service");
1257
1258 let config = service.get_config().expect("Failed to get config");
1259
1260 assert!(config.ai.api_key.is_some());
1263 }
1264
1265 #[cfg(unix)]
1266 #[test]
1267 fn test_secure_write_config_file_sets_0600_permissions() {
1268 use std::os::unix::fs::PermissionsExt;
1269
1270 let dir = tempfile::tempdir().expect("create tempdir");
1271 let nested = dir.path().join("subdir");
1272 let path = nested.join("config.toml");
1273
1274 super::secure_write_config_file(&path, "api_key = \"secret\"\n")
1275 .expect("secure write should succeed");
1276
1277 let meta = std::fs::metadata(&path).expect("file must exist");
1278 let mode = meta.permissions().mode() & 0o777;
1279 assert_eq!(
1280 mode, 0o600,
1281 "file permissions must be 0o600, got {:o}",
1282 mode
1283 );
1284
1285 let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
1286 let dir_mode = dir_meta.permissions().mode() & 0o777;
1287 assert_eq!(
1288 dir_mode, 0o700,
1289 "directory permissions must be 0o700, got {:o}",
1290 dir_mode
1291 );
1292
1293 let contents = std::fs::read_to_string(&path).unwrap();
1294 assert_eq!(contents, "api_key = \"secret\"\n");
1295 }
1296
1297 #[cfg(unix)]
1298 #[test]
1299 fn test_secure_write_config_file_truncates_existing_file() {
1300 use std::os::unix::fs::PermissionsExt;
1301
1302 let dir = tempfile::tempdir().expect("create tempdir");
1303 let path = dir.path().join("config.toml");
1304
1305 std::fs::write(&path, "stale contents that should be replaced").unwrap();
1307 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1308
1309 super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
1310
1311 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1312 assert_eq!(mode, 0o600);
1313 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
1314 }
1315
1316 #[test]
1321 fn test_production_config_get_config_caches_result() {
1322 let dir = tempfile::tempdir().unwrap();
1323 let service = make_service_with_tmp_config(&dir);
1324 let config1 = service.get_config().unwrap();
1325 let config2 = service.get_config().unwrap();
1326 assert_eq!(config1.ai.provider, config2.ai.provider);
1327 assert_eq!(config1.ai.model, config2.ai.model);
1328 }
1329
1330 #[test]
1331 fn test_production_config_reload_clears_cache_and_reloads() {
1332 let dir = tempfile::tempdir().unwrap();
1333 let service = make_service_with_tmp_config(&dir);
1334 service.get_config().unwrap(); service.reload().unwrap(); let config = service.get_config().unwrap();
1337 assert_eq!(config.ai.provider, "openai");
1338 }
1339
1340 #[test]
1345 fn test_azure_openai_api_key_sets_provider_and_key() {
1346 let mut env = TestEnvironmentProvider::new();
1347 env.set_var("AZURE_OPENAI_API_KEY", "azure-api-key-test");
1348 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_api_key_test.toml");
1349 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1350 let config = service.get_config().unwrap();
1351 assert_eq!(config.ai.provider, "azure-openai");
1352 assert_eq!(config.ai.api_key, Some("azure-api-key-test".to_string()));
1353 }
1354
1355 #[test]
1356 fn test_azure_openai_endpoint_sets_base_url() {
1357 let mut env = TestEnvironmentProvider::new();
1358 env.set_var(
1359 "AZURE_OPENAI_ENDPOINT",
1360 "https://my-instance.openai.azure.com",
1361 );
1362 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_endpoint_test.toml");
1363 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1364 let config = service.get_config().unwrap();
1365 assert_eq!(config.ai.base_url, "https://my-instance.openai.azure.com");
1366 }
1367
1368 #[test]
1369 fn test_azure_openai_api_version_sets_api_version() {
1370 let mut env = TestEnvironmentProvider::new();
1371 env.set_var("AZURE_OPENAI_API_VERSION", "2024-02-01");
1372 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_version_test.toml");
1373 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1374 let config = service.get_config().unwrap();
1375 assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1376 }
1377
1378 #[test]
1379 fn test_azure_openai_deployment_id_sets_model() {
1380 let mut env = TestEnvironmentProvider::new();
1381 env.set_var("AZURE_OPENAI_API_KEY", "azure-key-for-deploy");
1382 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "my-gpt4-deployment");
1383 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_deploy_test.toml");
1384 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1385 let config = service.get_config().unwrap();
1386 assert_eq!(config.ai.model, "my-gpt4-deployment");
1387 }
1388
1389 #[test]
1390 fn test_azure_openai_all_env_vars_together() {
1391 let mut env = TestEnvironmentProvider::new();
1392 env.set_var("AZURE_OPENAI_API_KEY", "full-azure-api-key");
1393 env.set_var("AZURE_OPENAI_ENDPOINT", "https://full.openai.azure.com");
1394 env.set_var("AZURE_OPENAI_API_VERSION", "2024-05-01");
1395 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "full-deployment-name");
1396 env.set_var("SUBX_CONFIG_PATH", "/nonexistent/azure_full_test.toml");
1397 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1398 let config = service.get_config().unwrap();
1399 assert_eq!(config.ai.provider, "azure-openai");
1400 assert_eq!(config.ai.api_key, Some("full-azure-api-key".to_string()));
1401 assert_eq!(config.ai.base_url, "https://full.openai.azure.com");
1402 assert_eq!(config.ai.api_version, Some("2024-05-01".to_string()));
1403 assert_eq!(config.ai.model, "full-deployment-name");
1404 }
1405
1406 #[test]
1411 fn test_get_config_file_path_uses_subx_config_path_env() {
1412 let mut env = TestEnvironmentProvider::new();
1413 env.set_var("SUBX_CONFIG_PATH", "/custom/path/config.toml");
1414 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1415 let path = service.get_config_file_path().unwrap();
1416 assert_eq!(path, PathBuf::from("/custom/path/config.toml"));
1417 }
1418
1419 #[test]
1420 fn test_get_config_file_path_default_contains_subx() {
1421 let env = TestEnvironmentProvider::new(); let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
1423 let path = service.get_config_file_path().unwrap();
1424 let s = path.to_str().unwrap();
1425 assert!(s.contains("subx"), "expected 'subx' in path: {s}");
1426 assert!(
1427 s.ends_with("config.toml"),
1428 "expected 'config.toml' suffix: {s}"
1429 );
1430 }
1431
1432 #[test]
1437 fn test_save_config_to_file_writes_valid_toml() {
1438 let dir = tempfile::tempdir().unwrap();
1439 let service = make_service_with_tmp_config(&dir);
1440 let save_path = dir.path().join("output.toml");
1441 service.save_config_to_file(&save_path).unwrap();
1442 let content = std::fs::read_to_string(&save_path).unwrap();
1443 assert!(content.contains("[ai]"), "missing [ai] section: {content}");
1444 assert!(
1445 content.contains("provider"),
1446 "missing 'provider': {content}"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_save_config_writes_to_configured_path() {
1452 let dir = tempfile::tempdir().unwrap();
1453 let service = make_service_with_tmp_config(&dir);
1454 service.save_config().unwrap();
1455 let config_path = dir.path().join("config.toml");
1456 assert!(config_path.exists(), "config file was not created");
1457 let content = std::fs::read_to_string(&config_path).unwrap();
1458 assert!(content.contains("[ai]"));
1459 }
1460
1461 #[test]
1466 fn test_reset_to_defaults_restores_default_config() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let service = make_service_with_tmp_config(&dir);
1469 service.save_config().unwrap();
1471 service.reset_to_defaults().unwrap();
1472 let config = service.get_config().unwrap();
1473 assert_eq!(config.ai.provider, "openai");
1474 assert_eq!(config.ai.model, "gpt-4.1-mini");
1475 assert_eq!(config.formats.default_output, "srt");
1476 }
1477
1478 #[test]
1483 fn test_get_config_value_all_ai_keys() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let service = make_service_with_tmp_config(&dir);
1486 for key in &[
1487 "ai.provider",
1488 "ai.model",
1489 "ai.api_key",
1490 "ai.base_url",
1491 "ai.max_sample_length",
1492 "ai.temperature",
1493 "ai.max_tokens",
1494 "ai.retry_attempts",
1495 "ai.retry_delay_ms",
1496 "ai.request_timeout_seconds",
1497 ] {
1498 assert!(
1499 service.get_config_value(key).is_ok(),
1500 "failed for key: {key}"
1501 );
1502 }
1503 }
1504
1505 #[test]
1506 fn test_get_config_value_all_formats_keys() {
1507 let dir = tempfile::tempdir().unwrap();
1508 let service = make_service_with_tmp_config(&dir);
1509 for key in &[
1510 "formats.default_output",
1511 "formats.default_encoding",
1512 "formats.preserve_styling",
1513 "formats.encoding_detection_confidence",
1514 ] {
1515 assert!(
1516 service.get_config_value(key).is_ok(),
1517 "failed for key: {key}"
1518 );
1519 }
1520 }
1521
1522 #[test]
1523 fn test_get_config_value_all_sync_keys() {
1524 let dir = tempfile::tempdir().unwrap();
1525 let service = make_service_with_tmp_config(&dir);
1526 for key in &[
1527 "sync.default_method",
1528 "sync.max_offset_seconds",
1529 "sync.vad.enabled",
1530 "sync.vad.sensitivity",
1531 "sync.vad.padding_chunks",
1532 "sync.vad.min_speech_duration_ms",
1533 ] {
1534 assert!(
1535 service.get_config_value(key).is_ok(),
1536 "failed for key: {key}"
1537 );
1538 }
1539 }
1540
1541 #[test]
1542 fn test_get_config_value_all_general_keys() {
1543 let dir = tempfile::tempdir().unwrap();
1544 let service = make_service_with_tmp_config(&dir);
1545 for key in &[
1546 "general.backup_enabled",
1547 "general.max_concurrent_jobs",
1548 "general.task_timeout_seconds",
1549 "general.enable_progress_bar",
1550 "general.worker_idle_timeout_seconds",
1551 "general.max_subtitle_bytes",
1552 "general.max_audio_bytes",
1553 ] {
1554 assert!(
1555 service.get_config_value(key).is_ok(),
1556 "failed for key: {key}"
1557 );
1558 }
1559 }
1560
1561 #[test]
1562 fn test_get_config_value_all_parallel_keys() {
1563 let dir = tempfile::tempdir().unwrap();
1564 let service = make_service_with_tmp_config(&dir);
1565 for key in &[
1566 "parallel.max_workers",
1567 "parallel.task_queue_size",
1568 "parallel.enable_task_priorities",
1569 "parallel.auto_balance_workers",
1570 "parallel.overflow_strategy",
1571 ] {
1572 assert!(
1573 service.get_config_value(key).is_ok(),
1574 "failed for key: {key}"
1575 );
1576 }
1577 }
1578
1579 #[test]
1580 fn test_get_config_value_unknown_key_returns_error() {
1581 let dir = tempfile::tempdir().unwrap();
1582 let service = make_service_with_tmp_config(&dir);
1583 assert!(service.get_config_value("nonexistent.key").is_err());
1584 assert!(service.get_config_value("ai").is_err());
1585 }
1586
1587 #[test]
1588 fn test_get_config_value_returns_correct_defaults() {
1589 let dir = tempfile::tempdir().unwrap();
1590 let service = make_service_with_tmp_config(&dir);
1591 assert_eq!(service.get_config_value("ai.provider").unwrap(), "openai");
1592 assert_eq!(
1593 service.get_config_value("ai.model").unwrap(),
1594 "gpt-4.1-mini"
1595 );
1596 assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1597 assert_eq!(
1598 service.get_config_value("formats.default_output").unwrap(),
1599 "srt"
1600 );
1601 assert_eq!(
1602 service.get_config_value("general.backup_enabled").unwrap(),
1603 "false"
1604 );
1605 }
1606
1607 #[test]
1612 fn test_set_config_value_ai_provider() {
1613 let dir = tempfile::tempdir().unwrap();
1614 let service = make_service_with_tmp_config(&dir);
1615 service
1616 .set_config_value("ai.provider", "openrouter")
1617 .unwrap();
1618 assert_eq!(
1619 service.get_config_value("ai.provider").unwrap(),
1620 "openrouter"
1621 );
1622 }
1623
1624 #[test]
1629 fn test_set_config_value_ai_provider_canonicalizes_alias_and_case() {
1630 let cases = [
1631 ("OLLAMA", "local"),
1632 ("ollama", "local"),
1633 (" ollama ", "local"),
1634 ("OPENAI", "openai"),
1635 (" Azure-OpenAI ", "azure-openai"),
1636 ];
1637 for (input, expected) in cases {
1638 let dir = tempfile::tempdir().unwrap();
1639 let service = make_service_with_tmp_config(&dir);
1640 service
1641 .set_config_value("ai.provider", input)
1642 .unwrap_or_else(|e| panic!("input {input:?} should be accepted: {e}"));
1643 assert_eq!(
1644 service.get_config_value("ai.provider").unwrap(),
1645 expected,
1646 "input {input:?} should canonicalize to {expected:?}"
1647 );
1648 }
1649 }
1650
1651 #[test]
1653 fn test_set_config_value_ai_provider_rejects_unknown_after_normalization() {
1654 let dir = tempfile::tempdir().unwrap();
1655 let service = make_service_with_tmp_config(&dir);
1656 assert!(service.set_config_value("ai.provider", "GROK").is_err());
1657 }
1658
1659 #[test]
1660 fn test_set_config_value_ai_model() {
1661 let dir = tempfile::tempdir().unwrap();
1662 let service = make_service_with_tmp_config(&dir);
1663 service.set_config_value("ai.model", "gpt-4.1").unwrap();
1664 assert_eq!(service.get_config_value("ai.model").unwrap(), "gpt-4.1");
1665 }
1666
1667 #[test]
1668 fn test_set_config_value_ai_api_key_non_empty() {
1669 let dir = tempfile::tempdir().unwrap();
1670 let service = make_service_with_tmp_config(&dir);
1671 service
1672 .set_config_value("ai.api_key", "sk-test-apikey-12345")
1673 .unwrap();
1674 assert_eq!(
1675 service.get_config_value("ai.api_key").unwrap(),
1676 "sk-test-apikey-12345"
1677 );
1678 }
1679
1680 #[test]
1681 fn test_set_config_value_ai_api_key_empty_clears_key() {
1682 let dir = tempfile::tempdir().unwrap();
1683 let service = make_service_with_tmp_config(&dir);
1684 service
1686 .set_config_value("ai.api_key", "sk-test-apikey-12345")
1687 .unwrap();
1688 service.set_config_value("ai.api_key", "").unwrap();
1690 assert_eq!(service.get_config_value("ai.api_key").unwrap(), "");
1691 let config = service.get_config().unwrap();
1692 assert!(config.ai.api_key.is_none());
1693 }
1694
1695 #[test]
1696 fn test_set_config_value_ai_base_url() {
1697 let dir = tempfile::tempdir().unwrap();
1698 let service = make_service_with_tmp_config(&dir);
1699 service
1700 .set_config_value("ai.base_url", "https://custom.example.com/v1")
1701 .unwrap();
1702 let config = service.get_config().unwrap();
1703 assert_eq!(config.ai.base_url, "https://custom.example.com/v1");
1704 }
1705
1706 #[test]
1707 fn test_set_config_value_ai_temperature() {
1708 let dir = tempfile::tempdir().unwrap();
1709 let service = make_service_with_tmp_config(&dir);
1710 service.set_config_value("ai.temperature", "0.7").unwrap();
1711 let config = service.get_config().unwrap();
1712 assert!((config.ai.temperature - 0.7).abs() < 0.001);
1713 }
1714
1715 #[test]
1716 fn test_set_config_value_ai_max_tokens() {
1717 let dir = tempfile::tempdir().unwrap();
1718 let service = make_service_with_tmp_config(&dir);
1719 service.set_config_value("ai.max_tokens", "5000").unwrap();
1720 assert_eq!(service.get_config_value("ai.max_tokens").unwrap(), "5000");
1721 }
1722
1723 #[test]
1724 fn test_set_config_value_ai_retry_attempts() {
1725 let dir = tempfile::tempdir().unwrap();
1726 let service = make_service_with_tmp_config(&dir);
1727 service.set_config_value("ai.retry_attempts", "5").unwrap();
1728 assert_eq!(service.get_config_value("ai.retry_attempts").unwrap(), "5");
1729 }
1730
1731 #[test]
1732 fn test_set_config_value_ai_retry_delay_ms() {
1733 let dir = tempfile::tempdir().unwrap();
1734 let service = make_service_with_tmp_config(&dir);
1735 service
1736 .set_config_value("ai.retry_delay_ms", "2000")
1737 .unwrap();
1738 assert_eq!(
1739 service.get_config_value("ai.retry_delay_ms").unwrap(),
1740 "2000"
1741 );
1742 }
1743
1744 #[test]
1745 fn test_set_config_value_ai_request_timeout_seconds() {
1746 let dir = tempfile::tempdir().unwrap();
1747 let service = make_service_with_tmp_config(&dir);
1748 service
1749 .set_config_value("ai.request_timeout_seconds", "60")
1750 .unwrap();
1751 assert_eq!(
1752 service
1753 .get_config_value("ai.request_timeout_seconds")
1754 .unwrap(),
1755 "60"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_set_config_value_ai_max_sample_length() {
1761 let dir = tempfile::tempdir().unwrap();
1762 let service = make_service_with_tmp_config(&dir);
1763 service
1764 .set_config_value("ai.max_sample_length", "500")
1765 .unwrap();
1766 assert_eq!(
1767 service.get_config_value("ai.max_sample_length").unwrap(),
1768 "500"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_set_config_value_ai_api_version_non_empty() {
1774 let dir = tempfile::tempdir().unwrap();
1775 let service = make_service_with_tmp_config(&dir);
1776 service
1777 .set_config_value("ai.api_version", "2024-02-01")
1778 .unwrap();
1779 let config = service.get_config().unwrap();
1780 assert_eq!(config.ai.api_version, Some("2024-02-01".to_string()));
1781 }
1782
1783 #[test]
1788 fn test_set_config_value_formats_default_output() {
1789 let dir = tempfile::tempdir().unwrap();
1790 let service = make_service_with_tmp_config(&dir);
1791 service
1792 .set_config_value("formats.default_output", "ass")
1793 .unwrap();
1794 assert_eq!(
1795 service.get_config_value("formats.default_output").unwrap(),
1796 "ass"
1797 );
1798 }
1799
1800 #[test]
1801 fn test_set_config_value_formats_preserve_styling() {
1802 let dir = tempfile::tempdir().unwrap();
1803 let service = make_service_with_tmp_config(&dir);
1804 service
1805 .set_config_value("formats.preserve_styling", "true")
1806 .unwrap();
1807 let config = service.get_config().unwrap();
1808 assert!(config.formats.preserve_styling);
1809 }
1810
1811 #[test]
1812 fn test_set_config_value_formats_default_encoding() {
1813 let dir = tempfile::tempdir().unwrap();
1814 let service = make_service_with_tmp_config(&dir);
1815 service
1816 .set_config_value("formats.default_encoding", "utf-8")
1817 .unwrap();
1818 assert_eq!(
1819 service
1820 .get_config_value("formats.default_encoding")
1821 .unwrap(),
1822 "utf-8"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_set_config_value_formats_encoding_detection_confidence() {
1828 let dir = tempfile::tempdir().unwrap();
1829 let service = make_service_with_tmp_config(&dir);
1830 service
1831 .set_config_value("formats.encoding_detection_confidence", "0.9")
1832 .unwrap();
1833 let config = service.get_config().unwrap();
1834 assert!((config.formats.encoding_detection_confidence - 0.9).abs() < 0.001);
1835 }
1836
1837 #[test]
1842 fn test_set_config_value_sync_max_offset_seconds() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let service = make_service_with_tmp_config(&dir);
1845 service
1846 .set_config_value("sync.max_offset_seconds", "30")
1847 .unwrap();
1848 let config = service.get_config().unwrap();
1849 assert!((config.sync.max_offset_seconds - 30.0).abs() < 0.001);
1850 }
1851
1852 #[test]
1853 fn test_set_config_value_sync_default_method() {
1854 let dir = tempfile::tempdir().unwrap();
1855 let service = make_service_with_tmp_config(&dir);
1856 service
1857 .set_config_value("sync.default_method", "vad")
1858 .unwrap();
1859 assert_eq!(
1860 service.get_config_value("sync.default_method").unwrap(),
1861 "vad"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_set_config_value_sync_vad_enabled() {
1867 let dir = tempfile::tempdir().unwrap();
1868 let service = make_service_with_tmp_config(&dir);
1869 service
1870 .set_config_value("sync.vad.enabled", "false")
1871 .unwrap();
1872 let config = service.get_config().unwrap();
1873 assert!(!config.sync.vad.enabled);
1874 }
1875
1876 #[test]
1877 fn test_set_config_value_sync_vad_sensitivity() {
1878 let dir = tempfile::tempdir().unwrap();
1879 let service = make_service_with_tmp_config(&dir);
1880 service
1881 .set_config_value("sync.vad.sensitivity", "0.5")
1882 .unwrap();
1883 let config = service.get_config().unwrap();
1884 assert!((config.sync.vad.sensitivity - 0.5).abs() < 0.001);
1885 }
1886
1887 #[test]
1888 fn test_set_config_value_sync_vad_padding_chunks() {
1889 let dir = tempfile::tempdir().unwrap();
1890 let service = make_service_with_tmp_config(&dir);
1891 service
1892 .set_config_value("sync.vad.padding_chunks", "5")
1893 .unwrap();
1894 assert_eq!(
1895 service.get_config_value("sync.vad.padding_chunks").unwrap(),
1896 "5"
1897 );
1898 }
1899
1900 #[test]
1901 fn test_set_config_value_sync_vad_min_speech_duration_ms() {
1902 let dir = tempfile::tempdir().unwrap();
1903 let service = make_service_with_tmp_config(&dir);
1904 service
1905 .set_config_value("sync.vad.min_speech_duration_ms", "500")
1906 .unwrap();
1907 assert_eq!(
1908 service
1909 .get_config_value("sync.vad.min_speech_duration_ms")
1910 .unwrap(),
1911 "500"
1912 );
1913 }
1914
1915 #[test]
1920 fn test_set_config_value_general_backup_enabled() {
1921 let dir = tempfile::tempdir().unwrap();
1922 let service = make_service_with_tmp_config(&dir);
1923 service
1924 .set_config_value("general.backup_enabled", "true")
1925 .unwrap();
1926 let config = service.get_config().unwrap();
1927 assert!(config.general.backup_enabled);
1928 }
1929
1930 #[test]
1931 fn test_set_config_value_general_max_concurrent_jobs() {
1932 let dir = tempfile::tempdir().unwrap();
1933 let service = make_service_with_tmp_config(&dir);
1934 service
1935 .set_config_value("general.max_concurrent_jobs", "8")
1936 .unwrap();
1937 assert_eq!(
1938 service
1939 .get_config_value("general.max_concurrent_jobs")
1940 .unwrap(),
1941 "8"
1942 );
1943 }
1944
1945 #[test]
1946 fn test_set_config_value_general_task_timeout_seconds() {
1947 let dir = tempfile::tempdir().unwrap();
1948 let service = make_service_with_tmp_config(&dir);
1949 service
1950 .set_config_value("general.task_timeout_seconds", "120")
1951 .unwrap();
1952 assert_eq!(
1953 service
1954 .get_config_value("general.task_timeout_seconds")
1955 .unwrap(),
1956 "120"
1957 );
1958 }
1959
1960 #[test]
1961 fn test_set_config_value_general_enable_progress_bar() {
1962 let dir = tempfile::tempdir().unwrap();
1963 let service = make_service_with_tmp_config(&dir);
1964 service
1965 .set_config_value("general.enable_progress_bar", "false")
1966 .unwrap();
1967 let config = service.get_config().unwrap();
1968 assert!(!config.general.enable_progress_bar);
1969 }
1970
1971 #[test]
1972 fn test_set_config_value_general_worker_idle_timeout_seconds() {
1973 let dir = tempfile::tempdir().unwrap();
1974 let service = make_service_with_tmp_config(&dir);
1975 service
1976 .set_config_value("general.worker_idle_timeout_seconds", "60")
1977 .unwrap();
1978 assert_eq!(
1979 service
1980 .get_config_value("general.worker_idle_timeout_seconds")
1981 .unwrap(),
1982 "60"
1983 );
1984 }
1985
1986 #[test]
1991 fn test_set_config_value_parallel_max_workers() {
1992 let dir = tempfile::tempdir().unwrap();
1993 let service = make_service_with_tmp_config(&dir);
1994 service
1995 .set_config_value("parallel.max_workers", "4")
1996 .unwrap();
1997 assert_eq!(
1998 service.get_config_value("parallel.max_workers").unwrap(),
1999 "4"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_set_config_value_parallel_task_queue_size() {
2005 let dir = tempfile::tempdir().unwrap();
2006 let service = make_service_with_tmp_config(&dir);
2007 service
2008 .set_config_value("parallel.task_queue_size", "200")
2009 .unwrap();
2010 assert_eq!(
2011 service
2012 .get_config_value("parallel.task_queue_size")
2013 .unwrap(),
2014 "200"
2015 );
2016 }
2017
2018 #[test]
2019 fn test_set_config_value_parallel_enable_task_priorities() {
2020 let dir = tempfile::tempdir().unwrap();
2021 let service = make_service_with_tmp_config(&dir);
2022 service
2023 .set_config_value("parallel.enable_task_priorities", "true")
2024 .unwrap();
2025 let config = service.get_config().unwrap();
2026 assert!(config.parallel.enable_task_priorities);
2027 }
2028
2029 #[test]
2030 fn test_set_config_value_parallel_auto_balance_workers() {
2031 let dir = tempfile::tempdir().unwrap();
2032 let service = make_service_with_tmp_config(&dir);
2033 service
2034 .set_config_value("parallel.auto_balance_workers", "false")
2035 .unwrap();
2036 let config = service.get_config().unwrap();
2037 assert!(!config.parallel.auto_balance_workers);
2038 }
2039
2040 #[test]
2041 fn test_set_config_value_parallel_overflow_strategy_block() {
2042 let dir = tempfile::tempdir().unwrap();
2043 let service = make_service_with_tmp_config(&dir);
2044 service
2045 .set_config_value("parallel.overflow_strategy", "Block")
2046 .unwrap();
2047 let config = service.get_config().unwrap();
2048 assert_eq!(
2049 config.parallel.overflow_strategy,
2050 crate::config::OverflowStrategy::Block
2051 );
2052 }
2053
2054 #[test]
2055 fn test_set_config_value_parallel_overflow_strategy_drop() {
2056 let dir = tempfile::tempdir().unwrap();
2057 let service = make_service_with_tmp_config(&dir);
2058 service
2059 .set_config_value("parallel.overflow_strategy", "Drop")
2060 .unwrap();
2061 let config = service.get_config().unwrap();
2062 assert_eq!(
2063 config.parallel.overflow_strategy,
2064 crate::config::OverflowStrategy::Drop
2065 );
2066 }
2067
2068 #[test]
2069 fn test_set_config_value_parallel_overflow_strategy_expand() {
2070 let dir = tempfile::tempdir().unwrap();
2071 let service = make_service_with_tmp_config(&dir);
2072 service
2073 .set_config_value("parallel.overflow_strategy", "Expand")
2074 .unwrap();
2075 let config = service.get_config().unwrap();
2076 assert_eq!(
2077 config.parallel.overflow_strategy,
2078 crate::config::OverflowStrategy::Expand
2079 );
2080 }
2081
2082 #[test]
2087 fn test_set_config_value_unknown_key_returns_error() {
2088 let dir = tempfile::tempdir().unwrap();
2089 let service = make_service_with_tmp_config(&dir);
2090 assert!(
2091 service
2092 .set_config_value("nonexistent.key", "value")
2093 .is_err()
2094 );
2095 }
2096
2097 #[test]
2098 fn test_set_config_value_invalid_value_returns_error() {
2099 let dir = tempfile::tempdir().unwrap();
2100 let service = make_service_with_tmp_config(&dir);
2101 assert!(service.set_config_value("ai.temperature", "99.9").is_err());
2103 assert!(
2105 service
2106 .set_config_value("ai.provider", "unknown-provider")
2107 .is_err()
2108 );
2109 }
2110
2111 #[test]
2116 fn test_production_config_service_default_trait_impl() {
2117 let dir = tempfile::tempdir().unwrap();
2122 let service = make_service_with_tmp_config(&dir);
2123 let config = service.get_config().unwrap();
2124 assert_eq!(config.ai.provider, "openai");
2125 }
2126
2127 #[test]
2132 fn test_production_config_service_loads_values_from_toml_file() {
2133 let dir = tempfile::tempdir().unwrap();
2134 let config_path = dir.path().join("custom.toml");
2135
2136 let mut cfg = crate::config::Config::default();
2138 cfg.ai.provider = "openrouter".to_string();
2139 cfg.ai.model = "toml-loaded-model".to_string();
2140 let toml_str = toml::to_string_pretty(&cfg).unwrap();
2141 std::fs::write(&config_path, toml_str).unwrap();
2142
2143 let mut env = TestEnvironmentProvider::new();
2144 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2145 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2146 let loaded = service.get_config().unwrap();
2147 assert_eq!(loaded.ai.provider, "openrouter");
2148 assert_eq!(loaded.ai.model, "toml-loaded-model");
2149 }
2150
2151 #[test]
2158 fn test_test_config_service_set_ai_settings_and_key_instance_method() {
2159 let service = TestConfigService::with_defaults();
2160 service.set_ai_settings_and_key("openrouter", "my-model", "test-key-1234567890");
2161 let config = service.get_config().unwrap();
2162 assert_eq!(config.ai.provider, "openrouter");
2163 assert_eq!(config.ai.model, "my-model");
2164 assert_eq!(config.ai.api_key, Some("test-key-1234567890".to_string()));
2165 }
2166
2167 #[test]
2168 fn test_test_config_service_set_ai_settings_and_key_empty_clears_key() {
2169 let service = TestConfigService::with_defaults();
2170 service.set_ai_settings_and_key("openai", "gpt-4", "");
2171 let config = service.get_config().unwrap();
2172 assert!(config.ai.api_key.is_none());
2173 }
2174
2175 #[test]
2176 fn test_test_config_service_set_ai_settings_with_base_url() {
2177 let service = TestConfigService::with_defaults();
2178 service.set_ai_settings_with_base_url(
2179 "openai",
2180 "gpt-4.1",
2181 "sk-test-key-12345",
2182 "https://proxy.example.com/v1",
2183 );
2184 let config = service.get_config().unwrap();
2185 assert_eq!(config.ai.provider, "openai");
2186 assert_eq!(config.ai.model, "gpt-4.1");
2187 assert_eq!(config.ai.api_key, Some("sk-test-key-12345".to_string()));
2188 assert_eq!(config.ai.base_url, "https://proxy.example.com/v1");
2189 }
2190
2191 #[test]
2196 fn test_set_config_value_persists_to_disk() {
2197 let dir = tempfile::tempdir().unwrap();
2198 let config_path = dir.path().join("config.toml");
2199 let mut env = TestEnvironmentProvider::new();
2200 env.set_var("SUBX_CONFIG_PATH", config_path.to_str().unwrap());
2201 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2202
2203 service.set_config_value("ai.model", "gpt-4.1").unwrap();
2204
2205 let file_content = std::fs::read_to_string(&config_path).unwrap();
2206 assert!(
2207 file_content.contains("gpt-4.1"),
2208 "model not persisted to disk: {file_content}"
2209 );
2210 }
2211
2212 #[cfg(unix)]
2217 #[test]
2218 fn test_secure_write_config_file_existing_parent_dir() {
2219 use std::os::unix::fs::PermissionsExt;
2220
2221 let dir = tempfile::tempdir().unwrap();
2222 let path = dir.path().join("config.toml");
2223
2224 super::secure_write_config_file(&path, "key = \"value\"\n")
2225 .expect("write to existing dir should succeed");
2226
2227 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
2228 assert_eq!(mode, 0o600);
2229 assert_eq!(std::fs::read_to_string(&path).unwrap(), "key = \"value\"\n");
2230 }
2231
2232 fn env_with_isolated_config() -> (TestEnvironmentProvider, tempfile::TempDir) {
2241 let dir = tempfile::tempdir().expect("create tempdir");
2242 let mut env = TestEnvironmentProvider::new();
2243 let p = dir.path().join("nonexistent_config.toml");
2244 env.set_var("SUBX_CONFIG_PATH", p.to_str().unwrap());
2245 (env, dir)
2246 }
2247
2248 #[test]
2249 fn test_local_llm_base_url_honored_when_provider_is_local() {
2250 let (mut env, _dir) = env_with_isolated_config();
2251 env.set_var("SUBX_AI_PROVIDER", "local");
2252 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:8080/v1");
2253
2254 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2255 let config = service.get_config().expect("get_config");
2256
2257 assert_eq!(config.ai.provider, "local");
2258 assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2259 }
2260
2261 #[test]
2262 fn test_local_llm_api_key_honored_when_provider_is_local() {
2263 let (mut env, _dir) = env_with_isolated_config();
2264 env.set_var("SUBX_AI_PROVIDER", "local");
2265 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2266 env.set_var("LOCAL_LLM_API_KEY", "local-secret-token");
2267
2268 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2269 let config = service.get_config().expect("get_config");
2270
2271 assert_eq!(config.ai.provider, "local");
2272 assert_eq!(config.ai.api_key.as_deref(), Some("local-secret-token"));
2273 }
2274
2275 #[test]
2276 fn test_local_llm_env_vars_ignored_for_non_local_provider() {
2277 let (mut env, _dir) = env_with_isolated_config();
2278 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2280 env.set_var("LOCAL_LLM_API_KEY", "leak-me");
2281
2282 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2283 let config = service.get_config().expect("get_config");
2284
2285 assert_eq!(config.ai.provider, "openai");
2286 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
2288 assert_ne!(config.ai.api_key.as_deref(), Some("leak-me"));
2290 }
2291
2292 #[test]
2293 fn test_subx_ai_base_url_outranks_local_llm_base_url() {
2294 let (mut env, _dir) = env_with_isolated_config();
2295 env.set_var("SUBX_AI_PROVIDER", "local");
2296 env.set_var("LOCAL_LLM_BASE_URL", "http://localhost:11434/v1");
2297 env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2298
2299 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2300 let config = service.get_config().expect("get_config");
2301
2302 assert_eq!(config.ai.provider, "local");
2303 assert_eq!(config.ai.base_url, "http://localhost:8080/v1");
2304 }
2305
2306 #[test]
2307 fn test_subx_ai_apikey_outranks_local_llm_api_key() {
2308 let (mut env, _dir) = env_with_isolated_config();
2309 env.set_var("SUBX_AI_PROVIDER", "local");
2310 env.set_var("SUBX_AI_BASE_URL", "http://localhost:8080/v1");
2311 env.set_var("LOCAL_LLM_API_KEY", "local-loser");
2312 env.set_var("SUBX_AI_APIKEY", "subx-winner");
2313
2314 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2315 let config = service.get_config().expect("get_config");
2316
2317 assert_eq!(config.ai.provider, "local");
2318 assert_eq!(config.ai.api_key.as_deref(), Some("subx-winner"));
2319 }
2320
2321 #[test]
2322 fn test_openai_api_key_does_not_populate_api_key_when_provider_is_local() {
2323 let (mut env, _dir) = env_with_isolated_config();
2324 env.set_var("SUBX_AI_PROVIDER", "local");
2325 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2326 env.set_var("OPENAI_API_KEY", "sk-leak-into-local");
2327
2328 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2329 let config = service.get_config().expect("get_config");
2330
2331 assert_eq!(config.ai.provider, "local");
2332 assert_eq!(config.ai.api_key, None);
2333 }
2334
2335 #[test]
2336 fn test_openrouter_api_key_does_not_switch_provider_away_from_local() {
2337 let (mut env, _dir) = env_with_isolated_config();
2338 env.set_var("SUBX_AI_PROVIDER", "local");
2339 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2340 env.set_var("OPENROUTER_API_KEY", "or-leak-into-local");
2341
2342 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2343 let config = service.get_config().expect("get_config");
2344
2345 assert_eq!(config.ai.provider, "local");
2346 assert_eq!(config.ai.api_key, None);
2347 }
2348
2349 #[test]
2350 fn test_azure_openai_env_vars_do_not_populate_when_provider_is_local() {
2351 let (mut env, _dir) = env_with_isolated_config();
2352 env.set_var("SUBX_AI_PROVIDER", "local");
2353 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2354 env.set_var("AZURE_OPENAI_API_KEY", "azure-leak");
2355 env.set_var("AZURE_OPENAI_ENDPOINT", "https://leak.openai.azure.com/");
2356 env.set_var("AZURE_OPENAI_DEPLOYMENT_ID", "leaked-deployment");
2357
2358 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2359 let config = service.get_config().expect("get_config");
2360
2361 assert_eq!(config.ai.provider, "local");
2362 assert_eq!(config.ai.api_key, None);
2363 assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2364 assert_ne!(config.ai.model, "leaked-deployment");
2365 }
2366
2367 #[test]
2368 fn test_subx_ai_provider_ollama_triggers_local_carve_out() {
2369 let (mut env, _dir) = env_with_isolated_config();
2374 env.set_var("SUBX_AI_PROVIDER", "ollama");
2375 env.set_var("SUBX_AI_BASE_URL", "http://localhost:11434/v1");
2376 env.set_var("OPENAI_API_KEY", "sk-should-not-leak");
2377 env.set_var("OPENROUTER_API_KEY", "or-should-not-leak");
2378
2379 let service = ProductionConfigService::with_env_provider(Arc::new(env)).unwrap();
2380 let config = service.get_config().expect("get_config");
2381
2382 assert_eq!(config.ai.provider, "local");
2383 assert_eq!(config.ai.api_key, None);
2384 assert_eq!(config.ai.base_url, "http://localhost:11434/v1");
2385 }
2386
2387 #[test]
2388 fn test_set_config_value_normalizes_ollama_to_local() {
2389 let dir = tempfile::tempdir().expect("tempdir");
2391 let service = make_service_with_tmp_config(&dir);
2392 service
2393 .set_config_value("ai.provider", "ollama")
2394 .expect("set ai.provider=ollama");
2395 service
2398 .set_config_value("ai.base_url", "http://localhost:11434/v1")
2399 .expect("set base_url");
2400
2401 assert_eq!(
2402 service.get_config_value("ai.provider").unwrap(),
2403 "local",
2404 "persisted ai.provider must be the canonical form"
2405 );
2406 }
2407}