spotify_cli/storage/
fuzzy.rs1use super::config::FuzzyConfig;
2
3pub fn calculate_score(name: &str, query: &str, config: &FuzzyConfig) -> f64 {
6 let name_lower = name.to_lowercase();
7 let query_lower = query.to_lowercase();
8 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
9
10 let mut score = 0.0;
11
12 if name_lower == query_lower {
13 return config.exact_match;
14 }
15
16 if name_lower.starts_with(&query_lower) {
17 score += config.starts_with;
18 }
19
20 if name_lower.contains(&query_lower) {
21 score += config.contains;
22 }
23
24 for word in &query_words {
25 if name_lower.contains(word) {
26 score += config.word_match;
27 }
28 }
29
30 let distance = levenshtein_distance(&name_lower, &query_lower);
31 let max_len = name_lower.len().max(query_lower.len());
32 if max_len > 0 {
33 let similarity = 1.0 - (distance as f64 / max_len as f64);
34 if similarity > config.similarity_threshold {
35 score += similarity * config.similarity_weight;
36 }
37 }
38
39 score
40}
41
42pub fn levenshtein_distance(a: &str, b: &str) -> usize {
44 let a_chars: Vec<char> = a.chars().collect();
45 let b_chars: Vec<char> = b.chars().collect();
46 let a_len = a_chars.len();
47 let b_len = b_chars.len();
48
49 if a_len == 0 {
50 return b_len;
51 }
52 if b_len == 0 {
53 return a_len;
54 }
55
56 let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
57
58 for (i, row) in matrix.iter_mut().enumerate() {
59 row[0] = i;
60 }
61 for (j, cell) in matrix[0].iter_mut().enumerate() {
62 *cell = j;
63 }
64
65 for i in 1..=a_len {
66 for j in 1..=b_len {
67 let cost = if a_chars[i - 1] == b_chars[j - 1] {
68 0
69 } else {
70 1
71 };
72 matrix[i][j] = (matrix[i - 1][j] + 1)
73 .min(matrix[i][j - 1] + 1)
74 .min(matrix[i - 1][j - 1] + cost);
75 }
76 }
77
78 matrix[a_len][b_len]
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 fn default_config() -> FuzzyConfig {
86 FuzzyConfig::default()
87 }
88
89 #[test]
90 fn exact_match_highest_score() {
91 let config = default_config();
92 let score = calculate_score("TOOL", "tool", &config);
93 assert_eq!(score, config.exact_match);
94 }
95
96 #[test]
97 fn starts_with_scores_high() {
98 let config = default_config();
99 let score = calculate_score("TOOL - Lateralus", "tool", &config);
100 assert!(score >= config.starts_with);
101 }
102
103 #[test]
104 fn no_match_scores_low() {
105 let config = default_config();
106 let score = calculate_score("Weezer", "tool", &config);
107 assert!(score < config.contains);
108 }
109
110 #[test]
111 fn levenshtein_basic() {
112 assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
113 assert_eq!(levenshtein_distance("tool", "tool"), 0);
114 assert_eq!(levenshtein_distance("", "abc"), 3);
115 }
116
117 #[test]
118 fn levenshtein_empty_strings() {
119 assert_eq!(levenshtein_distance("", ""), 0);
120 assert_eq!(levenshtein_distance("abc", ""), 3);
121 assert_eq!(levenshtein_distance("", "xyz"), 3);
122 }
123
124 #[test]
125 fn levenshtein_single_char() {
126 assert_eq!(levenshtein_distance("a", "b"), 1);
127 assert_eq!(levenshtein_distance("a", "a"), 0);
128 }
129
130 #[test]
131 fn levenshtein_longer_strings() {
132 assert_eq!(levenshtein_distance("hello", "hello"), 0);
133 assert_eq!(levenshtein_distance("hello", "hallo"), 1);
134 assert_eq!(levenshtein_distance("hello", "world"), 4);
135 }
136
137 #[test]
138 fn contains_partial_match() {
139 let config = default_config();
140 let score = calculate_score("My Favorite Tool", "tool", &config);
141 assert!(score >= config.contains);
143 }
144
145 #[test]
146 fn word_match_scoring() {
147 let config = default_config();
148 let score = calculate_score("rock and roll", "rock roll", &config);
149 assert!(score >= config.word_match * 2.0);
151 }
152
153 #[test]
154 fn similarity_bonus_applied() {
155 let config = default_config();
156 let score = calculate_score("tools", "tool", &config);
158 assert!(score > 0.0);
160 }
161
162 #[test]
163 fn case_insensitive_matching() {
164 let config = default_config();
165 let score1 = calculate_score("TOOL", "tool", &config);
166 let score2 = calculate_score("tool", "TOOL", &config);
167 let score3 = calculate_score("Tool", "TOOL", &config);
168 assert_eq!(score1, score2);
169 assert_eq!(score2, score3);
170 }
171
172 #[test]
173 fn custom_config_values() {
174 let config = FuzzyConfig {
175 exact_match: 200.0,
176 starts_with: 100.0,
177 contains: 50.0,
178 word_match: 20.0,
179 similarity_threshold: 0.5,
180 similarity_weight: 30.0,
181 };
182 let score = calculate_score("test", "test", &config);
183 assert_eq!(score, 200.0);
184 }
185
186 #[test]
187 fn zero_score_for_unrelated() {
188 let config = FuzzyConfig {
189 exact_match: 100.0,
190 starts_with: 50.0,
191 contains: 30.0,
192 word_match: 10.0,
193 similarity_threshold: 0.9, similarity_weight: 20.0,
195 };
196 let score = calculate_score("completely different", "xyz", &config);
197 assert!(score < 30.0); }
200}