1pub mod agent;
10pub mod ares_bridge;
11pub mod bash;
12pub mod batch;
13#[cfg(feature = "deagle")]
14pub mod deagle;
15pub mod edit;
16#[cfg(test)]
17mod edit_tests;
18pub mod file;
19pub mod git;
20pub mod lsp_tool;
21pub mod mise;
22pub mod native;
23pub mod native_search;
24pub mod rmux;
25pub mod search;
26pub mod task;
27
28use async_trait::async_trait;
29use serde_json::Value;
30use std::collections::HashMap;
31use std::sync::Arc;
32
33pub use thulp_core::ToolDefinition;
40
41#[async_trait]
43pub trait Tool: Send + Sync {
44 fn name(&self) -> &str;
46
47 fn description(&self) -> &str;
49
50 fn mutating(&self) -> bool {
55 false }
57
58 fn parameters_schema(&self) -> Value;
60
61 async fn execute(&self, args: Value) -> crate::Result<Value>;
63 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
66 let params = thulp_core::ToolDefinition::parse_mcp_input_schema(&self.parameters_schema())
67 .unwrap_or_default();
68 thulp_core::ToolDefinition::builder(self.name())
69 .description(self.description())
70 .parameters(params)
71 .build()
72 }
73
74 fn validate_args(&self, args: &Value) -> std::result::Result<(), String> {
77 self.thulp_definition()
78 .validate_args(args)
79 .map_err(|e| e.to_string())
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum ToolTier {
88 Core,
90 Standard,
92 Extended,
94}
95
96pub struct ToolRegistry {
101 tools: HashMap<String, Arc<dyn Tool>>,
102 tiers: HashMap<String, ToolTier>,
103 activated: std::sync::Mutex<std::collections::HashSet<String>>,
105 tool_text_cache: HashMap<String, String>,
107}
108
109impl ToolRegistry {
110 pub fn new() -> Self {
112 Self {
113 tools: HashMap::new(),
114 tiers: HashMap::new(),
115 activated: std::sync::Mutex::new(std::collections::HashSet::new()),
116 tool_text_cache: HashMap::new(),
117 }
118 }
119
120 pub fn with_defaults(workspace_root: std::path::PathBuf) -> Self {
126 let mut registry = Self::new();
127 use ToolTier::*;
128
129 registry.register_with_tier(Arc::new(bash::BashTool::new(workspace_root.clone())), Core);
131 registry.register_with_tier(
132 Arc::new(file::ReadFileTool::new(workspace_root.clone())),
133 Core,
134 );
135 registry.register_with_tier(
136 Arc::new(file::WriteFileTool::new(workspace_root.clone())),
137 Core,
138 );
139 registry.register_with_tier(
140 Arc::new(edit::EditFileTool::new(workspace_root.clone())),
141 Core,
142 );
143 registry.register_with_tier(
144 Arc::new(native::AstGrepTool::new(workspace_root.clone())),
145 Core,
146 );
147 registry.register_with_tier(
148 Arc::new(native::GlobSearchTool::new(workspace_root.clone())),
149 Core,
150 );
151 registry.register_with_tier(
152 Arc::new(native::GrepSearchTool::new(workspace_root.clone())),
153 Core,
154 );
155
156 registry.register_with_tier(
158 Arc::new(file::ListDirectoryTool::new(workspace_root.clone())),
159 Standard,
160 );
161 registry.register_with_tier(
162 Arc::new(edit::EditFileLinesTool::new(workspace_root.clone())),
163 Standard,
164 );
165 registry.register_with_tier(
166 Arc::new(edit::InsertAfterTool::new(workspace_root.clone())),
167 Standard,
168 );
169 registry.register_with_tier(
170 Arc::new(edit::AppendFileTool::new(workspace_root.clone())),
171 Standard,
172 );
173 registry.register_with_tier(
174 Arc::new(git::GitStatusTool::new(workspace_root.clone())),
175 Standard,
176 );
177 registry.register_with_tier(
178 Arc::new(git::GitDiffTool::new(workspace_root.clone())),
179 Standard,
180 );
181 registry.register_with_tier(
182 Arc::new(git::GitAddTool::new(workspace_root.clone())),
183 Standard,
184 );
185 registry.register_with_tier(
186 Arc::new(git::GitCommitTool::new(workspace_root.clone())),
187 Standard,
188 );
189 registry.register_with_tier(
190 Arc::new(git::GitLogTool::new(workspace_root.clone())),
191 Standard,
192 );
193 registry.register_with_tier(
194 Arc::new(git::GitBlameTool::new(workspace_root.clone())),
195 Standard,
196 );
197 registry.register_with_tier(
198 Arc::new(git::GitBranchTool::new(workspace_root.clone())),
199 Standard,
200 );
201 registry.register_with_tier(
202 Arc::new(git::GitCheckoutTool::new(workspace_root.clone())),
203 Standard,
204 );
205 registry.register_with_tier(
206 Arc::new(git::GitStashTool::new(workspace_root.clone())),
207 Standard,
208 );
209 registry.register_with_tier(
210 Arc::new(agent::SpawnAgentsTool::new(workspace_root.clone())),
211 Standard,
212 );
213 registry.register_with_tier(
214 Arc::new(agent::SpawnAgentTool::new(workspace_root.clone())),
215 Standard,
216 );
217 registry.register_with_tier(
218 Arc::new(batch::BatchTool::new(workspace_root.clone())),
219 Standard,
220 );
221 registry.register_with_tier(
222 Arc::new(task::TaskTool::new(workspace_root.clone())),
223 Standard,
224 );
225
226 registry.register_with_tier(
228 Arc::new(native::RipgrepTool::new(workspace_root.clone())),
229 Extended,
230 );
231 registry.register_with_tier(
232 Arc::new(native::FdTool::new(workspace_root.clone())),
233 Extended,
234 );
235 registry.register_with_tier(
236 Arc::new(native::SdTool::new(workspace_root.clone())),
237 Extended,
238 );
239 registry.register_with_tier(
240 Arc::new(native::ErdTool::new(workspace_root.clone())),
241 Extended,
242 );
243 registry.register_with_tier(
244 Arc::new(native::MiseTool::new(workspace_root.clone())),
245 Extended,
246 );
247 registry.register_with_tier(
248 Arc::new(native::ZoxideTool::new(workspace_root.clone())),
249 Extended,
250 );
251 registry.register_with_tier(
252 Arc::new(native::LspTool::new(workspace_root.clone())),
253 Extended,
254 );
255
256 registry.register_with_tier(Arc::new(rmux::RmuxTool::new()), Standard);
258
259 #[cfg(feature = "deagle")]
261 {
262 registry.register_with_tier(
263 Arc::new(deagle::DeagleSearchTool::new(workspace_root.clone())),
264 Extended,
265 );
266 registry.register_with_tier(
267 Arc::new(deagle::DeagleKeywordTool::new(workspace_root.clone())),
268 Extended,
269 );
270 registry.register_with_tier(
271 Arc::new(deagle::DeagleSgTool::new(workspace_root.clone())),
272 Extended,
273 );
274 registry.register_with_tier(
275 Arc::new(deagle::DeagleStatsTool::new(workspace_root.clone())),
276 Extended,
277 );
278 registry.register_with_tier(
279 Arc::new(deagle::DeagleMapTool::new(workspace_root)),
280 Extended,
281 );
282 }
283
284 registry
285 }
286
287 pub fn register(&mut self, tool: Arc<dyn Tool>) {
289 self.register_with_tier(tool, ToolTier::Standard);
290 }
291
292 pub fn register_with_tier(&mut self, tool: Arc<dyn Tool>, tier: ToolTier) {
294 let name = tool.name().to_string();
295 let cached_text = format!("{} {}", name, tool.description()).to_lowercase();
296 self.tool_text_cache.insert(name.clone(), cached_text);
297 self.tiers.insert(name.clone(), tier);
298 self.tools.insert(name, tool);
299 }
300
301 pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
303 self.tools.get(name)
304 }
305
306 pub fn has_tool(&self, name: &str) -> bool {
308 self.tools.contains_key(name)
309 }
310
311 pub async fn execute(&self, name: &str, args: Value) -> crate::Result<Value> {
313 match self.tools.get(name) {
314 Some(tool) => tool.execute(args).await,
315 None => Err(crate::PawanError::NotFound(format!(
316 "Tool not found: {}",
317 name
318 ))),
319 }
320 }
321
322 pub fn get_definitions(&self) -> Vec<ToolDefinition> {
325 let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
326 self.tools
327 .iter()
328 .filter(|(name, _)| {
329 match self
330 .tiers
331 .get(name.as_str())
332 .copied()
333 .unwrap_or(ToolTier::Standard)
334 {
335 ToolTier::Core | ToolTier::Standard => true,
336 ToolTier::Extended => activated.contains(name.as_str()),
337 }
338 })
339 .map(|(_, tool)| tool.thulp_definition())
340 .collect()
341 }
342
343 pub fn select_for_query(&self, query: &str, max_tools: usize) -> Vec<ToolDefinition> {
349 let query_lower = query.to_lowercase();
350 let query_words: Vec<&str> = query_lower.split_whitespace().collect();
351
352 let mut scored: Vec<(i32, String)> = Vec::new();
353
354 for name in self.tools.keys() {
355 let tier = self
356 .tiers
357 .get(name.as_str())
358 .copied()
359 .unwrap_or(ToolTier::Standard);
360
361 if tier == ToolTier::Core {
363 continue;
364 }
365
366 let tool_text = self
368 .tool_text_cache
369 .get(name.as_str())
370 .map(|s| s.as_str())
371 .unwrap_or("");
372 let mut score: i32 = 0;
373
374 for word in &query_words {
375 if word.len() < 3 {
376 continue;
377 } if tool_text.contains(word) {
379 score += 2;
380 }
381 }
382
383 let search_words = [
385 "search",
386 "find",
387 "web",
388 "query",
389 "look",
390 "google",
391 "bing",
392 "wikipedia",
393 ];
394 let git_words = [
395 "git", "commit", "branch", "diff", "status", "log", "stash", "checkout", "blame",
396 ];
397 let file_words = [
398 "file",
399 "read",
400 "write",
401 "edit",
402 "append",
403 "insert",
404 "directory",
405 "list",
406 ];
407 let code_words = [
408 "refactor", "rename", "replace", "ast", "lsp", "symbol", "function", "struct",
409 ];
410 let tool_words = [
411 "install", "mise", "tool", "runtime", "build", "test", "cargo",
412 ];
413
414 for word in &query_words {
415 if search_words.contains(word) && tool_text.contains("search") {
416 score += 3;
417 }
418 if git_words.contains(word) && tool_text.contains("git") {
419 score += 3;
420 }
421 if file_words.contains(word)
422 && (tool_text.contains("file") || tool_text.contains("edit"))
423 {
424 score += 3;
425 }
426 if code_words.contains(word)
427 && (tool_text.contains("ast") || tool_text.contains("lsp"))
428 {
429 score += 3;
430 }
431 if tool_words.contains(word) && tool_text.contains("mise") {
432 score += 3;
433 }
434 }
435
436 if name.starts_with("mcp_") {
438 score += 1;
439 if name.contains("search") || name.contains("web") {
440 let web_words = [
441 "web", "search", "internet", "online", "find", "look up", "google",
442 ];
443 if web_words.iter().any(|w| query_lower.contains(w)) {
444 score += 10; }
446 }
447 }
448
449 let activated = self.activated.lock().unwrap_or_else(|e| e.into_inner());
451 if tier == ToolTier::Extended && activated.contains(name.as_str()) {
452 score += 2;
453 }
454
455 if score > 0 || tier == ToolTier::Standard {
456 scored.push((score, name.clone()));
457 }
458 }
459
460 scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
462
463 let mut result: Vec<ToolDefinition> = self
465 .tools
466 .iter()
467 .filter(|(name, _)| {
468 self.tiers
469 .get(name.as_str())
470 .copied()
471 .unwrap_or(ToolTier::Standard)
472 == ToolTier::Core
473 })
474 .map(|(_, tool)| tool.thulp_definition())
475 .collect();
476
477 let remaining_slots = max_tools.saturating_sub(result.len());
478 for (_, name) in scored.into_iter().take(remaining_slots) {
479 if let Some(tool) = self.tools.get(&name) {
480 result.push(tool.thulp_definition());
481 }
482 }
483
484 result
485 }
486
487 pub fn get_all_definitions(&self) -> Vec<ToolDefinition> {
489 self.tools.values().map(|t| t.thulp_definition()).collect()
490 }
491
492 pub fn activate(&self, name: &str) {
494 if self.tools.contains_key(name) {
495 self.activated
496 .lock()
497 .unwrap_or_else(|e| e.into_inner())
498 .insert(name.to_string());
499 }
500 }
501
502 pub fn tool_names(&self) -> Vec<&str> {
504 self.tools.keys().map(|s| s.as_str()).collect()
505 }
506
507 pub fn query_tools(&self, query: &str) -> Vec<thulp_core::ToolDefinition> {
524 let criteria = match thulp_query::parse_query(query) {
525 Ok(c) => c,
526 Err(e) => {
527 tracing::warn!(query = %query, error = %e, "failed to parse tool query");
528 return Vec::new();
529 }
530 };
531
532 self.tools
533 .values()
534 .map(|tool| tool.thulp_definition())
535 .filter(|def| criteria.matches(def))
536 .collect()
537 }
538}
539
540impl Default for ToolRegistry {
541 fn default() -> Self {
542 Self::new()
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use std::path::PathBuf;
550
551 #[test]
552 fn test_registry_new_is_empty() {
553 let registry = ToolRegistry::new();
554 assert!(registry.tool_names().is_empty());
555 assert!(!registry.has_tool("bash"));
556 assert!(registry.get("nonexistent").is_none());
557 }
558
559 #[test]
560 fn test_registry_with_defaults_contains_core_tools() {
561 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
562 for name in &[
564 "bash",
565 "read_file",
566 "write_file",
567 "edit_file",
568 "grep_search",
569 "glob_search",
570 ] {
571 assert!(
572 registry.has_tool(name),
573 "default registry missing core tool: {}",
574 name
575 );
576 }
577 assert!(registry.has_tool("git_status"));
579 assert!(registry.has_tool("git_commit"));
580 assert!(registry.has_tool("rg"));
582 assert!(registry.has_tool("fd"));
583 }
584
585 #[test]
586 fn test_registry_get_definitions_hides_extended_until_activated() {
587 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
588 let initial: Vec<String> = registry
589 .get_definitions()
590 .iter()
591 .map(|d| d.name.clone())
592 .collect();
593
594 assert!(
596 !initial.contains(&"rg".to_string()),
597 "rg should be hidden until activated"
598 );
599 assert!(
600 !initial.contains(&"fd".to_string()),
601 "fd should be hidden until activated"
602 );
603 assert!(initial.contains(&"bash".to_string()));
605 assert!(initial.contains(&"read_file".to_string()));
606
607 registry.activate("rg");
609 let after: Vec<String> = registry
610 .get_definitions()
611 .iter()
612 .map(|d| d.name.clone())
613 .collect();
614 assert!(
615 after.contains(&"rg".to_string()),
616 "rg should be visible after activate"
617 );
618 assert!(
619 after.len() > initial.len(),
620 "activation should grow visible set"
621 );
622 }
623
624 #[test]
625 fn test_registry_get_all_definitions_returns_everything() {
626 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
627 let all = registry.get_all_definitions();
628 let visible = registry.get_definitions();
629 assert!(
631 all.len() > visible.len(),
632 "get_all_definitions ({}) should include hidden extended tools beyond get_definitions ({})",
633 all.len(),
634 visible.len()
635 );
636 let all_names: Vec<String> = all.iter().map(|d| d.name.clone()).collect();
638 assert!(all_names.contains(&"rg".to_string()));
639 }
640
641 #[test]
642 fn test_registry_query_tools_filters_by_dsl() {
643 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
644 let bash_match = registry.query_tools("name:bash");
646 assert!(
647 !bash_match.is_empty(),
648 "query_tools('name:bash') should match the bash tool"
649 );
650 let names: Vec<String> = bash_match.iter().map(|d| d.name.clone()).collect();
651 assert!(names.contains(&"bash".to_string()));
652
653 let no_match = registry.query_tools("name:definitely_not_a_tool_xyz");
655 assert!(
656 no_match.is_empty(),
657 "query_tools for nonexistent name should return empty, got {:?}",
658 no_match.iter().map(|d| &d.name).collect::<Vec<_>>()
659 );
660 }
661
662 struct MockTool {
664 name: String,
665 description: String,
666 return_value: Value,
667 }
668
669 impl MockTool {
670 fn new(name: &str, description: &str, return_value: Value) -> Self {
671 Self {
672 name: name.to_string(),
673 description: description.to_string(),
674 return_value,
675 }
676 }
677 }
678
679 #[async_trait]
680 impl Tool for MockTool {
681 fn name(&self) -> &str {
682 &self.name
683 }
684 fn description(&self) -> &str {
685 &self.description
686 }
687 fn parameters_schema(&self) -> Value {
688 serde_json::json!({ "type": "object", "properties": {} })
689 }
690 async fn execute(&self, _args: Value) -> crate::Result<Value> {
691 Ok(self.return_value.clone())
692 }
693 }
694
695 #[test]
696 fn test_register_defaults_to_standard_tier() {
697 let mut registry = ToolRegistry::new();
698 registry.register(Arc::new(MockTool::new(
699 "mock_std",
700 "a test mock",
701 Value::Null,
702 )));
703 let visible: Vec<String> = registry
705 .get_definitions()
706 .iter()
707 .map(|d| d.name.clone())
708 .collect();
709 assert!(
710 visible.contains(&"mock_std".to_string()),
711 "register() should default to Standard tier (visible without activation), got {:?}",
712 visible
713 );
714 }
715
716 #[test]
717 fn test_register_with_tier_overwrites_same_name() {
718 let mut registry = ToolRegistry::new();
719 registry.register_with_tier(
720 Arc::new(MockTool::new("dup", "first registration", Value::Null)),
721 ToolTier::Standard,
722 );
723 registry.register_with_tier(
724 Arc::new(MockTool::new("dup", "second registration", Value::Null)),
725 ToolTier::Core,
726 );
727
728 let names = registry.tool_names();
731 assert_eq!(
732 names.iter().filter(|n| **n == "dup").count(),
733 1,
734 "register_with_tier of an existing name must replace, not duplicate"
735 );
736 let def = registry
737 .get("dup")
738 .expect("dup should exist after overwrite");
739 assert_eq!(def.description(), "second registration");
740 let visible: Vec<String> = registry
742 .get_definitions()
743 .iter()
744 .map(|d| d.name.clone())
745 .collect();
746 assert!(visible.contains(&"dup".to_string()));
747 }
748
749 #[tokio::test]
750 async fn test_execute_dispatches_to_registered_tool() {
751 let mut registry = ToolRegistry::new();
752 registry.register(Arc::new(MockTool::new(
753 "echo",
754 "returns a fixed value",
755 serde_json::json!({ "answer": 42 }),
756 )));
757
758 let out = registry
759 .execute("echo", Value::Null)
760 .await
761 .expect("execute on a registered tool should succeed");
762 assert_eq!(out, serde_json::json!({ "answer": 42 }));
763 }
764
765 #[tokio::test]
766 async fn test_execute_unknown_tool_returns_not_found() {
767 let registry = ToolRegistry::new();
768 let err = registry
769 .execute("nonexistent_tool", Value::Null)
770 .await
771 .expect_err("execute on missing tool should fail");
772 match err {
773 crate::PawanError::NotFound(msg) => {
774 assert!(
775 msg.contains("nonexistent_tool"),
776 "error should name the missing tool, got: {}",
777 msg
778 );
779 }
780 other => panic!("expected PawanError::NotFound, got: {:?}", other),
781 }
782 }
783
784 #[test]
785 fn test_select_for_query_always_includes_core_tools() {
786 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
787 let selected = registry.select_for_query("xyzzy plover", 5);
790 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
791 for core in &[
792 "bash",
793 "read_file",
794 "write_file",
795 "edit_file",
796 "grep_search",
797 "glob_search",
798 "ast_grep",
799 ] {
800 assert!(
801 names.contains(&core.to_string()),
802 "select_for_query must include core tool {} regardless of query, got {:?}",
803 core,
804 names
805 );
806 }
807 }
808
809 #[test]
810 fn test_select_for_query_caps_at_max_tools_when_possible() {
811 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
812 let selected = registry.select_for_query("git commit my changes", 10);
816 assert!(
817 selected.len() <= 10,
818 "select_for_query(max=10) returned {} tools, must not exceed cap",
819 selected.len()
820 );
821 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
823 assert!(
824 names.iter().any(|n| n.starts_with("git_")),
825 "git query should pull in at least one git_ tool, got {:?}",
826 names
827 );
828 }
829
830 #[test]
831 fn test_activate_no_op_for_unknown_tool_does_not_panic() {
832 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
833 registry.activate("not_a_real_tool_at_all");
836 let visible: Vec<String> = registry
837 .get_definitions()
838 .iter()
839 .map(|d| d.name.clone())
840 .collect();
841 assert!(
842 !visible.contains(&"not_a_real_tool_at_all".to_string()),
843 "activate of unknown tool must not make it visible"
844 );
845 }
846
847 #[test]
848 fn test_tool_names_lists_every_registered_tool() {
849 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
850 let names = registry.tool_names();
851 assert!(
855 names.len() >= 30,
856 "default registry should expose >=30 tools via tool_names(), got {}",
857 names.len()
858 );
859 for name in &names {
861 assert!(registry.has_tool(name));
862 assert!(registry.get(name).is_some());
863 }
864 }
865
866 #[test]
867 fn test_default_impl_returns_empty_registry() {
868 let registry = ToolRegistry::default();
869 assert!(registry.tool_names().is_empty());
870 assert!(registry.get_definitions().is_empty());
871 }
872
873 #[test]
874 fn test_select_for_query_empty_query_returns_core_tools() {
875 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
876 let selected = registry.select_for_query("", 50);
877 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
878
879 for core in &[
880 "bash",
881 "read_file",
882 "write_file",
883 "edit_file",
884 "grep_search",
885 "glob_search",
886 "ast_grep",
887 ] {
888 assert!(
889 names.contains(&core.to_string()),
890 "empty query must still include core tool {}, got {:?}",
891 core,
892 names
893 );
894 }
895 }
896
897 #[test]
898 fn test_select_for_query_max_tools_below_core_count_still_returns_all_core() {
899 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
900 let selected = registry.select_for_query("irrelevant", 2);
902 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
903
904 assert!(
905 names.len() >= 7,
906 "select_for_query must never drop Core tools even when max_tools < core count, got {:?}",
907 names
908 );
909 for core in &["bash", "read_file", "grep_search"] {
910 assert!(
911 names.contains(&core.to_string()),
912 "missing core tool {} in {:?}",
913 core,
914 names
915 );
916 }
917 }
918
919 #[test]
920 fn test_query_tools_malformed_dsl_returns_empty_vec() {
921 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
922 let matches = registry.query_tools("((( not a valid thulp-query dsl");
923 assert!(
924 matches.is_empty(),
925 "malformed DSL must return empty vec, got {:?}",
926 matches.iter().map(|d| &d.name).collect::<Vec<_>>()
927 );
928 }
929
930 #[test]
931 fn test_select_for_query_mcp_web_search_gets_priority_boost() {
932 let mut registry = ToolRegistry::new();
933 registry.register_with_tier(
934 Arc::new(MockTool::new(
935 "mcp_web_search",
936 "search the web for live information",
937 Value::Null,
938 )),
939 ToolTier::Extended,
940 );
941 registry.register_with_tier(
942 Arc::new(MockTool::new(
943 "git_status",
944 "show git working tree status",
945 Value::Null,
946 )),
947 ToolTier::Standard,
948 );
949 for i in 0..8 {
951 registry.register_with_tier(
952 Arc::new(MockTool::new(
953 &format!("filler_{i}"),
954 "generic helper tool",
955 Value::Null,
956 )),
957 ToolTier::Standard,
958 );
959 }
960
961 let selected = registry.select_for_query("search the web online", 3);
962 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
963 assert!(
964 names.contains(&"mcp_web_search".to_string()),
965 "web-search query should boost mcp_web_search into the capped window, got {:?}",
966 names
967 );
968 }
969
970 #[test]
971 fn test_select_for_query_short_words_do_not_contribute_to_scoring() {
972 let mut registry = ToolRegistry::new();
973 registry.register_with_tier(
974 Arc::new(MockTool::new(
975 "xyzzy_plover",
976 "obscure tool that only matches long tokens",
977 Value::Null,
978 )),
979 ToolTier::Extended,
980 );
981
982 let selected = registry.select_for_query("go do it", 5);
984 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
985 assert!(
986 !names.contains(&"xyzzy_plover".to_string()),
987 "short query words must not match extended tools, got {:?}",
988 names
989 );
990 }
991
992 #[test]
993 fn test_select_for_query_multiple_category_matches_rank_git_tools() {
994 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
995 let selected = registry.select_for_query("git commit branch diff status", 15);
996 let names: Vec<String> = selected.iter().map(|d| d.name.clone()).collect();
997 let git_tools: Vec<&String> = names.iter().filter(|n| n.starts_with("git_")).collect();
998 assert!(
999 git_tools.len() >= 3,
1000 "multi-keyword git query should surface several git_ tools, got {:?}",
1001 names
1002 );
1003 }
1004
1005 #[test]
1006 fn test_get_lookup_returns_none_for_missing_tool() {
1007 let registry = ToolRegistry::with_defaults(PathBuf::from("/tmp/test"));
1008 assert!(registry.get("totally_missing_tool").is_none());
1009 assert!(!registry.has_tool("totally_missing_tool"));
1010 }
1011}