tocat_api/normalize.rs
1//! normalize.rs: one spelling rule for every identifier tocat matches.
2//!
3//! Schemes, endpoint options, plugin names, plugin option keys and enum values
4//! are all matched the same way: case is ignored and dashes and underscores are
5//! noise. `max-connections`, `max_connections` and `MaxConnections` are one
6//! option, and a user who guesses wrong about a convention is right anyway.
7//!
8//! The rule is matching only. A normalized string is never stored, forwarded or
9//! displayed: [`canonical`] answers *which declared spelling was meant*, and
10//! the caller then uses that declared spelling. Free-form values (paths,
11//! labels, instance aliases, commands) never come near this module.
12
13/// Normalize an identifier to a consistent form: lowercase, with no dashes or
14/// underscores.
15#[must_use]
16pub fn normalize(item: &str) -> String {
17 item.chars()
18 .filter(|&c| c != '-' && c != '_')
19 .flat_map(char::to_lowercase)
20 .collect()
21}
22
23/// Which of `declared` the user meant by `candidate`, if exactly one.
24///
25/// An exact match wins outright, so the common case costs one comparison and
26/// cannot regress. Otherwise a candidate matches a declaration when the two
27/// normalize alike, and only an unambiguous match counts.
28///
29/// `None` means the caller should pass `candidate` through untouched rather
30/// than guess. That is what keeps a plugin's own `#[serde(alias)]` spellings
31/// working, since serde does not report aliases alongside the names it
32/// declares, and what leaves an unknown identifier to be reported by whoever
33/// owns the vocabulary.
34#[must_use]
35pub fn canonical<'a>(candidate: &str, declared: &[&'a str]) -> Option<&'a str> {
36 if let Some(exact) = declared.iter().find(|d| **d == candidate) {
37 return Some(exact);
38 }
39
40 let wanted = normalize(candidate);
41 let mut hits = declared.iter().filter(|d| normalize(d) == wanted);
42 let first = *hits.next()?;
43
44 hits.next().is_none().then_some(first)
45}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn separators_and_case_are_noise() {
53 assert_eq!(normalize("Max-Connections"), "maxconnections");
54 assert_eq!(normalize("max_connections"), "maxconnections");
55 assert_eq!(normalize("MAXCONNECTIONS"), "maxconnections");
56 }
57
58 #[test]
59 fn exact_match_wins() {
60 assert_eq!(
61 canonical("max-conn", &["max-conn", "maxconn"]),
62 Some("max-conn")
63 );
64 }
65
66 #[test]
67 fn spelling_is_recovered() {
68 assert_eq!(
69 canonical("Raw_Binary", &["hex", "raw-binary"]),
70 Some("raw-binary")
71 );
72 }
73
74 #[test]
75 fn ambiguity_and_strangers_are_left_alone() {
76 assert_eq!(canonical("maxconn", &["max-conn", "max_conn"]), None);
77 assert_eq!(canonical("nonsense", &["hex", "raw-binary"]), None);
78 }
79}