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;
pub fn validate_unknown_keys<C: Config>(
table: &Table,
source: &str,
path: &Path,
normalize_keys: bool,
) -> Result<(), ClapfigError>
where
C::Layer: for<'de> Deserialize<'de>,
{
let mut unknown_keys: Vec<String> = Vec::new();
let value = Value::Table(table.clone());
let _layer: C::Layer = serde_ignored::deserialize(value, |ignored_path| {
unknown_keys.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)),
})?;
if unknown_keys.is_empty() {
return Ok(());
}
let source_arc: Arc<str> = Arc::from(source);
let infos: Vec<UnknownKeyInfo> = unknown_keys
.into_iter()
.map(|key| {
let line = find_key_line(source, &key, normalize_keys);
UnknownKeyInfo {
key,
path: path.to_path_buf(),
line,
source: Some(Arc::clone(&source_arc)),
}
})
.collect();
Err(ClapfigError::UnknownKeys(infos))
}
fn find_key_line(content: &str, dotted_key: &str, normalize_keys: bool) -> usize {
let segments: Vec<&str> = dotted_key.split('.').collect();
let leaf = segments.last().unwrap_or(&dotted_key);
let expected_section = &segments[..segments.len() - 1];
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('=')
&& keys_match(candidate.trim_end(), leaf, normalize_keys)
&& !rest.is_empty()
{
return i + 1;
}
}
0
}
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;
fn path() -> PathBuf {
PathBuf::from("/test/config.toml")
}
fn parse(content: &str) -> Table {
content.parse::<Table>().unwrap()
}
#[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(), 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(), 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(), 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(), 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(), 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(), 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(), 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(), 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, 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(), 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(), 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(), 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(), 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(), 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(), 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(), false);
assert!(result.is_err());
}
}