1use crate::llm::ToolSpec;
20
21pub const DEFAULT_MAX_RESULTS: usize = 5;
24
25pub const MAX_RESULTS_CAP: usize = 20;
27
28pub type SpecWithHint = (ToolSpec, Option<String>);
32
33pub trait ToolSearchEngine: Send + Sync + std::fmt::Debug {
37 fn resolve(&self, query: &str, candidates: &[SpecWithHint]) -> Vec<String>;
40}
41
42mod weights {
52 pub const NAME_EXACT_REGULAR: i32 = 10;
53 pub const NAME_EXACT_MCP: i32 = 12;
54 pub const NAME_PARTIAL_REGULAR: i32 = 5;
55 pub const NAME_PARTIAL_MCP: i32 = 6;
56 pub const NAME_FALLBACK: i32 = 3;
57 pub const SEARCH_HINT: i32 = 4;
58 pub const DESCRIPTION: i32 = 2;
59}
60
61#[derive(Debug, Default, Clone)]
65pub struct KeywordSearchEngine;
66
67impl KeywordSearchEngine {
68 pub fn new() -> Self {
69 Self
70 }
71}
72
73impl ToolSearchEngine for KeywordSearchEngine {
74 fn resolve(&self, query: &str, candidates: &[SpecWithHint]) -> Vec<String> {
75 resolve(query, candidates, DEFAULT_MAX_RESULTS)
76 }
77}
78
79pub(crate) fn resolve(query: &str, candidates: &[SpecWithHint], max_results: usize) -> Vec<String> {
82 let max_results = max_results.clamp(1, MAX_RESULTS_CAP);
83 let query_lower = query.to_lowercase();
84 let query_trim = query_lower.trim();
85
86 if query_trim.is_empty() {
87 return Vec::new();
88 }
89
90 if let Some(names_str) = query_trim.strip_prefix("select:") {
92 let requested: Vec<&str> = names_str.split(',').map(|s| s.trim()).collect();
93 let mut result = Vec::new();
94 for req in requested {
95 if let Some((spec, _)) = candidates
96 .iter()
97 .find(|(s, _)| s.name.to_lowercase() == req)
98 {
99 result.push(spec.name.clone());
100 }
101 }
102 result.truncate(max_results);
103 return result;
104 }
105
106 if let Some((spec, _)) = candidates
108 .iter()
109 .find(|(s, _)| s.name.to_lowercase() == query_trim)
110 {
111 return vec![spec.name.clone()];
112 }
113
114 if query_trim.starts_with("mcp__") && query_trim.len() > 5 {
117 let mut hits: Vec<String> = candidates
118 .iter()
119 .filter(|(s, _)| s.name.to_lowercase().starts_with(query_trim))
120 .map(|(s, _)| s.name.clone())
121 .collect();
122 if !hits.is_empty() {
123 hits.truncate(max_results);
124 return hits;
125 }
126 }
127
128 let raw_terms: Vec<&str> = query_trim.split_whitespace().collect();
131 let mut required: Vec<String> = Vec::new();
132 let mut optional: Vec<String> = Vec::new();
133 for term in raw_terms {
134 if let Some(stripped) = term.strip_prefix('+') {
135 if !stripped.is_empty() {
136 required.push(stripped.to_string());
137 }
138 } else {
139 optional.push(term.to_string());
140 }
141 }
142 let scoring_terms: Vec<String> = if !required.is_empty() {
143 required.iter().chain(optional.iter()).cloned().collect()
144 } else {
145 optional.clone()
146 };
147
148 if scoring_terms.is_empty() {
149 return Vec::new();
150 }
151
152 let filtered: Vec<&SpecWithHint> = if required.is_empty() {
156 candidates.iter().collect()
157 } else {
158 candidates
159 .iter()
160 .filter(|(s, hint)| {
161 let parsed = parse_tool_name(&s.name);
162 let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
163 let desc = s.description.to_lowercase();
164 required.iter().all(|term| {
165 parsed.parts.iter().any(|p| p == term)
166 || parsed.parts.iter().any(|p| p.contains(term.as_str()))
167 || word_boundary_match(term, &desc)
168 || (!hint_lower.is_empty() && word_boundary_match(term, &hint_lower))
169 })
170 })
171 .collect()
172 };
173
174 let mut scored: Vec<(String, i32)> = filtered
175 .into_iter()
176 .map(|(spec, hint)| {
177 let parsed = parse_tool_name(&spec.name);
178 let desc_lower = spec.description.to_lowercase();
179 let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
180 let mut score: i32 = 0;
181 for term in &scoring_terms {
182 if parsed.parts.iter().any(|p| p == term) {
184 score += if parsed.is_mcp {
185 weights::NAME_EXACT_MCP
186 } else {
187 weights::NAME_EXACT_REGULAR
188 };
189 } else if parsed.parts.iter().any(|p| p.contains(term.as_str())) {
190 score += if parsed.is_mcp {
191 weights::NAME_PARTIAL_MCP
192 } else {
193 weights::NAME_PARTIAL_REGULAR
194 };
195 }
196 if score == 0 && parsed.full.contains(term.as_str()) {
199 score += weights::NAME_FALLBACK;
200 }
201 if !hint_lower.is_empty() && word_boundary_match(term, &hint_lower) {
203 score += weights::SEARCH_HINT;
204 }
205 if word_boundary_match(term, &desc_lower) {
207 score += weights::DESCRIPTION;
208 }
209 }
210 (spec.name.clone(), score)
211 })
212 .filter(|(_, score)| *score > 0)
213 .collect();
214
215 scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
216 scored.truncate(max_results);
217 scored.into_iter().map(|(name, _)| name).collect()
218}
219
220struct ParsedName {
223 parts: Vec<String>,
224 full: String,
225 is_mcp: bool,
226}
227
228fn parse_tool_name(name: &str) -> ParsedName {
229 if let Some(stripped) = name.strip_prefix("mcp__") {
230 let lowered = stripped.to_lowercase();
231 let parts: Vec<String> = lowered
232 .split("__")
233 .flat_map(|p| p.split('_'))
234 .filter(|p| !p.is_empty())
235 .map(String::from)
236 .collect();
237 let full = lowered.replace("__", " ").replace('_', " ");
238 ParsedName {
239 parts,
240 full,
241 is_mcp: true,
242 }
243 } else {
244 let mut parts: Vec<String> = Vec::new();
246 let mut buf = String::new();
247 for ch in name.chars() {
248 if ch == '_' {
249 if !buf.is_empty() {
250 parts.push(buf.to_lowercase());
251 buf.clear();
252 }
253 } else if ch.is_uppercase() {
254 if !buf.is_empty() {
255 parts.push(buf.to_lowercase());
256 buf.clear();
257 }
258 buf.push(ch.to_ascii_lowercase());
259 } else {
260 buf.push(ch.to_ascii_lowercase());
261 }
262 }
263 if !buf.is_empty() {
264 parts.push(buf);
265 }
266 let full = parts.join(" ");
267 ParsedName {
268 parts,
269 full,
270 is_mcp: false,
271 }
272 }
273}
274
275fn word_boundary_match(term: &str, haystack: &str) -> bool {
278 if term.is_empty() {
279 return false;
280 }
281 let mut start = 0;
282 while let Some(pos) = haystack[start..].find(term) {
283 let abs = start + pos;
284 let before_ok = abs == 0 || !haystack.as_bytes()[abs - 1].is_ascii_alphanumeric();
285 let end = abs + term.len();
286 let after_ok = end == haystack.len() || !haystack.as_bytes()[end].is_ascii_alphanumeric();
287 if before_ok && after_ok {
288 return true;
289 }
290 start = abs + 1;
291 }
292 false
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use serde_json::json;
299
300 fn spec(name: &str, description: &str) -> ToolSpec {
301 ToolSpec {
302 name: name.to_string(),
303 description: description.to_string(),
304 parameters: json!({"type": "object", "properties": {}}),
305 }
306 }
307
308 fn specs() -> Vec<SpecWithHint> {
309 vec![
310 (
311 spec("ReadFile", "Read a UTF-8 text file under the workspace."),
312 Some("open file contents".to_string()),
313 ),
314 (
315 spec(
316 "WriteFile",
317 "Write content to a file. Overwrites if the file exists.",
318 ),
319 Some("save content to disk".to_string()),
320 ),
321 (
322 spec(
323 "RunShell",
324 "Execute a shell command and return stdout/stderr.",
325 ),
326 Some("bash command execution".to_string()),
327 ),
328 (
329 spec(
330 "SearchFiles",
331 "Find files whose path matches a glob pattern.",
332 ),
333 Some("glob filename lookup".to_string()),
334 ),
335 (
336 spec("WebFetch", "Fetch a URL and return its content as text."),
337 Some("download webpage html".to_string()),
338 ),
339 (
340 spec(
341 "mcp__slack__send_message",
342 "Send a message to a Slack channel.",
343 ),
344 None,
345 ),
346 (
347 spec(
348 "mcp__github__create_pr",
349 "Create a pull request on a GitHub repository.",
350 ),
351 None,
352 ),
353 ]
354 }
355
356 #[test]
357 fn exact_name_match_returns_single_result() {
358 let names = resolve("ReadFile", &specs(), 5);
359 assert_eq!(names, vec!["ReadFile".to_string()]);
360 }
361
362 #[test]
363 fn exact_name_match_is_case_insensitive() {
364 let names = resolve("readfile", &specs(), 5);
365 assert_eq!(names, vec!["ReadFile".to_string()]);
366 }
367
368 #[test]
369 fn mcp_prefix_returns_matching_tools() {
370 let names = resolve("mcp__slack", &specs(), 5);
371 assert_eq!(names, vec!["mcp__slack__send_message".to_string()]);
372 }
373
374 #[test]
375 fn keyword_search_uses_search_hint_weight() {
376 let names = resolve("open", &specs(), 5);
378 assert!(
379 names.first().map(|s| s.as_str()) == Some("ReadFile"),
380 "expected ReadFile first, got {:?}",
381 names
382 );
383 }
384
385 #[test]
386 fn keyword_search_splits_camel_case() {
387 let names = resolve("shell", &specs(), 5);
389 assert!(
390 names.contains(&"RunShell".to_string()),
391 "expected RunShell in {:?}",
392 names
393 );
394 }
395
396 #[test]
397 fn required_term_filters_candidates() {
398 let names = resolve("+slack send", &specs(), 5);
400 assert!(names.contains(&"mcp__slack__send_message".to_string()));
401 }
402
403 #[test]
404 fn required_term_excludes_non_matches() {
405 let names = resolve("+jupyter notebook", &specs(), 5);
407 assert!(names.is_empty());
408 }
409
410 #[test]
411 fn empty_query_returns_empty() {
412 let names = resolve("", &specs(), 5);
413 assert!(names.is_empty());
414 }
415
416 #[test]
417 fn whitespace_only_query_returns_empty() {
418 let names = resolve(" ", &specs(), 5);
419 assert!(names.is_empty());
420 }
421
422 #[test]
423 fn max_results_caps_output() {
424 let names = resolve("slack github", &specs(), 1);
429 assert_eq!(names.len(), 1);
430 }
431
432 #[test]
433 fn max_results_capped_at_internal_limit() {
434 let names = resolve("mcp__", &specs(), 1000);
435 assert!(names.len() <= MAX_RESULTS_CAP);
436 }
437
438 #[test]
439 fn word_boundary_prevents_substring_false_positive() {
440 let s = vec![(
444 spec("shellscript", "Run a shellscript under the workspace."),
445 None,
446 )];
447 let names = resolve("shell", &s, 5);
448 assert_eq!(names, vec!["shellscript".to_string()]);
453 let s2 = vec![(
457 spec("Unrelated", "Run a shellscript under the workspace."),
458 None,
459 )];
460 let names2 = resolve("shell", &s2, 5);
461 assert!(names2.is_empty(), "expected empty, got {:?}", names2);
465 }
466
467 #[test]
468 fn parse_tool_name_handles_camel_case() {
469 let p = parse_tool_name("ReadFile");
470 assert_eq!(p.parts, vec!["read", "file"]);
471 assert_eq!(p.full, "read file");
472 assert!(!p.is_mcp);
473 }
474
475 #[test]
476 fn parse_tool_name_handles_mcp() {
477 let p = parse_tool_name("mcp__slack__send_message");
478 assert_eq!(p.parts, vec!["slack", "send", "message"]);
479 assert_eq!(p.full, "slack send message");
480 assert!(p.is_mcp);
481 }
482
483 #[test]
484 fn parse_tool_name_handles_snake_case() {
485 let p = parse_tool_name("my_tool_name");
486 assert_eq!(p.parts, vec!["my", "tool", "name"]);
487 assert!(!p.is_mcp);
488 }
489
490 #[test]
491 fn ordering_ranks_search_hint_above_description() {
492 let s = vec![
497 (
498 spec(
499 "NoHintTool",
500 "Something that can open a file in the editor.",
501 ),
502 None,
503 ),
504 (
505 spec("HintTool", "Does something else."),
506 Some("open a file".to_string()),
507 ),
508 ];
509 let names = resolve("open", &s, 5);
510 assert_eq!(
511 names.first().map(String::as_str),
512 Some("HintTool"),
513 "searchHint match should rank above description match: {:?}",
514 names
515 );
516 }
517
518 #[test]
519 fn no_match_returns_empty() {
520 let names = resolve("zzzzz_no_such_thing", &specs(), 5);
521 assert!(names.is_empty());
522 }
523
524 #[test]
527 fn word_boundary_empty_term_returns_false() {
528 assert!(!word_boundary_match("", "hello world"));
529 }
530
531 #[test]
532 fn word_boundary_full_word_matches() {
533 assert!(word_boundary_match("shell", "run shell command"));
534 assert!(word_boundary_match("read", "read a file"));
535 }
536
537 #[test]
538 fn word_boundary_word_at_start_of_string() {
539 assert!(word_boundary_match("hello", "hello world"));
542 }
543
544 #[test]
545 fn word_boundary_word_at_end_of_string() {
546 assert!(word_boundary_match("world", "hello world"));
549 }
550
551 #[test]
552 fn word_boundary_substring_not_at_boundary_returns_false() {
553 assert!(!word_boundary_match("shell", "shellscript"));
555 assert!(!word_boundary_match("read", "thread"));
557 }
558
559 #[test]
560 fn word_boundary_second_occurrence_matches() {
561 assert!(word_boundary_match("file", "afile file"));
564 }
565
566 #[test]
567 fn word_boundary_exact_match_whole_string() {
568 assert!(word_boundary_match("read", "read"));
570 }
571
572 #[test]
573 fn word_boundary_separator_is_non_alphanumeric() {
574 assert!(word_boundary_match("read", "read-file"));
576 assert!(word_boundary_match("file", "read-file"));
577 assert!(word_boundary_match("read", "read.write"));
578 }
579
580 #[test]
583 fn parse_tool_name_all_uppercase_splits_as_single() {
584 let p = parse_tool_name("EOF");
585 assert!(!p.parts.is_empty());
587 assert!(!p.is_mcp);
588 }
589
590 #[test]
591 fn parse_tool_name_mcp_empty_server_part() {
592 let p = parse_tool_name("mcp__tool");
594 assert!(p.is_mcp);
595 assert!(p.parts.contains(&"tool".to_string()));
596 }
597}