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: TomlSyntaxError,
},
#[error("config: settings shape mismatch: {0}")]
Shape(toml::de::Error),
#[error("config: env-var override `{var}` invalid: {detail}")]
EnvOverride { var: String, detail: String },
}
impl ConfigError {
#[must_use]
pub fn is_missing_config(&self) -> bool {
matches!(self, Self::Io { source, .. } if source.kind() == std::io::ErrorKind::NotFound)
}
#[must_use]
pub fn location(&self) -> String {
match self {
Self::Io { path, source } => format!("{path} ({})", source.kind()),
Self::Parse { path, source } => match source.line_col {
Some((line, col)) => format!("{path}, line {line}, column {col}"),
None => path.clone(),
},
Self::Shape(_) => "the merged settings (a value has the wrong type)".into(),
Self::EnvOverride { var, .. } => format!("env var `{var}`"),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TomlSyntaxError {
message: String,
line_col: Option<(usize, usize)>,
}
impl TomlSyntaxError {
fn new(err: &toml::de::Error, text: &str) -> Self {
let line_col = err.span().and_then(|s| {
let before = text.get(..s.start)?;
let line = before.matches('\n').count() + 1;
let col = before.rsplit('\n').next().map_or(0, |l| l.chars().count()) + 1;
Some((line, col))
});
Self {
message: err.message().to_owned(),
line_col,
}
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
#[must_use]
pub fn line_col(&self) -> Option<(usize, usize)> {
self.line_col
}
}
impl std::fmt::Display for TomlSyntaxError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let message = self.message.replace('\n', "; ");
match self.line_col {
Some((line, col)) => write!(f, "{message} at line {line}, column {col}"),
None => f.write_str(&message),
}
}
}
impl std::error::Error for TomlSyntaxError {}
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 tiered_path = root.join(format!("{env}_settings.toml"));
let legacy_path = root.join(format!("{env}.toml"));
let mut tree =
read_toml(&default_path, true)?.expect("read_toml(_, required=true) returns Some on Ok");
let tiered = read_toml(&tiered_path, false)?;
let legacy = read_toml(&legacy_path, false)?;
match (tiered, legacy) {
(Some(t), Some(_)) => {
eprintln!(
"config: both {} and {} exist — using the `_settings` variant; \
delete the legacy file to silence this warning",
tiered_path.display(),
legacy_path.display(),
);
merge(&mut tree, t);
}
(Some(t), None) => merge(&mut tree, t),
(None, Some(l)) => merge(&mut tree, l),
(None, None) => {}
}
apply_env_overrides(&mut tree, env_vars)?;
let settings: Settings = serde_path_to_error::deserialize(tree).map_err(shape_error)?;
Ok(settings)
}
fn shape_error(e: serde_path_to_error::Error<toml::de::Error>) -> ConfigError {
let path = e.path().to_string();
let message = e.inner().message();
let what = if message.starts_with("missing field `")
|| message.starts_with("unknown field `")
|| message.starts_with("invalid length ")
{
message.to_owned()
} else {
match message.rsplit_once(", expected ") {
Some((_, expected)) => format!("expected {expected}"),
None => "wrong type or value".to_owned(),
}
};
let safe = format!("`{path}`: {what}");
ConfigError::Shape(<toml::de::Error as serde::de::Error>::custom(safe))
}
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(|e| ConfigError::Parse {
path: path.display().to_string(),
source: TomlSyntaxError::new(&e, &s),
})?;
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 {
if let Some(fixed) = mistyped_override(&var) {
eprintln!("config: env var {var} is ignored; did you mean {fixed}?");
}
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 mistyped_override(var: &str) -> Option<String> {
let rest = var.strip_prefix("RUSTANGO_")?;
(!rest.starts_with('_') && rest.contains("__")).then(|| format!("RUSTANGO__{rest}"))
}
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 a_single_underscore_override_is_flagged() {
assert_eq!(
mistyped_override(concat!("RUSTANGO_", "MAIL__SMTP_PASSWORD")).as_deref(),
Some("RUSTANGO__MAIL__SMTP_PASSWORD")
);
for ok in [
"RUSTANGO__MAIL__SMTP_PASSWORD",
"RUSTANGO_SESSION_SECRET",
"RUSTANGO_ENV",
"OTHER_MAIL__X",
] {
assert_eq!(mistyped_override(ok), None, "{ok}");
}
}
#[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 tiered_settings_filename_loads() {
let root = fresh_root("tiered");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://localhost/dev\"\npool_max_size = 5\n",
);
write(
&root,
"prod_settings.toml",
"[database]\npool_max_size = 100\n",
);
let cfg = load_with_root(&root, "prod").unwrap();
assert_eq!(cfg.database.pool_max_size, Some(100));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn legacy_filename_still_loads() {
let root = fresh_root("legacy");
write(&root, "default.toml", "[database]\npool_max_size = 5\n");
write(&root, "prod.toml", "[database]\npool_max_size = 25\n");
let cfg = load_with_root(&root, "prod").unwrap();
assert_eq!(cfg.database.pool_max_size, Some(25));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn both_filenames_present_settings_wins() {
let root = fresh_root("both");
write(&root, "default.toml", "[database]\npool_max_size = 5\n");
write(&root, "prod.toml", "[database]\npool_max_size = 25\n");
write(
&root,
"prod_settings.toml",
"[database]\npool_max_size = 100\n",
);
let cfg = load_with_root(&root, "prod").unwrap();
assert_eq!(
cfg.database.pool_max_size,
Some(100),
"_settings variant must win when both exist"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn slice2_sections_parse_and_merge() {
let root = fresh_root("slice2");
write(
&root,
"default.toml",
r##"
[database]
url = "postgres://localhost/dev"
[server]
bind = "127.0.0.1:9000"
request_timeout_secs = 30
[auth]
argon2_memory_kib = 19456
argon2_iterations = 2
lockout_threshold = 5
[auth.jwt]
access_ttl_secs = 900
refresh_ttl_secs = 604800
issuer = "myapp"
[brand]
name = "Acme Operator"
primary_color = "#2c6fb0"
[security]
headers_preset = "strict"
hsts_max_age_secs = 31536000
[routes]
admin_url = "/admin"
login_url = "/login"
[audit]
retention_days = 90
"##,
);
let cfg = load_with_root(&root, "missing-env").unwrap();
assert_eq!(
cfg.database.url.as_deref(),
Some("postgres://localhost/dev")
);
assert_eq!(cfg.server.bind.as_deref(), Some("127.0.0.1:9000"));
assert_eq!(cfg.server.request_timeout_secs, Some(30));
assert_eq!(cfg.auth.argon2_memory_kib, Some(19456));
assert_eq!(cfg.auth.lockout_threshold, Some(5));
assert_eq!(cfg.auth.jwt.access_ttl_secs, Some(900));
assert_eq!(cfg.auth.jwt.issuer.as_deref(), Some("myapp"));
assert_eq!(cfg.brand.name.as_deref(), Some("Acme Operator"));
assert_eq!(cfg.brand.primary_color.as_deref(), Some("#2c6fb0"));
assert_eq!(cfg.security.headers_preset.as_deref(), Some("strict"));
assert_eq!(cfg.security.hsts_max_age_secs, Some(31536000));
assert_eq!(cfg.routes.admin_url.as_deref(), Some("/admin"));
assert_eq!(cfg.routes.login_url.as_deref(), Some("/login"));
assert_eq!(cfg.audit.retention_days, Some(90));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn detected_features_lists_compiled_in_features() {
use crate::config::Settings;
let feats = Settings::detected_features();
assert!(!feats.is_empty(), "expected at least one feature");
let compiled = [
("postgres", cfg!(feature = "postgres")),
("mysql", cfg!(feature = "mysql")),
("sqlite", cfg!(feature = "sqlite")),
];
for (backend, on) in compiled {
assert_eq!(feats.contains(&backend), on, "{backend}: {feats:?}");
}
}
#[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);
}
#[test]
fn parse_error_text_never_quotes_the_source() {
let root = fresh_root("parse_error_secret");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://app:s3cret@db/app\n",
);
let err = load_with_root(&root, "any").unwrap_err();
let mut texts = vec![format!("{err:?}"), format!("{err:#?}")];
let mut cur: Option<&dyn std::error::Error> = Some(&err);
while let Some(e) = cur {
texts.push(e.to_string());
cur = e.source();
}
for text in texts {
assert!(!text.contains("s3cret"), "{text}");
}
assert!(err.to_string().contains("line 2"), "{err}");
assert!(err.location().contains("line 2"), "{}", err.location());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn shape_error_text_never_quotes_the_value() {
for (body, key) in [
(
"[database]\npool_max_size = \"s3cret\"\n",
"database.pool_max_size",
),
(
"[security]\nsecure_ssl_redirect = \"s3cret\"\n",
"security.secure_ssl_redirect",
),
] {
let root = fresh_root("shape_error_secret");
write(&root, "default.toml", body);
let err = load_with_root(&root, "any").unwrap_err();
assert!(matches!(err, ConfigError::Shape(_)), "{err:?}");
let texts = [err.to_string(), format!("{err:?}"), format!("{err:#?}")];
for text in &texts {
assert!(!text.contains("s3cret"), "{text}");
}
assert!(
texts[0].contains(key) && texts[0].contains("expected"),
"{err}"
);
let _ = std::fs::remove_dir_all(&root);
}
}
}