//! Names people give themselves (team links): what is accepted, and the key two //! names collide on. //! //! A name is shown as the author of proposals, revisions and signals, so nobody //! may take a name already there, even dressed differently: « émile » for //! « Émile », an accent written apart, a Cyrillic « в » or a Greek « Υ » for a //! Latin letter, full-width « Yvv ». Hence a short alphabet — Latin letters //! as French, English and Spanish write them, figures, space, hyphen, //! apostrophe, dot — and a key that forgets case, accents and punctuation. use unicode_normalization::UnicodeNormalization; use unicode_normalization::char::is_combining_mark; const MIN_NAME: usize = 2; const MAX_NAME: usize = 60; #[derive(Debug, PartialEq, Eq)] pub struct Name { /// As shown: NFC, spaces collapsed. pub display: String, /// What two names collide on. pub key: String, } fn allowed(c: char) -> bool { c.is_ascii_alphanumeric() || (c.is_alphabetic() && matches!(c as u32, 0xC0..=0x17F)) || matches!(c, ' ' | '-' | '\'' | '’' | '.') } /// Lowercase, no accents, letters and figures only: « Émile », « EMILE », /// « É-mile » share the key « emile ». pub fn key(s: &str) -> String { s.nfkd().filter(|c| !is_combining_mark(*c)).flat_map(char::to_lowercase).filter(|c| c.is_alphanumeric()).collect() } /// A name a person may take, or `None`. pub fn check(raw: &str) -> Option { let raw = raw.trim(); // One line, ordinary spaces only (no line or paragraph separator). if raw.chars().any(|c| c.is_whitespace() && c != ' ') { return None; } let display = raw.nfc().collect::().split(' ').filter(|w| !w.is_empty()).collect::>().join(" "); let n = display.chars().count(); if !(MIN_NAME..=MAX_NAME).contains(&n) || !display.chars().all(allowed) { return None; } let key = key(&display); if key.chars().count() < MIN_NAME { return None; } Some(Name { display, key }) } #[cfg(test)] mod tests { use super::*; #[test] fn ordinary_names() { assert_eq!(check(" Émile Zola ").map(|n| n.display), Some("Émile Zola".into())); assert_eq!(check("Jean-Marc O'Neil").map(|n| n.key), Some("jeanmarconeil".into())); assert_eq!(check("Núria").map(|n| n.key), Some("nuria".into())); assert!(check("Chiara 2").is_some()); } #[test] fn the_same_name_dressed_differently_collides() { let emile = key("Émile"); for other in ["émile", "EMILE", "E\u{301}mile", "É-mile", "Emile."] { assert_eq!(check(other).map(|n| n.key), Some(emile.clone()), "{other}"); } } #[test] fn look_alikes_and_invisibles_are_refused() { // Eighth and ninth independent reviews, 2026-10-21. for name in ["Yvв", "Υvv", "Yvv", "Yv\u{2028}v", "Yvv\u{fff9}", "Yv\u{200b}v", "Y\nvv", "Y", "--", "Yvv 🙂"] { assert_eq!(check(name), None, "{name:?}"); } } }