Skip to main content

matchr/
lib.rs

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
18/// Blends all three algorithms into one similarity score.
19///
20/// Returns `0.0` (no similarity) to `1.0` (identical).
21/// Levenshtein is normalised to 0–1 before blending.
22///
23/// # Examples
24///
25/// ```
26/// use matchr::combined_score;
27/// assert!((combined_score("cat", "cat") - 1.0).abs() < 0.001);
28/// ```
29pub 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
51/// Returns the closest match from a list of candidates.
52///
53/// # Examples
54///
55/// ```
56/// use matchr::best_match;
57/// let candidates = vec!["apple", "grape", "mango"];
58/// let (winner, score) = best_match("appel", &candidates).unwrap();
59/// assert_eq!(winner, "apple");
60/// ```
61pub 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
72/// Returns all candidates ranked by similarity score, highest first.
73///
74/// # Examples
75///
76/// ```
77/// use matchr::rank_matches;
78/// let candidates = vec!["apple", "grape", "mango"];
79/// let ranked = rank_matches("appel", &candidates);
80/// assert_eq!(ranked[0].0, "apple");
81/// ```
82pub 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
96/// Returns the best match for each query against the candidate list.
97///
98/// Candidates are normalised once (not per query). Queries are
99/// processed in parallel via rayon — scales with available cores.
100///
101/// # Examples
102///
103/// ```
104/// use matchr::batch_best_match;
105/// let candidates = ["apple", "grape", "mango"];
106/// let queries = ["appel", "mngo"];
107/// let results = batch_best_match(&queries, &candidates);
108/// assert_eq!(results[0].unwrap().0, "apple");
109/// assert_eq!(results[1].unwrap().0, "mango");
110/// ```
111pub 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;