返回 CodeWhale
matcher.rs
根目录 / crates / tui / src / plugins / matcher.rs
1 //! Pure keyword matcher over plugin metadata.
2 //!
3 //! Matches live as data: explicit `keywords`, `domains` (scheme / `www.` /
4 //! path stripped), and the plugin `name`. There is no `regex` dependency —
5 //! matching is substring search guarded by ASCII word boundaries.
6
7 use std::cmp::Reverse;
8
9 /// A plugin to match a draft against.
10 pub struct KeywordCandidate<'a> {
11 pub name: &'a str,
12 pub domains: &'a [String],
13 pub keywords: &'a [String],
14 }
15
16 /// Return the candidate index and exact term that matched `draft`.
17 ///
18 /// Returns `None` when `draft` has fewer than 3 characters or nothing matches.
19 /// Longer keywords take precedence; a keyword matches only when the occurrence
20 /// is flanked by ASCII word boundaries.
21 pub fn match_plugin_keyword(
22 draft: &str,
23 candidates: &[KeywordCandidate<'_>],
24 ) -> Option<(usize, String)> {
25 if draft.trim_start().starts_with('/') || draft.chars().count() < 3 {
26 return None;
27 }
28 let draft_lc = draft.to_ascii_lowercase();
29 let haystack = draft_lc.as_bytes();
30
31 let mut pairs: Vec<(String, usize)> = Vec::new();
32 for (idx, candidate) in candidates.iter().enumerate() {
33 for keyword in effective_keywords(candidate) {
34 pairs.push((keyword, idx));
35 }
36 }
37 pairs.sort_by_key(|(keyword, _)| Reverse(keyword.len()));
38
39 pairs
40 .iter()
41 .find(|(keyword, _)| keyword_matches(haystack, keyword.as_bytes()))
42 .map(|(keyword, idx)| (*idx, keyword.clone()))
43 }
44
45 fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec<String> {
46 let mut keywords = Vec::new();
47 for keyword in candidate.keywords {
48 let normalized = keyword.trim().to_ascii_lowercase();
49 if is_matchable_term(&normalized) {
50 keywords.push(normalized);
51 }
52 }
53 for domain in candidate.domains {
54 // A homepage on a code-hosting platform names where the plugin
55 // *lives*, not what it is; matching it would make every github-hosted
56 // plugin fire on any "github" mention. Everything else matches on
57 // declared data, which the host does not second-guess.
58 if let Some(normalized) = normalize_domain(domain)
59 && is_matchable_term(&normalized)
60 && !matches!(
61 normalized.as_str(),
62 "github.com" | "gitlab.com" | "bitbucket.org"
63 )
64 {
65 keywords.push(normalized);
66 }
67 }
68 let name = candidate.name.trim().to_ascii_lowercase();
69 if is_matchable_term(&name) {
70 keywords.push(name);
71 }
72 keywords
73 }
74
75 /// Generic words that never trigger a proactive plugin offer (0.10.1 plugin
76 /// offering policy, rule 6). Everyday requests like "fix the accessibility of
77 /// the login form" or "take a screenshot" are not evidence that the user needs
78 /// an integration.
79 ///
80 /// The marketplace repo's `scripts/check-marketplace.mjs` carries the same
81 /// list as `STOPLIST` and rejects a manifest keyword on it, so a catalog
82 /// author finds out at review time instead of the term silently never
83 /// matching here. Change both together; kept sorted so the two diff cleanly.
84 pub(crate) const GENERIC_TERM_STOPLIST: &[&str] = &[
85 "accessibility",
86 "automation",
87 "browser",
88 "browsers",
89 "chrome",
90 "codebase",
91 "docs",
92 "documentation",
93 "extension",
94 "extensions",
95 "screenshot",
96 "screenshots",
97 "web",
98 "website",
99 "wiki",
100 ];
101
102 /// Admissibility for a match term: long enough to be a word, free of control
103 /// characters, and not a generic word from [`GENERIC_TERM_STOPLIST`].
104 ///
105 /// Everything else a catalog author declares stays matchable (`mcp`, `agent`,
106 /// `model`, …): the stoplist is a short shared list, not a per-host judgment.
107 /// The remaining noise controls are the send-time toast's shared tips switch
108 /// and per-session budget, and per-plugin dismissal. There is no score
109 /// threshold on the proactive path (see `recommend.rs`).
110 fn is_matchable_term(term: &str) -> bool {
111 term.chars().count() >= 3
112 && !term.chars().any(char::is_control)
113 && !GENERIC_TERM_STOPLIST.contains(&term)
114 }
115
116 pub(crate) fn normalize_domain(domain: &str) -> Option<String> {
117 let trimmed = domain.trim();
118 let after_scheme = match trimmed.find("://") {
119 Some(i) => &trimmed[i + 3..],
120 None => trimmed,
121 };
122 let host = after_scheme
123 .split(['/', '?', '#'])
124 .next()
125 .unwrap_or(after_scheme)
126 .to_ascii_lowercase();
127 let host = host.strip_prefix("www.").unwrap_or(&host);
128 if host.is_empty() {
129 None
130 } else {
131 Some(host.to_string())
132 }
133 }
134
135 fn keyword_matches(haystack: &[u8], keyword: &[u8]) -> bool {
136 if keyword.is_empty() {
137 return false;
138 }
139 let len = haystack.len();
140 haystack
141 .windows(keyword.len())
142 .enumerate()
143 .any(|(start, window)| {
144 if window != keyword {
145 return false;
146 }
147 let end = start + keyword.len();
148 let start_ok = start == 0 || is_word(haystack[start - 1]) != is_word(haystack[start]);
149 let end_ok = end == len || is_word(haystack[end - 1]) != is_word(haystack[end]);
150 start_ok && end_ok
151 })
152 }
153
154 fn is_word(byte: u8) -> bool {
155 byte.is_ascii_alphanumeric() || byte == b'_'
156 }
157
158 #[cfg(test)]
159 mod tests {
160 use super::*;
161
162 // Existing selection tests assert the index; the receipt test below also
163 // checks the matched term carried through to the UI.
164 fn match_plugin_keyword(draft: &str, candidates: &[KeywordCandidate<'_>]) -> Option<usize> {
165 super::match_plugin_keyword(draft, candidates).map(|(index, _)| index)
166 }
167
168 #[test]
169 fn match_receipt_names_the_exact_trigger() {
170 let keywords = vec!["finance".to_string(), "mcp".to_string()];
171 let candidates = [candidate("kimi-datasource", &[], &keywords)];
172 assert_eq!(
173 super::match_plugin_keyword("help with finance", &candidates),
174 Some((0, "finance".into()))
175 );
176 // Declared terms are the catalog author's call (#6290 rework): `mcp`
177 // matches when declared, and the receipt names it.
178 assert_eq!(
179 super::match_plugin_keyword("help with mcp", &candidates),
180 Some((0, "mcp".into()))
181 );
182 }
183
184 fn candidate<'a>(
185 name: &'a str,
186 domains: &'a [String],
187 keywords: &'a [String],
188 ) -> KeywordCandidate<'a> {
189 KeywordCandidate {
190 name,
191 domains,
192 keywords,
193 }
194 }
195
196 #[test]
197 fn longest_keyword_takes_precedence() {
198 let short = vec!["editor".to_string()];
199 let long = vec!["code editor".to_string()];
200 let candidates = [
201 candidate("plugin-a", &[], &short),
202 candidate("plugin-b", &[], &long),
203 ];
204 assert_eq!(
205 match_plugin_keyword("my code editor rocks", &candidates),
206 Some(1)
207 );
208 }
209
210 #[test]
211 fn word_boundary_required() {
212 let keywords = vec!["box".to_string()];
213 let candidates = [candidate("box", &[], &keywords)];
214 assert_eq!(match_plugin_keyword("i love boxing", &candidates), None);
215 assert_eq!(match_plugin_keyword("i love box", &candidates), Some(0));
216 }
217
218 #[test]
219 fn boxing_does_not_match_box() {
220 let keywords = vec!["box".to_string()];
221 let candidates = [candidate("box", &[], &keywords)];
222 assert_eq!(match_plugin_keyword("try boxing drills", &candidates), None);
223 }
224
225 #[test]
226 fn domains_match_inside_pasted_urls() {
227 let domains = vec!["figma.com".to_string()];
228 let none: Vec<String> = Vec::new();
229 let candidates = [candidate("design-app", &domains, &none)];
230 assert_eq!(
231 match_plugin_keyword("open https://www.figma.com/board/x please", &candidates),
232 Some(0)
233 );
234 assert_eq!(
235 match_plugin_keyword("open figma.com please", &candidates),
236 Some(0)
237 );
238 assert_eq!(match_plugin_keyword("open figma please", &candidates), None);
239 }
240
241 #[test]
242 fn name_is_used_as_fallback() {
243 let none: Vec<String> = Vec::new();
244 let candidates = [candidate("obsidian", &[], &none)];
245 assert_eq!(
246 match_plugin_keyword("open obsidian now", &candidates),
247 Some(0)
248 );
249 }
250
251 #[test]
252 fn draft_below_min_length_never_matches() {
253 let keywords = vec!["go".to_string()];
254 let candidates = [candidate("go", &[], &keywords)];
255 assert_eq!(match_plugin_keyword("go", &candidates), None);
256 let git = vec!["git".to_string()];
257 let candidates = [candidate("git", &[], &git)];
258 assert_eq!(match_plugin_keyword("git", &candidates), Some(0));
259 }
260
261 #[test]
262 fn declared_vocabulary_matches_and_only_mechanics_filter_terms() {
263 // Declared keywords are the catalog author's call (#6290 rework):
264 // `mcp`, `agent`, `model`, … match when declared. The remaining
265 // filters are mechanical (>= 3 characters, no control characters),
266 // the shared generic-term stoplist, the `/`-command guard, and the
267 // code-hosting homepage exclusion.
268 let words = [
269 "mcp", "plugin", "skill", "agent", "tool", "code", "data", "model", "session",
270 ];
271 let keywords = words
272 .iter()
273 .map(|word| word.to_string())
274 .collect::<Vec<_>>();
275 let candidates = [candidate("mcp", &[], &keywords)];
276 for word in words {
277 assert_eq!(
278 match_plugin_keyword(&format!("please help with {word}"), &candidates),
279 Some(0),
280 "{word}"
281 );
282 }
283 // Two-character terms stay out on the mechanical floor.
284 let short_keywords = vec!["go".to_string()];
285 let short = [candidate("git", &[], &short_keywords)];
286 assert_eq!(match_plugin_keyword("go", &short), None);
287
288 let shared_host = vec!["https://github.com/example/plugin".to_string()];
289 let candidates = [candidate("supabase", &shared_host, &[])];
290 assert_eq!(
291 match_plugin_keyword("open github.com/example/repo", &candidates),
292 None
293 );
294 for command in ["/mcp", " /plugin show supabase", "/skills supabase"] {
295 assert_eq!(match_plugin_keyword(command, &candidates), None);
296 }
297 assert_eq!(
298 match_plugin_keyword("add supabase auth", &candidates),
299 Some(0)
300 );
301 }
302
303 #[test]
304 fn generic_terms_never_match_even_when_declared() {
305 // Policy rule 6: "improve accessibility" and "take a screenshot" are
306 // ordinary requests, not evidence the user wants an integration.
307 let keywords = GENERIC_TERM_STOPLIST
308 .iter()
309 .map(|word| word.to_string())
310 .collect::<Vec<_>>();
311 let candidates = [candidate("computer-use", &[], &keywords)];
312 for draft in [
313 "improve accessibility",
314 "take a screenshot",
315 "fix the accessibility of the login form",
316 "open the browser and check the web page",
317 "update the docs and the wiki",
318 ] {
319 assert_eq!(match_plugin_keyword(draft, &candidates), None, "{draft}");
320 }
321 // A plugin named with a generic word is not matchable by that name.
322 let none: Vec<String> = Vec::new();
323 let named = [candidate("browser", &[], &none)];
324 assert_eq!(match_plugin_keyword("open the browser", &named), None);
325 // A specific term on the same plugin still matches.
326 let specific = vec!["accessibility".to_string(), "computer use".to_string()];
327 let candidates = [candidate("computer-use", &[], &specific)];
328 assert_eq!(
329 match_plugin_keyword("let computer use drive the app", &candidates),
330 Some(0)
331 );
332 }
333
334 #[test]
335 fn stoplist_is_sorted_lowercase_and_unique() {
336 let mut sorted = GENERIC_TERM_STOPLIST.to_vec();
337 sorted.sort_unstable();
338 sorted.dedup();
339 assert_eq!(sorted, GENERIC_TERM_STOPLIST);
340 assert!(
341 GENERIC_TERM_STOPLIST
342 .iter()
343 .all(|term| *term == term.to_ascii_lowercase())
344 );
345 }
346 }
347
347 lines RUST