1use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
2use nucleo_matcher::{Config, Matcher, Utf32Str};
3
4pub fn normalize_query(query: &str) -> String {
7 let trimmed = query.trim();
8 if trimmed.is_empty() {
9 return String::new();
10 }
11
12 let mut normalized = String::with_capacity(trimmed.len());
13 let mut last_was_space = false;
14 for ch in trimmed.chars() {
15 if ch.is_whitespace() {
16 if !last_was_space && !normalized.is_empty() {
17 normalized.push(' ');
18 }
19 last_was_space = true;
20 } else {
21 normalized.extend(ch.to_lowercase());
22 last_was_space = false;
23 }
24 }
25
26 normalized.trim_end().to_owned()
27}
28
29pub fn fuzzy_match(query: &str, candidate: &str) -> bool {
32 if query.is_empty() {
33 return true;
34 }
35
36 let mut matcher = Matcher::new(Config::DEFAULT);
37 let mut buffer = Vec::new();
38 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
39 let utf32_candidate = Utf32Str::new(candidate, &mut buffer);
40 pattern.score(utf32_candidate, &mut matcher).is_some()
41}
42
43#[inline]
48pub fn exact_terms_match(query: &str, candidate: &str) -> bool {
49 if query.is_empty() {
50 return true;
51 }
52 query
53 .split_whitespace()
54 .all(|term| candidate.contains(term))
55}
56
57pub fn fuzzy_subsequence(needle: &str, haystack: &str) -> bool {
60 if needle.is_empty() {
61 return true;
62 }
63
64 let mut needle_chars = needle.chars();
65 let mut current = match needle_chars.next() {
66 Some(value) => value,
67 None => return true,
68 };
69
70 for ch in haystack.chars() {
71 if ch == current {
72 match needle_chars.next() {
73 Some(next) => current = next,
74 None => return true,
75 }
76 }
77 }
78
79 false
80}
81
82pub fn fuzzy_score(query: &str, candidate: &str) -> Option<u32> {
85 if query.is_empty() {
86 return Some(0); }
88
89 let mut matcher = Matcher::new(Config::DEFAULT);
90 let mut buffer = Vec::new();
91 let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
92 let utf32_candidate = Utf32Str::new(candidate, &mut buffer);
93 pattern.score(utf32_candidate, &mut matcher)
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn normalize_query_trims_and_lowercases() {
102 let normalized = normalize_query(" Foo Bar BAZ ");
103 assert_eq!(normalized, "foo bar baz");
104 }
105
106 #[test]
107 fn normalize_query_handles_whitespace_only() {
108 assert!(normalize_query(" ").is_empty());
109 }
110
111 #[test]
112 fn fuzzy_subsequence_requires_in_order_match() {
113 assert!(fuzzy_subsequence("abc", "a_b_c"));
114 assert!(!fuzzy_subsequence("abc", "acb"));
115 }
116
117 #[test]
118 fn fuzzy_match_supports_multiple_terms() {
119 assert!(fuzzy_match("run cmd", "run command"));
120 assert!(!fuzzy_match("missing", "run command"));
121 }
122
123 #[test]
124 fn fuzzy_match_with_nucleo_basic() {
125 assert!(fuzzy_match("smr", "src/main.rs"));
127 assert!(fuzzy_match("src main", "src/main.rs"));
128 assert!(fuzzy_match("main", "src/main.rs"));
129 assert!(!fuzzy_match("xyz", "src/main.rs"));
130 }
131
132 #[test]
133 fn fuzzy_score_returns_some_for_matches() {
134 assert!(fuzzy_score("smr", "src/main.rs").is_some());
136 assert!(fuzzy_score("main", "src/main.rs").is_some());
137 }
138
139 #[test]
140 fn fuzzy_score_returns_none_for_non_matches() {
141 assert!(fuzzy_score("xyz", "src/main.rs").is_none());
143 }
144
145 #[test]
146 fn exact_terms_match_requires_substring() {
147 assert!(exact_terms_match("openai", "openai openai gpt-5.4 gpt-5.4"));
149 assert!(exact_terms_match("gpt", "openai openai gpt-5.4 gpt-5.4"));
150 assert!(!exact_terms_match(
151 "anthropic",
152 "openai openai gpt-5.4 gpt-5.4"
153 ));
154 }
155
156 #[test]
157 fn exact_terms_match_multi_term_requires_all() {
158 let candidate = "anthropic anthropic claude 4 sonnet claude-4-sonnet";
159 assert!(exact_terms_match("anthropic claude", candidate));
160 assert!(exact_terms_match("sonnet", candidate));
161 assert!(!exact_terms_match("anthropic gpt", candidate));
162 }
163
164 #[test]
165 fn exact_terms_match_empty_query_matches_everything() {
166 assert!(exact_terms_match("", "anything"));
167 }
168
169 #[test]
170 fn exact_terms_match_rejects_fuzzy_subsequences() {
171 assert!(!exact_terms_match("smr", "src/main.rs"));
172 }
173
174 #[test]
175 fn exact_terms_match_provider_filtering() {
176 let openai = "openai openai gpt-5.4 gpt-5.4 reasoning tools image";
177 let anthropic = "anthropic anthropic claude 4 sonnet claude-4-sonnet reasoning tools";
178 let gemini = "gemini gemini gemini 2.5 pro gemini-2.5-pro reasoning tools";
179
180 assert!(exact_terms_match("openai", openai));
182 assert!(!exact_terms_match("openai", anthropic));
183 assert!(!exact_terms_match("openai", gemini));
184
185 assert!(exact_terms_match("openai gpt", openai));
187 assert!(!exact_terms_match("openai claude", openai));
188
189 assert!(exact_terms_match("reasoning", openai));
191 assert!(exact_terms_match("reasoning", anthropic));
192 }
193}