1pub mod jaro_winkler;
2pub mod levenshtein;
3pub mod normalize;
4pub mod token;
5pub mod trigram;
6
7pub use jaro_winkler::jaro_winkler;
8pub use levenshtein::levenshtein;
9pub use normalize::normalize;
10pub use token::{token_set_ratio, token_sort_ratio};
11pub use trigram::trigram_similarity;
12
13use jaro_winkler::jaro_winkler_raw;
14use levenshtein::levenshtein_raw;
15use rayon::prelude::*;
16use trigram::trigram_similarity_raw;
17
18pub fn combined_score(a: &str, b: &str) -> f64 {
30 let a = normalize(a);
31 let b = normalize(b);
32 combined_score_raw(&a, &b)
33}
34
35pub(crate) fn combined_score_raw(a: &str, b: &str) -> f64 {
36 let lev = levenshtein_raw(a, b);
37 let max_len = a.chars().count().max(b.chars().count());
38
39 let lev_sim = if max_len == 0 {
40 1.0
41 } else {
42 1.0 - (lev as f64 / max_len as f64)
43 };
44
45 let jw = jaro_winkler_raw(a, b);
46 let tri = trigram_similarity_raw(a, b);
47
48 lev_sim * 0.35 + jw * 0.40 + tri * 0.25
49}
50
51pub fn best_match<'a>(query: &str, candidates: &[&'a str]) -> Option<(&'a str, f64)> {
62 let q = normalize(query);
63 candidates
64 .iter()
65 .map(|c| {
66 let cn = normalize(c);
67 (*c, combined_score_raw(&q, &cn))
68 })
69 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
70}
71
72pub fn rank_matches<'a>(query: &str, candidates: &[&'a str]) -> Vec<(&'a str, f64)> {
83 let q = normalize(query);
84 let mut results: Vec<(&str, f64)> = candidates
85 .iter()
86 .map(|c| {
87 let cn = normalize(c);
88 (*c, combined_score_raw(&q, &cn))
89 })
90 .collect();
91
92 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
93 results
94}
95
96pub fn batch_best_match<'a>(
112 queries: &[&str],
113 candidates: &[&'a str],
114) -> Vec<Option<(&'a str, f64)>> {
115 let cand_norm: Vec<String> = candidates.iter().map(|c| normalize(c)).collect();
116
117 queries
118 .par_iter()
119 .map(|&q| {
120 let q_norm = normalize(q);
121 candidates
122 .iter()
123 .zip(cand_norm.iter())
124 .map(|(orig, norm)| (*orig, combined_score_raw(&q_norm, norm)))
125 .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
126 })
127 .collect()
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 fn approx(a: f64, b: f64) -> bool {
135 (a - b).abs() < 0.001
136 }
137
138 #[test]
139 fn test_combined_score_identical() {
140 assert!(approx(combined_score("cat", "cat"), 1.0));
141 }
142
143 #[test]
144 fn test_combined_score_case_insensitive() {
145 assert!(approx(combined_score("CAT", "cat"), 1.0));
146 }
147
148 #[test]
149 fn test_combined_score_raw_skips_normalization() {
150 assert!(combined_score_raw("CAT", "cat") < 1.0);
151 }
152
153 #[test]
154 fn test_combined_score_raw_matches_public_on_normalized_input() {
155 assert!(approx(
156 combined_score_raw("cat", "bat"),
157 combined_score("cat", "bat"),
158 ));
159 }
160
161 #[test]
162 fn test_best_match_handles_normalized_query() {
163 let candidates = vec!["apple", "grape", "mango"];
164 let (winner, _) = best_match("APPEL", &candidates).unwrap();
165 assert_eq!(winner, "apple");
166 }
167
168 #[test]
169 fn test_rank_matches_handles_normalized_query() {
170 let candidates = vec!["apple", "grape", "mango"];
171 let ranked = rank_matches(" APPEL ", &candidates);
172 assert_eq!(ranked[0].0, "apple");
173 assert_eq!(ranked.len(), 3);
174 }
175
176 #[test]
177 fn test_batch_best_match_single_query() {
178 let candidates = ["apple", "grape", "mango"];
179 let queries = ["appel"];
180 let results = batch_best_match(&queries, &candidates);
181 assert_eq!(results.len(), 1);
182 assert_eq!(results[0].unwrap().0, "apple");
183 }
184
185 #[test]
186 fn test_batch_best_match_multiple_queries() {
187 let candidates = ["apple", "grape", "mango"];
188 let queries = ["appel", "grpe", "mngo"];
189 let results = batch_best_match(&queries, &candidates);
190 assert_eq!(results.len(), 3);
191 assert_eq!(results[0].unwrap().0, "apple");
192 assert_eq!(results[1].unwrap().0, "grape");
193 assert_eq!(results[2].unwrap().0, "mango");
194 }
195
196 #[test]
197 fn test_batch_best_match_no_candidates() {
198 let candidates: [&str; 0] = [];
199 let queries = ["apple"];
200 let results = batch_best_match(&queries, &candidates);
201 assert_eq!(results.len(), 1);
202 assert!(results[0].is_none());
203 }
204
205 #[test]
206 fn test_batch_best_match_no_queries() {
207 let candidates = ["apple", "grape"];
208 let queries: [&str; 0] = [];
209 let results = batch_best_match(&queries, &candidates);
210 assert!(results.is_empty());
211 }
212
213 #[test]
214 fn test_batch_best_match_agrees_with_best_match() {
215 let candidates = ["apple", "grape", "mango"];
216 let queries = ["appel", "grpe"];
217 let batch = batch_best_match(&queries, &candidates);
218 for (i, &q) in queries.iter().enumerate() {
219 let serial = best_match(q, &candidates);
220 assert_eq!(batch[i], serial);
221 }
222 }
223}
224
225pub mod python;