use std::path::Path;
use std::sync::Arc;
use confique::Config;
use serde::Deserialize;
use crate::error::{ClapfigError, UnknownKeyInfo};
pub fn validate_unknown_keys<C: Config>(content: &str, path: &Path) -> Result<(), ClapfigError>
where
C::Layer: for<'de> Deserialize<'de>,
{
let mut unknown_keys: Vec<String> = Vec::new();
let deserializer = toml::Deserializer::new(content);
let _layer: C::Layer = serde_ignored::deserialize(deserializer, |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(content)),
})?;
if unknown_keys.is_empty() {
return Ok(());
}
let source: Arc<str> = Arc::from(content);
let infos: Vec<UnknownKeyInfo> = unknown_keys
.into_iter()
.map(|key| {
let line = find_key_line(content, &key);
UnknownKeyInfo {
key,
path: path.to_path_buf(),
line,
source: Some(Arc::clone(&source)),
}
})
.collect();
Err(ClapfigError::UnknownKeys(infos))
}
fn find_key_line(content: &str, dotted_key: &str) -> 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)| *a == b);
if in_right_section
&& let Some(after_key) = trimmed.strip_prefix(leaf)
&& after_key.trim_start().starts_with('=')
{
return i + 1;
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures::test::TestConfig;
use std::path::PathBuf;
fn path() -> PathBuf {
PathBuf::from("/test/config.toml")
}
#[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>(content, &path());
assert!(result.is_ok());
}
#[test]
fn unknown_top_level_key() {
let content = "host = \"localhost\"\ntypo_key = 42\n";
let result = validate_unknown_keys::<TestConfig>(content, &path());
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>(content, &path());
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>(content, &path());
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>(content, &path());
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 result = validate_unknown_keys::<TestConfig>("", &path());
assert!(result.is_ok());
}
#[test]
fn known_optional_field_ok() {
let content = "[database]\nurl = \"pg://\"\n";
let result = validate_unknown_keys::<TestConfig>(content, &path());
assert!(result.is_ok());
}
#[test]
fn sparse_config_ok() {
let content = "port = 3000\n";
let result = validate_unknown_keys::<TestConfig>(content, &path());
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>(content, &p).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>(content, &path());
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>(content, &path());
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);
}
}