use super::hierarchy::{AppConfig, ConfigHierarchy, ConfigValidationError};
use std::{
env, fs,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Json,
Toml,
Yaml,
}
impl ConfigFormat {
pub fn from_extension(path: &Path) -> Option<Self> {
match path.extension()?.to_str()? {
"json" => Some(ConfigFormat::Json),
"toml" => Some(ConfigFormat::Toml),
"yaml" | "yml" => Some(ConfigFormat::Yaml),
_ => None,
}
}
pub fn extension(&self) -> &'static str {
match self {
ConfigFormat::Json => "json",
ConfigFormat::Toml => "toml",
ConfigFormat::Yaml => "yaml",
}
}
pub fn mime_type(&self) -> &'static str {
match self {
ConfigFormat::Json => "application/json",
ConfigFormat::Toml => "application/toml",
ConfigFormat::Yaml => "application/yaml",
}
}
}
pub struct ConfigLoader {
search_paths: Vec<PathBuf>,
env_prefix: String,
config_name: String,
supported_formats: Vec<ConfigFormat>,
}
impl ConfigLoader {
pub fn new() -> Self {
Self {
search_paths: Self::default_search_paths(),
env_prefix: "VOIRS".to_string(),
config_name: "voirs".to_string(),
supported_formats: vec![ConfigFormat::Toml, ConfigFormat::Json, ConfigFormat::Yaml],
}
}
pub fn with_search_paths(mut self, paths: Vec<PathBuf>) -> Self {
self.search_paths = paths;
self
}
pub fn add_search_path(mut self, path: PathBuf) -> Self {
self.search_paths.push(path);
self
}
pub fn with_env_prefix(mut self, prefix: impl Into<String>) -> Self {
self.env_prefix = prefix.into();
self
}
pub fn with_config_name(mut self, name: impl Into<String>) -> Self {
self.config_name = name.into();
self
}
pub fn load(&self) -> Result<AppConfig, ConfigLoadError> {
let mut config = AppConfig::default();
for config_file in self.discover_config_files()? {
let file_config = self.load_from_file(&config_file)?;
config.merge_with(&file_config);
}
self.apply_env_overrides(&mut config)?;
config.validate().map_err(ConfigLoadError::Validation)?;
Ok(config)
}
pub fn load_from_file(&self, path: &Path) -> Result<AppConfig, ConfigLoadError> {
let content = fs::read_to_string(path).map_err(|e| ConfigLoadError::Io {
path: path.to_path_buf(),
error: e,
})?;
let format = ConfigFormat::from_extension(path).ok_or_else(|| {
ConfigLoadError::UnsupportedFormat {
path: path.to_path_buf(),
}
})?;
self.parse_config(&content, format)
.map_err(|e| ConfigLoadError::Parse {
path: path.to_path_buf(),
format,
error: e,
})
}
pub fn save_to_file(&self, config: &AppConfig, path: &Path) -> Result<(), ConfigSaveError> {
let format = ConfigFormat::from_extension(path).ok_or_else(|| {
ConfigSaveError::UnsupportedFormat {
path: path.to_path_buf(),
}
})?;
let content =
self.serialize_config(config, format)
.map_err(|e| ConfigSaveError::Serialize {
path: path.to_path_buf(),
format,
error: e,
})?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| ConfigSaveError::Io {
path: path.to_path_buf(),
error: e,
})?;
}
fs::write(path, content).map_err(|e| ConfigSaveError::Io {
path: path.to_path_buf(),
error: e,
})
}
pub fn discover_config_files(&self) -> Result<Vec<PathBuf>, ConfigLoadError> {
let mut found_files = Vec::new();
for search_path in &self.search_paths {
if !search_path.exists() {
continue;
}
for format in &self.supported_formats {
let config_file =
search_path.join(format!("{}.{}", self.config_name, format.extension()));
if config_file.exists() {
found_files.push(config_file);
}
}
}
Ok(found_files)
}
fn default_search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(current_dir) = env::current_dir() {
paths.push(current_dir);
}
paths
}
fn parse_config(&self, content: &str, format: ConfigFormat) -> Result<AppConfig, String> {
match format {
ConfigFormat::Json => {
serde_json::from_str(content).map_err(|e| format!("JSON parse error: {e}"))
}
ConfigFormat::Toml => {
toml::from_str(content).map_err(|e| format!("TOML parse error: {e}"))
}
ConfigFormat::Yaml => {
serde_yaml::from_str(content).map_err(|e| format!("YAML parse error: {e}"))
}
}
}
fn serialize_config(&self, config: &AppConfig, format: ConfigFormat) -> Result<String, String> {
match format {
ConfigFormat::Json => serde_json::to_string_pretty(config)
.map_err(|e| format!("JSON serialize error: {e}")),
ConfigFormat::Toml => {
toml::to_string_pretty(config).map_err(|e| format!("TOML serialize error: {e}"))
}
ConfigFormat::Yaml => {
serde_yaml::to_string(config).map_err(|e| format!("YAML serialize error: {e}"))
}
}
}
fn apply_env_overrides(&self, config: &mut AppConfig) -> Result<(), ConfigLoadError> {
if let Ok(device) = env::var(format!("{}_DEVICE", self.env_prefix)) {
config.pipeline.device = device;
}
if let Ok(gpu) = env::var(format!("{}_USE_GPU", self.env_prefix)) {
config.pipeline.use_gpu = gpu.parse().unwrap_or(false);
}
if let Ok(threads) = env::var(format!("{}_THREADS", self.env_prefix)) {
if let Ok(thread_count) = threads.parse::<usize>() {
config.pipeline.num_threads = Some(thread_count);
}
}
Ok(())
}
}
impl Default for ConfigLoader {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigLoadError {
#[error("I/O error reading config file {path}: {error}")]
Io {
path: PathBuf,
error: std::io::Error,
},
#[error("Error parsing config file {path} as {format:?}: {error}")]
Parse {
path: PathBuf,
format: ConfigFormat,
error: String,
},
#[error("Unsupported config file format for {path}")]
UnsupportedFormat { path: PathBuf },
#[error("Configuration validation error: {0}")]
Validation(#[from] ConfigValidationError),
#[error("Environment variable error for {var}: {error}")]
EnvVar { var: String, error: String },
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigSaveError {
#[error("I/O error writing config file {path}: {error}")]
Io {
path: PathBuf,
error: std::io::Error,
},
#[error("Error serializing config to {path} as {format:?}: {error}")]
Serialize {
path: PathBuf,
format: ConfigFormat,
error: String,
},
#[error("Unsupported config file format for {path}")]
UnsupportedFormat { path: PathBuf },
}
pub struct ConfigWatcher {
path: PathBuf,
loader: ConfigLoader,
last_modified: Option<std::time::SystemTime>,
_watcher: Option<notify::RecommendedWatcher>,
event_receiver: Option<std::sync::mpsc::Receiver<notify::Result<notify::Event>>>,
cached_config: Option<AppConfig>,
}
impl ConfigWatcher {
pub fn new(path: PathBuf, loader: ConfigLoader) -> Result<Self, ConfigWatchError> {
let last_modified = Self::get_file_modification_time(&path)?;
Ok(Self {
path,
loader,
last_modified: Some(last_modified),
_watcher: None,
event_receiver: None,
cached_config: None,
})
}
pub fn with_auto_watch(path: PathBuf, loader: ConfigLoader) -> Result<Self, ConfigWatchError> {
use notify::{RecursiveMode, Watcher};
let last_modified = Self::get_file_modification_time(&path)?;
let (tx, rx) = std::sync::mpsc::channel();
let mut watcher =
notify::recommended_watcher(tx).map_err(|e| ConfigWatchError::WatcherCreation {
path: path.clone(),
error: e.to_string(),
})?;
if let Some(parent_dir) = path.parent() {
watcher
.watch(parent_dir, RecursiveMode::NonRecursive)
.map_err(|e| ConfigWatchError::WatchStart {
path: path.clone(),
error: e.to_string(),
})?;
}
Ok(Self {
path,
loader,
last_modified: Some(last_modified),
_watcher: Some(watcher),
event_receiver: Some(rx),
cached_config: None,
})
}
pub fn reload_if_changed(&mut self) -> Result<Option<AppConfig>, ConfigLoadError> {
let current_modified =
Self::get_file_modification_time(&self.path).map_err(|e| ConfigLoadError::Io {
path: self.path.clone(),
error: std::io::Error::other(e.to_string()),
})?;
if self.last_modified.is_none() || Some(current_modified) > self.last_modified {
tracing::info!(
"Configuration file modified, reloading: {}",
self.path.display()
);
let new_config = self.loader.load_from_file(&self.path)?;
self.last_modified = Some(current_modified);
self.cached_config = Some(new_config.clone());
tracing::info!(
"Configuration successfully reloaded from: {}",
self.path.display()
);
Ok(Some(new_config))
} else {
Ok(None)
}
}
pub fn force_reload(&mut self) -> Result<AppConfig, ConfigLoadError> {
tracing::info!(
"Force reloading configuration from: {}",
self.path.display()
);
let new_config = self.loader.load_from_file(&self.path)?;
if let Ok(modified) = Self::get_file_modification_time(&self.path) {
self.last_modified = Some(modified);
}
self.cached_config = Some(new_config.clone());
tracing::info!(
"Configuration successfully force reloaded from: {}",
self.path.display()
);
Ok(new_config)
}
pub fn get_cached_config(&self) -> Option<&AppConfig> {
self.cached_config.as_ref()
}
pub fn check_events(&mut self) -> Result<Vec<ConfigChangeEvent>, ConfigWatchError> {
let Some(ref receiver) = self.event_receiver else {
return Ok(Vec::new());
};
let mut events = Vec::new();
while let Ok(event_result) = receiver.try_recv() {
match event_result {
Ok(event) => {
if event.paths.iter().any(|p| p == &self.path) {
let change_type = match event.kind {
notify::EventKind::Modify(_) => ConfigChangeType::Modified,
notify::EventKind::Create(_) => ConfigChangeType::Created,
notify::EventKind::Remove(_) => ConfigChangeType::Deleted,
_ => ConfigChangeType::Other,
};
events.push(ConfigChangeEvent {
path: self.path.clone(),
change_type,
timestamp: std::time::SystemTime::now(),
});
}
}
Err(e) => {
tracing::warn!("File system watch error: {}", e);
}
}
}
Ok(events)
}
pub fn watched_path(&self) -> &PathBuf {
&self.path
}
pub fn last_modification_time(&self) -> Option<std::time::SystemTime> {
self.last_modified
}
fn get_file_modification_time(
path: &PathBuf,
) -> Result<std::time::SystemTime, ConfigWatchError> {
let metadata = std::fs::metadata(path).map_err(|e| ConfigWatchError::FileAccess {
path: path.clone(),
error: e.to_string(),
})?;
metadata
.modified()
.map_err(|e| ConfigWatchError::FileAccess {
path: path.clone(),
error: e.to_string(),
})
}
}
impl std::fmt::Debug for ConfigWatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ConfigWatcher")
.field("path", &self.path)
.field("last_modified", &self.last_modified)
.field("has_watcher", &self._watcher.is_some())
.field("has_event_receiver", &self.event_receiver.is_some())
.field("has_cached_config", &self.cached_config.is_some())
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ConfigChangeEvent {
pub path: PathBuf,
pub change_type: ConfigChangeType,
pub timestamp: std::time::SystemTime,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigChangeType {
Modified,
Created,
Deleted,
Other,
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigWatchError {
#[error("Error accessing config file {path}: {error}")]
FileAccess { path: PathBuf, error: String },
#[error("Error creating file system watcher for {path}: {error}")]
WatcherCreation { path: PathBuf, error: String },
#[error("Error starting file system watch for {path}: {error}")]
WatchStart { path: PathBuf, error: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateType {
Development,
Production,
Testing,
}
pub struct ConfigPersistence;
impl ConfigPersistence {
pub fn load() -> Result<AppConfig, ConfigLoadError> {
ConfigLoader::new().load()
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<AppConfig, ConfigLoadError> {
ConfigLoader::new().load_from_file(path.as_ref())
}
pub fn save_to_file<P: AsRef<Path>>(
config: &AppConfig,
path: P,
) -> Result<(), ConfigSaveError> {
ConfigLoader::new().save_to_file(config, path.as_ref())
}
pub fn create_template<P: AsRef<Path>>(
template_type: TemplateType,
path: P,
) -> Result<(), ConfigSaveError> {
let config = match template_type {
TemplateType::Development => crate::config::presets::development(),
TemplateType::Production => crate::config::presets::production(),
TemplateType::Testing => crate::config::presets::testing(),
};
Self::save_to_file(&config, path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::thread;
use std::time::Duration;
use tempfile::NamedTempFile;
#[test]
fn test_config_loader_basic() {
let loader = ConfigLoader::new();
assert!(!loader.search_paths.is_empty());
assert_eq!(loader.env_prefix, "VOIRS");
assert_eq!(loader.config_name, "voirs");
}
#[test]
fn test_config_format_detection() {
use std::path::Path;
assert_eq!(
ConfigFormat::from_extension(Path::new("test.json")),
Some(ConfigFormat::Json)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("test.toml")),
Some(ConfigFormat::Toml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("test.yaml")),
Some(ConfigFormat::Yaml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("test.yml")),
Some(ConfigFormat::Yaml)
);
assert_eq!(
ConfigFormat::from_extension(Path::new("test.unknown")),
None
);
}
#[test]
fn test_config_format_properties() {
assert_eq!(ConfigFormat::Json.extension(), "json");
assert_eq!(ConfigFormat::Toml.extension(), "toml");
assert_eq!(ConfigFormat::Yaml.extension(), "yaml");
assert_eq!(ConfigFormat::Json.mime_type(), "application/json");
assert_eq!(ConfigFormat::Toml.mime_type(), "application/toml");
assert_eq!(ConfigFormat::Yaml.mime_type(), "application/yaml");
}
#[test]
fn test_config_loader_with_custom_settings() {
let loader = ConfigLoader::new()
.with_env_prefix("CUSTOM")
.with_config_name("myapp")
.with_search_paths(vec![std::path::PathBuf::from("/custom/path")]);
assert_eq!(loader.env_prefix, "CUSTOM");
assert_eq!(loader.config_name, "myapp");
assert_eq!(
loader.search_paths,
vec![std::path::PathBuf::from("/custom/path")]
);
}
#[test]
fn test_config_file_save_and_load() {
let mut temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_path = temp_file.path().with_extension("json");
let loader = ConfigLoader::new();
loader.save_to_file(&config, &json_path).unwrap();
let loaded_config = loader.load_from_file(&json_path).unwrap();
assert_eq!(loaded_config.pipeline.device, config.pipeline.device);
assert_eq!(loaded_config.pipeline.use_gpu, config.pipeline.use_gpu);
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn test_config_watcher_creation() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_content = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(temp_file.path(), json_content).unwrap();
let loader = ConfigLoader::new();
let watcher = ConfigWatcher::new(temp_file.path().to_path_buf(), loader).unwrap();
assert_eq!(watcher.watched_path(), temp_file.path());
assert!(watcher.last_modification_time().is_some());
assert!(watcher.get_cached_config().is_none());
}
#[test]
fn test_config_watcher_with_auto_watch() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_content = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(temp_file.path(), json_content).unwrap();
let loader = ConfigLoader::new();
let watcher =
ConfigWatcher::with_auto_watch(temp_file.path().to_path_buf(), loader).unwrap();
assert_eq!(watcher.watched_path(), temp_file.path());
assert!(watcher.last_modification_time().is_some());
assert!(watcher.get_cached_config().is_none());
}
#[test]
fn test_config_watcher_reload_if_changed() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_path = temp_file.path().with_extension("json");
let json_content = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&json_path, json_content).unwrap();
let loader = ConfigLoader::new();
let mut watcher = ConfigWatcher::new(json_path.clone(), loader).unwrap();
let result = watcher.reload_if_changed().unwrap();
thread::sleep(Duration::from_millis(100));
let mut modified_config = config.clone();
modified_config.pipeline.device = "initial_change".to_string();
let modified_json = serde_json::to_string_pretty(&modified_config).unwrap();
std::fs::write(&json_path, modified_json).unwrap();
let result = watcher.reload_if_changed().unwrap();
assert!(result.is_some());
assert!(watcher.get_cached_config().is_some());
let result = watcher.reload_if_changed().unwrap();
assert!(result.is_none());
thread::sleep(Duration::from_millis(100)); let mut second_modified_config = config.clone();
second_modified_config.pipeline.device = "second_change".to_string();
let second_modified_json = serde_json::to_string_pretty(&second_modified_config).unwrap();
std::fs::write(&json_path, second_modified_json).unwrap();
let result = watcher.reload_if_changed().unwrap();
assert!(result.is_some());
let reloaded_config = result.unwrap();
assert_eq!(reloaded_config.pipeline.device, "second_change");
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn test_config_watcher_force_reload() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_path = temp_file.path().with_extension("json");
let json_content = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(&json_path, json_content).unwrap();
let loader = ConfigLoader::new();
let mut watcher = ConfigWatcher::new(json_path.clone(), loader).unwrap();
let result = watcher.force_reload().unwrap();
assert_eq!(result.pipeline.device, config.pipeline.device);
assert!(watcher.get_cached_config().is_some());
let mut modified_config = config.clone();
modified_config.pipeline.device = "forced".to_string();
let modified_json = serde_json::to_string_pretty(&modified_config).unwrap();
std::fs::write(&json_path, modified_json).unwrap();
let result = watcher.force_reload().unwrap();
assert_eq!(result.pipeline.device, "forced");
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn test_config_watcher_events() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_content = serde_json::to_string_pretty(&config).unwrap();
std::fs::write(temp_file.path(), json_content).unwrap();
let loader = ConfigLoader::new();
let mut watcher =
ConfigWatcher::with_auto_watch(temp_file.path().to_path_buf(), loader).unwrap();
let events = watcher.check_events().unwrap();
assert!(events.is_empty());
let mut modified_config = config.clone();
modified_config.pipeline.device = "event_test".to_string();
let modified_json = serde_json::to_string_pretty(&modified_config).unwrap();
std::fs::write(temp_file.path(), modified_json).unwrap();
thread::sleep(Duration::from_millis(200));
let events = watcher.check_events().unwrap();
}
#[test]
fn test_config_watcher_error_handling() {
let non_existent_path = PathBuf::from("/non/existent/path/config.json");
let loader = ConfigLoader::new();
let result = ConfigWatcher::new(non_existent_path, loader);
assert!(result.is_err());
match result.unwrap_err() {
ConfigWatchError::FileAccess { path, .. } => {
assert_eq!(path, PathBuf::from("/non/existent/path/config.json"));
}
_ => panic!("Expected FileAccess error"),
}
}
#[test]
fn test_config_change_event_types() {
let event = ConfigChangeEvent {
path: PathBuf::from("/test/config.json"),
change_type: ConfigChangeType::Modified,
timestamp: std::time::SystemTime::now(),
};
assert_eq!(event.change_type, ConfigChangeType::Modified);
assert_eq!(event.path, PathBuf::from("/test/config.json"));
assert_eq!(ConfigChangeType::Modified, ConfigChangeType::Modified);
assert_eq!(ConfigChangeType::Created, ConfigChangeType::Created);
assert_eq!(ConfigChangeType::Deleted, ConfigChangeType::Deleted);
assert_eq!(ConfigChangeType::Other, ConfigChangeType::Other);
assert_ne!(ConfigChangeType::Modified, ConfigChangeType::Created);
}
#[test]
fn test_config_persistence_convenience_methods() {
let temp_file = NamedTempFile::new().unwrap();
let config = AppConfig::default();
let json_path = temp_file.path().with_extension("json");
ConfigPersistence::save_to_file(&config, &json_path).unwrap();
let loaded_config = ConfigPersistence::load_from_file(&json_path).unwrap();
assert_eq!(loaded_config.pipeline.device, config.pipeline.device);
let _ = std::fs::remove_file(&json_path);
}
#[test]
fn test_config_template_creation() {
let temp_dir = tempfile::tempdir().unwrap();
let dev_path = temp_dir.path().join("dev.json");
ConfigPersistence::create_template(TemplateType::Development, &dev_path).unwrap();
assert!(dev_path.exists());
let prod_path = temp_dir.path().join("prod.json");
ConfigPersistence::create_template(TemplateType::Production, &prod_path).unwrap();
assert!(prod_path.exists());
let test_path = temp_dir.path().join("test.json");
ConfigPersistence::create_template(TemplateType::Testing, &test_path).unwrap();
assert!(test_path.exists());
}
#[test]
fn test_config_loader_discover_files() {
let temp_dir = tempfile::tempdir().unwrap();
let config = AppConfig::default();
let json_file = temp_dir.path().join("voirs.json");
let toml_file = temp_dir.path().join("voirs.toml");
let yaml_file = temp_dir.path().join("voirs.yaml");
let loader = ConfigLoader::new().with_search_paths(vec![temp_dir.path().to_path_buf()]);
loader.save_to_file(&config, &json_file).unwrap();
loader.save_to_file(&config, &toml_file).unwrap();
loader.save_to_file(&config, &yaml_file).unwrap();
let discovered_files = loader.discover_config_files().unwrap();
assert_eq!(discovered_files.len(), 3);
assert!(discovered_files.contains(&json_file));
assert!(discovered_files.contains(&toml_file));
assert!(discovered_files.contains(&yaml_file));
}
}