use crate::{ConfigError, ConfigSource};
use async_trait::async_trait;
use serde_json::Value;
use std::collections::HashMap;
use std::time::Duration;
pub struct EnvConfigSource {
prefix: String,
separator: String,
poll_interval: Duration,
}
impl EnvConfigSource {
pub fn new(prefix: impl Into<String>) -> Self {
Self {
prefix: prefix.into(),
separator: "__".to_string(),
poll_interval: Duration::from_secs(5),
}
}
pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
self.separator = sep.into();
self
}
pub fn with_poll_interval(mut self, interval: Duration) -> Self {
self.poll_interval = interval;
self
}
fn collect(&self) -> HashMap<String, Value> {
let mut map = HashMap::new();
for (key, value) in std::env::vars() {
if let Some(stripped) = key.strip_prefix(&self.prefix) {
let mapped = stripped
.to_lowercase()
.replace(&self.separator, ".");
map.insert(mapped, crate::parse_config_value(&value));
}
}
map
}
}
#[async_trait]
impl ConfigSource for EnvConfigSource {
async fn load(&self) -> Result<HashMap<String, Value>, ConfigError> {
Ok(self.collect())
}
async fn watch(
&self,
callback: Box<dyn Fn(HashMap<String, Value>) + Send + Sync>,
) -> Result<(), ConfigError> {
let prefix = self.prefix.clone();
let separator = self.separator.clone();
let interval = self.poll_interval;
std::thread::spawn(move || {
let cfg = EnvConfigSource {
prefix,
separator,
poll_interval: interval,
};
let mut last = cfg.collect();
loop {
std::thread::sleep(interval);
let current = cfg.collect();
if current != last {
last = current.clone();
callback(current);
}
}
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_load_with_prefix_and_nested_mapping() {
std::env::set_var("SZORMCFG_TEST_DB__HOST", "localhost");
std::env::set_var("SZORMCFG_TEST_DB__PORT", "3306");
std::env::set_var("SZORMCFG_TEST_DEBUG", "true");
std::env::set_var("SZORMCFG_TEST_OTHER_VAR", "ignored");
let src = EnvConfigSource::new("SZORMCFG_TEST_");
let data = src.load().await.unwrap();
assert_eq!(data.get("db.host"), Some(&Value::from("localhost")));
assert_eq!(data.get("db.port"), Some(&Value::from(3306)));
assert_eq!(data.get("debug"), Some(&Value::from(true)));
assert_eq!(data.get("other_var"), Some(&Value::from("ignored")));
assert!(data.get("nonexistent").is_none());
std::env::remove_var("SZORMCFG_TEST_DB__HOST");
std::env::remove_var("SZORMCFG_TEST_DB__PORT");
std::env::remove_var("SZORMCFG_TEST_DEBUG");
std::env::remove_var("SZORMCFG_TEST_OTHER_VAR");
}
#[tokio::test]
async fn test_load_custom_separator() {
std::env::set_var("SZORMCFG_SEP_DB__HOST", "h");
let src = EnvConfigSource::new("SZORMCFG_SEP_").with_separator("__");
let data = src.load().await.unwrap();
assert_eq!(data.get("db.host"), Some(&Value::from("h")));
std::env::remove_var("SZORMCFG_SEP_DB__HOST");
}
#[tokio::test]
async fn test_load_no_matching_vars() {
let src = EnvConfigSource::new("DEFINITELY_NONEXISTENT_PREFIX_XYZ_");
let data = src.load().await.unwrap();
assert!(data.is_empty());
}
#[tokio::test]
async fn test_load_strips_prefix_and_lowercases() {
std::env::set_var("SZORMCFG_CASE_HOST", "H1");
let src = EnvConfigSource::new("SZORMCFG_CASE_");
let data = src.load().await.unwrap();
assert_eq!(data.get("host"), Some(&Value::from("H1")));
std::env::remove_var("SZORMCFG_CASE_HOST");
}
}