use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LabelMap {
classes: Vec<String>,
lookup: HashMap<String, usize>,
conflicts: Vec<(String, String, String)>,
malformed: Vec<String>,
}
impl LabelMap {
pub fn parse(lines: &[&str]) -> Self {
let mut map = LabelMap::default();
for line in lines {
let Some((name, synonyms)) = line.split_once('=') else {
if !line.trim().is_empty() {
map.malformed.push(line.to_string());
}
continue;
};
let name = name.trim();
if name.is_empty() {
map.malformed.push(line.to_string());
continue;
}
let idx = match map.classes.iter().position(|c| c == name) {
Some(i) => i,
None => {
map.classes.push(name.to_string());
map.classes.len() - 1
}
};
for syn in std::iter::once(name).chain(synonyms.split(',')) {
let key = canon(syn);
if key.is_empty() {
continue;
}
match map.lookup.get(&key) {
Some(&owner) if owner != idx => {
let (kept, asked) = (map.classes[owner].clone(), name.to_string());
map.conflicts.push((key, kept, asked));
}
Some(_) => {}
None => {
map.lookup.insert(key, idx);
}
}
}
}
map
}
pub fn conflicts(&self) -> &[(String, String, String)] {
&self.conflicts
}
pub fn malformed(&self) -> &[String] {
&self.malformed
}
pub fn class_of(&self, value: &str) -> Option<usize> {
self.lookup.get(&canon(value)).copied()
}
pub fn classes(&self) -> &[String] {
&self.classes
}
pub fn label_of(&self, value: &str) -> String {
match self.class_of(value) {
Some(i) => self.classes[i].clone(),
None => canon(value),
}
}
pub fn same(&self, a: &str, b: &str) -> bool {
match (self.class_of(a), self.class_of(b)) {
(Some(x), Some(y)) => x == y,
(None, None) => canon(a) == canon(b),
_ => false,
}
}
}
pub fn canon(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for word in value.split_whitespace() {
if !out.is_empty() {
out.push(' ');
}
out.push_str(&word.to_lowercase());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canon_folds_case_and_collapses_whitespace() {
assert_eq!(canon(" Low RISK\t"), "low risk");
assert_eq!(canon(""), "");
assert_eq!(canon(" "), "");
}
#[test]
fn a_label_class_splits_into_its_two_halves_even_half_typed() {
use crate::report::flow::split_label_class;
assert_eq!(
split_label_class("Low Risk = low risk, real, genuine"),
("Low Risk", "low risk, real, genuine")
);
assert_eq!(split_label_class(" Low Risk "), ("Low Risk", ""));
assert_eq!(split_label_class("Low Risk ="), ("Low Risk", ""));
let (name, syn) = split_label_class("Low Risk");
let map = LabelMap::parse(&[&format!("{name} = {syn}")]);
assert!(map.same("LOW risk", "Low Risk"));
}
#[test]
fn declared_synonyms_match_across_vocabularies() {
let map = LabelMap::parse(&[
"Pass = pass, ok, low risk, real",
"Fail = fail, reject, high risk, fake",
]);
assert!(map.same("fail", "High Risk"), "and the second class holds");
assert_eq!(map.classes(), ["Pass".to_string(), "Fail".to_string()]);
assert_eq!(map.label_of("REJECT"), "Fail", "counted under its class");
assert!(
map.same("Low Risk", "real"),
"an engine verdict matches a folder label through the class"
);
assert!(!map.same("Low Risk", "fake"));
assert_eq!(
map.class_of("REJECT"),
map.class_of("high risk"),
"however it was spelled"
);
}
#[test]
fn the_canonical_label_means_itself_without_being_repeated() {
let map = LabelMap::parse(&["Pass = ok"]);
assert!(map.same("pass", "OK"));
}
#[test]
fn undeclared_values_compare_as_themselves() {
let map = LabelMap::parse(&[]);
assert_eq!(map.class_of("approved"), None);
assert_eq!(
map.label_of("Approved"),
"approved",
"an unclassified value counts as itself"
);
assert!(map.same(" APPROVED ", "approved"));
assert!(!map.same("approved", "declined"));
}
#[test]
fn an_unclassified_value_never_matches_a_classified_one() {
let map = LabelMap::parse(&["Pass = ok"]);
assert!(!map.same("Pass", "passing"));
}
#[test]
fn a_synonym_claimed_twice_stays_with_the_first_class_and_is_reported() {
let map = LabelMap::parse(&["Pass = ok, maybe", "Fail = no, maybe"]);
assert_eq!(map.class_of("maybe"), map.class_of("ok"));
assert_eq!(
map.conflicts(),
[("maybe".to_string(), "Pass".to_string(), "Fail".to_string())],
"the clash is recorded for validation rather than resolved silently"
);
}
#[test]
fn redeclaring_a_class_extends_it() {
let map = LabelMap::parse(&["Pass = ok", "Pass = fine"]);
assert!(map.same("fine", "ok"));
assert!(
map.conflicts().is_empty(),
"extending a class is not a clash"
);
}
#[test]
fn malformed_lines_declare_nothing() {
let map = LabelMap::parse(&["", "no equals sign", " = orphan", "Pass ="]);
assert!(
map.same("pass", "PASS"),
"only the last line declared a class"
);
assert_eq!(map.class_of("orphan"), None);
assert_eq!(
map.malformed(),
["no equals sign".to_string(), " = orphan".to_string()],
"each unusable line is kept verbatim, and a blank one is not a line"
);
}
}