use std::collections::BTreeSet;
fn accepted_fields(table: Option<&str>) -> BTreeSet<String> {
let probe = match table {
Some(name) => format!("[{name}]\nkeyhog_probe_unknown_key = 1\n"),
None => "keyhog_probe_unknown_key = 1\n".to_string(),
};
let error = toml::from_str::<super::schema::ConfigFile>(&probe)
.err()
.unwrap_or_else(|| {
panic!(
"{probe:?} must be rejected by deny_unknown_fields, otherwise this gate is blind"
)
})
.to_string();
let listed = error
.rsplit_once("expected ")
.map(|(_, tail)| tail)
.unwrap_or_else(|| panic!("serde must enumerate the accepted fields; got: {error}"));
let fields: BTreeSet<String> = listed
.split('`')
.filter(|piece| {
!piece.is_empty()
&& piece
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
})
.map(str::to_owned)
.collect();
assert!(
!fields.is_empty(),
"no accepted fields parsed out of: {error}"
);
fields
}
fn configuration_reference() -> String {
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/src/reference/configuration.md"),
)
.expect("read the configuration reference")
}
fn documented_keys() -> BTreeSet<(Option<String>, String)> {
let reference = configuration_reference();
let mut keys = BTreeSet::new();
for line in reference.lines() {
let line = line.trim();
if !line.starts_with('|') {
continue;
}
let Some(cell) = line.split('|').nth(3) else {
continue;
};
for (index, token) in cell.split('`').enumerate() {
if index.is_multiple_of(2) {
continue;
}
let token = token.trim();
let token = token.split(['=', ' ']).next().unwrap_or_default().trim();
if token.is_empty() || token == "-" {
continue;
}
let (table, key) = match token.strip_prefix('[') {
Some(rest) => match rest.split_once("].") {
Some((table, key)) => (Some(table.to_owned()), key.to_owned()),
None => continue,
},
None => (None, token.to_owned()),
};
if key.is_empty()
|| !key
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
{
continue;
}
keys.insert((table, key));
}
}
keys
}
#[test]
fn documented_config_keys_exist_in_the_schema() {
let mut unknown = Vec::new();
for (table, key) in documented_keys() {
let accepted = accepted_fields(table.as_deref());
if !accepted.contains(&key) {
let owner = table.as_deref().unwrap_or("(root)");
unknown.push(format!("[{owner}].{key}"));
}
}
assert!(
unknown.is_empty(),
"the configuration reference names keys the schema does not accept:\n{}",
unknown.join("\n")
);
}
#[test]
fn every_tuning_schema_key_is_documented() {
let reference = configuration_reference();
let tuning_section = reference
.split_once("### `[tuning]`")
.map(|(_, section)| section)
.and_then(|section| section.split_once("\n### ").map(|(body, _)| body))
.expect("configuration reference has one bounded tuning section");
let tuning_block = tuning_section
.split_once("```toml")
.map(|(_, block)| block)
.and_then(|block| block.split_once("```").map(|(body, _)| body))
.expect("tuning section has a TOML example");
let documented: BTreeSet<String> = tuning_block
.lines()
.filter_map(|line| line.split_once('=').map(|(key, _)| key.trim().to_owned()))
.collect();
let missing: Vec<String> = accepted_fields(Some("tuning"))
.difference(&documented)
.cloned()
.collect();
assert!(
missing.is_empty(),
"the tuning schema accepts keys missing from its configuration reference: {missing:?}"
);
}
#[test]
fn documented_config_tables_exist_in_the_schema() {
let root = accepted_fields(None);
let mut unknown: Vec<String> = documented_keys()
.into_iter()
.filter_map(|(table, _)| table)
.filter(|table| !root.contains(table))
.collect();
unknown.sort();
unknown.dedup();
assert!(
unknown.is_empty(),
"the configuration reference names tables the schema does not accept: {unknown:?}"
);
}
#[test]
fn the_extractor_reads_a_substantial_number_of_documented_keys() {
let keys = documented_keys();
assert!(
keys.len() >= 40,
"expected at least 40 documented `.keyhog.toml` keys, found {}; the reference \
table format has probably changed",
keys.len()
);
assert!(
keys.iter().any(|(table, _)| table.is_some()),
"the extractor must recognise table-qualified keys such as `[scan].min_confidence`"
);
assert!(
keys.iter().any(|(table, _)| table.is_none()),
"the extractor must recognise root keys such as `detectors`"
);
}