Skip to main content

headless_lms_models/library/credit_registration/
sisu_name.rs

1//! What linking a student number does to the account's name: the name follows Sisu, which verifies
2//! identity strongly, while the account's own name is whatever the student once typed.
3
4use unicode_normalization::UnicodeNormalization;
5use unicode_normalization::char::is_combining_mark;
6use utoipa::ToSchema;
7
8use crate::prelude::*;
9
10/// The names the account may take on linking; the student picks the first name.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
12pub struct NameReplacement {
13    /// Each a Sisu first name, or the several the account already uses, corrected to Sisu's spelling.
14    pub first_name_options: Vec<String>,
15    /// One of `first_name_options`: what the account already uses, else Sisu's first first name.
16    pub suggested_first_name: String,
17    pub last_name: String,
18    /// Neither the account's first name nor its last name resembles Sisu's, so the account may belong
19    /// to someone other than the person Sisu names.
20    pub resembles_nothing: bool,
21}
22
23impl NameReplacement {
24    /// Whether `first_name` is one the student may pick.
25    pub fn allows(&self, first_name: &str) -> bool {
26        self.first_name_options
27            .iter()
28            .any(|option| option == first_name)
29    }
30}
31
32/// The name linking gives the account, or `None` when it keeps its own: it already uses Sisu's
33/// name, or Sisu holds none.
34///
35/// The name is kept when every name in the account's first name is one of Sisu's first names and the
36/// last names are the same, ignoring case. Otherwise it is replaced, with any first names that are
37/// only misspelt (case, diacritics, a typo or two) offered back in Sisu's spelling.
38pub fn align(
39    sisu_first_names: Option<&str>,
40    sisu_last_name: Option<&str>,
41    account_first_name: Option<&str>,
42    account_last_name: Option<&str>,
43) -> Option<NameReplacement> {
44    let sisu_given = words(sisu_first_names.unwrap_or_default());
45    let sisu_last = normalized(sisu_last_name.unwrap_or_default());
46    if sisu_given.is_empty() || sisu_last.is_empty() {
47        return None;
48    }
49    let account_given = words(account_first_name.unwrap_or_default());
50    let account_last = normalized(account_last_name.unwrap_or_default());
51
52    let mut used = vec![false; sisu_given.len()];
53    let mut matches = Vec::new();
54    for word in &account_given {
55        let Some(found) = best_match(word, &sisu_given, &used) else {
56            break;
57        };
58        used[found.index] = true;
59        matches.push(found);
60    }
61    let first_names_match = !account_given.is_empty() && matches.len() == account_given.len();
62    let last_name_resemblance = resemblance(&account_last, &sisu_last);
63
64    if first_names_match
65        && matches
66            .iter()
67            .all(|found| found.resemblance == Resemblance::Same)
68        && last_name_resemblance == Some(Resemblance::Same)
69    {
70        return None;
71    }
72
73    let mut first_name_options = Vec::new();
74    let mut suggested_first_name = sisu_given[0].clone();
75    if first_names_match {
76        let corrected = matches
77            .iter()
78            .map(|found| sisu_given[found.index].as_str())
79            .collect::<Vec<_>>()
80            .join(" ");
81        if matches.len() > 1 {
82            first_name_options.push(corrected.clone());
83        }
84        suggested_first_name = corrected;
85    }
86    for name in &sisu_given {
87        if !first_name_options.contains(name) {
88            first_name_options.push(name.clone());
89        }
90    }
91
92    let any_first_name_resembles = account_given.iter().any(|word| {
93        sisu_given
94            .iter()
95            .any(|name| resemblance(word, name).is_some())
96    });
97    let is_account_name_empty = account_given.is_empty() && account_last.is_empty();
98    Some(NameReplacement {
99        first_name_options,
100        suggested_first_name,
101        last_name: sisu_last,
102        resembles_nothing: !is_account_name_empty
103            && !any_first_name_resembles
104            && last_name_resemblance.is_none(),
105    })
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
109enum Resemblance {
110    Same,
111    /// Differs only in diacritics or by a typo or two.
112    Close,
113}
114
115struct Match {
116    index: usize,
117    resemblance: Resemblance,
118    distance: usize,
119}
120
121/// The closest of `names` not yet `used` that `word` resembles.
122fn best_match(word: &str, names: &[String], used: &[bool]) -> Option<Match> {
123    names
124        .iter()
125        .enumerate()
126        .filter(|(index, _)| !used[*index])
127        .filter_map(|(index, name)| {
128            resemblance(word, name).map(|resemblance| Match {
129                index,
130                resemblance,
131                distance: levenshtein(&folded(word), &folded(name)),
132            })
133        })
134        .min_by_key(|found| (found.resemblance, found.distance, found.index))
135}
136
137fn resemblance(a: &str, b: &str) -> Option<Resemblance> {
138    if a.is_empty() || b.is_empty() {
139        return None;
140    }
141    if a.to_lowercase() == b.to_lowercase() {
142        return Some(Resemblance::Same);
143    }
144    let (a, b) = (folded(a), folded(b));
145    // A single typo already turns most short names into another real name.
146    let allowed_typos = if b.chars().count() < 5 { 1 } else { 2 };
147    (levenshtein(&a, &b) <= allowed_typos).then_some(Resemblance::Close)
148}
149
150/// NFC, trimmed, with runs of whitespace collapsed to one space.
151fn normalized(name: &str) -> String {
152    words(name).join(" ")
153}
154
155fn words(name: &str) -> Vec<String> {
156    name.nfc()
157        .collect::<String>()
158        .split_whitespace()
159        .map(str::to_owned)
160        .collect()
161}
162
163/// Lowercased with diacritics removed, so "Mäkinen" and "makinen" compare equal.
164fn folded(name: &str) -> String {
165    name.nfd()
166        .filter(|c| !is_combining_mark(*c))
167        .collect::<String>()
168        .to_lowercase()
169}
170
171fn levenshtein(a: &str, b: &str) -> usize {
172    let b: Vec<char> = b.chars().collect();
173    let mut previous: Vec<usize> = (0..=b.len()).collect();
174    for (i, ca) in a.chars().enumerate() {
175        let mut current = vec![i + 1; b.len() + 1];
176        for (j, cb) in b.iter().enumerate() {
177            let substitution = previous[j] + usize::from(ca != *cb);
178            current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
179        }
180        previous = current;
181    }
182    previous[b.len()]
183}