1use strsim::{damerau_levenshtein, jaro_winkler, levenshtein};
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub enum Algorithm {
24 #[default]
29 JaroWinkler,
30
31 Levenshtein,
36
37 DamerauLevenshtein,
42}
43
44pub fn similarity(a: &str, b: &str, algo: Algorithm) -> f64 {
53 if a == b {
54 return 1.0;
55 }
56 if a.is_empty() || b.is_empty() {
57 return 0.0;
58 }
59
60 match algo {
61 Algorithm::JaroWinkler => jaro_winkler(a, b),
62 Algorithm::Levenshtein => {
63 let dist = levenshtein(a, b);
64 let max_len = a.chars().count().max(b.chars().count());
65 1.0 - (dist as f64 / max_len as f64)
66 }
67 Algorithm::DamerauLevenshtein => {
68 let dist = damerau_levenshtein(a, b);
69 let max_len = a.chars().count().max(b.chars().count());
70 1.0 - (dist as f64 / max_len as f64)
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq)]
77pub struct Match {
78 pub candidate: String,
80 pub similarity: f64,
82}
83
84impl Match {
85 pub fn new(candidate: impl Into<String>, similarity: f64) -> Self {
90 Self {
91 candidate: candidate.into(),
92 similarity,
93 }
94 }
95}
96
97pub fn find_closest<'a>(
101 input: &str,
102 candidates: impl IntoIterator<Item = &'a str>,
103 min_similarity: f64,
104 algo: Algorithm,
105) -> Option<Match> {
106 candidates
107 .into_iter()
108 .map(|c| Match::new(c, similarity(input, c, algo)))
109 .filter(|m| m.similarity >= min_similarity)
110 .max_by(|a, b| a.similarity.total_cmp(&b.similarity))
111}
112
113pub fn find_all_matches<'a>(
117 input: &str,
118 candidates: impl IntoIterator<Item = &'a str>,
119 min_similarity: f64,
120 algo: Algorithm,
121) -> Vec<Match> {
122 let mut matches: Vec<_> = candidates
123 .into_iter()
124 .map(|c| Match::new(c, similarity(input, c, algo)))
125 .filter(|m| m.similarity >= min_similarity)
126 .collect();
127
128 matches.sort_by(|a, b| b.similarity.total_cmp(&a.similarity));
129 matches
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn test_identical_strings() {
138 assert_eq!(similarity("hello", "hello", Algorithm::JaroWinkler), 1.0);
139 assert_eq!(similarity("hello", "hello", Algorithm::Levenshtein), 1.0);
140 assert_eq!(
141 similarity("hello", "hello", Algorithm::DamerauLevenshtein),
142 1.0
143 );
144 }
145
146 #[test]
147 fn test_empty_strings() {
148 assert_eq!(similarity("", "", Algorithm::JaroWinkler), 1.0);
149 assert_eq!(similarity("hello", "", Algorithm::JaroWinkler), 0.0);
150 assert_eq!(similarity("", "hello", Algorithm::JaroWinkler), 0.0);
151 }
152
153 #[test]
154 fn test_typo_detection_jaro_winkler() {
155 let sim = similarity("AddDeriv", "AddDerive", Algorithm::JaroWinkler);
157 assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
158
159 let sim = similarity("RenamIdent", "RenameIdent", Algorithm::JaroWinkler);
160 assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
161 }
162
163 #[test]
164 fn test_field_name_typo() {
165 let sim = similarity("target_name", "target", Algorithm::JaroWinkler);
166 assert!(sim > 0.7, "Expected > 0.7, got {}", sim);
167
168 let sim = similarity("struct_nam", "struct_name", Algorithm::JaroWinkler);
169 assert!(sim > 0.9, "Expected > 0.9, got {}", sim);
170 }
171
172 #[test]
173 fn test_find_closest() {
174 let candidates = ["AddDerive", "RemoveDerive", "AddField", "RemoveField"];
175 let result = find_closest(
176 "AddDeriv",
177 candidates.iter().copied(),
178 0.7,
179 Algorithm::JaroWinkler,
180 );
181
182 assert!(result.is_some());
183 let m = result.unwrap();
184 assert_eq!(m.candidate, "AddDerive");
185 assert!(m.similarity > 0.9);
186 }
187
188 #[test]
189 fn test_find_closest_no_match() {
190 let candidates = ["AddDerive", "RemoveDerive"];
191 let result = find_closest(
192 "CompletelyDifferent",
193 candidates.iter().copied(),
194 0.9, Algorithm::JaroWinkler,
196 );
197
198 assert!(result.is_none());
199 }
200
201 #[test]
202 fn test_find_all_matches() {
203 let candidates = ["target", "target_mod", "target_fn", "body"];
204 let matches = find_all_matches(
205 "target_name",
206 candidates.iter().copied(),
207 0.6,
208 Algorithm::JaroWinkler,
209 );
210
211 assert!(!matches.is_empty());
212 for i in 1..matches.len() {
214 assert!(matches[i - 1].similarity >= matches[i].similarity);
215 }
216 }
217
218 #[test]
219 fn test_levenshtein_normalization_non_ascii() {
220 let sim = similarity("こんにちは", "こんにちわ", Algorithm::Levenshtein);
224 assert!((sim - 0.8).abs() < 1e-9, "Expected 0.8, got {}", sim);
225
226 let sim = similarity("こんにちは", "こんにちわ", Algorithm::DamerauLevenshtein);
227 assert!((sim - 0.8).abs() < 1e-9, "Expected 0.8, got {}", sim);
228 }
229
230 #[test]
231 fn test_levenshtein_non_ascii_completely_different() {
232 let sim = similarity("りんご", "みかん", Algorithm::Levenshtein);
236 assert!((0.0..=1.0).contains(&sim), "Score out of range: {}", sim);
237 assert!((sim - 0.0).abs() < 1e-9, "Expected 0.0, got {}", sim);
238 }
239
240 #[test]
241 fn test_transposition_damerau() {
242 let sim_dl = similarity("teh", "the", Algorithm::DamerauLevenshtein);
244 let sim_l = similarity("teh", "the", Algorithm::Levenshtein);
245 assert!(sim_dl >= sim_l);
247 }
248}