spotify_cli/storage/
fuzzy.rs

1use super::config::FuzzyConfig;
2
3/// Calculate fuzzy match score for a name against a query
4/// Returns 0.0 for no match, higher values for better matches
5pub 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
42/// Simple Levenshtein distance implementation
43pub 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        // Should get contains + word_match bonus
142        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        // Should get word match bonuses for "rock" and "roll"
150        assert!(score >= config.word_match * 2.0);
151    }
152
153    #[test]
154    fn similarity_bonus_applied() {
155        let config = default_config();
156        // "tools" is very similar to "tool"
157        let score = calculate_score("tools", "tool", &config);
158        // Should get similarity bonus since they are 80% similar
159        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, // High threshold
194            similarity_weight: 20.0,
195        };
196        let score = calculate_score("completely different", "xyz", &config);
197        // Very different strings with high threshold should score low
198        assert!(score < 30.0); // Less than contains
199    }
200}