use crate::{Config, ConfigError, ConfigResult, FileFormat, ReloadStrategy};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct ConfigLoader {
config: Config,
search_paths: Vec<PathBuf>,
file_names: Vec<String>,
profiles: Vec<String>,
load_env: bool,
load_args: bool,
}
impl ConfigLoader {
pub fn new() -> Self {
Self {
config: Config::new(),
search_paths: vec![
PathBuf::from("./config"),
PathBuf::from("."),
PathBuf::from("/etc/hiver"),
],
file_names: vec!["application".to_string()],
profiles: vec!["default".to_string()],
load_env: true,
load_args: true,
}
}
pub fn builder() -> ConfigLoaderBuilder {
ConfigLoaderBuilder::new()
}
pub fn add_search_path(mut self, path: impl Into<PathBuf>) -> Self {
self.search_paths.push(path.into());
self
}
pub fn add_file_name(mut self, name: impl Into<String>) -> Self {
self.file_names.push(name.into());
self
}
pub fn add_profile(mut self, profile: impl Into<String>) -> Self {
self.profiles.push(profile.into());
self
}
pub fn load_env(mut self, load: bool) -> Self {
self.load_env = load;
self
}
pub fn load_args(mut self, load: bool) -> Self {
self.load_args = load;
self
}
pub fn load(mut self) -> ConfigResult<Config> {
self.load_application_files()?;
self.load_profile_files()?;
if self.load_env {
self.load_environment_vars()?;
}
if self.load_args {
self.load_command_line_args()?;
}
Ok(self.config)
}
fn load_application_files(&mut self) -> ConfigResult<()> {
let formats = [
FileFormat::Properties,
FileFormat::Yaml,
FileFormat::Toml,
FileFormat::Json,
];
for search_path in &self.search_paths {
for file_name in &self.file_names {
for format in &formats {
for ext in format.extensions() {
let path = search_path.join(format!("{}.{}", file_name, ext));
if path.exists() {
if let Err(e) = self.config.load_file(&path) {
tracing::debug!("Skipping {:?}: {}", path, e);
} else {
tracing::debug!("Loaded config from {:?}", path);
}
}
}
}
}
}
Ok(())
}
fn load_profile_files(&mut self) -> ConfigResult<()> {
let formats = [
FileFormat::Properties,
FileFormat::Yaml,
FileFormat::Toml,
FileFormat::Json,
];
for profile in &self.profiles {
for search_path in &self.search_paths {
for file_name in &self.file_names {
for format in &formats {
for ext in format.extensions() {
let path =
search_path.join(format!("{}-{}.{}", file_name, profile, ext));
if path.exists() {
if let Err(e) = self.config.load_file(&path) {
tracing::debug!("Skipping {:?}: {}", path, e);
} else {
tracing::debug!(
"Loaded config from {:?} (profile: {})",
path,
profile
);
}
}
}
}
}
}
}
Ok(())
}
fn load_environment_vars(&mut self) -> ConfigResult<()> {
use crate::{PropertySourceBuilder, PropertySourceType, Value};
let mut builder = PropertySourceBuilder::new("environmentVariables")
.source_type(PropertySourceType::SystemEnvironment)
.order(200);
for (key, value) in std::env::vars() {
let config_key = key.to_lowercase().replace('_', ".");
builder.put(config_key, Value::string(value.clone()));
builder.put(key, Value::string(value));
}
self.config.add_property_source(builder.build());
Ok(())
}
fn load_command_line_args(&mut self) -> ConfigResult<()> {
use crate::{PropertySourceBuilder, PropertySourceType, Value};
let mut builder = PropertySourceBuilder::new("commandLineArgs")
.source_type(PropertySourceType::CommandLine)
.order(100);
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if let Some(arg) = arg.strip_prefix("--") {
if let Some((key, value)) = arg.split_once('=') {
builder.put(key, Value::string(value));
} else {
builder.put(arg, Value::bool(true));
}
}
}
self.config.add_property_source(builder.build());
Ok(())
}
}
impl Default for ConfigLoader {
fn default() -> Self {
Self::new()
}
}
pub struct ConfigLoaderBuilder {
loader: ConfigLoader,
}
impl ConfigLoaderBuilder {
pub fn new() -> Self {
Self {
loader: ConfigLoader::new(),
}
}
pub fn search_path(mut self, path: impl Into<PathBuf>) -> Self {
self.loader = self.loader.add_search_path(path);
self
}
pub fn search_paths(mut self, paths: Vec<PathBuf>) -> Self {
for path in paths {
self.loader = self.loader.add_search_path(path);
}
self
}
pub fn file_name(mut self, name: impl Into<String>) -> Self {
self.loader = self.loader.add_file_name(name);
self
}
pub fn file_names(mut self, names: Vec<String>) -> Self {
for name in names {
self.loader = self.loader.add_file_name(name);
}
self
}
pub fn profile(mut self, profile: impl Into<String>) -> Self {
self.loader = self.loader.add_profile(profile);
self
}
pub fn profiles(mut self, profiles: Vec<String>) -> Self {
for profile in profiles {
self.loader = self.loader.add_profile(profile);
}
self
}
pub fn load_env(mut self, load: bool) -> Self {
self.loader = self.loader.load_env(load);
self
}
pub fn load_args(mut self, load: bool) -> Self {
self.loader = self.loader.load_args(load);
self
}
pub fn build(self) -> ConfigLoader {
self.loader
}
pub fn load(self) -> ConfigResult<Config> {
self.loader.load()
}
}
impl Default for ConfigLoaderBuilder {
fn default() -> Self {
Self::new()
}
}
pub struct Watcher {
config: Arc<Config>,
watched_files: Arc<std::sync::RwLock<HashMap<PathBuf, std::time::SystemTime>>>,
strategy: ReloadStrategy,
interval: Duration,
running: Arc<std::sync::atomic::AtomicBool>,
}
impl Watcher {
pub fn new(config: Arc<Config>) -> Self {
let strategy = config.reload_strategy();
Self {
config,
watched_files: Arc::new(std::sync::RwLock::new(HashMap::new())),
strategy,
interval: Duration::from_secs(5),
running: Arc::new(false.into()),
}
}
pub fn interval(mut self, interval: Duration) -> Self {
self.interval = interval;
self
}
pub fn watch_file(&self, path: PathBuf) {
if let Ok(metadata) = std::fs::metadata(&path)
&& let Ok(modified) = metadata.modified()
{
let mut files = self
.watched_files
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
files.insert(path, modified);
}
}
pub fn start(&self) -> ConfigResult<()> {
if self.strategy != ReloadStrategy::Watch {
return Err(ConfigError::OverrideNotAllowed(
"Watcher requires ReloadStrategy::Watch".to_string(),
));
}
self.running
.store(true, std::sync::atomic::Ordering::SeqCst);
let config = self.config.clone();
let watched_files = self.watched_files.clone();
let running = self.running.clone();
let interval = self.interval;
std::thread::spawn(move || {
while running.load(std::sync::atomic::Ordering::SeqCst) {
std::thread::sleep(interval);
let mut files = watched_files
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut changed = Vec::new();
for (path, last_modified) in files.iter() {
if let Ok(metadata) = std::fs::metadata(path)
&& let Ok(modified) = metadata.modified()
&& modified != *last_modified
{
changed.push((path.clone(), modified));
}
}
for (path, modified) in changed {
tracing::info!("Config file changed: {:?}, reloading...", path);
if let Err(e) = config.load_file(&path) {
tracing::error!("Failed to reload config {:?}: {}", path, e);
} else {
tracing::info!("Successfully reloaded config from {:?}", path);
}
files.insert(path, modified);
}
}
});
Ok(())
}
pub fn stop(&self) {
self.running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
pub(crate) trait ConfigPostProcessor: Send + Sync {
fn post_process(&self, config: &mut Config) -> ConfigResult<()>;
}
pub(crate) struct StandardPostProcessors;
impl StandardPostProcessors {
pub(crate) fn placeholder_expander() -> impl ConfigPostProcessor {
PlaceholderExpander
}
pub(crate) fn required_validator(required: Vec<String>) -> impl ConfigPostProcessor {
RequiredValidator { required }
}
}
struct PlaceholderExpander;
impl ConfigPostProcessor for PlaceholderExpander {
fn post_process(&self, _config: &mut Config) -> ConfigResult<()> {
Ok(())
}
}
struct RequiredValidator {
required: Vec<String>,
}
impl ConfigPostProcessor for RequiredValidator {
fn post_process(&self, config: &mut Config) -> ConfigResult<()> {
for key in &self.required {
if !config.contains_key(key) {
return Err(ConfigError::MissingProperty(key.clone()));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PropertySource, Value};
#[test]
fn test_loader_builder() {
let loader = ConfigLoaderBuilder::new()
.search_path("./test")
.profile("test")
.load_env(true)
.build();
assert_eq!(loader.profiles.len(), 2); }
#[test]
fn test_loader_new_defaults() {
let loader = ConfigLoader::new();
assert_eq!(loader.search_paths.len(), 3);
assert_eq!(loader.file_names.len(), 1);
assert_eq!(loader.file_names[0], "application");
assert_eq!(loader.profiles.len(), 1);
assert_eq!(loader.profiles[0], "default");
assert!(loader.load_env);
assert!(loader.load_args);
}
#[test]
fn test_loader_default() {
let loader = ConfigLoader::default();
assert_eq!(loader.search_paths.len(), 3);
}
#[test]
fn test_loader_builder_search_paths() {
let loader = ConfigLoaderBuilder::new()
.search_paths(vec![PathBuf::from("/a"), PathBuf::from("/b")])
.build();
assert!(loader.search_paths.len() >= 5);
}
#[test]
fn test_loader_builder_file_names() {
let loader = ConfigLoaderBuilder::new()
.file_names(vec!["custom".to_string(), "override".to_string()])
.build();
assert!(loader.file_names.len() >= 3);
}
#[test]
fn test_loader_builder_profiles() {
let loader = ConfigLoaderBuilder::new()
.profiles(vec!["staging".to_string(), "prod".to_string()])
.build();
assert!(loader.profiles.len() >= 3);
}
#[test]
fn test_loader_builder_disable_env_and_args() {
let loader = ConfigLoaderBuilder::new()
.load_env(false)
.load_args(false)
.build();
assert!(!loader.load_env);
assert!(!loader.load_args);
}
#[test]
fn test_loader_add_methods() {
let loader = ConfigLoader::new()
.add_search_path("/custom/path")
.add_file_name("myapp")
.add_profile("dev");
assert!(loader.search_paths.len() > 3);
assert!(loader.file_names.contains(&"myapp".to_string()));
assert!(loader.profiles.contains(&"dev".to_string()));
}
#[test]
fn test_loader_builder_default() {
let loader = ConfigLoaderBuilder::default().build();
assert_eq!(loader.search_paths.len(), 3);
}
#[test]
fn test_required_validator_pass() {
let mut config = Config::new();
let mut source = PropertySource::new("test");
source.put("db.url", Value::string("postgres://localhost"));
source.put("db.user", Value::string("admin"));
config.add_property_source(source);
let validator = StandardPostProcessors::required_validator(vec![
"db.url".to_string(),
"db.user".to_string(),
]);
assert!(validator.post_process(&mut config).is_ok());
}
#[test]
fn test_required_validator_fail() {
let mut config = Config::new();
let source = PropertySource::new("test");
config.add_property_source(source);
let validator = StandardPostProcessors::required_validator(vec!["missing.key".to_string()]);
assert!(validator.post_process(&mut config).is_err());
}
#[test]
fn test_placeholder_expander() {
let mut config = Config::new();
let expander = StandardPostProcessors::placeholder_expander();
assert!(expander.post_process(&mut config).is_ok());
}
#[test]
fn test_loader_load_no_files() {
let result = ConfigLoaderBuilder::new()
.search_path("/nonexistent_hiver_path")
.load_env(false)
.load_args(false)
.load();
assert!(result.is_ok());
}
}