use crate::{ConfigResult, PropertySource, Value, environment::Environment, error::ConfigError};
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileFormat {
Properties,
Yaml,
Toml,
Json,
}
impl FileFormat {
pub fn extensions(&self) -> &[&str] {
match self {
FileFormat::Properties => &["properties", "props"],
FileFormat::Yaml => &["yaml", "yml"],
FileFormat::Toml => &["toml"],
FileFormat::Json => &["json"],
}
}
pub fn from_path(path: &Path) -> Option<Self> {
let ext = path.extension()?.to_str()?.to_lowercase();
match ext.as_str() {
"properties" | "props" => Some(FileFormat::Properties),
"yaml" | "yml" => Some(FileFormat::Yaml),
"toml" => Some(FileFormat::Toml),
"json" => Some(FileFormat::Json),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReloadStrategy {
Never,
OnRequest,
Periodic(u64),
Watch,
}
#[derive(Debug, Clone)]
pub struct Config {
environment: Arc<Environment>,
files: Arc<RwLock<Vec<PathBuf>>>,
reload_strategy: ReloadStrategy,
values: Arc<RwLock<IndexMap<String, Value>>>,
}
impl Config {
pub fn new() -> Self {
Self {
environment: Arc::new(Environment::new()),
files: Arc::new(RwLock::new(Vec::new())),
reload_strategy: ReloadStrategy::Never,
values: Arc::new(RwLock::new(IndexMap::new())),
}
}
pub fn builder() -> ConfigBuilder {
ConfigBuilder::new()
}
pub fn load() -> ConfigResult<Self> {
Self::builder().build()
}
pub fn from_file<P: AsRef<Path>>(path: P) -> ConfigResult<Self> {
Self::builder().add_file(path).build()
}
pub fn add_property_source(&self, source: PropertySource) {
self.environment.add_property_source(source);
self.invalidate_cache();
}
pub fn add_property_source_first(&self, source: PropertySource) {
self.environment.add_property_source_first(source);
self.invalidate_cache();
}
pub fn get(&self, key: &str) -> Option<Value> {
if let Ok(cache) = self.values.read()
&& let Some(value) = cache.get(key)
{
return Some(value.clone());
}
let value = self.environment.get_property(key);
if let Some(ref v) = value
&& let Ok(mut cache) = self.values.write()
{
cache.insert(key.to_string(), v.clone());
}
value
}
pub fn get_as<T>(&self, key: &str) -> ConfigResult<T>
where
T: serde::de::DeserializeOwned,
{
let value = self
.get(key)
.ok_or_else(|| ConfigError::MissingProperty(key.to_string()))?;
value.into()
}
pub fn get_required(&self, key: &str) -> ConfigResult<Value> {
self.get(key)
.ok_or_else(|| ConfigError::MissingProperty(key.to_string()))
}
pub fn get_required_as<T>(&self, key: &str) -> ConfigResult<T>
where
T: serde::de::DeserializeOwned,
{
let value = self.get_required(key)?;
value.into()
}
pub fn get_or<T>(&self, key: &str, default: T) -> T
where
T: serde::de::DeserializeOwned,
{
self.get_as(key).unwrap_or(default)
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
pub fn get_prefix(&self, prefix: &str) -> IndexMap<String, Value> {
let mut result = IndexMap::new();
let sources = self.environment.get_property_sources();
for source in sources {
for (key, value) in source.iter() {
if key.starts_with(prefix) {
result.entry(key.clone()).or_insert(value.clone());
}
}
}
result
}
pub fn environment(&self) -> &Environment {
&self.environment
}
pub fn files(&self) -> Vec<PathBuf> {
self.files
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub fn reload_strategy(&self) -> ReloadStrategy {
self.reload_strategy
}
pub fn reload(&self) -> ConfigResult<()> {
self.invalidate_cache();
if self.reload_strategy != ReloadStrategy::Never {
for file in self.files() {
self.load_file(&file)?;
}
}
Ok(())
}
fn invalidate_cache(&self) {
if let Ok(mut cache) = self.values.write() {
cache.clear();
}
}
pub(crate) fn load_file<P: AsRef<Path>>(&self, path: P) -> ConfigResult<()> {
let path = path.as_ref();
let format = FileFormat::from_path(path)
.ok_or_else(|| ConfigError::InvalidFormat(format!("{:?}", path)))?;
let content = std::fs::read_to_string(path)?;
let source = match format {
FileFormat::Properties => self.parse_properties(&content),
FileFormat::Yaml => self.parse_yaml(&content),
FileFormat::Toml => self.parse_toml(&content),
FileFormat::Json => self.parse_json(&content),
}?;
let mut source = source;
source.set_file_path(path.to_path_buf());
self.environment.add_property_source(source);
if let Ok(mut files) = self.files.write() {
let path_buf = path.to_path_buf();
if !files.contains(&path_buf) {
files.push(path_buf);
}
}
Ok(())
}
fn parse_properties(&self, content: &str) -> ConfigResult<PropertySource> {
let mut map = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = Self::unescape_value(value.trim());
map.insert(key, Value::string(value));
}
}
Ok(PropertySource::with_map("application.properties", map))
}
fn unescape_value(value: &str) -> String {
let mut result = String::new();
let mut chars = value.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some('u') => {
let code: String = chars.by_ref().take(4).collect();
if let Ok(code_point) = u32::from_str_radix(&code, 16)
&& let Some(c) = char::from_u32(code_point)
{
result.push(c);
}
},
Some(next) => result.push(next),
None => result.push('\\'),
}
} else {
result.push(c);
}
}
result
}
fn parse_yaml(&self, content: &str) -> ConfigResult<PropertySource> {
let yaml: serde_yaml::Value =
serde_yaml::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))?;
let map = Self::yaml_to_map(&yaml)?;
Ok(PropertySource::with_map("application.yaml", map))
}
fn yaml_to_map(yaml: &serde_yaml::Value) -> ConfigResult<HashMap<String, Value>> {
let mut map = HashMap::new();
if let serde_yaml::Value::Mapping(mapping) = yaml {
for (key, value) in mapping {
if let serde_yaml::Value::String(key_str) = key {
let value = Self::yaml_to_value(value)?;
map.insert(key_str.clone(), value);
}
}
}
Ok(map)
}
fn yaml_to_value(yaml: &serde_yaml::Value) -> ConfigResult<Value> {
Ok(match yaml {
serde_yaml::Value::Null | serde_yaml::Value::Tagged(_) => Value::Null,
serde_yaml::Value::Bool(v) => Value::Bool(*v),
serde_yaml::Value::Number(v) => {
if let Some(i) = v.as_i64() {
Value::Integer(i)
} else if let Some(f) = v.as_f64() {
Value::Float(f)
} else {
Value::Null
}
},
serde_yaml::Value::String(v) => Value::String(v.clone()),
serde_yaml::Value::Sequence(v) => Value::List(
v.iter()
.map(|x| Self::yaml_to_value(x))
.collect::<ConfigResult<Vec<_>>>()?,
),
serde_yaml::Value::Mapping(v) => Value::Object(
v.iter()
.filter_map(|(k, v)| {
k.as_str()
.map(|key| (key.to_string(), Self::yaml_to_value(v).ok()))
})
.filter_map(|(k, v)| v.map(|val| (k, val)))
.collect(),
),
})
}
fn parse_toml(&self, content: &str) -> ConfigResult<PropertySource> {
let toml: toml::Value =
toml::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))?;
let map = Self::toml_to_map(&toml)?;
Ok(PropertySource::with_map("application.toml", map))
}
fn toml_to_map(toml: &toml::Value) -> ConfigResult<HashMap<String, Value>> {
let mut map = HashMap::new();
if let toml::Value::Table(table) = toml {
for (key, value) in table {
map.insert(key.clone(), Self::toml_to_value(value));
}
}
Ok(map)
}
fn toml_to_value(toml: &toml::Value) -> Value {
match toml {
toml::Value::Boolean(v) => Value::Bool(*v),
toml::Value::Integer(v) => Value::Integer(*v),
toml::Value::Float(v) => Value::Float(*v),
toml::Value::String(v) => Value::String(v.clone()),
toml::Value::Array(v) => Value::List(v.iter().map(Self::toml_to_value).collect()),
toml::Value::Table(table) => Value::Object(
table
.iter()
.map(|(k, v)| (k.clone(), Self::toml_to_value(v)))
.collect(),
),
toml::Value::Datetime(v) => Value::String(v.to_string()),
}
}
fn parse_json(&self, content: &str) -> ConfigResult<PropertySource> {
let json: serde_json::Value =
serde_json::from_str(content).map_err(|e| ConfigError::Parse(e.to_string()))?;
let map = Self::json_to_map(&json)?;
Ok(PropertySource::with_map("application.json", map))
}
fn json_to_map(json: &serde_json::Value) -> ConfigResult<HashMap<String, Value>> {
let mut map = HashMap::new();
if let serde_json::Value::Object(obj) = json {
for (key, value) in obj {
map.insert(key.clone(), Self::json_to_value(value));
}
}
Ok(map)
}
fn json_to_value(json: &serde_json::Value) -> Value {
match json {
serde_json::Value::Null => Value::Null,
serde_json::Value::Bool(v) => Value::Bool(*v),
serde_json::Value::Number(v) => {
if let Some(i) = v.as_i64() {
Value::Integer(i)
} else if let Some(f) = v.as_f64() {
Value::Float(f)
} else {
Value::Null
}
},
serde_json::Value::String(v) => Value::String(v.clone()),
serde_json::Value::Array(v) => Value::List(v.iter().map(Self::json_to_value).collect()),
serde_json::Value::Object(obj) => Value::Object(
obj.iter()
.map(|(k, v)| (k.clone(), Self::json_to_value(v)))
.collect(),
),
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
pub struct ConfigBuilder {
config: Config,
}
impl ConfigBuilder {
pub fn new() -> Self {
Self {
config: Config::new(),
}
}
pub fn add_file<P: AsRef<Path>>(self, path: P) -> Self {
let path = path.as_ref();
if let Err(e) = self.config.load_file(path) {
tracing::warn!("Failed to load config file {:?}: {}", path, e);
}
self
}
pub fn add_dir<P: AsRef<Path>>(mut self, dir: P) -> Self {
let dir = dir.as_ref();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && FileFormat::from_path(&path).is_some() {
self = self.add_file(path);
}
}
}
self
}
pub fn add_profile(self, profile: impl Into<crate::Profile>) -> Self {
self.config.environment.add_active_profile(profile.into());
self
}
pub fn set_profiles(self, profiles: Vec<crate::Profile>) -> Self {
self.config.environment.set_active_profiles(profiles);
self
}
pub fn add_property_source(self, source: PropertySource) -> Self {
self.config.add_property_source(source);
self
}
pub fn add_property(self, key: impl Into<String>, value: impl Into<Value>) -> Self {
let mut source = PropertySource::new("manual");
source.put(key, value);
self.config.add_property_source(source);
self
}
pub fn reload_strategy(mut self, strategy: ReloadStrategy) -> Self {
self.config.reload_strategy = strategy;
self
}
pub fn load_env(self) -> Self {
let mut source = PropertySource::new("systemEnvironment");
source.set_file_path(PathBuf::from("<env>"));
for (key, value) in std::env::vars() {
let config_key = key.to_lowercase().replace('_', ".");
source.put(config_key, Value::string(value));
}
self.config.add_property_source(source);
self
}
pub fn load_args(self) -> Self {
let args: Vec<String> = std::env::args().collect();
let mut source = PropertySource::new("commandLineArgs");
source.set_file_path(PathBuf::from("<args>"));
for arg in args.iter().skip(1) {
if let Some((key, value)) = arg.split_once('=')
&& key.starts_with("--")
{
let key = key[2..].to_string();
source.put(key, Value::string(value));
}
}
self.config.add_property_source(source);
self
}
pub fn build(mut self) -> ConfigResult<Config> {
if self.config.files().is_empty() {
self = self.load_defaults();
}
Ok(self.config)
}
fn load_defaults(self) -> Self {
let config_dir = ["config", "."];
let bases = ["application"];
let profiles: Vec<String> = self
.config
.environment()
.get_active_profiles()
.iter()
.map(|p| p.name().to_string())
.collect();
let formats = [
FileFormat::Properties,
FileFormat::Yaml,
FileFormat::Toml,
FileFormat::Json,
];
let mut builder = self;
for dir in &config_dir {
for base in &bases {
for format in &formats {
for ext in format.extensions() {
let path = PathBuf::from(dir).join(format!("{}.{}", base, ext));
if path.exists() {
builder = builder.add_file(path);
}
}
}
}
}
for profile in &profiles {
for dir in &config_dir {
for base in &bases {
for format in &formats {
for ext in format.extensions() {
let path =
PathBuf::from(dir).join(format!("{}-{}.{}", base, profile, ext));
if path.exists() {
builder = builder.add_file(path);
}
}
}
}
}
}
builder
}
}
impl Default for ConfigBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{PropertySource, Value};
use std::io::Write;
#[test]
fn test_file_format_extensions() {
assert_eq!(FileFormat::Properties.extensions(), &["properties", "props"]);
assert_eq!(FileFormat::Yaml.extensions(), &["yaml", "yml"]);
assert_eq!(FileFormat::Toml.extensions(), &["toml"]);
assert_eq!(FileFormat::Json.extensions(), &["json"]);
}
#[test]
fn test_file_format_from_path() {
assert_eq!(
FileFormat::from_path(Path::new("app.properties")),
Some(FileFormat::Properties)
);
assert_eq!(FileFormat::from_path(Path::new("app.props")), Some(FileFormat::Properties));
assert_eq!(FileFormat::from_path(Path::new("app.yaml")), Some(FileFormat::Yaml));
assert_eq!(FileFormat::from_path(Path::new("app.yml")), Some(FileFormat::Yaml));
assert_eq!(FileFormat::from_path(Path::new("app.toml")), Some(FileFormat::Toml));
assert_eq!(FileFormat::from_path(Path::new("app.json")), Some(FileFormat::Json));
assert_eq!(FileFormat::from_path(Path::new("app.txt")), None);
assert_eq!(FileFormat::from_path(Path::new("noext")), None);
}
#[test]
fn test_reload_strategy_eq() {
assert_eq!(ReloadStrategy::Never, ReloadStrategy::Never);
assert_eq!(ReloadStrategy::OnRequest, ReloadStrategy::OnRequest);
assert_eq!(ReloadStrategy::Periodic(30), ReloadStrategy::Periodic(30));
assert_eq!(ReloadStrategy::Watch, ReloadStrategy::Watch);
assert_ne!(ReloadStrategy::Never, ReloadStrategy::Watch);
}
#[test]
fn test_config_new() {
let config = Config::new();
assert!(config.get("nonexistent").is_none());
assert!(!config.contains_key("anything"));
assert!(config.files().is_empty());
assert_eq!(config.reload_strategy(), ReloadStrategy::Never);
}
#[test]
fn test_config_default() {
let config = Config::default();
assert!(config.files().is_empty());
}
#[test]
fn test_config_add_source_and_get() {
let config = Config::new();
let mut source = PropertySource::new("test");
source.put("app.name", Value::string("hiver"));
source.put("app.port", Value::integer(8080));
config.add_property_source(source);
assert_eq!(config.get("app.name").unwrap().as_str(), Some("hiver"));
assert_eq!(config.get("app.port").unwrap().as_i64(), Some(8080));
assert!(config.contains_key("app.name"));
assert!(!config.contains_key("missing"));
}
#[test]
fn test_config_get_as() {
let config = Config::new();
let mut source = PropertySource::new("test");
source.put("count", Value::integer(10));
config.add_property_source(source);
let val: i64 = config.get_as("count").unwrap();
assert_eq!(val, 10);
}
#[test]
fn test_config_get_as_missing() {
let config = Config::new();
let result: Result<String, _> = config.get_as("missing");
assert!(result.is_err());
}
#[test]
fn test_config_get_required() {
let config = Config::new();
let mut source = PropertySource::new("test");
source.put("present", Value::string("value"));
config.add_property_source(source);
assert!(config.get_required("present").is_ok());
assert!(config.get_required("absent").is_err());
}
#[test]
fn test_config_get_required_as() {
let config = Config::new();
let mut source = PropertySource::new("test");
source.put("enabled", Value::bool(true));
config.add_property_source(source);
let val: bool = config.get_required_as("enabled").unwrap();
assert!(val);
}
#[test]
fn test_config_get_or() {
let config = Config::new();
let val = config.get_or("missing", 999i32);
assert_eq!(val, 999);
let mut source = PropertySource::new("test");
source.put("found", Value::integer(42));
config.add_property_source(source);
let val = config.get_or("found", 999i32);
assert_eq!(val, 42);
}
#[test]
fn test_config_get_prefix() {
let config = Config::new();
let mut source = PropertySource::new("test");
source.put("server.host", Value::string("localhost"));
source.put("server.port", Value::integer(8080));
source.put("db.url", Value::string("postgres://localhost"));
config.add_property_source(source);
let server_props = config.get_prefix("server.");
assert_eq!(server_props.len(), 2);
assert!(server_props.contains_key("server.host"));
assert!(server_props.contains_key("server.port"));
let db_props = config.get_prefix("db.");
assert_eq!(db_props.len(), 1);
}
#[test]
fn test_config_environment() {
let config = Config::new();
let env = config.environment();
assert!(env.get_active_profiles().len() >= 1);
}
#[test]
fn test_parse_properties_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.properties");
let mut f = std::fs::File::create(&file_path).unwrap();
writeln!(f, "# comment line").unwrap();
writeln!(f, "! another comment").unwrap();
writeln!(f, "server.host=localhost").unwrap();
writeln!(f, "server.port=8080").unwrap();
writeln!(f, "").unwrap();
writeln!(f, "app.name=hiver").unwrap();
let config = Config::from_file(&file_path).unwrap();
assert_eq!(config.get("server.host").unwrap().as_str(), Some("localhost"));
assert_eq!(config.get("server.port").unwrap().as_str(), Some("8080"));
assert_eq!(config.get("app.name").unwrap().as_str(), Some("hiver"));
}
#[test]
fn test_parse_json_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.json");
let mut f = std::fs::File::create(&file_path).unwrap();
write!(f, r#"{{"server": {{"host": "0.0.0.0", "port": 9090}}, "debug": true}}"#).unwrap();
let config = Config::from_file(&file_path).unwrap();
assert!(config.get("server").is_some());
assert!(config.get("debug").is_some());
assert_eq!(config.get("debug").unwrap().as_bool(), Some(true));
}
#[test]
fn test_parse_toml_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.toml");
let mut f = std::fs::File::create(&file_path).unwrap();
write!(
f,
"[server]\nhost = \"localhost\"\nport = 3000\n\n[database]\nurl = \"postgres://db\"\n"
)
.unwrap();
let config = Config::from_file(&file_path).unwrap();
assert!(config.get("server").is_some());
assert!(config.get("database").is_some());
}
#[test]
fn test_parse_yaml_file() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.yaml");
let mut f = std::fs::File::create(&file_path).unwrap();
write!(f, "server:\n host: 127.0.0.1\n port: 4000\nlogging:\n level: info\n").unwrap();
let config = Config::from_file(&file_path).unwrap();
assert!(config.get("server").is_some());
assert!(config.get("logging").is_some());
}
#[test]
fn test_parse_unknown_format() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.txt");
let _f = std::fs::File::create(&file_path).unwrap();
let result = Config::from_file(&file_path);
assert!(result.is_ok());
}
#[test]
fn test_parse_nonexistent_file() {
let result = Config::from_file("/nonexistent/path/config.yaml");
assert!(result.is_ok());
}
#[test]
fn test_unescape_value() {
assert_eq!(Config::unescape_value("hello\\nworld"), "hello\nworld");
assert_eq!(Config::unescape_value("tab\\there"), "tab\there");
assert_eq!(Config::unescape_value("cr\\rhere"), "cr\rhere");
assert_eq!(Config::unescape_value("back\\slash"), "backslash");
assert_eq!(Config::unescape_value("end\\"), "end\\");
}
#[test]
fn test_builder_add_property() {
let config = Config::builder()
.add_property("key1", "value1")
.add_property("key2", 42)
.build()
.unwrap();
assert_eq!(config.get("key1").unwrap().as_str(), Some("value1"));
assert_eq!(config.get("key2").unwrap().as_i64(), Some(42));
}
#[test]
fn test_builder_add_property_source() {
let mut source = PropertySource::new("custom");
source.put("custom.key", Value::string("custom_value"));
let config = Config::builder()
.add_property_source(source)
.build()
.unwrap();
assert_eq!(config.get("custom.key").unwrap().as_str(), Some("custom_value"));
}
#[test]
fn test_builder_reload_strategy() {
let config = Config::builder()
.reload_strategy(ReloadStrategy::OnRequest)
.build()
.unwrap();
assert_eq!(config.reload_strategy(), ReloadStrategy::OnRequest);
}
#[test]
fn test_builder_default() {
let config = ConfigBuilder::default().build().unwrap();
assert_eq!(config.reload_strategy(), ReloadStrategy::Never);
}
#[test]
fn test_config_multiple_sources_merge() {
let config = Config::new();
let mut source1 = PropertySource::new("first");
source1.put("shared", Value::string("from_first"));
source1.put("only_first", Value::string("yes"));
config.add_property_source(source1);
let mut source2 = PropertySource::new("second");
source2.put("shared", Value::string("from_second"));
source2.put("only_second", Value::string("yes"));
config.add_property_source(source2);
assert_eq!(config.get("shared").unwrap().as_str(), Some("from_first"));
assert_eq!(config.get("only_first").unwrap().as_str(), Some("yes"));
assert_eq!(config.get("only_second").unwrap().as_str(), Some("yes"));
}
#[test]
fn test_add_property_source_first() {
let config = Config::new();
let mut source = PropertySource::new("s1");
source.put("key", Value::string("v1"));
config.add_property_source(source);
assert_eq!(config.get("key").unwrap().as_str(), Some("v1"));
let mut source2 = PropertySource::new("s2");
source2.put("key", Value::string("v2"));
config.add_property_source_first(source2);
assert_eq!(config.get("key").unwrap().as_str(), Some("v2"));
}
}