use std::path::Path;
use super::sections::Settings;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config: failed to read {path}: {source}")]
Io {
path: String,
#[source]
source: std::io::Error,
},
#[error("config: failed to parse {path}: {source}")]
Parse {
path: String,
#[source]
source: toml::de::Error,
},
#[error("config: settings shape mismatch: {0}")]
Shape(toml::de::Error),
#[error("config: env-var override `{var}` invalid: {detail}")]
EnvOverride { var: String, detail: String },
}
pub(super) fn load_with_root(root: &Path, env: &str) -> Result<Settings, ConfigError> {
load_with_root_and_env(root, env, std::env::vars())
}
fn load_with_root_and_env<I>(
root: &Path,
env: &str,
env_vars: I,
) -> Result<Settings, ConfigError>
where
I: IntoIterator<Item = (String, String)>,
{
let default_path = root.join("default.toml");
let env_path = root.join(format!("{env}.toml"));
let mut tree = read_toml(&default_path, true)?
.expect("read_toml(_, required=true) returns Some on Ok");
if let Some(overlay) = read_toml(&env_path, false)? {
merge(&mut tree, overlay);
}
apply_env_overrides(&mut tree, env_vars)?;
let settings: Settings = tree.try_into().map_err(ConfigError::Shape)?;
Ok(settings)
}
fn read_toml(path: &Path, required: bool) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(s) => {
let value = s.parse::<toml::Value>().map_err(|source| ConfigError::Parse {
path: path.display().to_string(),
source,
})?;
Ok(Some(value))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !required => Ok(None),
Err(e) => Err(ConfigError::Io {
path: path.display().to_string(),
source: e,
}),
}
}
fn merge(dst: &mut toml::Value, overlay: toml::Value) {
match (dst, overlay) {
(toml::Value::Table(d), toml::Value::Table(o)) => {
for (k, v) in o {
match d.get_mut(&k) {
Some(existing) => merge(existing, v),
None => {
d.insert(k, v);
}
}
}
}
(slot, other) => {
*slot = other;
}
}
}
fn apply_env_overrides<I>(tree: &mut toml::Value, env_vars: I) -> Result<(), ConfigError>
where
I: IntoIterator<Item = (String, String)>,
{
const PREFIX: &str = "RUSTANGO__";
for (var, raw) in env_vars {
let Some(rest) = var.strip_prefix(PREFIX) else {
continue;
};
if rest.is_empty() {
continue;
}
let path: Vec<String> = rest
.split("__")
.map(|seg| seg.to_ascii_lowercase())
.collect();
let value = parse_env_value(&var, &raw)?;
graft(tree, &path, value);
}
Ok(())
}
fn parse_env_value(_var: &str, raw: &str) -> Result<toml::Value, ConfigError> {
let probe = format!("__rustango_envvar = {raw}");
if let Ok(parsed) = probe.parse::<toml::Value>() {
if let toml::Value::Table(t) = parsed {
if let Some(v) = t.into_iter().next().map(|(_, v)| v) {
return Ok(v);
}
}
}
Ok(toml::Value::String(raw.to_owned()))
}
fn graft(tree: &mut toml::Value, path: &[String], value: toml::Value) {
if path.is_empty() {
*tree = value;
return;
}
if !matches!(tree, toml::Value::Table(_)) {
*tree = toml::Value::Table(toml::value::Table::new());
}
let toml::Value::Table(t) = tree else {
unreachable!()
};
let head = &path[0];
if path.len() == 1 {
t.insert(head.clone(), value);
} else {
let entry = t
.entry(head.clone())
.or_insert_with(|| toml::Value::Table(toml::value::Table::new()));
graft(entry, &path[1..], value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn fresh_root(label: &str) -> std::path::PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let pid = std::process::id();
let mut p = std::env::temp_dir();
p.push(format!("rustango_config_{label}_{pid}_{n}"));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write(root: &std::path::Path, name: &str, body: &str) {
std::fs::write(root.join(name), body).unwrap();
}
#[test]
fn loads_default_only() {
let root = fresh_root("default_only");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://localhost/dev\"\n",
);
let cfg = load_with_root(&root, "missing-env").unwrap();
assert_eq!(
cfg.database.url.as_deref(),
Some("postgres://localhost/dev")
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn env_file_overlays_default() {
let root = fresh_root("overlay");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://localhost/dev\"\npool_max_size = 5\n",
);
write(&root, "prod.toml", "[database]\npool_max_size = 50\n");
let cfg = load_with_root(&root, "prod").unwrap();
assert_eq!(
cfg.database.url.as_deref(),
Some("postgres://localhost/dev"),
"default url survives"
);
assert_eq!(cfg.database.pool_max_size, Some(50), "env file wins");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn env_var_overrides_file() {
let root = fresh_root("env_var");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://from-file\"\n",
);
let mock_env = vec![(
"RUSTANGO__DATABASE__URL".to_owned(),
"postgres://from-env".to_owned(),
)];
let cfg = load_with_root_and_env(&root, "missing-env", mock_env).unwrap();
assert_eq!(
cfg.database.url.as_deref(),
Some("postgres://from-env"),
"env var beats file"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn env_var_typed_int() {
let root = fresh_root("typed_int");
write(&root, "default.toml", "[database]\npool_max_size = 5\n");
let mock_env = vec![(
"RUSTANGO__DATABASE__POOL_MAX_SIZE".to_owned(),
"42".to_owned(),
)];
let cfg = load_with_root_and_env(&root, "missing-env", mock_env).unwrap();
assert_eq!(cfg.database.pool_max_size, Some(42));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn nested_section_via_env_var() {
let root = fresh_root("nested");
write(&root, "default.toml", "[admin]\n");
let mock_env = vec![(
"RUSTANGO__ADMIN__ALLOWED_TABLES".to_owned(),
r#"["user", "post"]"#.to_owned(),
)];
let cfg = load_with_root_and_env(&root, "missing-env", mock_env).unwrap();
assert_eq!(
cfg.admin.allowed_tables,
vec!["user".to_owned(), "post".to_owned()]
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn missing_default_errors() {
let root = fresh_root("missing_default");
let err = load_with_root(&root, "any").unwrap_err();
assert!(matches!(err, ConfigError::Io { .. }));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn parse_error_includes_path() {
let root = fresh_root("parse_error");
write(&root, "default.toml", "this is = not [valid toml");
let err = load_with_root(&root, "any").unwrap_err();
match err {
ConfigError::Parse { path, .. } => assert!(path.contains("default.toml")),
other => panic!("expected ConfigError::Parse, got {other:?}"),
}
let _ = std::fs::remove_dir_all(&root);
}
}