Skip to main content

pawan/tools/
mod.rs

1//! Tools for Pawan agent
2//!
3//! This module provides all the tools that Pawan can use to interact with
4//! the filesystem, execute commands, and perform coding operations.
5//!
6//! Native tools (rg, fd, sd, erd, mise) are thin wrappers over CLI binaries
7//! that provide structured JSON output and auto-install hints.
8
9pub 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
33/// Tool definition — re-exported from `thulp_core` so pawan and the rest of
34/// the dirmacs stack share a single source of truth for tool metadata.
35///
36/// Holds typed `Vec<Parameter>` rather than a raw JSON-schema `Value`. When a
37/// backend needs the JSON-schema form to send to an LLM API, it converts via
38/// `ToolDefinition::to_mcp_input_schema()` (added in thulp-core 0.3.2).
39pub use thulp_core::ToolDefinition;
40
41/// Trait for implementing tools
42#[async_trait]
43pub trait Tool: Send + Sync {
44    /// Returns the unique name of this tool
45    fn name(&self) -> &str;
46
47    /// Returns a description of what this tool does
48    fn description(&self) -> &str;
49
50    /// Returns whether this tool mutates state (writes files, runs commands, etc.)
51    ///
52    /// Read-only tools (mutating = false) are auto-approved.
53    /// Mutating tools (mutating = true) require user confirmation.
54    fn mutating(&self) -> bool {
55        false // Default to read-only for safety
56    }
57
58    /// Returns the JSON schema for this tool's parameters
59    fn parameters_schema(&self) -> Value;
60
61    /// Executes the tool with the given arguments
62    async fn execute(&self, args: Value) -> crate::Result<Value>;
63    /// Override in tools that use Parameter::builder() for rich validation.
64    /// Default: parses JSON schema back into thulp Parameters (best-effort).
65    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    /// Validate arguments using thulp-core typed parameters.
75    /// Returns Ok(()) or an error describing which params are wrong/missing.
76    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/// Tool tier — controls which tools are sent to the LLM in the prompt.
84/// All tools remain executable regardless of tier; tier only affects
85/// which tool definitions appear in the LLM system prompt.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub enum ToolTier {
88    /// Always sent to LLM — core file ops, bash, ast-grep
89    Core,
90    /// Sent to LLM by default — git, search, agent
91    Standard,
92    /// Only sent when explicitly requested or after first use — mise, tree, zoxide, sd, ripgrep, fd
93    Extended,
94}
95
96/// Registry for managing tools with tiered visibility.
97///
98/// All tools are always executable. Tier controls which definitions
99/// are sent to the LLM to save prompt tokens on simple tasks.
100pub struct ToolRegistry {
101    tools: HashMap<String, Arc<dyn Tool>>,
102    tiers: HashMap<String, ToolTier>,
103    /// Extended tools that have been activated (promoted to visible)
104    activated: std::sync::Mutex<std::collections::HashSet<String>>,
105    /// Precomputed lowercased "name description" for each tool (avoids per-query allocation)
106    tool_text_cache: HashMap<String, String>,
107}
108
109impl ToolRegistry {
110    /// Create a new empty registry
111    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    /// Create a registry with all default tools, assigned to tiers.
121    ///
122    /// Core (always in LLM prompt): bash, read/write/edit, ast_grep, glob/grep
123    /// Standard (in prompt by default): git, agents
124    /// Extended (in prompt after first use): ripgrep, fd, sd, erd, mise, zoxide
125    pub fn with_defaults(workspace_root: std::path::PathBuf) -> Self {
126        let mut registry = Self::new();
127        use ToolTier::*;
128
129        // ── Core tier: always visible to LLM ──
130        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        // ── Standard tier: visible by default ──
157        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        // ── Extended tier: hidden until first use ──
227        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        // ── Terminal sessions: visible by default because /rmux depends on it ──
257        registry.register_with_tier(Arc::new(rmux::RmuxTool::new()), Standard);
258
259        // ── Deagle code intelligence (Extended, feature-gated) ──
260        #[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    /// Register a tool at Standard tier (default)
288    pub fn register(&mut self, tool: Arc<dyn Tool>) {
289        self.register_with_tier(tool, ToolTier::Standard);
290    }
291
292    /// Register a tool at a specific tier
293    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    /// Get a tool by name
302    pub fn get(&self, name: &str) -> Option<&Arc<dyn Tool>> {
303        self.tools.get(name)
304    }
305
306    /// Check if a tool exists
307    pub fn has_tool(&self, name: &str) -> bool {
308        self.tools.contains_key(name)
309    }
310
311    /// Execute a tool by name
312    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    /// Get tool definitions visible to the LLM (Core + Standard + activated Extended).
323    /// Extended tools become visible after first use or explicit activation.
324    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    /// Dynamic tool selection — pick the most relevant tools for a given query.
344    ///
345    /// Returns Core tools (always) + top-K scored Standard/Extended tools based
346    /// on keyword matching between the query and tool names/descriptions.
347    /// This reduces 22+ tools to ~8-10, making MCP and extended tools visible.
348    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            // Core tools always included — skip scoring
362            if tier == ToolTier::Core {
363                continue;
364            }
365
366            // Score based on keyword overlap — use precomputed cache
367            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                } // skip short words
378                if tool_text.contains(word) {
379                    score += 2;
380                }
381            }
382
383            // Bonus for keyword categories
384            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            // MCP tools get a boost — especially web search when query mentions web/internet/online
437            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; // Strong boost — this is what the user wants
445                    }
446                }
447            }
448
449            // Activated extended tools get a boost (user has used them before)
450            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        // Sort by score descending
461        scored.sort_by_key(|&(score, _)| std::cmp::Reverse(score));
462
463        // Collect: all Core tools + top-K scored tools
464        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    /// Get ALL tool definitions regardless of tier (for tests and introspection)
488    pub fn get_all_definitions(&self) -> Vec<ToolDefinition> {
489        self.tools.values().map(|t| t.thulp_definition()).collect()
490    }
491
492    /// Activate an extended tool (makes it visible to the LLM)
493    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    /// Get tool names
503    pub fn tool_names(&self) -> Vec<&str> {
504        self.tools.keys().map(|s| s.as_str()).collect()
505    }
506
507    /// Query tools using thulp-query's DSL.
508    ///
509    /// Supports criteria like:
510    /// - `"name:git"` — tools whose name contains "git"
511    /// - `"has:path"` — tools with a "path" parameter
512    /// - `"desc:file"` — tools whose description contains "file"
513    /// - `"min:2"` — tools with ≥2 parameters
514    /// - `"max:5"` — tools with ≤5 parameters
515    /// - `"name:git and has:message"` — combine criteria with `and`
516    /// - `"name:read or name:write"` — combine with `or`
517    ///
518    /// Returns matching tool definitions (thulp_core format, not pawan's).
519    /// Use this for dynamic tool filtering in agent prompts — e.g. select
520    /// only git-related tools for a commit task.
521    ///
522    /// Returns an empty vec if the query fails to parse.
523    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        // Must include core tools that are always visible to the LLM
563        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        // Standard tier tools should also be there
578        assert!(registry.has_tool("git_status"));
579        assert!(registry.has_tool("git_commit"));
580        // Extended tier tools are registered but initially hidden
581        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        // Extended tools must NOT be in initial visible list
595        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        // Core tools must be present
604        assert!(initial.contains(&"bash".to_string()));
605        assert!(initial.contains(&"read_file".to_string()));
606
607        // Activate rg and verify it appears
608        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        // all (Core + Standard + Extended) should strictly contain more than default-visible
630        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        // rg should be in "all" even without activation
637        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        // thulp-query DSL: simple name substring match
645        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        // An impossible match returns empty
654        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    // ── Mock tool used by the registration / execution / selection tests ──
663    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        // Standard-tier tools must appear in the default LLM-visible set without activation.
704        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        // Only one tool with that name; the second registration wins for both
729        // the description string and the tier classification.
730        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        // Tier was upgraded to Core — must remain visible without explicit activation.
741        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        // Even an irrelevant query must keep Core tools in the result, since
788        // the LLM should never lose access to its file/bash/grep/glob primitives.
789        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        // The cap is best-effort: select_for_query always includes ALL Core
813        // tools (7 of them), then fills remaining slots with scored tools.
814        // So a max_tools >= core count should be respected for the non-core fill.
815        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        // And the git-related tools should rank into the visible window for a git query
822        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        // activate must silently ignore unknown names rather than panicking or
834        // polluting the activated set (which would mismatch tool_names()).
835        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        // The default registry registers 32 tools (7 Core + 13 Standard + 12 Extended).
852        // Use a lower-bound check rather than equality so adding tools doesn't break
853        // this test, but a major drop in count would catch a regression.
854        assert!(
855            names.len() >= 30,
856            "default registry should expose >=30 tools via tool_names(), got {}",
857            names.len()
858        );
859        // And every registered name must round-trip through has_tool / get.
860        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        // Core tier has 7 tools; max_tools=2 cannot trim them — cap is best-effort.
901        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        // Register filler Standard tools to crowd the ranked window.
950        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        // Words shorter than 3 chars are skipped — "go" and "do" must not score.
983        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}