1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11use unicode_normalization::UnicodeNormalization;
12
13pub const CLOSE: f64 = 0.3;
15pub const VERY_CLOSE: f64 = 0.6;
17pub const MAX_MATCHES: usize = 3;
19pub const MAX_SHARED: usize = 4;
21pub const MAX_UNITS: usize = 2000;
24
25const STOP: &[&str] = &[
28 "a",
29 "about",
30 "above",
31 "after",
32 "again",
33 "against",
34 "all",
35 "also",
36 "am",
37 "an",
38 "and",
39 "any",
40 "are",
41 "as",
42 "at",
43 "be",
44 "because",
45 "been",
46 "before",
47 "being",
48 "below",
49 "between",
50 "both",
51 "but",
52 "by",
53 "can",
54 "cannot",
55 "could",
56 "did",
57 "do",
58 "does",
59 "doing",
60 "down",
61 "during",
62 "each",
63 "either",
64 "else",
65 "even",
66 "ever",
67 "every",
68 "few",
69 "for",
70 "from",
71 "further",
72 "get",
73 "gets",
74 "given",
75 "had",
76 "has",
77 "have",
78 "having",
79 "he",
80 "her",
81 "here",
82 "hers",
83 "him",
84 "his",
85 "how",
86 "if",
87 "in",
88 "into",
89 "is",
90 "it",
91 "its",
92 "itself",
93 "just",
94 "let",
95 "lets",
96 "may",
97 "me",
98 "might",
99 "more",
100 "most",
101 "much",
102 "my",
103 "neither",
104 "no",
105 "nor",
106 "not",
107 "now",
108 "of",
109 "off",
110 "on",
111 "once",
112 "one",
113 "only",
114 "or",
115 "other",
116 "our",
117 "ours",
118 "out",
119 "over",
120 "own",
121 "per",
122 "same",
123 "shall",
124 "she",
125 "so",
126 "some",
127 "such",
128 "than",
129 "that",
130 "the",
131 "their",
132 "theirs",
133 "them",
134 "then",
135 "there",
136 "these",
137 "they",
138 "this",
139 "those",
140 "through",
141 "to",
142 "too",
143 "under",
144 "until",
145 "up",
146 "upon",
147 "us",
148 "very",
149 "via",
150 "was",
151 "we",
152 "were",
153 "what",
154 "when",
155 "where",
156 "whether",
157 "which",
158 "while",
159 "who",
160 "whom",
161 "why",
162 "will",
163 "with",
164 "within",
165 "without",
166 "would",
167 "yet",
168 "you",
169 "your",
170 "yours",
171 "able",
172 "allow",
173 "allows",
174 "allowed",
175 "must",
176 "should",
177 "system",
178 "user",
179 "users",
180 "feature",
181 "requirement",
182 "new",
183 "want",
184 "wants",
185 "need",
186 "needs",
187 "make",
188 "makes",
189 "use",
190 "uses",
191 "using",
192 "way",
193 "ways",
194 "etc",
195 "e",
196 "g",
197 "i",
198 "ie",
199 "eg",
200];
201
202fn stem(word: &str) -> String {
205 let n = word.chars().count();
206 if n > 4
207 && let Some(base) = word.strip_suffix("ies")
208 {
209 return format!("{base}y");
210 }
211 for (suffix, keep) in [("ing", 4), ("ed", 3), ("ly", 3), ("es", 3), ("s", 3)] {
212 if let Some(base) = word.strip_suffix(suffix)
213 && base.chars().count() >= keep
214 && !(suffix == "s" && base.ends_with('s'))
215 {
216 if suffix == "es"
218 && !["s", "x", "z", "ch", "sh"]
219 .iter()
220 .any(|e| base.ends_with(e))
221 {
222 continue;
223 }
224 return base.to_owned();
225 }
226 }
227 word.to_owned()
228}
229
230#[must_use]
233pub fn tokenize(text: &str) -> Vec<String> {
234 let normal: String = text.nfkc().collect::<String>().to_lowercase();
235 normal
236 .split(|c: char| !c.is_alphanumeric())
237 .filter(|w| w.chars().count() >= 2 && !STOP.contains(w))
238 .map(stem)
239 .filter(|w| w.chars().count() >= 2 && !STOP.contains(&w.as_str()))
240 .collect()
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct Requirement {
246 pub unit: String,
248 pub id: String,
250 pub text: String,
252 pub at: Option<Place>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
259pub struct Place {
260 pub path: String,
262 pub line: u32,
264 pub quote: String,
266}
267
268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
270#[serde(rename_all = "snake_case")]
271pub enum SimilarLevel {
272 Close,
274 VeryClose,
276}
277
278impl SimilarLevel {
279 #[must_use]
281 pub const fn word(self) -> &'static str {
282 match self {
283 Self::Close => "close",
284 Self::VeryClose => "very close",
285 }
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
291pub struct SimilarMatch {
292 pub unit: String,
294 pub requirement: String,
296 pub text: String,
298 pub score: f64,
300 pub level: SimilarLevel,
302 pub shared: Vec<String>,
304 pub at: Option<Place>,
306}
307
308fn unit_number(unit: &str) -> u64 {
310 unit.chars()
311 .take_while(char::is_ascii_digit)
312 .collect::<String>()
313 .parse()
314 .unwrap_or(u64::MAX)
315}
316
317type Vector = BTreeMap<String, f64>;
318
319fn counts(terms: &[String]) -> BTreeMap<String, f64> {
320 let mut m = BTreeMap::new();
321 for t in terms {
322 *m.entry(t.clone()).or_insert(0.0) += 1.0;
323 }
324 m
325}
326
327fn norm(v: &Vector) -> f64 {
328 v.values().map(|w| w * w).sum::<f64>().sqrt()
329}
330
331#[must_use]
335pub fn matches(query: &str, corpus: &[Requirement]) -> Vec<SimilarMatch> {
336 let q_terms = tokenize(query);
337 if q_terms.is_empty() || corpus.is_empty() {
338 return Vec::new();
339 }
340 let docs: Vec<BTreeMap<String, f64>> =
341 corpus.iter().map(|r| counts(&tokenize(&r.text))).collect();
342 let q_counts = counts(&q_terms);
343 let mut df: BTreeMap<&str, f64> = BTreeMap::new();
345 for d in docs.iter().chain(std::iter::once(&q_counts)) {
346 for t in d.keys() {
347 *df.entry(t.as_str()).or_insert(0.0) += 1.0;
348 }
349 }
350 #[allow(clippy::cast_precision_loss)]
351 let n = (docs.len() + 1) as f64;
352 let idf = |t: &str| ((1.0 + n) / (1.0 + df.get(t).copied().unwrap_or(0.0))).ln() + 1.0;
353 let weigh = |c: &BTreeMap<String, f64>| -> Vector {
354 c.iter().map(|(t, tf)| (t.clone(), tf * idf(t))).collect()
355 };
356 let q = weigh(&q_counts);
357 let qn = norm(&q);
358 let mut out: Vec<(f64, &Requirement, Vec<String>)> = Vec::new();
359 for (r, d) in corpus.iter().zip(&docs) {
360 let v = weigh(d);
361 let vn = norm(&v);
362 if vn == 0.0 || qn == 0.0 {
363 continue;
364 }
365 let mut shared: Vec<(f64, &str)> = q
366 .iter()
367 .filter_map(|(t, w)| v.get(t).map(|dw| (w * dw, t.as_str())))
368 .collect();
369 let dot: f64 = shared.iter().map(|(p, _)| p).sum();
370 let score = (dot / (qn * vn)).clamp(0.0, 1.0);
371 if score < CLOSE {
372 continue;
373 }
374 shared.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
375 let shared = shared
376 .into_iter()
377 .take(MAX_SHARED)
378 .map(|(_, t)| t.to_owned())
379 .collect();
380 out.push((score, r, shared));
381 }
382 out.sort_by(|a, b| {
383 b.0.total_cmp(&a.0)
384 .then_with(|| unit_number(&a.1.unit).cmp(&unit_number(&b.1.unit)))
385 .then_with(|| a.1.unit.cmp(&b.1.unit))
386 .then_with(|| a.1.id.cmp(&b.1.id))
387 });
388 out.into_iter()
389 .take(MAX_MATCHES)
390 .map(|(score, r, shared)| SimilarMatch {
391 unit: r.unit.clone(),
392 requirement: r.id.clone(),
393 text: r.text.clone(),
394 score: (score * 100.0).round() / 100.0,
395 level: if score >= VERY_CLOSE {
396 SimilarLevel::VeryClose
397 } else {
398 SimilarLevel::Close
399 },
400 shared,
401 at: r.at.clone(),
402 })
403 .collect()
404}
405
406#[must_use]
408pub fn shared_terms(a: &str, b: &str) -> BTreeSet<String> {
409 let a: BTreeSet<String> = tokenize(a).into_iter().collect();
410 tokenize(b).into_iter().filter(|t| a.contains(t)).collect()
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
415pub struct SimilarReport {
416 pub query: String,
418 pub compared: usize,
420 pub limited: bool,
423 pub matches: Vec<SimilarMatch>,
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn stems_fold_the_usual_suffixes() {
433 assert_eq!(stem("reports"), "report");
434 assert_eq!(stem("exporting"), "export");
435 assert_eq!(stem("exported"), "export");
436 assert_eq!(stem("categories"), "category");
437 assert_eq!(stem("boxes"), "box");
438 assert_eq!(stem("class"), "class");
439 assert_eq!(stem("quickly"), "quick");
440 assert_eq!(stem("is"), "is");
441 }
442
443 #[test]
444 fn tokens_are_normalised_and_stop_words_dropped() {
445 assert_eq!(
446 tokenize("Users MUST be able to export Reports as CSV"),
447 vec!["export", "report", "csv"]
448 );
449 assert!(tokenize("the a of").is_empty());
450 }
451}