headless_lms_models/library/credit_registration/
sisu_name.rs1use unicode_normalization::UnicodeNormalization;
5use unicode_normalization::char::is_combining_mark;
6use utoipa::ToSchema;
7
8use crate::prelude::*;
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
12pub struct NameReplacement {
13 pub first_name_options: Vec<String>,
15 pub suggested_first_name: String,
17 pub last_name: String,
18 pub resembles_nothing: bool,
21}
22
23impl NameReplacement {
24 pub fn allows(&self, first_name: &str) -> bool {
26 self.first_name_options
27 .iter()
28 .any(|option| option == first_name)
29 }
30}
31
32pub 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 Close,
113}
114
115struct Match {
116 index: usize,
117 resemblance: Resemblance,
118 distance: usize,
119}
120
121fn 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 let allowed_typos = if b.chars().count() < 5 { 1 } else { 2 };
147 (levenshtein(&a, &b) <= allowed_typos).then_some(Resemblance::Close)
148}
149
150fn 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
163fn 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}