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((spec, _)) = candidates
92 .iter()
93 .find(|(s, _)| s.name.to_lowercase() == query_trim)
94 {
95 return vec![spec.name.clone()];
96 }
97
98 if query_trim.starts_with("mcp__") && query_trim.len() > 5 {
101 let mut hits: Vec<String> = candidates
102 .iter()
103 .filter(|(s, _)| s.name.to_lowercase().starts_with(query_trim))
104 .map(|(s, _)| s.name.clone())
105 .collect();
106 if !hits.is_empty() {
107 hits.truncate(max_results);
108 return hits;
109 }
110 }
111
112 let raw_terms: Vec<&str> = query_trim.split_whitespace().collect();
115 let mut required: Vec<String> = Vec::new();
116 let mut optional: Vec<String> = Vec::new();
117 for term in raw_terms {
118 if let Some(stripped) = term.strip_prefix('+') {
119 if !stripped.is_empty() {
120 required.push(stripped.to_string());
121 }
122 } else {
123 optional.push(term.to_string());
124 }
125 }
126 let scoring_terms: Vec<String> = if !required.is_empty() {
127 required.iter().chain(optional.iter()).cloned().collect()
128 } else {
129 optional.clone()
130 };
131
132 if scoring_terms.is_empty() {
133 return Vec::new();
134 }
135
136 let filtered: Vec<&SpecWithHint> = if required.is_empty() {
140 candidates.iter().collect()
141 } else {
142 candidates
143 .iter()
144 .filter(|(s, hint)| {
145 let parsed = parse_tool_name(&s.name);
146 let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
147 let desc = s.description.to_lowercase();
148 required.iter().all(|term| {
149 parsed.parts.iter().any(|p| p == term)
150 || parsed.parts.iter().any(|p| p.contains(term.as_str()))
151 || word_boundary_match(term, &desc)
152 || (!hint_lower.is_empty() && word_boundary_match(term, &hint_lower))
153 })
154 })
155 .collect()
156 };
157
158 let mut scored: Vec<(String, i32)> = filtered
159 .into_iter()
160 .map(|(spec, hint)| {
161 let parsed = parse_tool_name(&spec.name);
162 let desc_lower = spec.description.to_lowercase();
163 let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
164 let mut score: i32 = 0;
165 for term in &scoring_terms {
166 if parsed.parts.iter().any(|p| p == term) {
168 score += if parsed.is_mcp {
169 weights::NAME_EXACT_MCP
170 } else {
171 weights::NAME_EXACT_REGULAR
172 };
173 } else if parsed.parts.iter().any(|p| p.contains(term.as_str())) {
174 score += if parsed.is_mcp {
175 weights::NAME_PARTIAL_MCP
176 } else {
177 weights::NAME_PARTIAL_REGULAR
178 };
179 }
180 if score == 0 && parsed.full.contains(term.as_str()) {
183 score += weights::NAME_FALLBACK;
184 }
185 if !hint_lower.is_empty() && word_boundary_match(term, &hint_lower) {
187 score += weights::SEARCH_HINT;
188 }
189 if word_boundary_match(term, &desc_lower) {
191 score += weights::DESCRIPTION;
192 }
193 }
194 (spec.name.clone(), score)
195 })
196 .filter(|(_, score)| *score > 0)
197 .collect();
198
199 scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
200 scored.truncate(max_results);
201 scored.into_iter().map(|(name, _)| name).collect()
202}
203
204struct ParsedName {
207 parts: Vec<String>,
208 full: String,
209 is_mcp: bool,
210}
211
212fn parse_tool_name(name: &str) -> ParsedName {
213 if let Some(stripped) = name.strip_prefix("mcp__") {
214 let lowered = stripped.to_lowercase();
215 let parts: Vec<String> = lowered
216 .split("__")
217 .flat_map(|p| p.split('_'))
218 .filter(|p| !p.is_empty())
219 .map(String::from)
220 .collect();
221 let full = lowered.replace("__", " ").replace('_', " ");
222 ParsedName {
223 parts,
224 full,
225 is_mcp: true,
226 }
227 } else {
228 let mut parts: Vec<String> = Vec::new();
230 let mut buf = String::new();
231 for ch in name.chars() {
232 if ch == '_' {
233 if !buf.is_empty() {
234 parts.push(buf.to_lowercase());
235 buf.clear();
236 }
237 } else if ch.is_uppercase() {
238 if !buf.is_empty() {
239 parts.push(buf.to_lowercase());
240 buf.clear();
241 }
242 buf.push(ch.to_ascii_lowercase());
243 } else {
244 buf.push(ch.to_ascii_lowercase());
245 }
246 }
247 if !buf.is_empty() {
248 parts.push(buf);
249 }
250 let full = parts.join(" ");
251 ParsedName {
252 parts,
253 full,
254 is_mcp: false,
255 }
256 }
257}
258
259fn word_boundary_match(term: &str, haystack: &str) -> bool {
262 if term.is_empty() {
263 return false;
264 }
265 let mut start = 0;
266 while let Some(pos) = haystack[start..].find(term) {
267 let abs = start + pos;
268 let before_ok = abs == 0 || !haystack.as_bytes()[abs - 1].is_ascii_alphanumeric();
269 let end = abs + term.len();
270 let after_ok = end == haystack.len() || !haystack.as_bytes()[end].is_ascii_alphanumeric();
271 if before_ok && after_ok {
272 return true;
273 }
274 start = abs + 1;
275 }
276 false
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use serde_json::json;
283
284 fn spec(name: &str, description: &str) -> ToolSpec {
285 ToolSpec {
286 name: name.to_string(),
287 description: description.to_string(),
288 parameters: json!({"type": "object", "properties": {}}),
289 }
290 }
291
292 fn specs() -> Vec<SpecWithHint> {
293 vec![
294 (
295 spec("ReadFile", "Read a UTF-8 text file under the workspace."),
296 Some("open file contents".to_string()),
297 ),
298 (
299 spec(
300 "WriteFile",
301 "Write content to a file. Overwrites if the file exists.",
302 ),
303 Some("save content to disk".to_string()),
304 ),
305 (
306 spec(
307 "RunShell",
308 "Execute a shell command and return stdout/stderr.",
309 ),
310 Some("bash command execution".to_string()),
311 ),
312 (
313 spec(
314 "SearchFiles",
315 "Find files whose path matches a glob pattern.",
316 ),
317 Some("glob filename lookup".to_string()),
318 ),
319 (
320 spec("WebFetch", "Fetch a URL and return its content as text."),
321 Some("download webpage html".to_string()),
322 ),
323 (
324 spec(
325 "mcp__slack__send_message",
326 "Send a message to a Slack channel.",
327 ),
328 None,
329 ),
330 (
331 spec(
332 "mcp__github__create_pr",
333 "Create a pull request on a GitHub repository.",
334 ),
335 None,
336 ),
337 ]
338 }
339
340 #[test]
341 fn exact_name_match_returns_single_result() {
342 let names = resolve("ReadFile", &specs(), 5);
343 assert_eq!(names, vec!["ReadFile".to_string()]);
344 }
345
346 #[test]
347 fn exact_name_match_is_case_insensitive() {
348 let names = resolve("readfile", &specs(), 5);
349 assert_eq!(names, vec!["ReadFile".to_string()]);
350 }
351
352 #[test]
353 fn mcp_prefix_returns_matching_tools() {
354 let names = resolve("mcp__slack", &specs(), 5);
355 assert_eq!(names, vec!["mcp__slack__send_message".to_string()]);
356 }
357
358 #[test]
359 fn keyword_search_uses_search_hint_weight() {
360 let names = resolve("open", &specs(), 5);
362 assert!(
363 names.first().map(|s| s.as_str()) == Some("ReadFile"),
364 "expected ReadFile first, got {:?}",
365 names
366 );
367 }
368
369 #[test]
370 fn keyword_search_splits_camel_case() {
371 let names = resolve("shell", &specs(), 5);
373 assert!(
374 names.contains(&"RunShell".to_string()),
375 "expected RunShell in {:?}",
376 names
377 );
378 }
379
380 #[test]
381 fn required_term_filters_candidates() {
382 let names = resolve("+slack send", &specs(), 5);
384 assert!(names.contains(&"mcp__slack__send_message".to_string()));
385 }
386
387 #[test]
388 fn required_term_excludes_non_matches() {
389 let names = resolve("+jupyter notebook", &specs(), 5);
391 assert!(names.is_empty());
392 }
393
394 #[test]
395 fn empty_query_returns_empty() {
396 let names = resolve("", &specs(), 5);
397 assert!(names.is_empty());
398 }
399
400 #[test]
401 fn whitespace_only_query_returns_empty() {
402 let names = resolve(" ", &specs(), 5);
403 assert!(names.is_empty());
404 }
405
406 #[test]
407 fn max_results_caps_output() {
408 let names = resolve("slack github", &specs(), 1);
413 assert_eq!(names.len(), 1);
414 }
415
416 #[test]
417 fn max_results_capped_at_internal_limit() {
418 let names = resolve("mcp__", &specs(), 1000);
419 assert!(names.len() <= MAX_RESULTS_CAP);
420 }
421
422 #[test]
423 fn word_boundary_prevents_substring_false_positive() {
424 let s = vec![(
428 spec("shellscript", "Run a shellscript under the workspace."),
429 None,
430 )];
431 let names = resolve("shell", &s, 5);
432 assert_eq!(names, vec!["shellscript".to_string()]);
437 let s2 = vec![(
441 spec("Unrelated", "Run a shellscript under the workspace."),
442 None,
443 )];
444 let names2 = resolve("shell", &s2, 5);
445 assert!(names2.is_empty(), "expected empty, got {:?}", names2);
449 }
450
451 #[test]
452 fn parse_tool_name_handles_camel_case() {
453 let p = parse_tool_name("ReadFile");
454 assert_eq!(p.parts, vec!["read", "file"]);
455 assert_eq!(p.full, "read file");
456 assert!(!p.is_mcp);
457 }
458
459 #[test]
460 fn parse_tool_name_handles_mcp() {
461 let p = parse_tool_name("mcp__slack__send_message");
462 assert_eq!(p.parts, vec!["slack", "send", "message"]);
463 assert_eq!(p.full, "slack send message");
464 assert!(p.is_mcp);
465 }
466
467 #[test]
468 fn parse_tool_name_handles_snake_case() {
469 let p = parse_tool_name("my_tool_name");
470 assert_eq!(p.parts, vec!["my", "tool", "name"]);
471 assert!(!p.is_mcp);
472 }
473
474 #[test]
475 fn ordering_ranks_search_hint_above_description() {
476 let s = vec![
481 (
482 spec(
483 "NoHintTool",
484 "Something that can open a file in the editor.",
485 ),
486 None,
487 ),
488 (
489 spec("HintTool", "Does something else."),
490 Some("open a file".to_string()),
491 ),
492 ];
493 let names = resolve("open", &s, 5);
494 assert_eq!(
495 names.first().map(String::as_str),
496 Some("HintTool"),
497 "searchHint match should rank above description match: {:?}",
498 names
499 );
500 }
501
502 #[test]
503 fn no_match_returns_empty() {
504 let names = resolve("zzzzz_no_such_thing", &specs(), 5);
505 assert!(names.is_empty());
506 }
507}