use crate::roster::Roster;
pub fn normalize(s: &str) -> String {
s.chars()
.map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { ' ' })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn homoglyph_risk(s: &str) -> bool {
let has_ascii_letter = s.chars().any(|c| c.is_ascii_alphabetic());
let has_confusable = s.chars().any(|c| {
let cp = c as u32;
(0x0370..=0x03FF).contains(&cp) || (0x0400..=0x04FF).contains(&cp) });
has_ascii_letter && has_confusable
}
fn tokens(s: &str) -> Vec<String> {
const STOP: &[&str] = &["the", "a", "an", "my", "our", "your", "that", "one", "agent", "is"];
normalize(s)
.split_whitespace()
.filter(|t| !STOP.contains(t))
.map(String::from)
.collect()
}
fn lev(a: &str, b: &str) -> usize {
let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
let mut prev: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.iter().enumerate() {
let mut cur = vec![i + 1];
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
cur.push((prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost));
}
prev = cur;
}
prev[b.len()]
}
fn token_subset(a: &str, b: &str) -> bool {
let (ta, tb) = (tokens(a), tokens(b));
if ta.is_empty() || tb.is_empty() {
return false;
}
let (short, long) = if ta.len() <= tb.len() { (&ta, &tb) } else { (&tb, &ta) };
short.iter().all(|t| long.contains(t))
}
fn identifiers<'a>(roster: &'a Roster, id: &'a str) -> Vec<String> {
let mut v = vec![id.to_string()];
if let Some(r) = roster.get(id) {
if let Some(d) = &r.display {
v.push(d.clone());
}
v.extend(r.aliases.iter().cloned());
if let Some(h) = &r.host {
v.push(h.clone());
}
}
v
}
pub fn conflict(roster: &Roster, me: &str, proposed: &str) -> Option<(String, String, String)> {
let np = normalize(proposed);
if np.is_empty() {
return Some((String::new(), proposed.to_string(), "empty after normalizing".into()));
}
for id in roster.keys() {
if id == me {
continue;
}
for ident in identifiers(roster, id) {
let ni = normalize(&ident);
if ni.is_empty() {
continue;
}
if ni == np {
return Some((id.clone(), ident, "already identifies".into()));
}
if token_subset(&np, &ni) {
return Some((id.clone(), ident, "shares all significant words with".into()));
}
if np.len() >= 4 && ni.len() >= 4 && lev(&np, &ni) <= 1 {
return Some((id.clone(), ident, "is one keystroke from".into()));
}
}
}
None
}
pub struct Match {
pub id: String,
pub score: i32,
}
pub fn resolve(roster: &Roster, phrase: &str) -> Vec<Match> {
let np = normalize(phrase);
let pt = tokens(phrase);
let mut out: Vec<Match> = Vec::new();
for id in roster.keys() {
let mut score = 0i32;
let mut targets = identifiers(roster, id);
if let Some(d) = roster.get(id).and_then(|r| r.desc.as_deref()) {
targets.push(d.to_string());
}
for ident in &targets {
let ni = normalize(ident);
if ni.is_empty() {
continue;
}
let it = tokens(ident);
if ni == np {
score = score.max(100);
} else if !pt.is_empty() && pt.iter().all(|t| it.contains(t)) {
score = score.max(75); } else if !it.is_empty() && it.iter().all(|t| pt.contains(t)) {
score = score.max(65); } else if ni.contains(&np) || np.contains(&ni) {
score = score.max(45);
} else {
let shared = pt.iter().filter(|t| it.contains(*t)).count() as i32;
if shared > 0 {
score = score.max(25 + 10 * shared);
}
}
}
if score > 0 {
out.push(Match { id: id.clone(), score });
}
}
out.sort_by(|a, b| b.score.cmp(&a.score).then(a.id.cmp(&b.id)));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::roster::Role;
fn role(display: &str, host: &str, desc: &str, aliases: &[&str]) -> Role {
Role {
display: Some(display.into()),
host: Some(host.into()),
desc: (!desc.is_empty()).then(|| desc.to_string()),
aliases: aliases.iter().map(|s| s.to_string()).collect(),
status: None,
pubkey: None,
}
}
fn fixture() -> Roster {
let mut m = Roster::new();
m.insert("bob".into(), role("Mobile Reader", "host-b.local", "mobile reader app", &["mobile agent", "spare box"]));
m.insert("carol".into(), role("Design Studio", "host-a.local", "document layout + typesetting", &["design agent"]));
m
}
#[test]
fn homoglyph_risk_flags_mixed_latin_cyrillic_not_legit_names() {
assert!(homoglyph_risk("ali\u{0441}e")); assert!(homoglyph_risk("A\u{0430}ron")); assert!(!homoglyph_risk("alice")); assert!(!homoglyph_risk("José")); assert!(!homoglyph_risk("Confer / confer repo")); assert!(!homoglyph_risk("Алиса")); }
#[test]
fn resolve_loose_phrases() {
let r = fixture();
assert_eq!(resolve(&r, "my mobile agent")[0].id, "bob");
assert_eq!(resolve(&r, "the spare box one")[0].id, "bob");
assert_eq!(resolve(&r, "design")[0].id, "carol");
assert_eq!(resolve(&r, "the reader app")[0].id, "bob");
assert_eq!(resolve(&r, "typesetting")[0].id, "carol");
}
#[test]
fn conflict_blocks_collisions_not_distinct() {
let r = fixture();
assert!(conflict(&r, "carol", "mobile agent").is_some()); assert!(conflict(&r, "carol", "bob").is_some()); assert!(conflict(&r, "carol", "spare box").is_some()); assert!(conflict(&r, "carol", "printer").is_none());
assert!(conflict(&r, "bob", "mobile agent").is_none());
}
}