use std::path::Path;
use std::sync::Arc;
use confique::Config;
use serde::Deserialize;
use toml::{Table, Value};
use crate::error::{ClapfigError, UnknownKeyInfo};
use crate::normalize::normalize_key;
use crate::spec::{FieldKindRef, SchemaRef};
use crate::strict::{
CollectedUnknown, StrictnessOverrides, UnknownKeyContext, UnknownKeyDecision, UnknownKeyHook,
};
pub(crate) struct ValidateContext<'a> {
pub overrides: &'a StrictnessOverrides,
pub default_strict: bool,
pub callback: Option<&'a UnknownKeyHook>,
pub normalize_keys: bool,
}
pub fn validate_unknown_keys<C: Config>(
table: &Table,
source: &str,
path: &Path,
ctx: &ValidateContext<'_>,
) -> Result<Vec<CollectedUnknown>, ClapfigError>
where
C::Layer: for<'de> Deserialize<'de>,
{
let mut unknown_paths: Vec<String> = Vec::new();
let value = Value::Table(table.clone());
let _layer: C::Layer = serde_ignored::deserialize(value, |ignored_path| {
unknown_paths.push(ignored_path.to_string());
})
.map_err(|e| ClapfigError::ParseError {
path: path.to_path_buf(),
source: Box::new(e),
source_text: Some(Arc::from(source)),
})?;
let unknown_keys: Vec<UnknownKey> = unknown_paths
.into_iter()
.map(UnknownKey::from_path)
.collect();
filter_through_cascade(table, source, path, unknown_keys, ctx)
}
pub(crate) struct UnknownKey {
pub path: String,
pub leaf: String,
}
impl UnknownKey {
pub fn from_path(path: String) -> Self {
let leaf = path.rsplit('.').next().unwrap_or(&path).to_string();
Self { path, leaf }
}
}
pub(crate) fn collect_unknown_paths_ref(
table: &Table,
schema: SchemaRef<'_>,
prefix: &str,
) -> Vec<UnknownKey> {
let mut out = Vec::new();
walk_against_schema(table, schema, prefix, &mut out);
out
}
fn walk_against_schema<'a>(
table: &Table,
schema: SchemaRef<'a>,
prefix: &str,
out: &mut Vec<UnknownKey>,
) {
let fields: Vec<(&'a str, FieldKindRef<'a>)> =
schema.fields().map(|f| (f.name, f.kind)).collect();
for (key, value) in table {
let full = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
let kind = fields
.iter()
.find(|(n, _)| *n == key.as_str())
.map(|(_, k)| *k);
match kind {
None => {
out.push(UnknownKey {
path: full,
leaf: key.clone(),
});
}
Some(FieldKindRef::Leaf(_)) => {
}
Some(FieldKindRef::Nested { schema: nested }) => {
if let Value::Table(t) = value {
walk_against_schema(t, nested, &full, out);
}
}
Some(FieldKindRef::MapOf {
schema: item_schema,
}) => {
if let Value::Table(entries) = value {
for (entry_key, entry_value) in entries {
if let Value::Table(t) = entry_value {
let entry_path = format!("{full}.{entry_key}");
walk_against_schema(t, item_schema, &entry_path, out);
}
}
}
}
Some(FieldKindRef::ArrayOf { .. }) => {
}
}
}
}
pub(crate) fn filter_through_cascade(
table: &Table,
source: &str,
path: &Path,
unknown_keys: Vec<UnknownKey>,
ctx: &ValidateContext<'_>,
) -> Result<Vec<CollectedUnknown>, ClapfigError> {
if unknown_keys.is_empty() {
return Ok(Vec::new());
}
let source_arc: Arc<str> = Arc::from(source);
let mut rejected: Vec<UnknownKeyInfo> = Vec::new();
let mut collected: Vec<CollectedUnknown> = Vec::new();
for entry in unknown_keys {
let UnknownKey { path: key, leaf } = entry;
let strict = ctx
.overrides
.effective_strict(&key, &leaf, ctx.default_strict);
if !strict {
continue;
}
let line = find_key_line(source, &key, &leaf, ctx.normalize_keys);
let value_ref = lookup_value(table, &key, &leaf);
if let Some(callback) = ctx.callback {
let context = UnknownKeyContext {
path: &key,
leaf: &leaf,
value: value_ref,
file: Some(path),
line: if line > 0 { Some(line) } else { None },
};
match callback(&context) {
UnknownKeyDecision::Accept => continue,
UnknownKeyDecision::Collect => {
collected.push(CollectedUnknown {
path: key,
leaf,
value: value_ref.cloned(),
file: Some(path.to_path_buf()),
line: if line > 0 { Some(line) } else { None },
});
continue;
}
UnknownKeyDecision::Reject => { }
}
}
rejected.push(UnknownKeyInfo {
key,
path: path.to_path_buf(),
line,
source: Some(Arc::clone(&source_arc)),
});
}
if rejected.is_empty() {
Ok(collected)
} else {
Err(ClapfigError::UnknownKeys(rejected))
}
}
fn lookup_value<'a>(table: &'a Table, path: &str, leaf: &str) -> Option<&'a Value> {
let section = crate::strict::section_path_of(path, leaf);
if section.is_empty() {
return table.get(leaf);
}
let mut segments = section.split('.');
let first = segments.next().unwrap();
let (first_name, first_idx) = parse_segment(first);
let mut cursor: &Value = table.get(first_name)?;
if let Some(i) = first_idx {
cursor = cursor.as_array()?.get(i)?;
}
for seg in segments {
let (name, idx) = parse_segment(seg);
cursor = cursor.as_table()?.get(name)?;
if let Some(i) = idx {
cursor = cursor.as_array()?.get(i)?;
}
}
cursor.as_table()?.get(leaf)
}
fn parse_segment(seg: &str) -> (&str, Option<usize>) {
if let Some(open) = seg.find('[')
&& let Some(close) = seg[open..].find(']')
{
let name = &seg[..open];
let idx_str = &seg[open + 1..open + close];
if let Ok(i) = idx_str.parse::<usize>() {
return (name, Some(i));
}
}
(seg, None)
}
fn find_key_line(content: &str, dotted_path: &str, leaf: &str, normalize_keys: bool) -> usize {
let section_path = crate::strict::section_path_of(dotted_path, leaf);
let expected_section: Vec<&str> = if section_path.is_empty() {
Vec::new()
} else {
section_path.split('.').collect()
};
let mut current_section: Vec<String> = Vec::new();
for (i, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('[') && !trimmed.starts_with("[[") {
let header = trimmed.trim_start_matches('[').trim_end_matches(']').trim();
current_section = header.split('.').map(|s| s.trim().to_string()).collect();
continue;
}
let in_right_section = expected_section.len() == current_section.len()
&& expected_section
.iter()
.zip(¤t_section)
.all(|(a, b)| keys_match(a, b, normalize_keys));
if !in_right_section {
continue;
}
if let Some((candidate, rest)) = trimmed.split_once('=')
&& leaf_matches_source_key(candidate.trim_end(), leaf, normalize_keys)
&& !rest.is_empty()
{
return i + 1;
}
}
0
}
fn leaf_matches_source_key(candidate: &str, leaf: &str, normalize_keys: bool) -> bool {
let candidate = candidate.trim();
if keys_match(candidate, leaf, normalize_keys) {
return true;
}
let bytes = candidate.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
{
let inner = &candidate[1..candidate.len() - 1];
return keys_match(inner, leaf, normalize_keys);
}
false
}
fn keys_match(a: &str, b: &str, normalize_keys: bool) -> bool {
let a = a.trim();
let b = b.trim();
if normalize_keys {
normalize_key(a) == normalize_key(b)
} else {
a == b
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::test::TestConfig;
use std::path::PathBuf;
use std::sync::OnceLock;
fn path() -> PathBuf {
PathBuf::from("/test/config.toml")
}
fn parse(content: &str) -> Table {
content.parse::<Table>().unwrap()
}
fn test_ctx(normalize_keys: bool) -> ValidateContext<'static> {
static EMPTY: OnceLock<StrictnessOverrides> = OnceLock::new();
let overrides = EMPTY.get_or_init(StrictnessOverrides::new);
ValidateContext {
overrides,
default_strict: true,
callback: None,
normalize_keys,
}
}
#[test]
fn valid_config_passes() {
let content = r#"
host = "0.0.0.0"
port = 3000
debug = true
[database]
url = "postgres://localhost"
pool_size = 10
"#;
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
assert!(result.is_ok());
}
#[test]
fn unknown_top_level_key() {
let content = "host = \"localhost\"\ntypo_key = 42\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].key, "typo_key");
assert_eq!(keys[0].line, 2);
assert!(keys[0].source.is_some());
}
#[test]
fn unknown_nested_key() {
let content = "[database]\nurl = \"pg://\"\ntypo = \"bad\"\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys.len(), 1);
assert_eq!(keys[0].key, "database.typo");
assert_eq!(keys[0].leaf(), "typo");
}
#[test]
fn multiple_unknown_keys() {
let content = "typo1 = 1\ntypo2 = 2\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys.len(), 2);
}
#[test]
fn line_number_accuracy() {
let content = "host = \"x\"\nport = 8080\ndebug = false\n\n# comment\nbad_key = 1\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys[0].line, 6);
}
#[test]
fn empty_content_ok() {
let table = Table::new();
let result = validate_unknown_keys::<TestConfig>(&table, "", &path(), &test_ctx(false));
assert!(result.is_ok());
}
#[test]
fn known_optional_field_ok() {
let content = "[database]\nurl = \"pg://\"\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
assert!(result.is_ok());
}
#[test]
fn sparse_config_ok() {
let content = "port = 3000\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
assert!(result.is_ok());
}
#[test]
fn error_includes_file_path() {
let content = "typo = 1\n";
let p = PathBuf::from("/home/user/.config/myapp/config.toml");
let err =
validate_unknown_keys::<TestConfig>(&parse(content), content, &p, &test_ctx(false))
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("config.toml") || msg.contains("Unknown keys"));
}
#[test]
fn line_number_finds_correct_section_for_duplicate_leaf() {
let content = "host = \"x\"\nport = 8080\n[database]\ntypo = \"bad\"\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys[0].key, "database.typo");
assert_eq!(keys[0].line, 4);
}
#[test]
fn line_number_top_level_not_confused_by_nested_same_name() {
let content = "typo = 99\n[database]\npool_size = 5\n";
let result = validate_unknown_keys::<TestConfig>(
&parse(content),
content,
&path(),
&test_ctx(false),
);
let err = result.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert_eq!(keys[0].key, "typo");
assert_eq!(keys[0].line, 1);
}
use crate::normalize::normalize_table;
fn parse_and_normalize(content: &str) -> Table {
let mut t = parse(content);
normalize_table(&mut t).expect("test fixtures must not contain collisions");
t
}
#[test]
fn normalize_kebab_top_level_key_is_valid() {
let content = "host = \"x\"\n";
let table = parse_and_normalize(content);
let result = validate_unknown_keys::<TestConfig>(&table, content, &path(), &test_ctx(true));
assert!(result.is_ok());
}
#[test]
fn normalize_kebab_nested_key_is_valid() {
let content = "[database]\npool-size = 25\n";
let table = parse_and_normalize(content);
let result = validate_unknown_keys::<TestConfig>(&table, content, &path(), &test_ctx(true));
assert!(result.is_ok(), "kebab key should be accepted: {result:?}");
}
#[test]
fn normalize_kebab_typo_reports_line_at_kebab_source() {
let content = "host = \"x\"\n[database]\npool-zize = 99\n";
let table = parse_and_normalize(content);
let result = validate_unknown_keys::<TestConfig>(&table, content, &path(), &test_ctx(true));
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");
assert_eq!(keys[0].line, 3);
}
#[test]
fn normalize_kebab_section_header_resolves_line() {
let content = "[my-section]\nfoo = 1\n";
let table = parse_and_normalize(content);
let err = validate_unknown_keys::<TestConfig>(&table, content, &path(), &test_ctx(true))
.unwrap_err();
let keys = err.unknown_keys().expect("expected UnknownKeys");
assert!(keys.iter().any(|k| k.key == "my_section"));
}
#[test]
fn normalize_off_treats_kebab_as_unknown() {
let content = "[database]\npool-size = 25\n";
let table = parse(content);
let result =
validate_unknown_keys::<TestConfig>(&table, content, &path(), &test_ctx(false));
assert!(result.is_err());
}
}