Files
decision/crates/ld-engine/src/names.rs
T
YvvandClaude Opus 5.5 753b52202b v3 : neuvième relecture — noms uniques, places justes, quantités
- Noms des liens d'équipe : comparés sans casse, accents ni ponctuation
  (ld_engine::names), lettres latines seulement (pas de sosie cyrillique,
  grec ou pleine chasse), une ligne ; index unique (migration additive
  0003) contre deux ouvertures simultanées sous le même nom.
- Une personne déjà connectée ne prend une place que si le lien lui
  ajoute un groupe ; « Ce n'est pas moi » la déconnecte sur un navigateur
  partagé ; un lien collé dans le même onglet est relu.
- Coller ou couper un mot de quantité n'est pas une coquille
  (« sur tout » → « surtout », « par tout » → « partout »).
- Libellés sans caractère de contrôle ; la soumission revérifie le titre.

Boucle : 68 tests Rust, svelte-check, 9 vitest, e2e 13/13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:40:04 +02:00

85 lines
3.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Name> {
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::<String>().split(' ').filter(|w| !w.is_empty()).collect::<Vec<_>>().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:?}");
}
}
}