#![allow(deprecated)]
use crate::config::{EnvironmentProvider, SystemEnvironmentProvider};
use crate::{Result, config::Config, error::SubXError};
use config::{Config as ConfigCrate, ConfigBuilder, Environment, File, builder::DefaultState};
use log::debug;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[cfg(unix)]
fn secure_write_config_file(path: &Path, content: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
}
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(content.as_bytes())?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(not(unix))]
fn secure_write_config_file(path: &Path, content: &str) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, content)
}
pub trait ConfigService: Send + Sync {
fn get_config(&self) -> Result<Config>;
fn reload(&self) -> Result<()>;
fn save_config(&self) -> Result<()>;
fn save_config_to_file(&self, path: &Path) -> Result<()>;
fn get_config_file_path(&self) -> Result<PathBuf>;
fn get_config_value(&self, key: &str) -> Result<String>;
fn reset_to_defaults(&self) -> Result<()>;
fn set_config_value(&self, key: &str, value: &str) -> Result<()>;
}
pub struct ProductionConfigService {
config_builder: ConfigBuilder<DefaultState>,
cached_config: Arc<RwLock<Option<Config>>>,
env_provider: Arc<dyn EnvironmentProvider>,
}
impl ProductionConfigService {
pub fn new() -> Result<Self> {
Self::with_env_provider(Arc::new(SystemEnvironmentProvider::new()))
}
pub fn with_env_provider(env_provider: Arc<dyn EnvironmentProvider>) -> Result<Self> {
let config_file_path = if let Some(custom_path) = env_provider.get_var("SUBX_CONFIG_PATH") {
PathBuf::from(custom_path)
} else {
Self::user_config_path()
};
let config_builder = ConfigCrate::builder()
.add_source(File::with_name("config/default").required(false))
.add_source(File::from(config_file_path).required(false))
.add_source(Environment::with_prefix("SUBX").separator("_"));
Ok(Self {
config_builder,
cached_config: Arc::new(RwLock::new(None)),
env_provider,
})
}
pub fn with_custom_file(mut self, file_path: PathBuf) -> Result<Self> {
self.config_builder = self.config_builder.add_source(File::from(file_path));
Ok(self)
}
fn user_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("subx")
.join("config.toml")
}
fn load_and_validate(&self) -> Result<Config> {
debug!("ProductionConfigService: Loading configuration from sources");
let config_crate = self.config_builder.build_cloned().map_err(|e| {
debug!("ProductionConfigService: Config build failed: {e}");
SubXError::config(format!("Failed to build configuration: {e}"))
})?;
let mut app_config = Config::default();
if let Ok(config) = config_crate.clone().try_deserialize::<Config>() {
app_config = config;
debug!("ProductionConfigService: Full configuration loaded successfully");
} else {
debug!("ProductionConfigService: Full deserialization failed, attempting partial load");
if let Ok(raw_map) = config_crate
.try_deserialize::<std::collections::HashMap<String, serde_json::Value>>()
{
if let Some(ai_section) = raw_map.get("ai") {
if let Some(ai_obj) = ai_section.as_object() {
if let Some(api_key) = ai_obj.get("apikey").and_then(|v| v.as_str()) {
app_config.ai.api_key = Some(api_key.to_string());
debug!(
"ProductionConfigService: AI API key loaded from SUBX_AI_APIKEY"
);
}
if let Some(provider) = ai_obj.get("provider").and_then(|v| v.as_str()) {
app_config.ai.provider = provider.to_string();
debug!(
"ProductionConfigService: AI provider loaded from SUBX_AI_PROVIDER"
);
}
if let Some(model) = ai_obj.get("model").and_then(|v| v.as_str()) {
app_config.ai.model = model.to_string();
debug!("ProductionConfigService: AI model loaded from SUBX_AI_MODEL");
}
if let Some(base_url) = ai_obj.get("base_url").and_then(|v| v.as_str()) {
app_config.ai.base_url = base_url.to_string();
debug!(
"ProductionConfigService: AI base URL loaded from SUBX_AI_BASE_URL"
);
}
}
}
}
}
if let Some(api_key) = self.env_provider.get_var("OPENROUTER_API_KEY") {
debug!("ProductionConfigService: Found OPENROUTER_API_KEY environment variable");
app_config.ai.provider = "openrouter".to_string();
app_config.ai.api_key = Some(api_key);
}
if app_config.ai.api_key.is_none() {
if let Some(api_key) = self.env_provider.get_var("OPENAI_API_KEY") {
debug!("ProductionConfigService: Found OPENAI_API_KEY environment variable");
app_config.ai.api_key = Some(api_key);
}
}
if let Some(base_url) = self.env_provider.get_var("OPENAI_BASE_URL") {
debug!("ProductionConfigService: Found OPENAI_BASE_URL environment variable");
app_config.ai.base_url = base_url;
}
if let Some(api_key) = self.env_provider.get_var("AZURE_OPENAI_API_KEY") {
debug!("ProductionConfigService: Found AZURE_OPENAI_API_KEY environment variable");
app_config.ai.provider = "azure-openai".to_string();
app_config.ai.api_key = Some(api_key);
}
if let Some(endpoint) = self.env_provider.get_var("AZURE_OPENAI_ENDPOINT") {
debug!("ProductionConfigService: Found AZURE_OPENAI_ENDPOINT environment variable");
app_config.ai.base_url = endpoint;
}
if let Some(version) = self.env_provider.get_var("AZURE_OPENAI_API_VERSION") {
debug!("ProductionConfigService: Found AZURE_OPENAI_API_VERSION environment variable");
app_config.ai.api_version = Some(version);
}
if let Some(deployment) = self.env_provider.get_var("AZURE_OPENAI_DEPLOYMENT_ID") {
debug!(
"ProductionConfigService: Found AZURE_OPENAI_DEPLOYMENT_ID environment variable"
);
app_config.ai.model = deployment;
}
crate::config::validator::validate_config(&app_config).map_err(|e| {
debug!("ProductionConfigService: Config validation failed: {e}");
SubXError::config(format!("Configuration validation failed: {e}"))
})?;
debug!("ProductionConfigService: Configuration loaded and validated successfully");
Ok(app_config)
}
fn validate_and_set_value(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
use crate::config::field_validator;
field_validator::validate_field(key, value)?;
self.set_value_internal(config, key, value)?;
self.validate_configuration(config)?;
Ok(())
}
fn set_value_internal(&self, config: &mut Config, key: &str, value: &str) -> Result<()> {
use crate::config::OverflowStrategy;
use crate::config::validation::*;
use crate::error::SubXError;
let parts: Vec<&str> = key.split('.').collect();
match parts.as_slice() {
["ai", "provider"] => {
config.ai.provider = value.to_string();
}
["ai", "api_key"] => {
if !value.is_empty() {
config.ai.api_key = Some(value.to_string());
} else {
config.ai.api_key = None;
}
}
["ai", "model"] => {
config.ai.model = value.to_string();
}
["ai", "base_url"] => {
config.ai.base_url = value.to_string();
}
["ai", "max_sample_length"] => {
let v = value.parse().unwrap(); config.ai.max_sample_length = v;
}
["ai", "temperature"] => {
let v = value.parse().unwrap(); config.ai.temperature = v;
}
["ai", "max_tokens"] => {
let v = value.parse().unwrap(); config.ai.max_tokens = v;
}
["ai", "retry_attempts"] => {
let v = value.parse().unwrap(); config.ai.retry_attempts = v;
}
["ai", "retry_delay_ms"] => {
let v = value.parse().unwrap(); config.ai.retry_delay_ms = v;
}
["ai", "request_timeout_seconds"] => {
let v = value.parse().unwrap(); config.ai.request_timeout_seconds = v;
}
["ai", "api_version"] => {
if !value.is_empty() {
config.ai.api_version = Some(value.to_string());
} else {
config.ai.api_version = None;
}
}
["formats", "default_output"] => {
config.formats.default_output = value.to_string();
}
["formats", "preserve_styling"] => {
let v = parse_bool(value)?;
config.formats.preserve_styling = v;
}
["formats", "default_encoding"] => {
config.formats.default_encoding = value.to_string();
}
["formats", "encoding_detection_confidence"] => {
let v = value.parse().unwrap(); config.formats.encoding_detection_confidence = v;
}
["sync", "max_offset_seconds"] => {
let v = value.parse().unwrap(); config.sync.max_offset_seconds = v;
}
["sync", "default_method"] => {
config.sync.default_method = value.to_string();
}
["sync", "vad", "enabled"] => {
let v = parse_bool(value)?;
config.sync.vad.enabled = v;
}
["sync", "vad", "sensitivity"] => {
let v = value.parse().unwrap(); config.sync.vad.sensitivity = v;
}
["sync", "vad", "padding_chunks"] => {
let v = value.parse().unwrap(); config.sync.vad.padding_chunks = v;
}
["sync", "vad", "min_speech_duration_ms"] => {
let v = value.parse().unwrap(); config.sync.vad.min_speech_duration_ms = v;
}
["general", "backup_enabled"] => {
let v = parse_bool(value)?;
config.general.backup_enabled = v;
}
["general", "max_concurrent_jobs"] => {
let v = value.parse().unwrap(); config.general.max_concurrent_jobs = v;
}
["general", "task_timeout_seconds"] => {
let v = value.parse().unwrap(); config.general.task_timeout_seconds = v;
}
["general", "enable_progress_bar"] => {
let v = parse_bool(value)?;
config.general.enable_progress_bar = v;
}
["general", "worker_idle_timeout_seconds"] => {
let v = value.parse().unwrap(); config.general.worker_idle_timeout_seconds = v;
}
["general", "max_subtitle_bytes"] => {
let v = value.parse().unwrap(); config.general.max_subtitle_bytes = v;
}
["general", "max_audio_bytes"] => {
let v = value.parse().unwrap(); config.general.max_audio_bytes = v;
}
["parallel", "max_workers"] => {
let v = value.parse().unwrap(); config.parallel.max_workers = v;
}
["parallel", "task_queue_size"] => {
let v = value.parse().unwrap(); config.parallel.task_queue_size = v;
}
["parallel", "enable_task_priorities"] => {
let v = parse_bool(value)?;
config.parallel.enable_task_priorities = v;
}
["parallel", "auto_balance_workers"] => {
let v = parse_bool(value)?;
config.parallel.auto_balance_workers = v;
}
["parallel", "overflow_strategy"] => {
config.parallel.overflow_strategy = match value {
"Block" => OverflowStrategy::Block,
"Drop" => OverflowStrategy::Drop,
"Expand" => OverflowStrategy::Expand,
_ => unreachable!(), };
}
_ => {
return Err(SubXError::config(format!(
"Unknown configuration key: {key}"
)));
}
}
Ok(())
}
fn validate_configuration(&self, config: &Config) -> Result<()> {
use crate::config::validator;
validator::validate_config(config)
}
fn save_config_to_file_with_config(
&self,
path: &std::path::Path,
config: &Config,
) -> Result<()> {
let toml_content = toml::to_string_pretty(config)
.map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
secure_write_config_file(path, &toml_content)
.map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
Ok(())
}
}
impl ConfigService for ProductionConfigService {
fn get_config(&self) -> Result<Config> {
{
let cache = self.cached_config.read().unwrap();
if let Some(config) = cache.as_ref() {
debug!("ProductionConfigService: Returning cached configuration");
return Ok(config.clone());
}
}
let app_config = self.load_and_validate()?;
{
let mut cache = self.cached_config.write().unwrap();
*cache = Some(app_config.clone());
}
Ok(app_config)
}
fn reload(&self) -> Result<()> {
debug!("ProductionConfigService: Reloading configuration");
{
let mut cache = self.cached_config.write().unwrap();
*cache = None;
}
self.get_config()?;
debug!("ProductionConfigService: Configuration reloaded successfully");
Ok(())
}
fn save_config(&self) -> Result<()> {
let _config = self.get_config()?;
let path = self.get_config_file_path()?;
self.save_config_to_file(&path)
}
fn save_config_to_file(&self, path: &Path) -> Result<()> {
let config = self.get_config()?;
let toml_content = toml::to_string_pretty(&config)
.map_err(|e| SubXError::config(format!("TOML serialization error: {e}")))?;
secure_write_config_file(path, &toml_content)
.map_err(|e| SubXError::config(format!("Failed to write config file: {e}")))?;
Ok(())
}
fn get_config_file_path(&self) -> Result<PathBuf> {
if let Some(custom) = self.env_provider.get_var("SUBX_CONFIG_PATH") {
return Ok(PathBuf::from(custom));
}
let config_dir = dirs::config_dir()
.ok_or_else(|| SubXError::config("Unable to determine config directory"))?;
Ok(config_dir.join("subx").join("config.toml"))
}
fn get_config_value(&self, key: &str) -> Result<String> {
let config = self.get_config()?;
let parts: Vec<&str> = key.split('.').collect();
match parts.as_slice() {
["ai", "provider"] => Ok(config.ai.provider.clone()),
["ai", "model"] => Ok(config.ai.model.clone()),
["ai", "api_key"] => Ok(config.ai.api_key.clone().unwrap_or_default()),
["ai", "base_url"] => Ok(config.ai.base_url.clone()),
["ai", "max_sample_length"] => Ok(config.ai.max_sample_length.to_string()),
["ai", "temperature"] => Ok(config.ai.temperature.to_string()),
["ai", "max_tokens"] => Ok(config.ai.max_tokens.to_string()),
["ai", "retry_attempts"] => Ok(config.ai.retry_attempts.to_string()),
["ai", "retry_delay_ms"] => Ok(config.ai.retry_delay_ms.to_string()),
["ai", "request_timeout_seconds"] => Ok(config.ai.request_timeout_seconds.to_string()),
["formats", "default_output"] => Ok(config.formats.default_output.clone()),
["formats", "default_encoding"] => Ok(config.formats.default_encoding.clone()),
["formats", "preserve_styling"] => Ok(config.formats.preserve_styling.to_string()),
["formats", "encoding_detection_confidence"] => {
Ok(config.formats.encoding_detection_confidence.to_string())
}
["sync", "default_method"] => Ok(config.sync.default_method.clone()),
["sync", "max_offset_seconds"] => Ok(config.sync.max_offset_seconds.to_string()),
["sync", "vad", "enabled"] => Ok(config.sync.vad.enabled.to_string()),
["sync", "vad", "sensitivity"] => Ok(config.sync.vad.sensitivity.to_string()),
["sync", "vad", "padding_chunks"] => Ok(config.sync.vad.padding_chunks.to_string()),
["sync", "vad", "min_speech_duration_ms"] => {
Ok(config.sync.vad.min_speech_duration_ms.to_string())
}
["general", "backup_enabled"] => Ok(config.general.backup_enabled.to_string()),
["general", "max_concurrent_jobs"] => {
Ok(config.general.max_concurrent_jobs.to_string())
}
["general", "task_timeout_seconds"] => {
Ok(config.general.task_timeout_seconds.to_string())
}
["general", "enable_progress_bar"] => {
Ok(config.general.enable_progress_bar.to_string())
}
["general", "worker_idle_timeout_seconds"] => {
Ok(config.general.worker_idle_timeout_seconds.to_string())
}
["general", "max_subtitle_bytes"] => Ok(config.general.max_subtitle_bytes.to_string()),
["general", "max_audio_bytes"] => Ok(config.general.max_audio_bytes.to_string()),
["parallel", "max_workers"] => Ok(config.parallel.max_workers.to_string()),
["parallel", "task_queue_size"] => Ok(config.parallel.task_queue_size.to_string()),
["parallel", "enable_task_priorities"] => {
Ok(config.parallel.enable_task_priorities.to_string())
}
["parallel", "auto_balance_workers"] => {
Ok(config.parallel.auto_balance_workers.to_string())
}
["parallel", "overflow_strategy"] => {
Ok(format!("{:?}", config.parallel.overflow_strategy))
}
_ => Err(SubXError::config(format!(
"Unknown configuration key: {}",
key
))),
}
}
fn set_config_value(&self, key: &str, value: &str) -> Result<()> {
let mut config = self.get_config()?;
self.validate_and_set_value(&mut config, key, value)?;
crate::config::validator::validate_config(&config)?;
let path = self.get_config_file_path()?;
self.save_config_to_file_with_config(&path, &config)?;
{
let mut cache = self.cached_config.write().unwrap();
*cache = Some(config);
}
Ok(())
}
fn reset_to_defaults(&self) -> Result<()> {
let default_config = Config::default();
let path = self.get_config_file_path()?;
let toml_content = toml::to_string_pretty(&default_config)
.map_err(|e| SubXError::config(format!("TOML serialization error: {}", e)))?;
secure_write_config_file(&path, &toml_content)
.map_err(|e| SubXError::config(format!("Failed to write config file: {}", e)))?;
self.reload()
}
}
impl Default for ProductionConfigService {
fn default() -> Self {
Self::new().expect("Failed to create default ProductionConfigService")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TestConfigService;
use crate::config::TestEnvironmentProvider;
use std::sync::Arc;
#[test]
fn test_production_config_service_creation() {
let service = ProductionConfigService::new();
assert!(service.is_ok());
}
#[test]
fn test_production_config_service_with_custom_file() {
let service = ProductionConfigService::new()
.unwrap()
.with_custom_file(PathBuf::from("test.toml"));
assert!(service.is_ok());
}
#[test]
fn test_production_service_implements_config_service_trait() {
let service = ProductionConfigService::new().unwrap();
let config1 = service.get_config();
assert!(config1.is_ok());
let reload_result = service.reload();
assert!(reload_result.is_ok());
let config2 = service.get_config();
assert!(config2.is_ok());
}
#[test]
fn test_production_config_service_openrouter_api_key_loading() {
use crate::config::TestEnvironmentProvider;
use std::sync::Arc;
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var("OPENROUTER_API_KEY", "test-openrouter-key");
env_provider.set_var("SUBX_CONFIG_PATH", "/tmp/test_config_openrouter.toml");
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert_eq!(config.ai.api_key, Some("test-openrouter-key".to_string()));
}
#[test]
fn test_config_service_with_openai_api_key() {
let test_service = TestConfigService::with_ai_settings_and_key(
"openai",
"gpt-4.1-mini",
"sk-test-openai-key-123",
);
let config = test_service.get_config().unwrap();
assert_eq!(
config.ai.api_key,
Some("sk-test-openai-key-123".to_string())
);
assert_eq!(config.ai.provider, "openai");
assert_eq!(config.ai.model, "gpt-4.1-mini");
}
#[test]
fn test_config_service_with_custom_base_url() {
let mut config = Config::default();
config.ai.base_url = "https://custom.openai.endpoint".to_string();
let test_service = TestConfigService::new(config);
let loaded_config = test_service.get_config().unwrap();
assert_eq!(loaded_config.ai.base_url, "https://custom.openai.endpoint");
}
#[test]
fn test_config_service_with_both_openai_settings() {
let mut config = Config::default();
config.ai.api_key = Some("sk-test-api-key-combined".to_string());
config.ai.base_url = "https://api.custom-openai.com".to_string();
let test_service = TestConfigService::new(config);
let loaded_config = test_service.get_config().unwrap();
assert_eq!(
loaded_config.ai.api_key,
Some("sk-test-api-key-combined".to_string())
);
assert_eq!(loaded_config.ai.base_url, "https://api.custom-openai.com");
}
#[test]
fn test_config_service_provider_precedence() {
let test_service =
TestConfigService::with_ai_settings_and_key("openai", "gpt-4.1", "sk-explicit-key");
let config = test_service.get_config().unwrap();
assert_eq!(config.ai.api_key, Some("sk-explicit-key".to_string()));
assert_eq!(config.ai.provider, "openai");
assert_eq!(config.ai.model, "gpt-4.1");
}
#[test]
fn test_config_service_fallback_behavior() {
let test_service = TestConfigService::with_defaults();
let config = test_service.get_config().unwrap();
assert_eq!(config.ai.provider, "openai");
assert_eq!(config.ai.model, "gpt-4.1-mini");
assert_eq!(config.ai.base_url, "https://api.openai.com/v1");
assert_eq!(config.ai.api_key, None); }
#[test]
fn test_config_service_reload_functionality() {
let test_service = TestConfigService::with_defaults();
let config1 = test_service.get_config().unwrap();
assert_eq!(config1.ai.provider, "openai");
let reload_result = test_service.reload();
assert!(reload_result.is_ok());
let config2 = test_service.get_config().unwrap();
assert_eq!(config2.ai.provider, "openai");
}
#[test]
fn test_config_service_custom_base_url_override() {
let mut config = Config::default();
config.ai.base_url = "https://my-proxy.openai.com/v1".to_string();
let test_service = TestConfigService::new(config);
let loaded_config = test_service.get_config().unwrap();
assert_eq!(loaded_config.ai.base_url, "https://my-proxy.openai.com/v1");
}
#[test]
fn test_config_service_sync_settings() {
let test_service = TestConfigService::with_sync_settings(0.8, 45.0);
let config = test_service.get_config().unwrap();
assert_eq!(config.sync.correlation_threshold, 0.8);
assert_eq!(config.sync.max_offset_seconds, 45.0);
}
#[test]
fn test_config_service_parallel_settings() {
let test_service = TestConfigService::with_parallel_settings(8, 200);
let config = test_service.get_config().unwrap();
assert_eq!(config.general.max_concurrent_jobs, 8);
assert_eq!(config.parallel.task_queue_size, 200);
}
#[test]
fn test_config_size_limits_defaults() {
let service = TestConfigService::with_defaults();
let cfg = service.get_config().unwrap();
assert_eq!(cfg.general.max_subtitle_bytes, 52_428_800);
assert_eq!(cfg.general.max_audio_bytes, 2_147_483_648);
}
#[test]
fn test_config_size_limits_roundtrip() {
let service = TestConfigService::with_defaults();
service
.set_config_value("general.max_subtitle_bytes", "65536")
.unwrap();
service
.set_config_value("general.max_audio_bytes", "1048576")
.unwrap();
assert_eq!(
service
.get_config_value("general.max_subtitle_bytes")
.unwrap(),
"65536"
);
assert_eq!(
service.get_config_value("general.max_audio_bytes").unwrap(),
"1048576"
);
}
#[test]
fn test_config_size_limits_validation_reject() {
let service = TestConfigService::with_defaults();
assert!(
service
.set_config_value("general.max_subtitle_bytes", "100")
.is_err()
);
assert!(
service
.set_config_value("general.max_subtitle_bytes", "2147483648")
.is_err()
);
}
#[test]
fn test_config_service_direct_access() {
let test_service = TestConfigService::with_defaults();
assert_eq!(test_service.config().ai.provider, "openai");
test_service.config_mut().ai.provider = "modified".to_string();
assert_eq!(test_service.config().ai.provider, "modified");
let config = test_service.get_config().unwrap();
assert_eq!(config.ai.provider, "modified");
}
#[test]
fn test_production_config_service_openai_api_key_loading() {
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var("OPENAI_API_KEY", "sk-test-openai-key-env");
env_provider.set_var(
"SUBX_CONFIG_PATH",
"/tmp/test_config_that_does_not_exist.toml",
);
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert_eq!(
config.ai.api_key,
Some("sk-test-openai-key-env".to_string())
);
}
#[test]
fn test_production_config_service_openai_base_url_loading() {
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var("OPENAI_BASE_URL", "https://test.openai.com/v1");
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert_eq!(config.ai.base_url, "https://test.openai.com/v1");
}
#[test]
fn test_production_config_service_both_openai_env_vars() {
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var("OPENAI_API_KEY", "sk-test-key-both");
env_provider.set_var("OPENAI_BASE_URL", "https://both.openai.com/v1");
env_provider.set_var(
"SUBX_CONFIG_PATH",
"/tmp/test_config_both_that_does_not_exist.toml",
);
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert_eq!(config.ai.api_key, Some("sk-test-key-both".to_string()));
assert_eq!(config.ai.base_url, "https://both.openai.com/v1");
}
#[test]
fn test_production_config_service_no_openai_env_vars() {
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var(
"SUBX_CONFIG_PATH",
"/tmp/test_config_no_openai_that_does_not_exist.toml",
);
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert_eq!(config.ai.api_key, None);
assert_eq!(config.ai.base_url, "https://api.openai.com/v1"); }
#[test]
fn test_production_config_service_api_key_priority() {
let mut env_provider = TestEnvironmentProvider::new();
env_provider.set_var("OPENAI_API_KEY", "sk-env-key");
env_provider.set_var("SUBX_AI_APIKEY", "sk-config-key");
let service = ProductionConfigService::with_env_provider(Arc::new(env_provider))
.expect("Failed to create config service");
let config = service.get_config().expect("Failed to get config");
assert!(config.ai.api_key.is_some());
}
#[cfg(unix)]
#[test]
fn test_secure_write_config_file_sets_0600_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("create tempdir");
let nested = dir.path().join("subdir");
let path = nested.join("config.toml");
super::secure_write_config_file(&path, "api_key = \"secret\"\n")
.expect("secure write should succeed");
let meta = std::fs::metadata(&path).expect("file must exist");
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"file permissions must be 0o600, got {:o}",
mode
);
let dir_meta = std::fs::metadata(&nested).expect("parent must exist");
let dir_mode = dir_meta.permissions().mode() & 0o777;
assert_eq!(
dir_mode, 0o700,
"directory permissions must be 0o700, got {:o}",
dir_mode
);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, "api_key = \"secret\"\n");
}
#[cfg(unix)]
#[test]
fn test_secure_write_config_file_truncates_existing_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("create tempdir");
let path = dir.path().join("config.toml");
std::fs::write(&path, "stale contents that should be replaced").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
super::secure_write_config_file(&path, "new = \"value\"\n").expect("secure write");
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new = \"value\"\n");
}
}