use anyhow::{Context, Result};
use serde::de::DeserializeOwned;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct ConfigRepo {
entries: BTreeMap<String, serde_json::Value>,
}
impl ConfigRepo {
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
}
}
pub fn load_dir(dir: impl AsRef<Path>) -> Result<Self> {
let dir = dir.as_ref();
let mut repo = Self::new();
if !dir.is_dir() {
return Ok(repo); }
let mut paths: Vec<_> = fs::read_dir(dir)
.with_context(|| format!("Cannot read config directory `{}`", dir.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "toml"))
.collect();
paths.sort();
for path in &paths {
let content = fs::read_to_string(path)
.with_context(|| format!("Reading `{}`", path.display()))?;
let table: toml::Table = toml::from_str(&content)
.with_context(|| format!("Parsing `{}`", path.display()))?;
repo.merge_toml_table(&table, "");
}
Ok(repo)
}
pub fn load_file(&mut self, path: impl AsRef<Path>) -> Result<()> {
let path = path.as_ref();
let content =
fs::read_to_string(path).with_context(|| format!("Reading `{}`", path.display()))?;
let table: toml::Table =
toml::from_str(&content).with_context(|| format!("Parsing `{}`", path.display()))?;
self.merge_toml_table(&table, "");
Ok(())
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
self.entries.insert(key.into(), value.into());
}
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
self.entries
.get(key)
.and_then(|v| serde_json::from_value(v.clone()).ok())
}
pub fn get_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
self.get(key).unwrap_or(default)
}
pub fn has(&self, key: &str) -> bool {
self.entries.contains_key(key)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &serde_json::Value)> {
self.entries.iter()
}
pub fn apply_env_overrides(&mut self) {
for (key, value) in std::env::vars() {
if let Some(dotted) = Self::env_key_to_dotted(&key) {
self.set(dotted, serde_json::Value::String(value));
}
}
}
pub fn env_key_to_dotted(key: &str) -> Option<String> {
let rest = key.strip_prefix("APP_")?;
if rest.is_empty() {
return None;
}
let parts: Vec<&str> = rest.splitn(2, '_').collect();
let dotted = match parts.len() {
1 => parts[0].to_lowercase(),
_ => format!("{}.{}", parts[0].to_lowercase(), parts[1].to_lowercase()),
};
Some(dotted)
}
fn merge_toml_table(&mut self, table: &toml::Table, prefix: &str) {
for (key, value) in table {
let full_key = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
match value {
toml::Value::Table(inner) => {
self.merge_toml_table(inner, &full_key);
}
other => {
let json_val = toml_value_to_json(other);
self.entries.insert(full_key, json_val);
}
}
}
}
}
impl Default for ConfigRepo {
fn default() -> Self {
Self::new()
}
}
fn toml_value_to_json(v: &toml::Value) -> serde_json::Value {
match v {
toml::Value::String(s) => s.clone().into(),
toml::Value::Integer(i) => (*i).into(),
toml::Value::Float(f) => (*f).into(),
toml::Value::Boolean(b) => (*b).into(),
toml::Value::Datetime(d) => d.to_string().into(),
toml::Value::Array(arr) => {
let items: Vec<serde_json::Value> = arr.iter().map(toml_value_to_json).collect();
serde_json::Value::Array(items)
}
toml::Value::Table(t) => {
let map: serde_json::Map<String, serde_json::Value> = t
.iter()
.map(|(k, v)| (k.clone(), toml_value_to_json(v)))
.collect();
serde_json::Value::Object(map)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_repo() {
let repo = ConfigRepo::new();
assert!(repo.is_empty());
assert_eq!(repo.get::<String>("nonexistent"), None);
}
#[test]
fn test_set_and_get() {
let mut repo = ConfigRepo::new();
repo.set("app.name", "MyApp");
repo.set("app.port", 8080u16);
assert_eq!(repo.get::<String>("app.name").unwrap(), "MyApp");
assert_eq!(repo.get::<u16>("app.port").unwrap(), 8080);
assert_eq!(repo.get_or("app.debug", false), false);
}
#[test]
fn test_load_dir() {
let tmp = std::env::temp_dir().join("ravel_config_test");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(
tmp.join("app.toml"),
r#"
[app]
name = "MyApp"
version = "1.0"
[database]
host = "localhost"
port = 5432
"#,
)
.unwrap();
fs::write(
tmp.join("overrides.toml"),
r#"
[app]
port = 9000
"#,
)
.unwrap();
let repo = ConfigRepo::load_dir(&tmp).unwrap();
assert_eq!(repo.get::<String>("app.name").unwrap(), "MyApp");
assert_eq!(repo.get::<String>("app.version").unwrap(), "1.0");
assert_eq!(repo.get::<i64>("app.port").unwrap(), 9000); assert_eq!(repo.get::<String>("database.host").unwrap(), "localhost");
assert_eq!(repo.get::<i64>("database.port").unwrap(), 5432);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_missing_dir_is_ok() {
let repo = ConfigRepo::load_dir("/nonexistent/path/ravel").unwrap();
assert!(repo.is_empty());
}
#[test]
fn test_array_value() {
let mut repo = ConfigRepo::new();
repo.set("app.allowed_hosts", vec!["a.com", "b.com"]);
let hosts: Vec<String> = repo.get("app.allowed_hosts").unwrap();
assert_eq!(hosts, vec!["a.com", "b.com"]);
}
#[test]
fn test_env_key_simple() {
assert_eq!(
ConfigRepo::env_key_to_dotted("APP_NAME"),
Some("name".into())
);
}
#[test]
fn test_env_key_nested() {
assert_eq!(
ConfigRepo::env_key_to_dotted("APP_SERVER_HOST"),
Some("server.host".into())
);
assert_eq!(
ConfigRepo::env_key_to_dotted("APP_DATABASE_PORT"),
Some("database.port".into())
);
}
#[test]
fn test_env_key_three_segments() {
assert_eq!(
ConfigRepo::env_key_to_dotted("APP_DATABASE_MAX_CONNECTIONS"),
Some("database.max_connections".into())
);
}
#[test]
fn test_env_key_non_app_ignored() {
assert_eq!(ConfigRepo::env_key_to_dotted("PATH"), None);
assert_eq!(ConfigRepo::env_key_to_dotted("HOME"), None);
assert_eq!(ConfigRepo::env_key_to_dotted("SERVER_PORT"), None);
}
#[test]
fn test_env_key_empty_prefix() {
assert_eq!(ConfigRepo::env_key_to_dotted("APP_"), None);
}
#[test]
fn test_apply_env_overrides_simple() {
unsafe {
std::env::set_var("APP_NAME", "EnvApp");
std::env::set_var("APP_PORT", "9999");
}
let mut repo = ConfigRepo::new();
repo.set("name", "TomlApp");
repo.apply_env_overrides();
assert_eq!(repo.get::<String>("name").unwrap(), "EnvApp");
assert_eq!(repo.get::<String>("port").unwrap(), "9999");
unsafe {
std::env::remove_var("APP_NAME");
std::env::remove_var("APP_PORT");
}
}
#[test]
fn test_apply_env_overrides_nested() {
unsafe {
std::env::set_var("APP_SERVER_HOST", "0.0.0.0");
}
let mut repo = ConfigRepo::new();
repo.set("server.host", "127.0.0.1");
repo.set("server.port", 3000u16);
repo.apply_env_overrides();
assert_eq!(repo.get::<String>("server.host").unwrap(), "0.0.0.0");
assert_eq!(repo.get::<u16>("server.port").unwrap(), 3000);
unsafe {
std::env::remove_var("APP_SERVER_HOST");
}
}
#[test]
fn test_apply_env_overrides_ignores_non_app() {
let mut repo = ConfigRepo::new();
repo.set("server.host", "127.0.0.1");
repo.apply_env_overrides();
assert_eq!(repo.get::<String>("server.host").unwrap(), "127.0.0.1");
assert!(!repo.has("path"));
assert!(!repo.has("home"));
}
}