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
162pub struct ProductionConfigService {
171 config_builder: ConfigBuilder<DefaultState>,
172 cached_config: Arc<RwLock<Option<Config>>>,
173 env_provider: Arc<dyn EnvironmentProvider>,
174}
175
176impl ProductionConfigService {
177 pub fn new() -> Result<Self> {
184 Self::with_env_provider(Arc::new(SystemEnvironmentProvider::new()))
185 }
186
187 pub fn with_env_provider(env_provider: Arc<dyn EnvironmentProvider>) -> Result<Self> {
192 let config_file_path = if let Some(custom_path) = env_provider.get_var("SUBX_CONFIG_PATH") {
194 PathBuf::from(custom_path)
195 } else {
196 Self::user_config_path()
197 };
198
199 let config_builder = ConfigCrate::builder()
200 .add_source(File::with_name("config/default").required(false))
201 .add_source(File::from(config_file_path).required(false))
202 .add_source(Environment::with_prefix("SUBX").separator("_"));
203
204 Ok(Self {
205 config_builder,
206 cached_config: Arc::new(RwLock::new(None)),
207 env_provider,
208 })
209 }
210
211 pub fn with_custom_file(mut self, file_path: PathBuf) -> Result<Self> {
223 self.config_builder = self.config_builder.add_source(File::from(file_path));
224 Ok(self)
225 }
226
227 fn user_config_path() -> PathBuf {
232 dirs::config_dir()
233 .unwrap_or_else(|| PathBuf::from("."))
234 .join("subx")
235 .join("config.toml")
236 }
237
238 fn load_and_validate(&self) -> Result<Config> {
244 debug!("ProductionConfigService: Loading configuration from sources");
245
246 let config_crate = self.config_builder.build_cloned().map_err(|e| {
248 debug!("ProductionConfigService: Config build failed: {e}");
249 SubXError::config(format!("Failed to build configuration: {e}"))
250 })?;
251
252 let mut app_config = Config::default();
254
255 if let Ok(config) = config_crate.clone().try_deserialize::<Config>() {
257 app_config = config;
258 debug!("ProductionConfigService: Full configuration loaded successfully");
259 } else {
260 debug!("ProductionConfigService: Full deserialization failed, attempting partial load");
261
262 if let Ok(raw_map) = config_crate
264 .try_deserialize::<std::collections::HashMap<String, serde_json::Value>>()
265 {
266 if let Some(ai_section) = raw_map.get("ai") {
268 if let Some(ai_obj) = ai_section.as_object() {
269 if let Some(api_key) = ai_obj.get("apikey").and_then(|v| v.as_str()) {
271 app_config.ai.api_key = Some(api_key.to_string());
272 debug!(
273 "ProductionConfigService: AI API key loaded from SUBX_AI_APIKEY"
274 );
275 }
276 if let Some(provider) = ai_obj.get("provider").and_then(|v| v.as_str()) {
277 app_config.ai.provider = provider.to_string();
278 debug!(
279 "ProductionConfigService: AI provider loaded from SUBX_AI_PROVIDER"
280 );
281 }
282 if let Some(model) = ai_obj.get("model").and_then(|v| v.as_str()) {
283 app_config.ai.model = model.to_string();
284 debug!("ProductionConfigService: AI model loaded from SUBX_AI_MODEL");
285 }
286 if let Some(base_url) = ai_obj.get("base_url").and_then(|v| v.as_str()) {
287 app_config.ai.base_url = base_url.to_string();
288 debug!(
289 "ProductionConfigService: AI base URL loaded from SUBX_AI_BASE_URL"
290 );
291 }
292 }
293 }
294 }
295 }
296
297 if let Some(api_key) = self.env_provider.get_var("OPENROUTER_API_KEY") {
299 debug!("ProductionConfigService: Found OPENROUTER_API_KEY environment variable");
300 app_config.ai.provider = "openrouter".to_string();
301 app_config.ai.api_key = Some(api_key);
302 }
303
304 if app_config.ai.api_key.is_none() {
307 if let Some(api_key) = self.env_provider.get_var("OPENAI_API_KEY") {
308 debug!("ProductionConfigService: Found OPENAI_API_KEY environment variable");
309 app_config.ai.api_key = Some(api_key);
310 }
311 }
312
313 if let Some(base_url) = self.env_provider.get_var("OPENAI_BASE_URL") {
315 debug!("ProductionConfigService: Found OPENAI_BASE_URL environment variable");
316 app_config.ai.base_url = base_url;
317 }
318
319 if let Some(api_key) = self.env_provider.get_var("AZURE_OPENAI_API_KEY") {
321 debug!("ProductionConfigService: Found AZURE_OPENAI_API_KEY environment variable");
322 app_config.ai.provider = "azure-openai".to_string();
323 app_config.ai.api_key = Some(api_key);
324 }
325 if let Some(endpoint) = self.env_provider.get_var("AZURE_OPENAI_ENDPOINT") {
326 debug!("ProductionConfigService: Found AZURE_OPENAI_ENDPOINT environment variable");
327 app_config.ai.base_url = endpoint;
328 }
329 if let Some(version) = self.env_provider.get_var("AZURE_OPENAI_API_VERSION") {
330 debug!("ProductionConfigService: Found AZURE_OPENAI_API_VERSION environment variable");
331 app_config.ai.api_version = Some(version);
332 }
333 if let Some(deployment) = self.env_provider.get_var("AZURE_OPENAI_DEPLOYMENT_ID") {
335 debug!(
336 "ProductionConfigService: Found AZURE_OPENAI_DEPLOYMENT_ID environment variable"
337 );
338 app_config.ai.model = deployment;
339 }
340
341 crate::config::validator::validate_config(&app_config).map_err(|e| {
343 debug!("ProductionConfigService: Config validation failed: {e}");
344 SubXError::config(format!("Configuration validation failed: {e}"))
345 })?;
346
347 debug!("ProductionConfigService: Configuration loaded and validated successfully");
348 Ok(app_config)
349 }
350
351 fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
355 use crate::config::field_validator;
356
357 field_validator::validate_field(key, value)?;
359
360 self.set_value_internal(config, key, value)?;
362
363 self.validate_configuration(config)?;
365
366 Ok(())
367 }
368
369 fn set_value_internal(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
371 use crate::config::OverflowStrategy;
372 use crate::config::validation::*;
373 use crate::error::SubXError;
374
375 let parts: Vec<&str> = key.split('.').collect();
376 match parts.as_slice() {
377 ["ai", "provider"] => {
378 config.ai.provider = value.to_string();
379 }
380 ["ai", "api_key"] => {
381 if !value.is_empty() {
382 config.ai.api_key = Some(value.to_string());
383 } else {
384 config.ai.api_key = None;
385 }
386 }
387 ["ai", "model"] => {
388 config.ai.model = value.to_string();
389 }
390 ["ai", "base_url"] => {
391 config.ai.base_url = value.to_string();
392 }
393 ["ai", "max_sample_length"] => {
394 let v = value.parse().unwrap(); config.ai.max_sample_length = v;
396 }
397 ["ai", "temperature"] => {
398 let v = value.parse().unwrap(); config.ai.temperature = v;
400 }
401 ["ai", "max_tokens"] => {
402 let v = value.parse().unwrap(); config.ai.max_tokens = v;
404 }
405 ["ai", "retry_attempts"] => {
406 let v = value.parse().unwrap(); config.ai.retry_attempts = v;
408 }
409 ["ai", "retry_delay_ms"] => {
410 let v = value.parse().unwrap(); config.ai.retry_delay_ms = v;
412 }
413 ["ai", "request_timeout_seconds"] => {
414 let v = value.parse().unwrap(); config.ai.request_timeout_seconds = v;
416 }
417 ["ai", "api_version"] => {
418 if !value.is_empty() {
419 config.ai.api_version = Some(value.to_string());
420 } else {
421 config.ai.api_version = None;
422 }
423 }
424 ["formats", "default_output"] => {
425 config.formats.default_output = value.to_string();
426 }
427 ["formats", "preserve_styling"] => {
428 let v = parse_bool(value)?;
429 config.formats.preserve_styling = v;
430 }
431 ["formats", "default_encoding"] => {
432 config.formats.default_encoding = value.to_string();
433 }
434 ["formats", "encoding_detection_confidence"] => {
435 let v = value.parse().unwrap(); config.formats.encoding_detection_confidence = v;
437 }
438 ["sync", "max_offset_seconds"] => {
439 let v = value.parse().unwrap(); config.sync.max_offset_seconds = v;
441 }
442 ["sync", "default_method"] => {
443 config.sync.default_method = value.to_string();
444 }
445 ["sync", "vad", "enabled"] => {
446 let v = parse_bool(value)?;
447 config.sync.vad.enabled = v;
448 }
449 ["sync", "vad", "sensitivity"] => {
450 let v = value.parse().unwrap(); config.sync.vad.sensitivity = v;
452 }
453 ["sync", "vad", "padding_chunks"] => {
454 let v = value.parse().unwrap(); config.sync.vad.padding_chunks = v;
456 }
457 ["sync", "vad", "min_speech_duration_ms"] => {
458 let v = value.parse().unwrap(); config.sync.vad.min_speech_duration_ms = v;
460 }
461 ["general", "backup_enabled"] => {
462 let v = parse_bool(value)?;
463 config.general.backup_enabled = v;
464 }
465 ["general", "max_concurrent_jobs"] => {
466 let v = value.parse().unwrap(); config.general.max_concurrent_jobs = v;
468 }
469 ["general", "task_timeout_seconds"] => {
470 let v = value.parse().unwrap(); config.general.task_timeout_seconds = v;
472 }
473 ["general", "enable_progress_bar"] => {
474 let v = parse_bool(value)?;
475 config.general.enable_progress_bar = v;
476 }
477 ["general", "worker_idle_timeout_seconds"] => {
478 let v = value.parse().unwrap(); config.general.worker_idle_timeout_seconds = v;
480 }
481 ["general", "max_subtitle_bytes"] => {
482 let v = value.parse().unwrap(); config.general.max_subtitle_bytes = v;
484 }
485 ["general", "max_audio_bytes"] => {
486 let v = value.parse().unwrap(); config.general.max_audio_bytes = v;
488 }
489 ["parallel", "max_workers"] => {
490 let v = value.parse().unwrap(); config.parallel.max_workers = v;
492 }
493 ["parallel", "task_queue_size"] => {
494 let v = value.parse().unwrap(); config.parallel.task_queue_size = v;
496 }
497 ["parallel", "enable_task_priorities"] => {
498 let v = parse_bool(value)?;
499 config.parallel.enable_task_priorities = v;
500 }
501 ["parallel", "auto_balance_workers"] => {
502 let v = parse_bool(value)?;
503 config.parallel.auto_balance_workers = v;
504 }
505 ["parallel", "overflow_strategy"] => {
506 config.parallel.overflow_strategy = match value {
507 "Block" => OverflowStrategy::Block,
508 "Drop" => OverflowStrategy::Drop,
509 "Expand" => OverflowStrategy::Expand,
510 _ => unreachable!(), };
512 }
513 _ => {
514 return Err(SubXError::config(format!(
515 "Unknown configuration key: {key}"
516 )));
517 }
518 }
519 Ok(())
520 }
521
522 fn validate_configuration(&self, config: &Config) -> Result<()> {
524 use crate::config::validator;
525 validator::validate_config(config)
526 }
527
528 fn save_config_to_file_with_config(
530 &self,
531 path: &std::path::Path,
532 config: &Config,
533 ) -> Result<()> {
534 let toml_content = toml::to_string_pretty(config)
535 .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
536 secure_write_config_file(path, &toml_content)
537 .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
538 Ok(())
539 }
540}
541
542impl ConfigService for ProductionConfigService {
543 fn get_config(&self) -> Result<Config> {
544 {
546 let cache = self.cached_config.read().unwrap();
547 if let Some(config) = cache.as_ref() {
548 debug!("ProductionConfigService: Returning cached configuration");
549 return Ok(config.clone());
550 }
551 }
552
553 let app_config = self.load_and_validate()?;
555
556 {
558 let mut cache = self.cached_config.write().unwrap();
559 *cache = Some(app_config.clone());
560 }
561
562 Ok(app_config)
563 }
564
565 fn reload(&self) -> Result<()> {
566 debug!("ProductionConfigService: Reloading configuration");
567
568 {
570 let mut cache = self.cached_config.write().unwrap();
571 *cache = None;
572 }
573
574 self.get_config()?;
576
577 debug!("ProductionConfigService: Configuration reloaded successfully");
578 Ok(())
579 }
580
581 fn save_config(&self) -> Result<()> {
582 let _config = self.get_config()?;
583 let path = self.get_config_file_path()?;
584 self.save_config_to_file(&path)
585 }
586
587 fn save_config_to_file(&self, path: &Path) -> Result<()> {
588 let config = self.get_config()?;
589 let toml_content = toml::to_string_pretty(&config)
590 .map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
591
592 secure_write_config_file(path, &toml_content)
593 .map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
594
595 Ok(())
596 }
597
598 fn get_config_file_path(&self) -> Result<PathBuf> {
599 if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
601 return Ok(PathBuf::from(custom));
602 }
603
604 let config_dir = dirs::config_dir()
605 .ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
606 Ok(config_dir.join("subx").join("config.toml"))
607 }
608
609 fn get_config_value(&self, key: &str) -> Result<String> {
610 let config = self.get_config()?;
611 let parts: Vec<&str> = key.split('.').collect();
612 match parts.as_slice() {
613 ["ai", "provider"] => Ok(config.ai.provider.clone()),
614 ["ai", "model"] => Ok(config.ai.model.clone()),
615 ["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
616 ["ai", "base_url"] => Ok(config.ai.base_url.clone()),
617 ["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
618 ["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
619 ["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
620 ["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
621 ["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
622 ["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
623
624 ["formats", "default_output"] => Ok(config.formats.default_output.clone()),
625 ["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
626 ["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
627 ["formats", "encoding_detection_confidence"] => {
628 Ok(config.formats.encoding_detection_confidence.to_string())
629 }
630
631 ["sync", "default_method"] => Ok(config.sync.default_method.clone()),
632 ["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
633 ["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
634 ["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
635 ["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
636 ["sync", "vad", "min_speech_duration_ms"] => {
637 Ok(config.sync.vad.min_speech_duration_ms.to_string())
638 }
639
640 ["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
641 ["general", "max_concurrent_jobs"] => {
642 Ok(config.general.max_concurrent_jobs.to_string())
643 }
644 ["general", "task_timeout_seconds"] => {
645 Ok(config.general.task_timeout_seconds.to_string())
646 }
647 ["general", "enable_progress_bar"] => {
648 Ok(config.general.enable_progress_bar.to_string())
649 }
650 ["general", "worker_idle_timeout_seconds"] => {
651 Ok(config.general.worker_idle_timeout_seconds.to_string())
652 }
653 ["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
654 ["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
655
656 ["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
657 ["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
658 ["parallel", "enable_task_priorities"] => {
659 Ok(config.parallel.enable_task_priorities.to_string())
660 }
661 ["parallel", "auto_balance_workers"] => {
662 Ok(config.parallel.auto_balance_workers.to_string())
663 }
664 ["parallel", "overflow_strategy"] => {
665 Ok(format!("{:?}", config.parallel.overflow_strategy))
666 }
667
668 _ => Err(SubXError::config(format!(
669 "Unknown configuration key: {}",
670 key
671 ))),
672 }
673 }
674
675 fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
676 let mut config = self.get_config()?;
678
679 self.validate_and_set_value(&mut config, key, value)?;
681
682 crate::config::validator::validate_config(&config)?;
684
685 let path = self.get_config_file_path()?;
687 self.save_config_to_file_with_config(&path, &config)?;
688
689 {
691 let mut cache = self.cached_config.write().unwrap();
692 *cache = Some(config);
693 }
694
695 Ok(())
696 }
697
698 fn reset_to_defaults(&self) -> Result<()> {
699 let default_config = Config::default();
700 let path = self.get_config_file_path()?;
701
702 let toml_content = toml::to_string_pretty(&default_config)
703 .map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
704
705 secure_write_config_file(&path, &toml_content)
706 .map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
707
708 self.reload()
709 }
710}
711
712impl Default for ProductionConfigService {
713 fn default() -> Self {
714 Self::new().expect("Failed to create default ProductionConfigService")
715 }
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use crate::config::TestConfigService;
722 use crate::config::TestEnvironmentProvider;
723 use std::sync::Arc;
724
725 #[test]
726 fn test_production_config_service_creation() {
727 let service = ProductionConfigService::new();
728 assert!(service.is_ok());
729 }
730
731 #[test]
732 fn test_production_config_service_with_custom_file() {
733 let service = ProductionConfigService::new()
734 .unwrap()
735 .with_custom_file(PathBuf::from("test.toml"));
736 assert!(service.is_ok());
737 }
738
739 #[test]
740 fn test_production_service_implements_config_service_trait() {
741 let service = ProductionConfigService::new().unwrap();
742
743 let config1 = service.get_config();
745 assert!(config1.is_ok());
746
747 let reload_result = service.reload();
748 assert!(reload_result.is_ok());
749
750 let config2 = service.get_config();
751 assert!(config2.is_ok());
752 }
753
754 #[test]
755 fn test_production_config_service_openrouter_api_key_loading() {
756 use crate::config::TestEnvironmentProvider;
757 use std::sync::Arc;
758
759 let mut env_provider = TestEnvironmentProvider::new();
760 env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
761 env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
762
763 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
764 .expect("Failed to create config service");
765
766 let config = service.get_config().expect("Failed to get config");
767
768 assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
769 }
770
771 #[test]
772 fn test_config_service_with_openai_api_key() {
773 let test_service = TestConfigService::with_ai_settings_and_key(
775 "openai",
776 "gpt-4.1-mini",
777 "sk-test-openai-key-123",
778 );
779
780 let config = test_service.get_config().unwrap();
781 assert_eq!(
782 config.ai.api_key,
783 Some("sk-test-openai-key-123".to_string())
784 );
785 assert_eq!(config.ai.provider, "openai");
786 assert_eq!(config.ai.model, "gpt-4.1-mini");
787 }
788
789 #[test]
790 fn test_config_service_with_custom_base_url() {
791 let mut config = Config::default();
793 config.ai.base_url = "https://custom.openai.endpoint".to_string();
794
795 let test_service = TestConfigService::new(config);
796 let loaded_config = test_service.get_config().unwrap();
797
798 assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
799 }
800
801 #[test]
802 fn test_config_service_with_both_openai_settings() {
803 let mut config = Config::default();
805 config.ai.api_key = Some("sk-test-api-key-combined".to_string());
806 config.ai.base_url = "https://api.custom-openai.com".to_string();
807
808 let test_service = TestConfigService::new(config);
809 let loaded_config = test_service.get_config().unwrap();
810
811 assert_eq!(
812 loaded_config.ai.api_key,
813 Some("sk-test-api-key-combined".to_string())
814 );
815 assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
816 }
817
818 #[test]
819 fn test_config_service_provider_precedence() {
820 let test_service =
822 TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
823
824 let config = test_service.get_config().unwrap();
825 assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
826 assert_eq!(config.ai.provider, "openai");
827 assert_eq!(config.ai.model, "gpt-4.1");
828 }
829
830 #[test]
831 fn test_config_service_fallback_behavior() {
832 let test_service = TestConfigService::with_defaults();
834 let config = test_service.get_config().unwrap();
835
836 assert_eq!(config.ai.provider, "openai");
838 assert_eq!(config.ai.model, "gpt-4.1-mini");
839 assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
840 assert_eq!(config.ai.api_key, None); }
842
843 #[test]
844 fn test_config_service_reload_functionality() {
845 let test_service = TestConfigService::with_defaults();
847
848 let config1 = test_service.get_config().unwrap();
850 assert_eq!(config1.ai.provider, "openai");
851
852 let reload_result = test_service.reload();
854 assert!(reload_result.is_ok());
855
856 let config2 = test_service.get_config().unwrap();
858 assert_eq!(config2.ai.provider, "openai");
859 }
860
861 #[test]
862 fn test_config_service_custom_base_url_override() {
863 let mut config = Config::default();
865 config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
866
867 let test_service = TestConfigService::new(config);
868 let loaded_config = test_service.get_config().unwrap();
869
870 assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
871 }
872
873 #[test]
874 fn test_config_service_sync_settings() {
875 let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
877 let config = test_service.get_config().unwrap();
878
879 assert_eq!(config.sync.correlation_threshold, 0.8);
880 assert_eq!(config.sync.max_offset_seconds, 45.0);
881 }
882
883 #[test]
884 fn test_config_service_parallel_settings() {
885 let test_service = TestConfigService::with_parallel_settings(8, 200);
887 let config = test_service.get_config().unwrap();
888
889 assert_eq!(config.general.max_concurrent_jobs, 8);
890 assert_eq!(config.parallel.task_queue_size, 200);
891 }
892
893 #[test]
894 fn test_config_size_limits_defaults() {
895 let service = TestConfigService::with_defaults();
896 let cfg = service.get_config().unwrap();
897 assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
898 assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
899 }
900
901 #[test]
902 fn test_config_size_limits_roundtrip() {
903 let service = TestConfigService::with_defaults();
904
905 service
906 .set_config_value("general.max_subtitle_bytes", "65536")
907 .unwrap();
908 service
909 .set_config_value("general.max_audio_bytes", "1048576")
910 .unwrap();
911
912 assert_eq!(
913 service
914 .get_config_value("general.max_subtitle_bytes")
915 .unwrap(),
916 "65536"
917 );
918 assert_eq!(
919 service.get_config_value("general.max_audio_bytes").unwrap(),
920 "1048576"
921 );
922 }
923
924 #[test]
925 fn test_config_size_limits_validation_reject() {
926 let service = TestConfigService::with_defaults();
927 assert!(
929 service
930 .set_config_value("general.max_subtitle_bytes", "100")
931 .is_err()
932 );
933 assert!(
935 service
936 .set_config_value("general.max_subtitle_bytes", "2147483648")
937 .is_err()
938 );
939 }
940
941 #[test]
942 fn test_config_service_direct_access() {
943 let test_service = TestConfigService::with_defaults();
945
946 assert_eq!(test_service.config().ai.provider, "openai");
948
949 test_service.config_mut().ai.provider = "modified".to_string();
951 assert_eq!(test_service.config().ai.provider, "modified");
952
953 let config = test_service.get_config().unwrap();
955 assert_eq!(config.ai.provider, "modified");
956 }
957
958 #[test]
959 fn test_production_config_service_openai_api_key_loading() {
960 let mut env_provider = TestEnvironmentProvider::new();
962 env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
963
964 env_provider.set_var(
966 "SUBX_CONFIG_PATH",
967 "/tmp/test_config_that_does_not_exist.toml",
968 );
969
970 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
971 .expect("Failed to create config service");
972
973 let config = service.get_config().expect("Failed to get config");
974
975 assert_eq!(
976 config.ai.api_key,
977 Some("sk-test-openai-key-env".to_string())
978 );
979 }
980
981 #[test]
982 fn test_production_config_service_openai_base_url_loading() {
983 let mut env_provider = TestEnvironmentProvider::new();
985 env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
986
987 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
988 .expect("Failed to create config service");
989
990 let config = service.get_config().expect("Failed to get config");
991
992 assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
993 }
994
995 #[test]
996 fn test_production_config_service_both_openai_env_vars() {
997 let mut env_provider = TestEnvironmentProvider::new();
999 env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
1000 env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
1001
1002 env_provider.set_var(
1004 "SUBX_CONFIG_PATH",
1005 "/tmp/test_config_both_that_does_not_exist.toml",
1006 );
1007
1008 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1009 .expect("Failed to create config service");
1010
1011 let config = service.get_config().expect("Failed to get config");
1012
1013 assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
1014 assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
1015 }
1016
1017 #[test]
1018 fn test_production_config_service_no_openai_env_vars() {
1019 let mut env_provider = TestEnvironmentProvider::new(); env_provider.set_var(
1024 "SUBX_CONFIG_PATH",
1025 "/tmp/test_config_no_openai_that_does_not_exist.toml",
1026 );
1027
1028 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1029 .expect("Failed to create config service");
1030
1031 let config = service.get_config().expect("Failed to get config");
1032
1033 assert_eq!(config.ai.api_key, None);
1035 assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); }
1037
1038 #[test]
1039 fn test_production_config_service_api_key_priority() {
1040 let mut env_provider = TestEnvironmentProvider::new();
1042 env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
1043 env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
1045
1046 let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
1047 .expect("Failed to create config service");
1048
1049 let config = service.get_config().expect("Failed to get config");
1050
1051 assert!(config.ai.api_key.is_some());
1054 }
1055
1056 #[cfg(unix)]
1057 #[test]
1058 fn test_secure_write_config_file_sets_0600_permissions() {
1059 use std::os::unix::fs::PermissionsExt;
1060
1061 let dir = tempfile::tempdir().expect("create tempdir");
1062 let nested = dir.path().join("subdir");
1063 let path = nested.join("config.toml");
1064
1065 super::secure_write_config_file(&path, "api_key = \"secret\"\n")
1066 .expect("secure write should succeed");
1067
1068 let meta = std::fs::metadata(&path).expect("file must exist");
1069 let mode = meta.permissions().mode() & 0o777;
1070 assert_eq!(
1071 mode, 0o600,
1072 "file permissions must be 0o600, got {:o}",
1073 mode
1074 );
1075
1076 let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
1077 let dir_mode = dir_meta.permissions().mode() & 0o777;
1078 assert_eq!(
1079 dir_mode, 0o700,
1080 "directory permissions must be 0o700, got {:o}",
1081 dir_mode
1082 );
1083
1084 let contents = std::fs::read_to_string(&path).unwrap();
1085 assert_eq!(contents, "api_key = \"secret\"\n");
1086 }
1087
1088 #[cfg(unix)]
1089 #[test]
1090 fn test_secure_write_config_file_truncates_existing_file() {
1091 use std::os::unix::fs::PermissionsExt;
1092
1093 let dir = tempfile::tempdir().expect("create tempdir");
1094 let path = dir.path().join("config.toml");
1095
1096 std::fs::write(&path, "stale contents that should be replaced").unwrap();
1098 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1099
1100 super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
1101
1102 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1103 assert_eq!(mode, 0o600);
1104 assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
1105 }
1106}