use std::collections::BTreeMap;
use std::path::Path;
use super::sections::Settings;
use crate::env::SettingSource;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
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 or key does not fit)".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, utf8_env_vars(std::env::vars_os()))
}
fn utf8_env_vars<I>(vars: I) -> Vec<(String, String)>
where
I: IntoIterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
{
vars.into_iter()
.filter_map(|(k, v)| match (k.into_string(), v.into_string()) {
(Ok(k), Ok(v)) => Some((k, v)),
(Ok(k), Err(_)) if k.starts_with("RUSTANGO") => {
eprintln!("config: env var {k} is not valid UTF-8; ignoring it");
None
}
_ => None,
})
.collect()
}
fn load_with_root_and_env<I>(root: &Path, env: &str, env_vars: I) -> Result<Settings, ConfigError>
where
I: IntoIterator<Item = (String, String)>,
{
let env_vars: Vec<(String, String)> = env_vars.into_iter().collect();
let strict = env_vars
.iter()
.any(|(k, v)| k == STRICT_VAR && is_truthy(v));
let Loaded {
settings, ignored, ..
} = load_report(root, env, env_vars)?;
let removed: Vec<String> = ignored
.iter()
.filter_map(|k| super::registry::removed(k))
.map(|(key, instead)| format!("`{key}` was removed: {instead}"))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
if !removed.is_empty() {
return Err(ConfigError::Shape(
<toml::de::Error as serde::de::Error>::custom(removed.join("; ")),
));
}
if !ignored.is_empty() {
if strict {
return Err(ConfigError::Shape(
<toml::de::Error as serde::de::Error>::custom(format!(
"unknown keys ({STRICT_VAR} is set): {}",
ignored.join(", ")
)),
));
}
for key in &ignored {
eprintln!("config: unknown key `{key}` is ignored; check its spelling and section");
}
}
Ok(settings)
}
const STRICT_VAR: &str = "RUSTANGO_CONFIG_STRICT";
fn is_truthy(v: &str) -> bool {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
pub(crate) struct Loaded {
pub(crate) settings: Settings,
pub(crate) ignored: Vec<String>,
pub(crate) tree: toml::Value,
pub(crate) sources: BTreeMap<String, SettingSource>,
}
pub(crate) fn report(root: &Path, env: &str) -> Result<Loaded, ConfigError> {
load_report(root, env, utf8_env_vars(std::env::vars_os()))
}
#[cfg(test)]
pub(crate) fn report_from(
root: &Path,
env: &str,
env_vars: Vec<(String, String)>,
) -> Result<Loaded, ConfigError> {
load_report(root, env, env_vars)
}
fn load_report(
root: &Path,
env: &str,
env_vars: Vec<(String, String)>,
) -> Result<Loaded, ConfigError> {
let default_path = root.join("default.toml");
let tier = crate::env::canonical_tier(env);
let names = [tier.as_str(), env.trim()];
let pick = names
.iter()
.find(|n| {
root.join(format!("{n}_settings.toml")).exists()
|| root.join(format!("{n}.toml")).exists()
})
.unwrap_or(&names[0]);
let tiered_path = root.join(format!("{pick}_settings.toml"));
let legacy_path = root.join(format!("{pick}.toml"));
let mut tree =
read_toml(&default_path, true)?.expect("read_toml(_, required=true) returns Some on Ok");
let mut sources = BTreeMap::new();
let file = |p: &Path| SettingSource::File(p.display().to_string());
leaves(&tree, "", &file(&default_path), &mut sources);
let tiered = read_toml(&tiered_path, false)?;
let legacy = read_toml(&legacy_path, false)?;
if tiered.is_none() && legacy.is_none() && tier != "dev" {
eprintln!(
"config: tier `{tier}` has no {} — running on default.toml alone",
tiered_path.display()
);
}
match (tiered, legacy) {
(Some(t), Some(_)) => {
leaves(&t, "", &file(&tiered_path), &mut sources);
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) => {
leaves(&t, "", &file(&tiered_path), &mut sources);
merge(&mut tree, t);
}
(None, Some(l)) => {
leaves(&l, "", &file(&legacy_path), &mut sources);
merge(&mut tree, l);
}
(None, None) => {}
}
let mut overrides = apply_env_overrides(&mut tree, env_vars)?;
for o in &overrides {
sources.retain(|k, _| !o.covers(k));
sources.insert(o.key.clone(), SettingSource::Override(o.var.clone()));
}
loop {
let mut ignored: Vec<String> = Vec::new();
let err = match serde_path_to_error::deserialize(serde_ignored::Deserializer::new(
tree.clone(),
&mut |path: serde_ignored::Path<'_>| ignored.push(path.to_string()),
)) {
Ok(settings) => {
ignored.sort();
ignored.dedup();
return Ok(Loaded {
settings,
ignored,
tree,
sources,
});
}
Err(e) => e,
};
let at = err.path().to_string();
let Some(o) = overrides.iter_mut().find(|o| o.covers(&at)) else {
return Err(shape_error(&err));
};
if o.key == at && !o.as_string {
o.as_string = true;
graft(&mut tree, &o.path, toml::Value::String(o.raw.clone()));
continue;
}
return Err(ConfigError::EnvOverride {
var: o.var.clone(),
detail: safe_shape_message(&err),
});
}
}
struct EnvOverride {
var: String,
path: Vec<String>,
key: String,
raw: String,
as_string: bool,
}
impl EnvOverride {
fn covers(&self, at: &str) -> bool {
at.strip_prefix(self.key.as_str())
.is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '[']))
}
}
fn shape_error(e: &serde_path_to_error::Error<toml::de::Error>) -> ConfigError {
ConfigError::Shape(<toml::de::Error as serde::de::Error>::custom(
safe_shape_message(e),
))
}
fn safe_shape_message(e: &serde_path_to_error::Error<toml::de::Error>) -> String {
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(),
}
};
format!("`{path}`: {what}")
}
fn leaves(
v: &toml::Value,
at: &str,
source: &SettingSource,
out: &mut BTreeMap<String, SettingSource>,
) {
match v {
toml::Value::Table(t) => {
for (k, child) in t {
let path = if at.is_empty() {
k.clone()
} else {
format!("{at}.{k}")
};
leaves(child, &path, source, out);
}
}
_ => {
out.retain(|k, _| !(k.strip_prefix(at).is_some_and(|r| r.starts_with('.'))));
out.insert(at.to_owned(), source.clone());
}
}
}
fn read_toml(path: &Path, required: bool) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(s) => {
let table = s.parse::<toml::Table>().map_err(|e| ConfigError::Parse {
path: path.display().to_string(),
source: TomlSyntaxError::new(&e, &s),
})?;
Ok(Some(toml::Value::Table(table)))
}
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<Vec<EnvOverride>, ConfigError>
where
I: IntoIterator<Item = (String, String)>,
{
const PREFIX: &str = "RUSTANGO__";
let mut grafted = Vec::new();
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)?;
let as_string = value.is_str();
graft(tree, &path, value);
grafted.push(EnvOverride {
key: path.join("."),
var,
path,
raw,
as_string,
});
}
Ok(grafted)
}
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(mut t) = probe.parse::<toml::Table>() {
if t.len() == 1 {
if let Some(v) = t.remove("__rustango_envvar") {
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_value_with_a_second_key_stays_a_string() {
let raw = "\"x\"\nA = \"y\"";
let v = super::parse_env_value("RUSTANGO__X", raw).unwrap();
assert_eq!(v, toml::Value::String(raw.to_owned()));
assert_eq!(
super::parse_env_value("RUSTANGO__X", "12").unwrap(),
toml::Value::Integer(12)
);
}
#[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 env_var_digits_fill_a_string_field() {
let root = fresh_root("digit_secret");
write(&root, "default.toml", "[mail]\n");
let mock_env = vec![
(
"RUSTANGO__MAIL__SMTP_PASSWORD".to_owned(),
"123456".to_owned(),
),
(
"RUSTANGO__MAIL__SMTP_USERNAME".to_owned(),
"true".to_owned(),
),
(
"RUSTANGO__DATABASE__POOL_MAX_SIZE".to_owned(),
"7".to_owned(),
),
];
let cfg = load_with_root_and_env(&root, "missing-env", mock_env).unwrap();
assert_eq!(cfg.mail.smtp_password.as_deref(), Some("123456"));
assert_eq!(cfg.mail.smtp_username.as_deref(), Some("true"));
assert_eq!(cfg.database.pool_max_size, Some(7));
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_bad_env_override_names_the_var() {
let root = fresh_root("bad_override");
write(&root, "default.toml", "[database]\n");
let mock_env = vec![(
"RUSTANGO__DATABASE__POOL_MAX_SIZE".to_owned(),
"tenSECRET".to_owned(),
)];
let err = load_with_root_and_env(&root, "missing-env", mock_env).unwrap_err();
assert!(
matches!(&err, ConfigError::EnvOverride { var, .. } if var == "RUSTANGO__DATABASE__POOL_MAX_SIZE"),
"{err:?}"
);
assert!(!err.to_string().contains("SECRET"), "{err}");
let _ = std::fs::remove_dir_all(&root);
}
#[cfg(unix)]
#[test]
fn non_utf8_env_vars_are_skipped() {
use std::os::unix::ffi::OsStringExt as _;
let bad = std::ffi::OsString::from_vec(vec![0xff, 0xfe]);
let vars = utf8_env_vars(vec![
(
std::ffi::OsString::from("RUSTANGO__MAIL__SMTP_USERNAME"),
bad.clone(),
),
(bad, std::ffi::OsString::from("x")),
("OK".into(), "1".into()),
]);
assert_eq!(vars, vec![("OK".to_owned(), "1".to_owned())]);
}
#[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 production_spellings_load_the_prod_file() {
let root = fresh_root("prod_spellings");
write(&root, "default.toml", "[database]\npool_max_size = 5\n");
write(
&root,
"prod_settings.toml",
"[database]\npool_max_size = 100\n",
);
for tier in ["production", "PROD", " Production "] {
let cfg = load_with_root(&root, tier).unwrap();
assert_eq!(cfg.database.pool_max_size, Some(100), "{tier:?}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn raw_tier_filename_still_loads() {
let root = fresh_root("raw_tier");
write(&root, "default.toml", "[database]\npool_max_size = 5\n");
write(
&root,
"production_settings.toml",
"[database]\npool_max_size = 7\n",
);
let cfg = load_with_root(&root, "production").unwrap();
assert_eq!(cfg.database.pool_max_size, Some(7));
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
login_field = "username"
[auth.jwt]
access_ttl_secs = 900
refresh_ttl_secs = 604800
[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.login_field,
Some(crate::login_field::LoginField::Username)
);
assert_eq!(cfg.auth.jwt.access_ttl_secs, Some(900));
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);
}
}
const MISPLACED: &str =
"top = 1\n[security]\nallowed_host = \"x\"\nheaders_preset = \"strict\"\n\
[auth]\naccess_ttl_secs = 5\n[auth.jwt]\naccess_ttl_secs = 900\n";
#[test]
fn unknown_keys_are_reported() {
let root = fresh_root("unknown_keys");
write(&root, "default.toml", MISPLACED);
let env = vec![("RUSTANGO__MAIL__SMTP_PASWORD".to_owned(), "x".to_owned())];
let Loaded {
settings: cfg,
ignored,
..
} = load_report(&root, "any", env).unwrap();
assert_eq!(cfg.auth.jwt.access_ttl_secs, Some(900));
assert_eq!(
ignored,
[
"auth.access_ttl_secs",
"mail.smtp_pasword",
"security.allowed_host",
"top"
]
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn sources_name_the_last_layer_per_key() {
let root = fresh_root("sources");
write(
&root,
"default.toml",
"[database]\nurl = \"postgres://a/b\"\npool_max_size = 5\npool_min_size = 1\n",
);
write(
&root,
"prod_settings.toml",
"[database]\npool_max_size = 50\n",
);
let env = vec![(
"RUSTANGO__DATABASE__POOL_MIN_SIZE".to_owned(),
"2".to_owned(),
)];
let got = load_report(&root, "prod", env).unwrap().sources;
let file = |name: &str| SettingSource::File(root.join(name).display().to_string());
assert_eq!(got["database.url"], file("default.toml"));
assert_eq!(got["database.pool_max_size"], file("prod_settings.toml"));
assert_eq!(
got["database.pool_min_size"],
SettingSource::Override("RUSTANGO__DATABASE__POOL_MIN_SIZE".into())
);
assert_eq!(got.len(), 3, "{got:?}");
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn strict_mode_refuses_unknown_keys() {
let root = fresh_root("unknown_keys_strict");
write(&root, "default.toml", MISPLACED);
let env = vec![("RUSTANGO_CONFIG_STRICT".to_owned(), "1".to_owned())];
let err = load_with_root_and_env(&root, "any", env).unwrap_err();
assert!(matches!(err, ConfigError::Shape(_)), "{err:?}");
assert!(err.to_string().contains("security.allowed_host"), "{err}");
assert!(load_with_root_and_env(&root, "any", Vec::new()).is_ok());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_removed_key_is_refused() {
let root = fresh_root("removed_keys");
write(
&root,
"default.toml",
"[sso]\nclient_secret = \"x\"\n\n[admin]\nprimary_color = \"#000000\"\n",
);
let err = load_with_root_and_env(&root, "any", Vec::new()).unwrap_err();
let msg = err.to_string();
assert!(matches!(err, ConfigError::Shape(_)), "{err:?}");
assert!(
msg.contains("`sso` was removed") && msg.contains("SSO provider"),
"{msg}"
);
assert!(msg.contains("`admin.primary_color` was removed"), "{msg}");
assert!(!msg.contains("#000000"), "a value leaked: {msg}");
write(&root, "default.toml", "");
let env = vec![("RUSTANGO__AUTH__JWT__ISSUER".to_owned(), "myapp".to_owned())];
let err = load_with_root_and_env(&root, "any", env).unwrap_err();
assert!(
err.to_string().contains("`auth.jwt.issuer` was removed"),
"{err}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn example_configs_load_in_strict_mode() {
let examples = Path::new(env!("CARGO_MANIFEST_DIR")).join("examples");
let strict = || vec![(STRICT_VAR.to_owned(), "1".to_owned())];
let mut checked = 0;
for entry in std::fs::read_dir(&examples).expect("read examples/") {
let config = entry.expect("examples/ entry").path().join("config");
if !config.join("default.toml").is_file() {
continue;
}
for file in std::fs::read_dir(&config).expect("read config/") {
let name = file.expect("config/ entry").file_name();
let name = name.to_str().unwrap();
let Some(stem) = name.strip_suffix(".toml") else {
continue;
};
let env = stem.strip_suffix("_settings").unwrap_or(stem);
if let Err(e) = load_with_root_and_env(&config, env, strict()) {
panic!("{}/{name}: {e}", config.display());
}
checked += 1;
}
}
assert!(checked >= 10, "checked only {checked} config files");
}
}