Skip to main content

pawan/tools/
native.rs

1//! Native CLI tool wrappers — rg, fd, sd, ag, erd
2//!
3//! Each tool auto-checks binary availability and provides structured output.
4//! Faster than bash for search/replace tasks because they bypass shell parsing.
5
6use super::Tool;
7use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10use std::process::Stdio;
11
12/// Check if a CLI binary is available in PATH.
13fn binary_exists(name: &str) -> bool {
14    which::which(name).is_ok()
15}
16
17/// Map tool binary names to their mise package names for auto-install.
18fn mise_package_name(binary: &str) -> &str {
19    match binary {
20        "erd" => "erdtree",
21        "sg" | "ast-grep" => "ast-grep",
22        "rg" => "ripgrep",
23        "fd" => "fd",
24        "sd" => "sd",
25        "bat" => "bat",
26        "delta" => "delta",
27        "jq" => "jq",
28        "yq" => "yq",
29        other => other,
30    }
31}
32
33/// Try to auto-install a missing tool via mise. Returns true if install succeeded.
34async fn auto_install(binary: &str, cwd: &std::path::Path) -> bool {
35    let mise_bin = if binary_exists("mise") {
36        "mise".to_string()
37    } else {
38        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
39        let local = format!("{}/.local/bin/mise", home);
40        if std::path::Path::new(&local).exists() { local } else { return false; }
41    };
42
43    let pkg = mise_package_name(binary);
44    tracing::info!(binary = binary, package = pkg, "Auto-installing missing tool via mise");
45
46    let result = tokio::process::Command::new(&mise_bin)
47        .args(["install", pkg, "-y"])
48        .current_dir(cwd)
49        .stdout(Stdio::piped())
50        .stderr(Stdio::piped())
51        .output()
52        .await;
53
54    match result {
55        Ok(output) if output.status.success() => {
56            // Also run `mise use --global` to make it available
57            let _ = tokio::process::Command::new(&mise_bin)
58                .args(["use", "--global", pkg])
59                .current_dir(cwd)
60                .output()
61                .await;
62            tracing::info!(binary = binary, "Auto-install succeeded");
63            true
64        }
65        Ok(output) => {
66            let stderr = String::from_utf8_lossy(&output.stderr);
67            tracing::warn!(binary = binary, stderr = %stderr, "Auto-install failed");
68            false
69        }
70        Err(e) => {
71            tracing::warn!(binary = binary, error = %e, "Auto-install failed to run mise");
72            false
73        }
74    }
75}
76
77/// Ensure a binary is available, auto-installing via mise if needed.
78async fn ensure_binary(name: &str, cwd: &std::path::Path) -> Result<(), crate::PawanError> {
79    if binary_exists(name) { return Ok(()); }
80    if auto_install(name, cwd).await && binary_exists(name) { return Ok(()); }
81    Err(crate::PawanError::Tool(format!(
82        "{} not found and auto-install failed. Install manually: mise install {}",
83        name, mise_package_name(name)
84    )))
85}
86
87/// Run a command and capture stdout+stderr
88/// Execute a command and capture stdout, stderr, and success status.
89async fn run_cmd(cmd: &str, args: &[&str], cwd: &std::path::Path) -> Result<(String, String, bool), String> {
90    let output = tokio::process::Command::new(cmd)
91        .args(args)
92        .current_dir(cwd)
93        .stdout(Stdio::piped())
94        .stderr(Stdio::piped())
95        .output()
96        .await
97        .map_err(|e| format!("Failed to run {}: {}", cmd, e))?;
98
99    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
100    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
101    Ok((stdout, stderr, output.status.success()))
102}
103
104// ─── ripgrep (rg) ───────────────────────────────────────────────────────────
105
106/// Tool for fast text search using ripgrep
107///
108/// This tool provides fast recursive search through files using the rg (ripgrep)
109/// command line tool. It returns structured JSON results for easy parsing.
110///
111/// # Fields
112/// - `workspace_root`: The root directory of the workspace
113pub struct RipgrepTool {
114    workspace_root: PathBuf,
115}
116
117impl RipgrepTool {
118    pub fn new(workspace_root: PathBuf) -> Self {
119        Self { workspace_root }
120    }
121}
122
123#[async_trait]
124impl Tool for RipgrepTool {
125    fn name(&self) -> &str { "rg" }
126
127    fn description(&self) -> &str {
128        "ripgrep — blazing fast regex search across files. Returns matching lines with file paths \
129         and line numbers. Use for finding code patterns, function definitions, imports, usages. \
130         Much faster than bash grep. Supports --type for language filtering (rust, py, js, go)."
131    }
132
133    fn parameters_schema(&self) -> Value {
134        json!({
135            "type": "object",
136            "properties": {
137                "pattern": { "type": "string", "description": "Regex pattern to search for" },
138                "path": { "type": "string", "description": "Path to search in (default: workspace root)" },
139                "type_filter": { "type": "string", "description": "File type filter: rust, py, js, go, ts, c, cpp, java, toml, md" },
140                "max_count": { "type": "integer", "description": "Max matches per file (default: 20)" },
141                "context": { "type": "integer", "description": "Lines of context around each match (default: 0)" },
142                "fixed_strings": { "type": "boolean", "description": "Treat pattern as literal string, not regex" },
143                "case_insensitive": { "type": "boolean", "description": "Case insensitive search (default: false)" },
144                "invert": { "type": "boolean", "description": "Invert match: show lines that do NOT match (default: false)" },
145                "hidden": { "type": "boolean", "description": "Search hidden files and directories (default: false)" },
146                "max_depth": { "type": "integer", "description": "Max directory depth to search (default: unlimited)" }
147            },
148            "required": ["pattern"]
149        })
150    }
151
152    async fn execute(&self, args: Value) -> crate::Result<Value> {
153        ensure_binary("rg", &self.workspace_root).await?;
154        let pattern = args["pattern"].as_str()
155            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
156        let search_path = args["path"].as_str().unwrap_or(".");
157        let max_count = args["max_count"].as_u64().unwrap_or(20);
158        let context = args["context"].as_u64().unwrap_or(0);
159
160        let max_count_str = max_count.to_string();
161        let ctx_str = context.to_string();
162        let mut cmd_args = vec![
163            "--line-number", "--no-heading", "--color", "never",
164            "--max-count", &max_count_str,
165        ];
166        if context > 0 {
167            cmd_args.extend_from_slice(&["--context", &ctx_str]);
168        }
169        if let Some(t) = args["type_filter"].as_str() {
170            cmd_args.extend_from_slice(&["--type", t]);
171        }
172        if args["fixed_strings"].as_bool().unwrap_or(false) {
173            cmd_args.push("--fixed-strings");
174        }
175        if args["case_insensitive"].as_bool().unwrap_or(false) {
176            cmd_args.push("-i");
177        }
178        if args["invert"].as_bool().unwrap_or(false) {
179            cmd_args.push("--invert-match");
180        }
181        if args["hidden"].as_bool().unwrap_or(false) {
182            cmd_args.push("--hidden");
183        }
184        let depth_str = args["max_depth"].as_u64().map(|d| d.to_string());
185        if let Some(ref ds) = depth_str {
186            cmd_args.push("--max-depth");
187            cmd_args.push(ds);
188        }
189        cmd_args.push(pattern);
190        cmd_args.push(search_path);
191
192        let cwd = if std::path::Path::new(search_path).is_absolute() {
193            std::path::PathBuf::from("/")
194        } else {
195            self.workspace_root.clone()
196        };
197
198        let (stdout, stderr, success) = run_cmd("rg", &cmd_args, &cwd).await
199            .map_err(crate::PawanError::Tool)?;
200
201        let match_count = stdout.lines().filter(|l| !l.is_empty()).count();
202
203        Ok(json!({
204            "matches": stdout.lines().take(100).collect::<Vec<_>>().join("\n"),
205            "match_count": match_count,
206            "success": success || match_count > 0,
207            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
208        }))
209    }
210}
211
212// ─── fd (fast find) ─────────────────────────────────────────────────────────
213
214/// Tool for fast file search using fd
215///
216/// This tool provides fast file and directory search using the fd command line
217/// tool. It's an alternative to find that's faster and more user-friendly.
218///
219/// # Fields
220/// - `workspace_root`: The root directory of the workspace
221pub struct FdTool {
222    workspace_root: PathBuf,
223}
224
225impl FdTool {
226    pub fn new(workspace_root: PathBuf) -> Self {
227        Self { workspace_root }
228    }
229}
230
231#[async_trait]
232impl Tool for FdTool {
233    fn name(&self) -> &str { "fd" }
234
235    fn description(&self) -> &str {
236        "fd — fast file finder. Finds files and directories by name pattern. \
237         Much faster than bash find. Use for locating files, exploring project structure."
238    }
239
240    fn parameters_schema(&self) -> Value {
241        json!({
242            "type": "object",
243            "properties": {
244                "pattern": { "type": "string", "description": "Search pattern (regex by default)" },
245                "path": { "type": "string", "description": "Directory to search in (default: workspace root)" },
246                "extension": { "type": "string", "description": "Filter by extension: rs, py, js, toml, md" },
247                "type_filter": { "type": "string", "description": "f=file, d=directory, l=symlink" },
248                "max_depth": { "type": "integer", "description": "Max directory depth" },
249                "max_results": { "type": "integer", "description": "Max results to return (default: 50, prevents context flooding)" },
250                "hidden": { "type": "boolean", "description": "Include hidden files (default: false)" }
251            },
252            "required": ["pattern"]
253        })
254    }
255
256    async fn execute(&self, args: Value) -> crate::Result<Value> {
257        ensure_binary("fd", &self.workspace_root).await?;
258        let pattern = args["pattern"].as_str()
259            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
260
261        let mut cmd_args: Vec<String> = vec!["--color".into(), "never".into()];
262        if let Some(ext) = args["extension"].as_str() {
263            cmd_args.push("-e".into()); cmd_args.push(ext.into());
264        }
265        if let Some(t) = args["type_filter"].as_str() {
266            cmd_args.push("-t".into()); cmd_args.push(t.into());
267        }
268        if let Some(d) = args["max_depth"].as_u64() {
269            cmd_args.push("--max-depth".into()); cmd_args.push(d.to_string());
270        }
271        if args["hidden"].as_bool().unwrap_or(false) {
272            cmd_args.push("--hidden".into());
273        }
274        cmd_args.push(pattern.into());
275        if let Some(p) = args["path"].as_str() {
276            cmd_args.push(p.into());
277        }
278
279        let cmd_args_ref: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
280        let (stdout, stderr, _) = run_cmd("fd", &cmd_args_ref, &self.workspace_root).await
281            .map_err(crate::PawanError::Tool)?;
282
283        let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
284        let all_files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
285        let total = all_files.len();
286        let files: Vec<&str> = all_files.into_iter().take(max_results).collect();
287        let truncated = total > max_results;
288
289        Ok(json!({
290            "files": files,
291            "count": files.len(),
292            "total_found": total,
293            "truncated": truncated,
294            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
295        }))
296    }
297}
298
299// ─── sd (fast sed) ──────────────────────────────────────────────────────────
300
301/// Tool for fast text replacement using sd
302///
303/// This tool provides fast text replacement using the sd command line tool.
304/// It's an alternative to sed that's more intuitive and faster.
305///
306/// # Fields
307/// - `workspace_root`: The root directory of the workspace
308pub struct SdTool {
309    workspace_root: PathBuf,
310}
311
312impl SdTool {
313    pub fn new(workspace_root: PathBuf) -> Self {
314        Self { workspace_root }
315    }
316}
317
318#[async_trait]
319impl Tool for SdTool {
320    fn name(&self) -> &str { "sd" }
321
322    fn description(&self) -> &str {
323        "sd — fast find-and-replace across files. Like sed but simpler syntax and faster. \
324         Use for bulk renaming, refactoring imports, changing patterns across entire codebase. \
325         Modifies files in-place."
326    }
327
328    fn parameters_schema(&self) -> Value {
329        json!({
330            "type": "object",
331            "properties": {
332                "find": { "type": "string", "description": "Pattern to find (regex)" },
333                "replace": { "type": "string", "description": "Replacement string" },
334                "path": { "type": "string", "description": "File or directory to process" },
335                "fixed_strings": { "type": "boolean", "description": "Treat find as literal, not regex (default: false)" }
336            },
337            "required": ["find", "replace", "path"]
338        })
339    }
340
341    async fn execute(&self, args: Value) -> crate::Result<Value> {
342        ensure_binary("sd", &self.workspace_root).await?;
343        let find = args["find"].as_str()
344            .ok_or_else(|| crate::PawanError::Tool("find required".into()))?;
345        let replace = args["replace"].as_str()
346            .ok_or_else(|| crate::PawanError::Tool("replace required".into()))?;
347        let path = args["path"].as_str()
348            .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
349
350        let mut cmd_args = vec![];
351        if args["fixed_strings"].as_bool().unwrap_or(false) {
352            cmd_args.push("-F");
353        }
354        cmd_args.extend_from_slice(&[find, replace, path]);
355
356        let (stdout, stderr, success) = run_cmd("sd", &cmd_args, &self.workspace_root).await
357            .map_err(crate::PawanError::Tool)?;
358
359        Ok(json!({
360            "success": success,
361            "output": stdout,
362            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
363        }))
364    }
365}
366
367// ─── erdtree (erd) ──────────────────────────────────────────────────────────
368
369pub struct ErdTool {
370    workspace_root: PathBuf,
371}
372
373impl ErdTool {
374    pub fn new(workspace_root: PathBuf) -> Self {
375        Self { workspace_root }
376    }
377}
378
379#[async_trait]
380impl Tool for ErdTool {
381    fn name(&self) -> &str { "tree" }
382
383    fn description(&self) -> &str {
384        "erdtree (erd) — fast filesystem tree with disk usage, file counts, and metadata. \
385         Use to map project structure, find large files/dirs, count lines of code, \
386         audit disk usage, or get a flat file listing. Much faster than find + du."
387    }
388
389    fn parameters_schema(&self) -> Value {
390        json!({
391            "type": "object",
392            "properties": {
393                "path": { "type": "string", "description": "Root directory (default: workspace root)" },
394                "depth": { "type": "integer", "description": "Max traversal depth (default: 3)" },
395                "pattern": { "type": "string", "description": "Filter by glob pattern (e.g. '*.rs', 'Cargo*')" },
396                "sort": {
397                    "type": "string",
398                    "enum": ["name", "size", "type"],
399                    "description": "Sort entries by name, size, or file type (default: name)"
400                },
401                "disk_usage": {
402                    "type": "string",
403                    "enum": ["physical", "logical", "line", "word", "block"],
404                    "description": "Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical."
405                },
406                "layout": {
407                    "type": "string",
408                    "enum": ["regular", "inverted", "flat", "iflat"],
409                    "description": "Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted."
410                },
411                "long": { "type": "boolean", "description": "Show extended metadata: permissions, owner, group, timestamps (default: false)" },
412                "hidden": { "type": "boolean", "description": "Show hidden (dot) files (default: false)" },
413                "dirs_only": { "type": "boolean", "description": "Only show directories, not files (default: false)" },
414                "human": { "type": "boolean", "description": "Human-readable sizes like 4.2M instead of bytes (default: true)" },
415                "icons": { "type": "boolean", "description": "Show file type icons (default: false)" },
416                "no_ignore": { "type": "boolean", "description": "Don't respect .gitignore (default: false)" },
417                "suppress_size": { "type": "boolean", "description": "Hide disk usage column (default: false)" }
418            }
419        })
420    }
421
422    async fn execute(&self, args: Value) -> crate::Result<Value> {
423        // Auto-install erd if missing; fall back to fd if mise unavailable
424        if !binary_exists("erd") {
425            if !auto_install("erd", &self.workspace_root).await || !binary_exists("erd") {
426                let path = args["path"].as_str().unwrap_or(".");
427                let depth = args["depth"].as_u64().unwrap_or(3).to_string();
428                let cmd_args = vec![".", path, "--max-depth", &depth, "--color", "never"];
429                let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
430                    .unwrap_or(("(erd and fd not available)".into(), String::new(), false));
431                return Ok(json!({ "tree": stdout, "tool": "fd-fallback" }));
432            }
433        }
434
435        let path = args["path"].as_str().unwrap_or(".");
436        let depth_str = args["depth"].as_u64().unwrap_or(3).to_string();
437
438        let mut cmd_args: Vec<String> = vec![
439            "--level".into(), depth_str,
440            "--no-config".into(),
441            "--color".into(), "none".into(),
442        ];
443
444        // Disk usage mode
445        if let Some(du) = args["disk_usage"].as_str() {
446            cmd_args.extend(["--disk-usage".into(), du.into()]);
447        }
448
449        // Layout
450        let layout = args["layout"].as_str().unwrap_or("inverted");
451        cmd_args.extend(["--layout".into(), layout.into()]);
452
453        // Sort
454        if let Some(sort) = args["sort"].as_str() {
455            cmd_args.extend(["--sort".into(), sort.into()]);
456        }
457
458        // Pattern filter
459        if let Some(p) = args["pattern"].as_str() {
460            cmd_args.extend(["--pattern".into(), p.into()]);
461        }
462
463        // Boolean flags
464        if args["long"].as_bool().unwrap_or(false) { cmd_args.push("--long".into()); }
465        if args["hidden"].as_bool().unwrap_or(false) { cmd_args.push("--hidden".into()); }
466        if args["dirs_only"].as_bool().unwrap_or(false) { cmd_args.push("--dirs-only".into()); }
467        if args["human"].as_bool().unwrap_or(true) { cmd_args.push("--human".into()); }
468        if args["icons"].as_bool().unwrap_or(false) { cmd_args.push("--icons".into()); }
469        if args["no_ignore"].as_bool().unwrap_or(false) { cmd_args.push("--no-ignore".into()); }
470        if args["suppress_size"].as_bool().unwrap_or(false) { cmd_args.push("--suppress-size".into()); }
471
472        cmd_args.push(path.into());
473
474        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
475        let (stdout, stderr, success) = run_cmd("erd", &cmd_refs, &self.workspace_root).await
476            .map_err(crate::PawanError::Tool)?;
477
478        Ok(json!({
479            "success": success,
480            "tree": stdout,
481            "tool": "erd",
482            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
483        }))
484    }
485}
486
487// ─── grep_search (rg wrapper for structured output) ─────────────────────────
488
489pub struct GrepSearchTool {
490    workspace_root: PathBuf,
491}
492
493impl GrepSearchTool {
494    pub fn new(workspace_root: PathBuf) -> Self {
495        Self { workspace_root }
496    }
497}
498
499#[async_trait]
500impl Tool for GrepSearchTool {
501    fn name(&self) -> &str { "grep_search" }
502
503    fn description(&self) -> &str {
504        "Search for a pattern in files using ripgrep. Returns file paths and matching lines. \
505         Prefer this over bash grep — it's faster and respects .gitignore."
506    }
507
508    fn parameters_schema(&self) -> Value {
509        json!({
510            "type": "object",
511            "properties": {
512                "pattern": { "type": "string", "description": "Search pattern (regex)" },
513                "path": { "type": "string", "description": "Path to search (default: workspace)" },
514                "include": { "type": "string", "description": "Glob to include (e.g. '*.rs')" }
515            },
516            "required": ["pattern"]
517        })
518    }
519
520    async fn execute(&self, args: Value) -> crate::Result<Value> {
521        let pattern = args["pattern"].as_str()
522            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
523        let path = args["path"].as_str().unwrap_or(".");
524
525        let mut cmd_args = vec!["--line-number", "--no-heading", "--color", "never", "--max-count", "30"];
526        let include;
527        if let Some(glob) = args["include"].as_str() {
528            include = format!("--glob={}", glob);
529            cmd_args.push(&include);
530        }
531        cmd_args.push(pattern);
532        cmd_args.push(path);
533
534        let cwd = if std::path::Path::new(path).is_absolute() {
535            std::path::PathBuf::from("/")
536        } else {
537            self.workspace_root.clone()
538        };
539
540        let (stdout, _, _) = run_cmd("rg", &cmd_args, &cwd).await
541            .map_err(crate::PawanError::Tool)?;
542
543        let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).take(50).collect();
544
545        Ok(json!({
546            "results": lines.join("\n"),
547            "count": lines.len()
548        }))
549    }
550}
551
552// ─── glob_search (fd wrapper) ───────────────────────────────────────────────
553
554pub struct GlobSearchTool {
555    workspace_root: PathBuf,
556}
557
558impl GlobSearchTool {
559    pub fn new(workspace_root: PathBuf) -> Self {
560        Self { workspace_root }
561    }
562}
563
564#[async_trait]
565impl Tool for GlobSearchTool {
566    fn name(&self) -> &str { "glob_search" }
567
568    fn description(&self) -> &str {
569        "Find files by glob pattern. Returns list of matching file paths. \
570         Uses fd under the hood — fast, respects .gitignore."
571    }
572
573    fn parameters_schema(&self) -> Value {
574        json!({
575            "type": "object",
576            "properties": {
577                "pattern": { "type": "string", "description": "Glob pattern (e.g. '*.rs', 'test_*')" },
578                "path": { "type": "string", "description": "Directory to search (default: workspace)" }
579            },
580            "required": ["pattern"]
581        })
582    }
583
584    async fn execute(&self, args: Value) -> crate::Result<Value> {
585        let pattern = args["pattern"].as_str()
586            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
587        let path = args["path"].as_str().unwrap_or(".");
588
589        let cmd_args = vec!["--glob", pattern, "--color", "never", path];
590        let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
591            .map_err(crate::PawanError::Tool)?;
592
593        let files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
594
595        Ok(json!({
596            "files": files,
597            "count": files.len()
598        }))
599    }
600}
601
602// ─── mise (universal tool installer) ────────────────────────────────────────
603
604pub struct MiseTool {
605    workspace_root: PathBuf,
606}
607
608impl MiseTool {
609    pub fn new(workspace_root: PathBuf) -> Self {
610        Self { workspace_root }
611    }
612}
613
614#[async_trait]
615impl Tool for MiseTool {
616    fn name(&self) -> &str { "mise" }
617
618    fn description(&self) -> &str {
619        "mise — polyglot tool manager, environment manager, and task runner. Replaces asdf, nvm, \
620         pyenv, direnv, make, and npm scripts. Three powers: (1) install/manage any dev tool or \
621         language runtime, (2) manage per-project env vars, (3) run/watch project tasks. \
622         Pawan should use this to self-install any missing CLI tool (erd, ast-grep, fd, rg, etc)."
623    }
624
625    fn parameters_schema(&self) -> Value {
626        json!({
627            "type": "object",
628            "properties": {
629                "action": {
630                    "type": "string",
631                    "enum": [
632                        "install", "uninstall", "upgrade", "list", "use", "search",
633                        "exec", "run", "tasks", "env", "outdated", "prune",
634                        "doctor", "self-update", "trust", "watch"
635                    ],
636                    "description": "Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
637                                    Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
638                                    Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
639                                    Maintenance: doctor, self-update, trust, prune."
640                },
641                "tool": {
642                    "type": "string",
643                    "description": "Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
644                                    'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'"
645                },
646                "task": {
647                    "type": "string",
648                    "description": "Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)"
649                },
650                "args": {
651                    "type": "string",
652                    "description": "Additional arguments (space-separated). For exec: command to run. For run: task args."
653                },
654                "global": {
655                    "type": "boolean",
656                    "description": "Apply globally (--global flag) instead of project-local. Default: false."
657                }
658            },
659            "required": ["action"]
660        })
661    }
662
663    async fn execute(&self, args: Value) -> crate::Result<Value> {
664        let mise_bin = if binary_exists("mise") {
665            "mise".to_string()
666        } else {
667            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
668            let local = format!("{}/.local/bin/mise", home);
669            if std::path::Path::new(&local).exists() { local } else {
670                return Err(crate::PawanError::Tool(
671                    "mise not found. Install: curl https://mise.run | sh".into()
672                ));
673            }
674        };
675
676        let action = args["action"].as_str()
677            .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
678        let global = args["global"].as_bool().unwrap_or(false);
679
680        let cmd_args: Vec<String> = match action {
681            "install" => {
682                let tool = args["tool"].as_str()
683                    .ok_or_else(|| crate::PawanError::Tool("tool required for install".into()))?;
684                vec!["install".into(), tool.into(), "-y".into()]
685            }
686            "uninstall" => {
687                let tool = args["tool"].as_str()
688                    .ok_or_else(|| crate::PawanError::Tool("tool required for uninstall".into()))?;
689                vec!["uninstall".into(), tool.into()]
690            }
691            "upgrade" => {
692                let mut v = vec!["upgrade".into()];
693                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
694                v
695            }
696            "list" => vec!["ls".into()],
697            "search" => {
698                let tool = args["tool"].as_str()
699                    .ok_or_else(|| crate::PawanError::Tool("tool required for search".into()))?;
700                vec!["registry".into(), tool.into()]
701            }
702            "use" => {
703                let tool = args["tool"].as_str()
704                    .ok_or_else(|| crate::PawanError::Tool("tool required for use".into()))?;
705                let mut v = vec!["use".into()];
706                if global { v.push("--global".into()); }
707                v.push(tool.into());
708                v
709            }
710            "outdated" => {
711                let mut v = vec!["outdated".into()];
712                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
713                v
714            }
715            "prune" => {
716                let mut v = vec!["prune".into(), "-y".into()];
717                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
718                v
719            }
720            "exec" => {
721                let tool = args["tool"].as_str()
722                    .ok_or_else(|| crate::PawanError::Tool("tool required for exec".into()))?;
723                let extra = args["args"].as_str().unwrap_or("");
724                let mut v = vec!["exec".into(), tool.into(), "--".into()];
725                if !extra.is_empty() {
726                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
727                }
728                v
729            }
730            "run" => {
731                let task = args["task"].as_str()
732                    .ok_or_else(|| crate::PawanError::Tool("task required for run".into()))?;
733                let mut v = vec!["run".into(), task.into()];
734                if let Some(extra) = args["args"].as_str() {
735                    v.push("--".into());
736                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
737                }
738                v
739            }
740            "watch" => {
741                let task = args["task"].as_str()
742                    .ok_or_else(|| crate::PawanError::Tool("task required for watch".into()))?;
743                let mut v = vec!["watch".into(), task.into()];
744                if let Some(extra) = args["args"].as_str() {
745                    v.push("--".into());
746                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
747                }
748                v
749            }
750            "tasks" => vec!["tasks".into(), "ls".into()],
751            "env" => vec!["env".into()],
752            "doctor" => vec!["doctor".into()],
753            "self-update" => vec!["self-update".into(), "-y".into()],
754            "trust" => {
755                let mut v = vec!["trust".into()];
756                if let Some(extra) = args["args"].as_str() { v.push(extra.into()); }
757                v
758            }
759            _ => return Err(crate::PawanError::Tool(
760                format!("Unknown action: {action}. See tool description for available actions.")
761            )),
762        };
763
764        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
765        let (stdout, stderr, success) = run_cmd(&mise_bin, &cmd_refs, &self.workspace_root).await
766            .map_err(crate::PawanError::Tool)?;
767
768        Ok(json!({
769            "success": success,
770            "action": action,
771            "output": stdout,
772            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
773        }))
774    }
775}
776
777// ─── zoxide (smart cd) ─────────────────────────────────────────────────────
778
779pub struct ZoxideTool {
780    workspace_root: PathBuf,
781}
782
783impl ZoxideTool {
784    pub fn new(workspace_root: PathBuf) -> Self {
785        Self { workspace_root }
786    }
787}
788
789#[async_trait]
790impl Tool for ZoxideTool {
791    fn name(&self) -> &str { "z" }
792
793    fn description(&self) -> &str {
794        "zoxide — smart directory jumper. Learns from your cd history. \
795         Use 'query' to find a directory by fuzzy match (e.g. 'pawan' finds /opt/pawan). \
796         Use 'add' to teach it a new path. Use 'list' to see known paths."
797    }
798
799    fn parameters_schema(&self) -> Value {
800        json!({
801            "type": "object",
802            "properties": {
803                "action": { "type": "string", "description": "query, add, or list" },
804                "path": { "type": "string", "description": "Path or search term" }
805            },
806            "required": ["action"]
807        })
808    }
809
810    async fn execute(&self, args: Value) -> crate::Result<Value> {
811        let action = args["action"].as_str()
812            .ok_or_else(|| crate::PawanError::Tool("action required (query/add/list)".into()))?;
813
814        let cmd_args: Vec<String> = match action {
815            "query" => {
816                let path = args["path"].as_str()
817                    .ok_or_else(|| crate::PawanError::Tool("path/search term required for query".into()))?;
818                vec!["query".into(), path.into()]
819            }
820            "add" => {
821                let path = args["path"].as_str()
822                    .ok_or_else(|| crate::PawanError::Tool("path required for add".into()))?;
823                vec!["add".into(), path.into()]
824            }
825            "list" => vec!["query".into(), "--list".into()],
826            _ => return Err(crate::PawanError::Tool(format!("Unknown action: {}. Use query/add/list", action))),
827        };
828
829        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
830        let (stdout, stderr, success) = run_cmd("zoxide", &cmd_refs, &self.workspace_root).await
831            .map_err(crate::PawanError::Tool)?;
832
833        Ok(json!({
834            "success": success,
835            "result": stdout.trim(),
836            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
837        }))
838    }
839}
840
841// ─── ast-grep ────────────────────────────────────────────────────────────────
842
843pub struct AstGrepTool {
844    workspace_root: PathBuf,
845}
846
847impl AstGrepTool {
848    pub fn new(workspace_root: PathBuf) -> Self {
849        Self { workspace_root }
850    }
851}
852
853#[async_trait]
854impl Tool for AstGrepTool {
855    fn name(&self) -> &str { "ast_grep" }
856
857    fn description(&self) -> &str {
858        "ast-grep — structural code search and rewrite using AST patterns. \
859         Unlike regex, this matches code by syntax tree structure. Use $NAME for \
860         single-node wildcards, $$$ARGS for variadic (multiple nodes). \
861         Actions: 'search' finds matches, 'rewrite' transforms them in-place. \
862         Examples: pattern='fn $NAME($$$ARGS)' finds all functions. \
863         pattern='$EXPR.unwrap()' rewrite='$EXPR?' replaces unwrap with ?. \
864         Supports: rust, python, javascript, typescript, go, c, cpp, java."
865    }
866
867    fn parameters_schema(&self) -> Value {
868        json!({
869            "type": "object",
870            "properties": {
871                "action": {
872                    "type": "string",
873                    "enum": ["search", "rewrite"],
874                    "description": "search: find matching code. rewrite: transform matching code in-place."
875                },
876                "pattern": {
877                    "type": "string",
878                    "description": "AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'"
879                },
880                "rewrite": {
881                    "type": "string",
882                    "description": "Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'"
883                },
884                "path": {
885                    "type": "string",
886                    "description": "File or directory to search/rewrite"
887                },
888                "lang": {
889                    "type": "string",
890                    "description": "Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)"
891                }
892            },
893            "required": ["action", "pattern", "path"]
894        })
895    }
896
897    async fn execute(&self, args: Value) -> crate::Result<Value> {
898        ensure_binary("ast-grep", &self.workspace_root).await?;
899
900        let action = args["action"].as_str()
901            .ok_or_else(|| crate::PawanError::Tool("action required (search or rewrite)".into()))?;
902        let pattern = args["pattern"].as_str()
903            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
904        let path = args["path"].as_str()
905            .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
906
907        let mut cmd_args: Vec<String> = vec!["run".into()];
908
909        // Language
910        if let Some(lang) = args["lang"].as_str() {
911            cmd_args.push("--lang".into());
912            cmd_args.push(lang.into());
913        }
914
915        // Pattern
916        cmd_args.push("--pattern".into());
917        cmd_args.push(pattern.into());
918
919        match action {
920            "search" => {
921                // Search mode: just find and report matches
922                cmd_args.push(path.into());
923            }
924            "rewrite" => {
925                let rewrite = args["rewrite"].as_str()
926                    .ok_or_else(|| crate::PawanError::Tool("rewrite pattern required for action=rewrite".into()))?;
927                cmd_args.push("--rewrite".into());
928                cmd_args.push(rewrite.into());
929                cmd_args.push("--update-all".into());
930                cmd_args.push(path.into());
931            }
932            _ => {
933                return Err(crate::PawanError::Tool(
934                    format!("Unknown action: {}. Use 'search' or 'rewrite'", action),
935                ));
936            }
937        }
938
939        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
940        let (stdout, stderr, success) = run_cmd("ast-grep", &cmd_refs, &self.workspace_root).await
941            .map_err(crate::PawanError::Tool)?;
942
943        // Count matches from output
944        let match_count = stdout.lines()
945            .filter(|l| l.starts_with('/') || l.contains("│"))
946            .count();
947
948        Ok(json!({
949            "success": success,
950            "action": action,
951            "matches": match_count,
952            "output": stdout,
953            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
954        }))
955    }
956}
957
958// ─── LSP (rust-analyzer powered code intelligence) ──────────────────────────
959
960pub struct LspTool {
961    workspace_root: PathBuf,
962}
963
964impl LspTool {
965    pub fn new(workspace_root: PathBuf) -> Self {
966        Self { workspace_root }
967    }
968}
969
970#[async_trait]
971impl Tool for LspTool {
972    fn name(&self) -> &str { "lsp" }
973
974    fn description(&self) -> &str {
975        "LSP code intelligence via rust-analyzer. Provides type-aware code understanding \
976         that grep/ast-grep can't: diagnostics without cargo check, structural search with \
977         type info, symbol extraction, and analysis stats. Actions: diagnostics (find errors), \
978         search (structural pattern search), ssr (structural search+replace with types), \
979         symbols (parse file symbols), analyze (project-wide type stats)."
980    }
981
982    fn parameters_schema(&self) -> Value {
983        json!({
984            "type": "object",
985            "properties": {
986                "action": {
987                    "type": "string",
988                    "enum": ["diagnostics", "search", "ssr", "symbols", "analyze"],
989                    "description": "diagnostics: find errors/warnings in project. \
990                                    search: structural pattern search (e.g. '$a.foo($b)'). \
991                                    ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
992                                    symbols: parse file and list symbols. \
993                                    analyze: project-wide type analysis stats."
994                },
995                "pattern": {
996                    "type": "string",
997                    "description": "For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'"
998                },
999                "path": {
1000                    "type": "string",
1001                    "description": "Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols."
1002                },
1003                "severity": {
1004                    "type": "string",
1005                    "enum": ["error", "warning", "info", "hint"],
1006                    "description": "Minimum severity for diagnostics (default: warning)"
1007                }
1008            },
1009            "required": ["action"]
1010        })
1011    }
1012
1013    async fn execute(&self, args: Value) -> crate::Result<Value> {
1014        ensure_binary("rust-analyzer", &self.workspace_root).await?;
1015
1016        let action = args["action"].as_str()
1017            .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
1018
1019        let timeout_dur = std::time::Duration::from_secs(60);
1020
1021        match action {
1022            "diagnostics" => {
1023                let path = args["path"].as_str()
1024                    .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1025                let mut cmd_args = vec!["diagnostics", path];
1026                if let Some(sev) = args["severity"].as_str() {
1027                    cmd_args.extend(["--severity", sev]);
1028                }
1029                let result = tokio::time::timeout(timeout_dur,
1030                    run_cmd("rust-analyzer", &cmd_args, &self.workspace_root)
1031                ).await;
1032                match result {
1033                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1034                        "success": success,
1035                        "diagnostics": stdout,
1036                        "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1037                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1038                    })),
1039                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1040                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer diagnostics timed out (60s)".into())),
1041                }
1042            }
1043            "search" => {
1044                let pattern = args["pattern"].as_str()
1045                    .ok_or_else(|| crate::PawanError::Tool("pattern required for search".into()))?;
1046                let result = tokio::time::timeout(timeout_dur,
1047                    run_cmd("rust-analyzer", &["search", pattern], &self.workspace_root)
1048                ).await;
1049                match result {
1050                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1051                        "success": success,
1052                        "matches": stdout,
1053                        "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1054                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1055                    })),
1056                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1057                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer search timed out (60s)".into())),
1058                }
1059            }
1060            "ssr" => {
1061                let pattern = args["pattern"].as_str()
1062                    .ok_or_else(|| crate::PawanError::Tool(
1063                        "pattern required for ssr (format: '$a.unwrap() ==>> $a?')".into()
1064                    ))?;
1065                let result = tokio::time::timeout(timeout_dur,
1066                    run_cmd("rust-analyzer", &["ssr", pattern], &self.workspace_root)
1067                ).await;
1068                match result {
1069                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1070                        "success": success,
1071                        "output": stdout,
1072                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1073                    })),
1074                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1075                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer ssr timed out (60s)".into())),
1076                }
1077            }
1078            "symbols" => {
1079                // Parse stdin and list symbols — pipe file content to rust-analyzer symbols
1080                let path = args["path"].as_str()
1081                    .ok_or_else(|| crate::PawanError::Tool("path required for symbols".into()))?;
1082                let full_path = if std::path::Path::new(path).is_absolute() {
1083                    PathBuf::from(path)
1084                } else {
1085                    self.workspace_root.join(path)
1086                };
1087                let content = tokio::fs::read_to_string(&full_path).await
1088                    .map_err(|e| crate::PawanError::Tool(format!("Failed to read {}: {}", path, e)))?;
1089
1090                let mut child = tokio::process::Command::new("rust-analyzer")
1091                    .arg("symbols")
1092                    .stdin(Stdio::piped())
1093                    .stdout(Stdio::piped())
1094                    .stderr(Stdio::piped())
1095                    .spawn()
1096                    .map_err(|e| crate::PawanError::Tool(format!("Failed to spawn rust-analyzer: {}", e)))?;
1097
1098                // Write file content to stdin
1099                if let Some(mut stdin) = child.stdin.take() {
1100                    use tokio::io::AsyncWriteExt;
1101                    let _ = stdin.write_all(content.as_bytes()).await;
1102                    drop(stdin);
1103                }
1104
1105                let output = child.wait_with_output().await
1106                    .map_err(|e| crate::PawanError::Tool(format!("rust-analyzer symbols failed: {}", e)))?;
1107
1108                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1109                Ok(json!({
1110                    "success": output.status.success(),
1111                    "symbols": stdout,
1112                    "count": stdout.lines().filter(|l| !l.is_empty()).count()
1113                }))
1114            }
1115            "analyze" => {
1116                let path = args["path"].as_str()
1117                    .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1118                let result = tokio::time::timeout(timeout_dur,
1119                    run_cmd("rust-analyzer", &["analysis-stats", "--skip-inference", path], &self.workspace_root)
1120                ).await;
1121                match result {
1122                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1123                        "success": success,
1124                        "stats": stdout,
1125                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1126                    })),
1127                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1128                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer analysis-stats timed out (60s)".into())),
1129                }
1130            }
1131            _ => Err(crate::PawanError::Tool(
1132                format!("Unknown action: {action}. Use diagnostics/search/ssr/symbols/analyze")
1133            )),
1134        }
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141    use tempfile::TempDir;
1142
1143    #[test]
1144    fn test_binary_exists_cargo() {
1145        assert!(binary_exists("cargo"));
1146    }
1147
1148    #[test]
1149    fn test_binary_exists_nonexistent() {
1150        assert!(!binary_exists("nonexistent_binary_xyz_123"));
1151    }
1152
1153    #[test]
1154    fn test_mise_package_name_mapping() {
1155        assert_eq!(mise_package_name("rg"), "ripgrep");
1156        assert_eq!(mise_package_name("fd"), "fd");
1157        assert_eq!(mise_package_name("sg"), "ast-grep");
1158        assert_eq!(mise_package_name("erd"), "erdtree");
1159        assert_eq!(mise_package_name("unknown"), "unknown");
1160    }
1161
1162    #[tokio::test]
1163    async fn test_ast_grep_tool_schema() {
1164        let tmp = TempDir::new().unwrap();
1165        let tool = AstGrepTool::new(tmp.path().to_path_buf());
1166        assert_eq!(tool.name(), "ast_grep");
1167        let schema = tool.parameters_schema();
1168        assert!(schema["properties"]["action"].is_object());
1169        assert!(schema["properties"]["pattern"].is_object());
1170    }
1171
1172    #[tokio::test]
1173    async fn test_lsp_tool_schema() {
1174        let tmp = TempDir::new().unwrap();
1175        let tool = LspTool::new(tmp.path().to_path_buf());
1176        assert_eq!(tool.name(), "lsp");
1177        let schema = tool.parameters_schema();
1178        assert!(schema["properties"]["action"].is_object());
1179    }
1180
1181    #[tokio::test]
1182    async fn test_erd_tool_schema() {
1183        let tmp = TempDir::new().unwrap();
1184        let tool = ErdTool::new(tmp.path().to_path_buf());
1185        assert_eq!(tool.name(), "tree");
1186        let schema = tool.parameters_schema();
1187        assert!(schema["properties"]["disk_usage"].is_object());
1188        assert!(schema["properties"]["layout"].is_object());
1189    }
1190
1191    #[tokio::test]
1192    async fn test_mise_tool_schema() {
1193        let tmp = TempDir::new().unwrap();
1194        let tool = MiseTool::new(tmp.path().to_path_buf());
1195        assert_eq!(tool.name(), "mise");
1196        let schema = tool.parameters_schema();
1197        assert!(schema["properties"]["action"].is_object());
1198        assert!(schema["properties"]["task"].is_object());
1199    }
1200}
1201