use std::path::PathBuf;
use std::sync::Arc;
use toml::{Table, Value};
use crate::env;
use crate::error::ClapfigError;
use crate::merge::deep_merge;
use crate::normalize::{normalize_key, normalize_table};
use crate::overrides;
use crate::spec::ConfigSpec;
use crate::strict::{StrictnessOverrides, UnknownKeyHook};
use crate::types::Layer;
use crate::validate::ValidateContext;
pub(crate) struct ResolveInput<'a, S: ConfigSpec> {
pub spec: &'a S,
pub files: Vec<(PathBuf, String)>,
pub env_vars: Vec<(String, String)>,
pub env_prefix: Option<String>,
#[cfg(feature = "url")]
pub url_overrides: Vec<(String, Value)>,
pub cli_overrides: Vec<(String, Value)>,
pub strict_default: bool,
pub strict_overrides: StrictnessOverrides,
pub unknown_key_hook: Option<UnknownKeyHook>,
pub normalize_keys: bool,
pub layer_order: Option<Vec<Layer>>,
}
fn normalize_override_keys(
entries: &[(String, toml::Value)],
normalize_keys: bool,
) -> Vec<(String, toml::Value)> {
if !normalize_keys {
return entries.to_vec();
}
entries
.iter()
.map(|(k, v)| (normalize_key(k), v.clone()))
.collect()
}
pub(crate) fn default_layer_order() -> Vec<Layer> {
vec![
Layer::Files,
Layer::Env,
#[cfg(feature = "url")]
Layer::Url,
Layer::Cli,
]
}
pub(crate) fn resolve<S: ConfigSpec>(
input: ResolveInput<'_, S>,
) -> Result<S::Output, ClapfigError> {
let validate_ctx = ValidateContext {
overrides: &input.strict_overrides,
default_strict: input.strict_default,
callback: input.unknown_key_hook.as_ref(),
normalize_keys: input.normalize_keys,
};
let cascade_active = input.strict_default || input.strict_overrides.has_any_strict();
let files_table = {
let mut t = Table::new();
for (path, content) in &input.files {
let mut table: Table =
toml::from_str(content).map_err(|e| ClapfigError::ParseError {
path: path.clone(),
source: Box::new(e),
source_text: Some(Arc::from(content.as_str())),
})?;
if input.normalize_keys {
normalize_table(&mut table).map_err(|c| ClapfigError::NormalizedKeyCollision {
path: path.clone(),
section: c.section,
normalized_key: c.normalized_key,
originals: c.originals,
})?;
}
if cascade_active {
input
.spec
.validate_unknown(&table, content, path, &validate_ctx)?;
}
t = deep_merge(t, table);
}
t
};
let env_table = input
.env_prefix
.as_ref()
.map(|prefix| env::env_to_table(prefix, input.env_vars));
#[cfg(feature = "url")]
let url_table = if input.url_overrides.is_empty() {
None
} else {
Some(overrides::overrides_to_table(&normalize_override_keys(
&input.url_overrides,
input.normalize_keys,
)))
};
let cli_table = if input.cli_overrides.is_empty() {
None
} else {
Some(overrides::overrides_to_table(&normalize_override_keys(
&input.cli_overrides,
input.normalize_keys,
)))
};
let default_order = default_layer_order();
let order = input.layer_order.as_deref().unwrap_or(&default_order);
let mut merged = Table::new();
for layer in order {
let table = match layer {
Layer::Files => Some(files_table.clone()),
Layer::Env => env_table.clone(),
#[cfg(feature = "url")]
Layer::Url => url_table.clone(),
Layer::Cli => cli_table.clone(),
};
if let Some(t) = table {
merged = deep_merge(merged, t);
}
}
input.spec.fill_defaults(&mut merged)?;
input.spec.finalize(merged)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::test::{NormalizedConfig, TestConfig};
use crate::spec::StaticSpec;
const TEST_SPEC: StaticSpec<TestConfig> = StaticSpec::new();
const NORM_SPEC: StaticSpec<NormalizedConfig> = StaticSpec::new();
fn empty_input<'a, S: ConfigSpec>(spec: &'a S) -> ResolveInput<'a, S> {
ResolveInput {
spec,
files: vec![],
env_vars: vec![],
env_prefix: None,
#[cfg(feature = "url")]
url_overrides: vec![],
cli_overrides: vec![],
strict_default: true,
strict_overrides: StrictnessOverrides::new(),
unknown_key_hook: None,
normalize_keys: false,
layer_order: None,
}
}
#[test]
fn defaults_only() {
let config: TestConfig = resolve(empty_input(&TEST_SPEC)).unwrap();
assert_eq!(config.host, "localhost");
assert_eq!(config.port, 8080);
assert!(!config.debug);
assert_eq!(config.database.pool_size, 5);
assert_eq!(config.database.url, None);
}
#[test]
fn file_overrides_default() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 3000);
assert_eq!(config.host, "localhost"); }
#[test]
fn later_file_overrides_earlier() {
let input = ResolveInput {
files: vec![
("first.toml".into(), "port = 1000\n".into()),
("second.toml".into(), "port = 2000\n".into()),
],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 2000);
}
#[test]
fn env_overrides_file() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 5000);
}
#[test]
fn cli_overrides_all() {
let input = ResolveInput {
spec: &TEST_SPEC,
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
#[cfg(feature = "url")]
url_overrides: vec![],
cli_overrides: vec![("port".into(), Value::Integer(9999))],
strict_default: true,
strict_overrides: StrictnessOverrides::new(),
unknown_key_hook: None,
normalize_keys: false,
layer_order: None,
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 9999);
}
#[test]
fn sparse_merge_across_layers() {
let input = ResolveInput {
spec: &TEST_SPEC,
files: vec![(
"test.toml".into(),
"host = \"filehost\"\n[database]\npool_size = 20\n".into(),
)],
env_vars: vec![("APP__PORT".into(), "4000".into())],
env_prefix: Some("APP".into()),
#[cfg(feature = "url")]
url_overrides: vec![],
cli_overrides: vec![("debug".into(), Value::Boolean(true))],
strict_default: true,
strict_overrides: StrictnessOverrides::new(),
unknown_key_hook: None,
normalize_keys: false,
layer_order: None,
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.host, "filehost"); assert_eq!(config.port, 4000); assert!(config.debug); assert_eq!(config.database.pool_size, 20); }
#[test]
fn nested_file_merge() {
let input = ResolveInput {
files: vec![
(
"base.toml".into(),
"[database]\nurl = \"pg://base\"\npool_size = 5\n".into(),
),
("local.toml".into(), "[database]\npool_size = 50\n".into()),
],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.url.as_deref(), Some("pg://base")); assert_eq!(config.database.pool_size, 50); }
#[test]
fn strict_rejects_unknown_key() {
let input = ResolveInput {
files: vec![("bad.toml".into(), "typo = 1\n".into())],
strict_default: true,
strict_overrides: StrictnessOverrides::new(),
unknown_key_hook: None,
..empty_input(&TEST_SPEC)
};
let result: Result<TestConfig, _> = resolve(input);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("typo") || msg.contains("Unknown"));
}
#[test]
fn lenient_allows_unknown_key() {
let input = ResolveInput {
files: vec![("ok.toml".into(), "typo = 1\nport = 3000\n".into())],
strict_default: false,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn deserialize_with_normalizes_from_file() {
let input = ResolveInput {
files: vec![("test.toml".into(), "color = \"BLUE\"\n".into())],
..empty_input(&NORM_SPEC)
};
let config: NormalizedConfig = resolve(input).unwrap();
assert_eq!(config.color, "blue");
}
#[test]
fn deserialize_with_normalizes_from_env() {
let input = ResolveInput {
env_vars: vec![("APP__COLOR".into(), "GREEN".into())],
env_prefix: Some("APP".into()),
..empty_input(&NORM_SPEC)
};
let config: NormalizedConfig = resolve(input).unwrap();
assert_eq!(config.color, "green");
}
#[test]
fn deserialize_with_normalizes_from_cli_override() {
let input = ResolveInput {
cli_overrides: vec![("color".into(), Value::String("MAGENTA".into()))],
..empty_input(&NORM_SPEC)
};
let config: NormalizedConfig = resolve(input).unwrap();
assert_eq!(config.color, "magenta");
}
#[test]
fn deserialize_with_default_is_not_normalized() {
let config: NormalizedConfig = resolve(empty_input(&NORM_SPEC)).unwrap();
assert_eq!(config.color, "red");
}
#[cfg(feature = "url")]
#[test]
fn url_overrides_env() {
let input = ResolveInput {
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
url_overrides: vec![("port".into(), Value::Integer(7777))],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 7777);
}
#[cfg(feature = "url")]
#[test]
fn cli_overrides_url() {
let input = ResolveInput {
url_overrides: vec![("port".into(), Value::Integer(7777))],
cli_overrides: vec![("port".into(), Value::Integer(9999))],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 9999);
}
#[cfg(feature = "url")]
#[test]
fn url_nested_key() {
let input = ResolveInput {
url_overrides: vec![("database.pool_size".into(), Value::Integer(42))],
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.pool_size, 42);
}
#[test]
fn custom_order_env_overrides_cli() {
let input = ResolveInput {
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
cli_overrides: vec![("port".into(), Value::Integer(9999))],
layer_order: Some(vec![Layer::Cli, Layer::Env]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 5000);
}
#[test]
fn custom_order_files_override_env() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
layer_order: Some(vec![Layer::Env, Layer::Files]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn custom_order_omitted_layer_excluded() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
layer_order: Some(vec![Layer::Files, Layer::Cli]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 3000);
}
#[test]
fn custom_order_cli_only() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
cli_overrides: vec![("port".into(), Value::Integer(7777))],
layer_order: Some(vec![Layer::Cli]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 7777);
}
#[test]
fn custom_order_empty_uses_only_defaults() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
cli_overrides: vec![("port".into(), Value::Integer(9999))],
layer_order: Some(vec![]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 8080);
}
#[test]
fn default_order_preserved_when_none() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
cli_overrides: vec![("port".into(), Value::Integer(9999))],
layer_order: None,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 9999); }
#[test]
fn custom_order_all_three_sources_reordered() {
let input = ResolveInput {
files: vec![(
"test.toml".into(),
"host = \"filehost\"\nport = 3000\n".into(),
)],
env_vars: vec![("APP__PORT".into(), "5000".into())],
env_prefix: Some("APP".into()),
cli_overrides: vec![
("port".into(), Value::Integer(9999)),
("debug".into(), Value::Boolean(true)),
],
layer_order: Some(vec![Layer::Cli, Layer::Files, Layer::Env]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 5000);
assert_eq!(config.host, "filehost");
assert!(config.debug);
}
#[cfg(feature = "url")]
#[test]
fn custom_order_url_highest_priority() {
let input = ResolveInput {
files: vec![("test.toml".into(), "port = 3000\n".into())],
env_vars: vec![("MYAPP__PORT".into(), "5000".into())],
env_prefix: Some("MYAPP".into()),
url_overrides: vec![("port".into(), Value::Integer(7777))],
cli_overrides: vec![("port".into(), Value::Integer(9999))],
layer_order: Some(vec![Layer::Files, Layer::Env, Layer::Cli, Layer::Url]),
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.port, 7777);
}
#[test]
fn normalize_off_kebab_file_key_rejected_by_strict() {
let input = ResolveInput {
files: vec![("test.toml".into(), "[database]\npool-size = 25\n".into())],
..empty_input(&TEST_SPEC)
};
let result: Result<TestConfig, _> = resolve(input);
assert!(result.is_err());
}
#[test]
fn normalize_on_kebab_file_key_accepted() {
let input = ResolveInput {
files: vec![("test.toml".into(), "[database]\npool-size = 25\n".into())],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.pool_size, 25);
}
#[test]
fn normalize_on_snake_file_key_still_works() {
let input = ResolveInput {
files: vec![("test.toml".into(), "[database]\npool_size = 30\n".into())],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.pool_size, 30);
}
#[test]
fn normalize_on_kebab_cli_override_accepted() {
let input = ResolveInput {
cli_overrides: vec![("database.pool-size".into(), Value::Integer(77))],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.pool_size, 77);
}
#[cfg(feature = "url")]
#[test]
fn normalize_on_kebab_url_override_accepted() {
let input = ResolveInput {
url_overrides: vec![("database.pool-size".into(), Value::Integer(88))],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.database.pool_size, 88);
}
#[test]
fn normalize_on_kebab_typo_still_strict_errors() {
let input = ResolveInput {
files: vec![("test.toml".into(), "[database]\npool-zize = 25\n".into())],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let result: Result<TestConfig, _> = resolve(input);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].key, "database.pool_zize");
}
#[test]
fn normalize_on_collision_in_file_errors() {
let input = ResolveInput {
files: vec![(
"test.toml".into(),
"[database]\npool-size = 5\npool_size = 10\n".into(),
)],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let result: Result<TestConfig, _> = resolve(input);
match result {
Err(ClapfigError::NormalizedKeyCollision {
normalized_key,
section,
originals,
..
}) => {
assert_eq!(normalized_key, "pool_size");
assert_eq!(section, "database");
assert_eq!(originals, vec!["pool-size", "pool_size"]);
}
other => panic!("expected NormalizedKeyCollision, got {other:?}"),
}
}
#[test]
fn normalize_on_mixed_kebab_and_snake_in_same_file() {
let input = ResolveInput {
files: vec![(
"test.toml".into(),
"host = \"x\"\n[database]\npool-size = 10\nurl = \"pg://y\"\n".into(),
)],
normalize_keys: true,
..empty_input(&TEST_SPEC)
};
let config: TestConfig = resolve(input).unwrap();
assert_eq!(config.host, "x");
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.database.url.as_deref(), Some("pg://y"));
}
}