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