Skip to main content

pawan/tools/
native.rs

1//! Native CLI tool wrappers — rg, fd, sd, ag
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    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
153        use thulp_core::{Parameter, ParameterType};
154        thulp_core::ToolDefinition::builder(self.name())
155            .description(self.description())
156            .parameter(
157                Parameter::builder("pattern")
158                    .param_type(ParameterType::String)
159                    .required(true)
160                    .description("Regex pattern to search for")
161                    .build(),
162            )
163            .parameter(
164                Parameter::builder("path")
165                    .param_type(ParameterType::String)
166                    .required(false)
167                    .description("Path to search in (default: workspace root)")
168                    .build(),
169            )
170            .parameter(
171                Parameter::builder("type_filter")
172                    .param_type(ParameterType::String)
173                    .required(false)
174                    .description("File type filter: rust, py, js, go, ts, c, cpp, java, toml, md")
175                    .build(),
176            )
177            .parameter(
178                Parameter::builder("max_count")
179                    .param_type(ParameterType::Integer)
180                    .required(false)
181                    .description("Max matches per file (default: 20)")
182                    .build(),
183            )
184            .parameter(
185                Parameter::builder("context")
186                    .param_type(ParameterType::Integer)
187                    .required(false)
188                    .description("Lines of context around each match (default: 0)")
189                    .build(),
190            )
191            .parameter(
192                Parameter::builder("fixed_strings")
193                    .param_type(ParameterType::Boolean)
194                    .required(false)
195                    .description("Treat pattern as literal string, not regex")
196                    .build(),
197            )
198            .parameter(
199                Parameter::builder("case_insensitive")
200                    .param_type(ParameterType::Boolean)
201                    .required(false)
202                    .description("Case insensitive search (default: false)")
203                    .build(),
204            )
205            .parameter(
206                Parameter::builder("invert")
207                    .param_type(ParameterType::Boolean)
208                    .required(false)
209                    .description("Invert match: show lines that do NOT match (default: false)")
210                    .build(),
211            )
212            .parameter(
213                Parameter::builder("hidden")
214                    .param_type(ParameterType::Boolean)
215                    .required(false)
216                    .description("Search hidden files and directories (default: false)")
217                    .build(),
218            )
219            .parameter(
220                Parameter::builder("max_depth")
221                    .param_type(ParameterType::Integer)
222                    .required(false)
223                    .description("Max directory depth to search (default: unlimited)")
224                    .build(),
225            )
226            .build()
227    }
228
229    async fn execute(&self, args: Value) -> crate::Result<Value> {
230        ensure_binary("rg", &self.workspace_root).await?;
231        let pattern = args["pattern"].as_str()
232            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
233        let search_path = args["path"].as_str().unwrap_or(".");
234        let max_count = args["max_count"].as_u64().unwrap_or(20);
235        let context = args["context"].as_u64().unwrap_or(0);
236
237        let max_count_str = max_count.to_string();
238        let ctx_str = context.to_string();
239        let mut cmd_args = vec![
240            "--line-number", "--no-heading", "--color", "never",
241            "--max-count", &max_count_str,
242        ];
243        if context > 0 {
244            cmd_args.extend_from_slice(&["--context", &ctx_str]);
245        }
246        if let Some(t) = args["type_filter"].as_str() {
247            cmd_args.extend_from_slice(&["--type", t]);
248        }
249        if args["fixed_strings"].as_bool().unwrap_or(false) {
250            cmd_args.push("--fixed-strings");
251        }
252        if args["case_insensitive"].as_bool().unwrap_or(false) {
253            cmd_args.push("-i");
254        }
255        if args["invert"].as_bool().unwrap_or(false) {
256            cmd_args.push("--invert-match");
257        }
258        if args["hidden"].as_bool().unwrap_or(false) {
259            cmd_args.push("--hidden");
260        }
261        let depth_str = args["max_depth"].as_u64().map(|d| d.to_string());
262        if let Some(ref ds) = depth_str {
263            cmd_args.push("--max-depth");
264            cmd_args.push(ds);
265        }
266        cmd_args.push(pattern);
267        cmd_args.push(search_path);
268
269        let cwd = if std::path::Path::new(search_path).is_absolute() {
270            std::path::PathBuf::from("/")
271        } else {
272            self.workspace_root.clone()
273        };
274
275        let (stdout, stderr, success) = run_cmd("rg", &cmd_args, &cwd).await
276            .map_err(crate::PawanError::Tool)?;
277
278        let match_count = stdout.lines().filter(|l| !l.is_empty()).count();
279
280        Ok(json!({
281            "matches": stdout.lines().take(100).collect::<Vec<_>>().join("\n"),
282            "match_count": match_count,
283            "success": success || match_count > 0,
284            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
285        }))
286    }
287}
288
289// ─── fd (fast find) ─────────────────────────────────────────────────────────
290
291/// Tool for fast file search using fd
292///
293/// This tool provides fast file and directory search using the fd command line
294/// tool. It's an alternative to find that's faster and more user-friendly.
295///
296/// # Fields
297/// - `workspace_root`: The root directory of the workspace
298pub struct FdTool {
299    workspace_root: PathBuf,
300}
301
302impl FdTool {
303    pub fn new(workspace_root: PathBuf) -> Self {
304        Self { workspace_root }
305    }
306}
307
308#[async_trait]
309impl Tool for FdTool {
310    fn name(&self) -> &str { "fd" }
311
312    fn description(&self) -> &str {
313        "fd — fast file finder. Finds files and directories by name pattern. \
314         Much faster than bash find. Use for locating files, exploring project structure."
315    }
316
317    fn parameters_schema(&self) -> Value {
318        json!({
319            "type": "object",
320            "properties": {
321                "pattern": { "type": "string", "description": "Search pattern (regex by default)" },
322                "path": { "type": "string", "description": "Directory to search in (default: workspace root)" },
323                "extension": { "type": "string", "description": "Filter by extension: rs, py, js, toml, md" },
324                "type_filter": { "type": "string", "description": "f=file, d=directory, l=symlink" },
325                "max_depth": { "type": "integer", "description": "Max directory depth" },
326                "max_results": { "type": "integer", "description": "Max results to return (default: 50, prevents context flooding)" },
327                "hidden": { "type": "boolean", "description": "Include hidden files (default: false)" }
328            },
329            "required": ["pattern"]
330        })
331    }
332
333    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
334        use thulp_core::{Parameter, ParameterType};
335        thulp_core::ToolDefinition::builder(self.name())
336            .description(self.description())
337            .parameter(
338                Parameter::builder("pattern")
339                    .param_type(ParameterType::String)
340                    .required(true)
341                    .description("Search pattern (regex by default)")
342                    .build(),
343            )
344            .parameter(
345                Parameter::builder("path")
346                    .param_type(ParameterType::String)
347                    .required(false)
348                    .description("Directory to search in (default: workspace root)")
349                    .build(),
350            )
351            .parameter(
352                Parameter::builder("extension")
353                    .param_type(ParameterType::String)
354                    .required(false)
355                    .description("Filter by extension: rs, py, js, toml, md")
356                    .build(),
357            )
358            .parameter(
359                Parameter::builder("type_filter")
360                    .param_type(ParameterType::String)
361                    .required(false)
362                    .description("f=file, d=directory, l=symlink")
363                    .build(),
364            )
365            .parameter(
366                Parameter::builder("max_depth")
367                    .param_type(ParameterType::Integer)
368                    .required(false)
369                    .description("Max directory depth")
370                    .build(),
371            )
372            .parameter(
373                Parameter::builder("max_results")
374                    .param_type(ParameterType::Integer)
375                    .required(false)
376                    .description("Max results to return (default: 50, prevents context flooding)")
377                    .build(),
378            )
379            .parameter(
380                Parameter::builder("hidden")
381                    .param_type(ParameterType::Boolean)
382                    .required(false)
383                    .description("Include hidden files (default: false)")
384                    .build(),
385            )
386            .build()
387    }
388
389    async fn execute(&self, args: Value) -> crate::Result<Value> {
390        ensure_binary("fd", &self.workspace_root).await?;
391        let pattern = args["pattern"].as_str()
392            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
393
394        let mut cmd_args: Vec<String> = vec!["--color".into(), "never".into()];
395        if let Some(ext) = args["extension"].as_str() {
396            cmd_args.push("-e".into()); cmd_args.push(ext.into());
397        }
398        if let Some(t) = args["type_filter"].as_str() {
399            cmd_args.push("-t".into()); cmd_args.push(t.into());
400        }
401        if let Some(d) = args["max_depth"].as_u64() {
402            cmd_args.push("--max-depth".into()); cmd_args.push(d.to_string());
403        }
404        if args["hidden"].as_bool().unwrap_or(false) {
405            cmd_args.push("--hidden".into());
406        }
407        cmd_args.push(pattern.into());
408        if let Some(p) = args["path"].as_str() {
409            cmd_args.push(p.into());
410        }
411
412        let cmd_args_ref: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
413        let (stdout, stderr, _) = run_cmd("fd", &cmd_args_ref, &self.workspace_root).await
414            .map_err(crate::PawanError::Tool)?;
415
416        let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
417        let all_files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
418        let total = all_files.len();
419        let files: Vec<&str> = all_files.into_iter().take(max_results).collect();
420        let truncated = total > max_results;
421
422        Ok(json!({
423            "files": files,
424            "count": files.len(),
425            "total_found": total,
426            "truncated": truncated,
427            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
428        }))
429    }
430}
431
432// ─── sd (fast sed) ──────────────────────────────────────────────────────────
433
434/// Tool for fast text replacement using sd
435///
436/// This tool provides fast text replacement using the sd command line tool.
437/// It's an alternative to sed that's more intuitive and faster.
438///
439/// # Fields
440/// - `workspace_root`: The root directory of the workspace
441pub struct SdTool {
442    workspace_root: PathBuf,
443}
444
445impl SdTool {
446    pub fn new(workspace_root: PathBuf) -> Self {
447        Self { workspace_root }
448    }
449}
450
451#[async_trait]
452impl Tool for SdTool {
453    fn name(&self) -> &str { "sd" }
454
455    fn description(&self) -> &str {
456        "sd — fast find-and-replace across files. Like sed but simpler syntax and faster. \
457         Use for bulk renaming, refactoring imports, changing patterns across entire codebase. \
458         Modifies files in-place."
459    }
460
461    fn parameters_schema(&self) -> Value {
462        json!({
463            "type": "object",
464            "properties": {
465                "find": { "type": "string", "description": "Pattern to find (regex)" },
466                "replace": { "type": "string", "description": "Replacement string" },
467                "path": { "type": "string", "description": "File or directory to process" },
468                "fixed_strings": { "type": "boolean", "description": "Treat find as literal, not regex (default: false)" }
469            },
470            "required": ["find", "replace", "path"]
471        })
472    }
473
474    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
475        use thulp_core::{Parameter, ParameterType};
476        thulp_core::ToolDefinition::builder(self.name())
477            .description(self.description())
478            .parameter(
479                Parameter::builder("find")
480                    .param_type(ParameterType::String)
481                    .required(true)
482                    .description("Pattern to find (regex)")
483                    .build(),
484            )
485            .parameter(
486                Parameter::builder("replace")
487                    .param_type(ParameterType::String)
488                    .required(true)
489                    .description("Replacement string")
490                    .build(),
491            )
492            .parameter(
493                Parameter::builder("path")
494                    .param_type(ParameterType::String)
495                    .required(true)
496                    .description("File or directory to process")
497                    .build(),
498            )
499            .parameter(
500                Parameter::builder("fixed_strings")
501                    .param_type(ParameterType::Boolean)
502                    .required(false)
503                    .description("Treat find as literal, not regex (default: false)")
504                    .build(),
505            )
506            .build()
507    }
508
509    async fn execute(&self, args: Value) -> crate::Result<Value> {
510        ensure_binary("sd", &self.workspace_root).await?;
511        let find = args["find"].as_str()
512            .ok_or_else(|| crate::PawanError::Tool("find required".into()))?;
513        let replace = args["replace"].as_str()
514            .ok_or_else(|| crate::PawanError::Tool("replace required".into()))?;
515        let path = args["path"].as_str()
516            .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
517
518        let mut cmd_args = vec![];
519        if args["fixed_strings"].as_bool().unwrap_or(false) {
520            cmd_args.push("-F");
521        }
522        cmd_args.extend_from_slice(&[find, replace, path]);
523
524        let (stdout, stderr, success) = run_cmd("sd", &cmd_args, &self.workspace_root).await
525            .map_err(crate::PawanError::Tool)?;
526
527        Ok(json!({
528            "success": success,
529            "output": stdout,
530            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
531        }))
532    }
533}
534
535// ─── erdtree (erd) ──────────────────────────────────────────────────────────
536
537pub struct ErdTool {
538    workspace_root: PathBuf,
539}
540
541impl ErdTool {
542    pub fn new(workspace_root: PathBuf) -> Self {
543        Self { workspace_root }
544    }
545}
546
547#[async_trait]
548impl Tool for ErdTool {
549    fn name(&self) -> &str { "tree" }
550
551    fn description(&self) -> &str {
552        "erdtree (erd) — fast filesystem tree with disk usage, file counts, and metadata. \
553         Use to map project structure, find large files/dirs, count lines of code, \
554         audit disk usage, or get a flat file listing. Much faster than find + du."
555    }
556
557    fn parameters_schema(&self) -> Value {
558        json!({
559            "type": "object",
560            "properties": {
561                "path": { "type": "string", "description": "Root directory (default: workspace root)" },
562                "depth": { "type": "integer", "description": "Max traversal depth (default: 3)" },
563                "pattern": { "type": "string", "description": "Filter by glob pattern (e.g. '*.rs', 'Cargo*')" },
564                "sort": {
565                    "type": "string",
566                    "enum": ["name", "size", "type"],
567                    "description": "Sort entries by name, size, or file type (default: name)"
568                },
569                "disk_usage": {
570                    "type": "string",
571                    "enum": ["physical", "logical", "line", "word", "block"],
572                    "description": "Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical."
573                },
574                "layout": {
575                    "type": "string",
576                    "enum": ["regular", "inverted", "flat", "iflat"],
577                    "description": "Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted."
578                },
579                "long": { "type": "boolean", "description": "Show extended metadata: permissions, owner, group, timestamps (default: false)" },
580                "hidden": { "type": "boolean", "description": "Show hidden (dot) files (default: false)" },
581                "dirs_only": { "type": "boolean", "description": "Only show directories, not files (default: false)" },
582                "human": { "type": "boolean", "description": "Human-readable sizes like 4.2M instead of bytes (default: true)" },
583                "icons": { "type": "boolean", "description": "Show file type icons (default: false)" },
584                "no_ignore": { "type": "boolean", "description": "Don't respect .gitignore (default: false)" },
585                "suppress_size": { "type": "boolean", "description": "Hide disk usage column (default: false)" }
586            }
587        })
588    }
589
590    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
591        use thulp_core::{Parameter, ParameterType};
592        thulp_core::ToolDefinition::builder(self.name())
593            .description(self.description())
594            .parameter(
595                Parameter::builder("path")
596                    .param_type(ParameterType::String)
597                    .required(false)
598                    .description("Root directory (default: workspace root)")
599                    .build(),
600            )
601            .parameter(
602                Parameter::builder("depth")
603                    .param_type(ParameterType::Integer)
604                    .required(false)
605                    .description("Max traversal depth (default: 3)")
606                    .build(),
607            )
608            .parameter(
609                Parameter::builder("pattern")
610                    .param_type(ParameterType::String)
611                    .required(false)
612                    .description("Filter by glob pattern (e.g. '*.rs', 'Cargo*')")
613                    .build(),
614            )
615            .parameter(
616                Parameter::builder("sort")
617                    .param_type(ParameterType::String)
618                    .required(false)
619                    .description("Sort entries by name, size, or file type (default: name)")
620                    .build(),
621            )
622            .parameter(
623                Parameter::builder("disk_usage")
624                    .param_type(ParameterType::String)
625                    .required(false)
626                    .description("Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical.")
627                    .build(),
628            )
629            .parameter(
630                Parameter::builder("layout")
631                    .param_type(ParameterType::String)
632                    .required(false)
633                    .description("Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted.")
634                    .build(),
635            )
636            .parameter(
637                Parameter::builder("long")
638                    .param_type(ParameterType::Boolean)
639                    .required(false)
640                    .description("Show extended metadata: permissions, owner, group, timestamps (default: false)")
641                    .build(),
642            )
643            .parameter(
644                Parameter::builder("hidden")
645                    .param_type(ParameterType::Boolean)
646                    .required(false)
647                    .description("Show hidden (dot) files (default: false)")
648                    .build(),
649            )
650            .parameter(
651                Parameter::builder("dirs_only")
652                    .param_type(ParameterType::Boolean)
653                    .required(false)
654                    .description("Only show directories, not files (default: false)")
655                    .build(),
656            )
657            .parameter(
658                Parameter::builder("human")
659                    .param_type(ParameterType::Boolean)
660                    .required(false)
661                    .description("Human-readable sizes like 4.2M instead of bytes (default: true)")
662                    .build(),
663            )
664            .parameter(
665                Parameter::builder("icons")
666                    .param_type(ParameterType::Boolean)
667                    .required(false)
668                    .description("Show file type icons (default: false)")
669                    .build(),
670            )
671            .parameter(
672                Parameter::builder("no_ignore")
673                    .param_type(ParameterType::Boolean)
674                    .required(false)
675                    .description("Don't respect .gitignore (default: false)")
676                    .build(),
677            )
678            .parameter(
679                Parameter::builder("suppress_size")
680                    .param_type(ParameterType::Boolean)
681                    .required(false)
682                    .description("Hide disk usage column (default: false)")
683                    .build(),
684            )
685            .build()
686    }
687
688    async fn execute(&self, args: Value) -> crate::Result<Value> {
689        // Auto-install erd if missing; fall back to fd if mise unavailable
690        if !binary_exists("erd")
691            && (!auto_install("erd", &self.workspace_root).await || !binary_exists("erd")) {
692                let path = args["path"].as_str().unwrap_or(".");
693                let depth = args["depth"].as_u64().unwrap_or(3).to_string();
694                let cmd_args = vec![".", path, "--max-depth", &depth, "--color", "never"];
695                let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
696                    .unwrap_or(("(erd and fd not available)".into(), String::new(), false));
697                return Ok(json!({ "tree": stdout, "tool": "fd-fallback" }));
698            }
699
700        let path = args["path"].as_str().unwrap_or(".");
701        let depth_str = args["depth"].as_u64().unwrap_or(3).to_string();
702
703        let mut cmd_args: Vec<String> = vec![
704            "--level".into(), depth_str,
705            "--no-config".into(),
706            "--color".into(), "none".into(),
707        ];
708
709        // Disk usage mode
710        if let Some(du) = args["disk_usage"].as_str() {
711            cmd_args.extend(["--disk-usage".into(), du.into()]);
712        }
713
714        // Layout
715        let layout = args["layout"].as_str().unwrap_or("inverted");
716        cmd_args.extend(["--layout".into(), layout.into()]);
717
718        // Sort
719        if let Some(sort) = args["sort"].as_str() {
720            cmd_args.extend(["--sort".into(), sort.into()]);
721        }
722
723        // Pattern filter
724        if let Some(p) = args["pattern"].as_str() {
725            cmd_args.extend(["--pattern".into(), p.into()]);
726        }
727
728        // Boolean flags
729        if args["long"].as_bool().unwrap_or(false) { cmd_args.push("--long".into()); }
730        if args["hidden"].as_bool().unwrap_or(false) { cmd_args.push("--hidden".into()); }
731        if args["dirs_only"].as_bool().unwrap_or(false) { cmd_args.push("--dirs-only".into()); }
732        if args["human"].as_bool().unwrap_or(true) { cmd_args.push("--human".into()); }
733        if args["icons"].as_bool().unwrap_or(false) { cmd_args.push("--icons".into()); }
734        if args["no_ignore"].as_bool().unwrap_or(false) { cmd_args.push("--no-ignore".into()); }
735        if args["suppress_size"].as_bool().unwrap_or(false) { cmd_args.push("--suppress-size".into()); }
736
737        cmd_args.push(path.into());
738
739        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
740        let (stdout, stderr, success) = run_cmd("erd", &cmd_refs, &self.workspace_root).await
741            .map_err(crate::PawanError::Tool)?;
742
743        Ok(json!({
744            "success": success,
745            "tree": stdout,
746            "tool": "erd",
747            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
748        }))
749    }
750}
751
752// ─── grep_search (rg wrapper for structured output) ─────────────────────────
753
754pub struct GrepSearchTool {
755    workspace_root: PathBuf,
756}
757
758impl GrepSearchTool {
759    pub fn new(workspace_root: PathBuf) -> Self {
760        Self { workspace_root }
761    }
762}
763
764#[async_trait]
765impl Tool for GrepSearchTool {
766    fn name(&self) -> &str { "grep_search" }
767
768    fn description(&self) -> &str {
769        "Search for a pattern in files using ripgrep. Returns file paths and matching lines. \
770         Prefer this over bash grep — it's faster and respects .gitignore."
771    }
772
773    fn parameters_schema(&self) -> Value {
774        json!({
775            "type": "object",
776            "properties": {
777                "pattern": { "type": "string", "description": "Search pattern (regex)" },
778                "path": { "type": "string", "description": "Path to search (default: workspace)" },
779                "include": { "type": "string", "description": "Glob to include (e.g. '*.rs')" }
780            },
781            "required": ["pattern"]
782        })
783    }
784
785    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
786        use thulp_core::{Parameter, ParameterType};
787        thulp_core::ToolDefinition::builder(self.name())
788            .description(self.description())
789            .parameter(
790                Parameter::builder("pattern")
791                    .param_type(ParameterType::String)
792                    .required(true)
793                    .description("Search pattern (regex)")
794                    .build(),
795            )
796            .parameter(
797                Parameter::builder("path")
798                    .param_type(ParameterType::String)
799                    .required(false)
800                    .description("Path to search (default: workspace)")
801                    .build(),
802            )
803            .parameter(
804                Parameter::builder("include")
805                    .param_type(ParameterType::String)
806                    .required(false)
807                    .description("Glob to include (e.g. '*.rs')")
808                    .build(),
809            )
810            .build()
811    }
812
813    async fn execute(&self, args: Value) -> crate::Result<Value> {
814        let pattern = args["pattern"].as_str()
815            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
816        let path = args["path"].as_str().unwrap_or(".");
817
818        let mut cmd_args = vec!["--line-number", "--no-heading", "--color", "never", "--max-count", "30"];
819        let include;
820        if let Some(glob) = args["include"].as_str() {
821            include = format!("--glob={}", glob);
822            cmd_args.push(&include);
823        }
824        cmd_args.push(pattern);
825        cmd_args.push(path);
826
827        let cwd = if std::path::Path::new(path).is_absolute() {
828            std::path::PathBuf::from("/")
829        } else {
830            self.workspace_root.clone()
831        };
832
833        let (stdout, _, _) = run_cmd("rg", &cmd_args, &cwd).await
834            .map_err(crate::PawanError::Tool)?;
835
836        let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).take(50).collect();
837
838        Ok(json!({
839            "results": lines.join("\n"),
840            "count": lines.len()
841        }))
842    }
843}
844
845// ─── glob_search (fd wrapper) ───────────────────────────────────────────────
846
847pub struct GlobSearchTool {
848    workspace_root: PathBuf,
849}
850
851impl GlobSearchTool {
852    pub fn new(workspace_root: PathBuf) -> Self {
853        Self { workspace_root }
854    }
855}
856
857#[async_trait]
858impl Tool for GlobSearchTool {
859    fn name(&self) -> &str { "glob_search" }
860
861    fn description(&self) -> &str {
862        "Find files by glob pattern. Returns list of matching file paths. \
863         Uses fd under the hood — fast, respects .gitignore."
864    }
865
866    fn parameters_schema(&self) -> Value {
867        json!({
868            "type": "object",
869            "properties": {
870                "pattern": { "type": "string", "description": "Glob pattern (e.g. '*.rs', 'test_*')" },
871                "path": { "type": "string", "description": "Directory to search (default: workspace)" }
872            },
873            "required": ["pattern"]
874        })
875    }
876
877    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
878        use thulp_core::{Parameter, ParameterType};
879        thulp_core::ToolDefinition::builder(self.name())
880            .description(self.description())
881            .parameter(
882                Parameter::builder("pattern")
883                    .param_type(ParameterType::String)
884                    .required(true)
885                    .description("Glob pattern (e.g. '*.rs', 'test_*')")
886                    .build(),
887            )
888            .parameter(
889                Parameter::builder("path")
890                    .param_type(ParameterType::String)
891                    .required(false)
892                    .description("Directory to search (default: workspace)")
893                    .build(),
894            )
895            .build()
896    }
897
898    async fn execute(&self, args: Value) -> crate::Result<Value> {
899        let pattern = args["pattern"].as_str()
900            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
901        let path = args["path"].as_str().unwrap_or(".");
902
903        let cmd_args = vec!["--glob", pattern, "--color", "never", path];
904        let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
905            .map_err(crate::PawanError::Tool)?;
906
907        let files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
908
909        Ok(json!({
910            "files": files,
911            "count": files.len()
912        }))
913    }
914}
915
916// ─── mise (universal tool installer) ────────────────────────────────────────
917
918pub struct MiseTool {
919    workspace_root: PathBuf,
920}
921
922impl MiseTool {
923    pub fn new(workspace_root: PathBuf) -> Self {
924        Self { workspace_root }
925    }
926}
927
928#[async_trait]
929impl Tool for MiseTool {
930    fn name(&self) -> &str { "mise" }
931
932    fn description(&self) -> &str {
933        "mise — polyglot tool manager, environment manager, and task runner. Replaces asdf, nvm, \
934         pyenv, direnv, make, and npm scripts. Three powers: (1) install/manage any dev tool or \
935         language runtime, (2) manage per-project env vars, (3) run/watch project tasks. \
936         Pawan should use this to self-install any missing CLI tool (erd, ast-grep, fd, rg, etc)."
937    }
938
939    fn parameters_schema(&self) -> Value {
940        json!({
941            "type": "object",
942            "properties": {
943                "action": {
944                    "type": "string",
945                    "enum": [
946                        "install", "uninstall", "upgrade", "list", "use", "search",
947                        "exec", "run", "tasks", "env", "outdated", "prune",
948                        "doctor", "self-update", "trust", "watch"
949                    ],
950                    "description": "Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
951                                    Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
952                                    Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
953                                    Maintenance: doctor, self-update, trust, prune."
954                },
955                "tool": {
956                    "type": "string",
957                    "description": "Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
958                                    'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'"
959                },
960                "task": {
961                    "type": "string",
962                    "description": "Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)"
963                },
964                "args": {
965                    "type": "string",
966                    "description": "Additional arguments (space-separated). For exec: command to run. For run: task args."
967                },
968                "global": {
969                    "type": "boolean",
970                    "description": "Apply globally (--global flag) instead of project-local. Default: false."
971                }
972            },
973            "required": ["action"]
974        })
975    }
976
977    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
978        use thulp_core::{Parameter, ParameterType};
979        thulp_core::ToolDefinition::builder(self.name())
980            .description(self.description())
981            .parameter(
982                Parameter::builder("action")
983                    .param_type(ParameterType::String)
984                    .required(true)
985                    .description("Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
986                                  Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
987                                  Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
988                                  Maintenance: doctor, self-update, trust, prune.")
989                    .build(),
990            )
991            .parameter(
992                Parameter::builder("tool")
993                    .param_type(ParameterType::String)
994                    .required(false)
995                    .description("Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
996                                  'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'")
997                    .build(),
998            )
999            .parameter(
1000                Parameter::builder("task")
1001                    .param_type(ParameterType::String)
1002                    .required(false)
1003                    .description("Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)")
1004                    .build(),
1005            )
1006            .parameter(
1007                Parameter::builder("args")
1008                    .param_type(ParameterType::String)
1009                    .required(false)
1010                    .description("Additional arguments (space-separated). For exec: command to run. For run: task args.")
1011                    .build(),
1012            )
1013            .parameter(
1014                Parameter::builder("global")
1015                    .param_type(ParameterType::Boolean)
1016                    .required(false)
1017                    .description("Apply globally (--global flag) instead of project-local. Default: false.")
1018                    .build(),
1019            )
1020            .build()
1021    }
1022
1023    async fn execute(&self, args: Value) -> crate::Result<Value> {
1024        let mise_bin = if binary_exists("mise") {
1025            "mise".to_string()
1026        } else {
1027            let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
1028            let local = format!("{}/.local/bin/mise", home);
1029            if std::path::Path::new(&local).exists() { local } else {
1030                return Err(crate::PawanError::Tool(
1031                    "mise not found. Install: curl https://mise.run | sh".into()
1032                ));
1033            }
1034        };
1035
1036        let action = args["action"].as_str()
1037            .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
1038        let global = args["global"].as_bool().unwrap_or(false);
1039
1040        let cmd_args: Vec<String> = match action {
1041            "install" => {
1042                let tool = args["tool"].as_str()
1043                    .ok_or_else(|| crate::PawanError::Tool("tool required for install".into()))?;
1044                vec!["install".into(), tool.into(), "-y".into()]
1045            }
1046            "uninstall" => {
1047                let tool = args["tool"].as_str()
1048                    .ok_or_else(|| crate::PawanError::Tool("tool required for uninstall".into()))?;
1049                vec!["uninstall".into(), tool.into()]
1050            }
1051            "upgrade" => {
1052                let mut v = vec!["upgrade".into()];
1053                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
1054                v
1055            }
1056            "list" => vec!["ls".into()],
1057            "search" => {
1058                let tool = args["tool"].as_str()
1059                    .ok_or_else(|| crate::PawanError::Tool("tool required for search".into()))?;
1060                vec!["registry".into(), tool.into()]
1061            }
1062            "use" => {
1063                let tool = args["tool"].as_str()
1064                    .ok_or_else(|| crate::PawanError::Tool("tool required for use".into()))?;
1065                let mut v = vec!["use".into()];
1066                if global { v.push("--global".into()); }
1067                v.push(tool.into());
1068                v
1069            }
1070            "outdated" => {
1071                let mut v = vec!["outdated".into()];
1072                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
1073                v
1074            }
1075            "prune" => {
1076                let mut v = vec!["prune".into(), "-y".into()];
1077                if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
1078                v
1079            }
1080            "exec" => {
1081                let tool = args["tool"].as_str()
1082                    .ok_or_else(|| crate::PawanError::Tool("tool required for exec".into()))?;
1083                let extra = args["args"].as_str().unwrap_or("");
1084                let mut v = vec!["exec".into(), tool.into(), "--".into()];
1085                if !extra.is_empty() {
1086                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
1087                }
1088                v
1089            }
1090            "run" => {
1091                let task = args["task"].as_str()
1092                    .ok_or_else(|| crate::PawanError::Tool("task required for run".into()))?;
1093                let mut v = vec!["run".into(), task.into()];
1094                if let Some(extra) = args["args"].as_str() {
1095                    v.push("--".into());
1096                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
1097                }
1098                v
1099            }
1100            "watch" => {
1101                let task = args["task"].as_str()
1102                    .ok_or_else(|| crate::PawanError::Tool("task required for watch".into()))?;
1103                let mut v = vec!["watch".into(), task.into()];
1104                if let Some(extra) = args["args"].as_str() {
1105                    v.push("--".into());
1106                    v.extend(extra.split_whitespace().map(|s| s.to_string()));
1107                }
1108                v
1109            }
1110            "tasks" => vec!["tasks".into(), "ls".into()],
1111            "env" => vec!["env".into()],
1112            "doctor" => vec!["doctor".into()],
1113            "self-update" => vec!["self-update".into(), "-y".into()],
1114            "trust" => {
1115                let mut v = vec!["trust".into()];
1116                if let Some(extra) = args["args"].as_str() { v.push(extra.into()); }
1117                v
1118            }
1119            _ => return Err(crate::PawanError::Tool(
1120                format!("Unknown action: {action}. See tool description for available actions.")
1121            )),
1122        };
1123
1124        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
1125        let (stdout, stderr, success) = run_cmd(&mise_bin, &cmd_refs, &self.workspace_root).await
1126            .map_err(crate::PawanError::Tool)?;
1127
1128        Ok(json!({
1129            "success": success,
1130            "action": action,
1131            "output": stdout,
1132            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1133        }))
1134    }
1135}
1136
1137// ─── zoxide (smart cd) ─────────────────────────────────────────────────────
1138
1139pub struct ZoxideTool {
1140    workspace_root: PathBuf,
1141}
1142
1143impl ZoxideTool {
1144    pub fn new(workspace_root: PathBuf) -> Self {
1145        Self { workspace_root }
1146    }
1147}
1148
1149#[async_trait]
1150impl Tool for ZoxideTool {
1151    fn name(&self) -> &str { "z" }
1152
1153    fn description(&self) -> &str {
1154        "zoxide — smart directory jumper. Learns from your cd history. \
1155         Use 'query' to find a directory by fuzzy match (e.g. 'myproject' finds ~/projects/myproject). \
1156         Use 'add' to teach it a new path. Use 'list' to see known paths."
1157    }
1158
1159    fn parameters_schema(&self) -> Value {
1160        json!({
1161            "type": "object",
1162            "properties": {
1163                "action": { "type": "string", "description": "query, add, or list" },
1164                "path": { "type": "string", "description": "Path or search term" }
1165            },
1166            "required": ["action"]
1167        })
1168    }
1169
1170    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
1171        use thulp_core::{Parameter, ParameterType};
1172        thulp_core::ToolDefinition::builder(self.name())
1173            .description(self.description())
1174            .parameter(
1175                Parameter::builder("action")
1176                    .param_type(ParameterType::String)
1177                    .required(true)
1178                    .description("query, add, or list")
1179                    .build(),
1180            )
1181            .parameter(
1182                Parameter::builder("path")
1183                    .param_type(ParameterType::String)
1184                    .required(false)
1185                    .description("Path or search term")
1186                    .build(),
1187            )
1188            .build()
1189    }
1190
1191    async fn execute(&self, args: Value) -> crate::Result<Value> {
1192        let action = args["action"].as_str()
1193            .ok_or_else(|| crate::PawanError::Tool("action required (query/add/list)".into()))?;
1194
1195        let cmd_args: Vec<String> = match action {
1196            "query" => {
1197                let path = args["path"].as_str()
1198                    .ok_or_else(|| crate::PawanError::Tool("path/search term required for query".into()))?;
1199                vec!["query".into(), path.into()]
1200            }
1201            "add" => {
1202                let path = args["path"].as_str()
1203                    .ok_or_else(|| crate::PawanError::Tool("path required for add".into()))?;
1204                vec!["add".into(), path.into()]
1205            }
1206            "list" => vec!["query".into(), "--list".into()],
1207            _ => return Err(crate::PawanError::Tool(format!("Unknown action: {}. Use query/add/list", action))),
1208        };
1209
1210        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
1211        let (stdout, stderr, success) = run_cmd("zoxide", &cmd_refs, &self.workspace_root).await
1212            .map_err(crate::PawanError::Tool)?;
1213
1214        Ok(json!({
1215            "success": success,
1216            "result": stdout.trim(),
1217            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1218        }))
1219    }
1220}
1221
1222// ─── ast-grep ────────────────────────────────────────────────────────────────
1223
1224pub struct AstGrepTool {
1225    workspace_root: PathBuf,
1226}
1227
1228impl AstGrepTool {
1229    pub fn new(workspace_root: PathBuf) -> Self {
1230        Self { workspace_root }
1231    }
1232}
1233
1234#[async_trait]
1235impl Tool for AstGrepTool {
1236    fn name(&self) -> &str { "ast_grep" }
1237
1238    fn description(&self) -> &str {
1239        "ast-grep — structural code search and rewrite using AST patterns. \
1240         Unlike regex, this matches code by syntax tree structure. Use $NAME for \
1241         single-node wildcards, $$$ARGS for variadic (multiple nodes). \
1242         Actions: 'search' finds matches, 'rewrite' transforms them in-place. \
1243         Examples: pattern='fn $NAME($$$ARGS)' finds all functions. \
1244         pattern='$EXPR.unwrap()' rewrite='$EXPR?' replaces unwrap with ?. \
1245         Supports: rust, python, javascript, typescript, go, c, cpp, java."
1246    }
1247
1248    fn parameters_schema(&self) -> Value {
1249        json!({
1250            "type": "object",
1251            "properties": {
1252                "action": {
1253                    "type": "string",
1254                    "enum": ["search", "rewrite"],
1255                    "description": "search: find matching code. rewrite: transform matching code in-place."
1256                },
1257                "pattern": {
1258                    "type": "string",
1259                    "description": "AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'"
1260                },
1261                "rewrite": {
1262                    "type": "string",
1263                    "description": "Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'"
1264                },
1265                "path": {
1266                    "type": "string",
1267                    "description": "File or directory to search/rewrite"
1268                },
1269                "lang": {
1270                    "type": "string",
1271                    "description": "Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)"
1272                }
1273            },
1274            "required": ["action", "pattern", "path"]
1275        })
1276    }
1277
1278    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
1279        use thulp_core::{Parameter, ParameterType};
1280        thulp_core::ToolDefinition::builder(self.name())
1281            .description(self.description())
1282            .parameter(
1283                Parameter::builder("action")
1284                    .param_type(ParameterType::String)
1285                    .required(true)
1286                    .description("search: find matching code. rewrite: transform matching code in-place.")
1287                    .build(),
1288            )
1289            .parameter(
1290                Parameter::builder("pattern")
1291                    .param_type(ParameterType::String)
1292                    .required(true)
1293                    .description("AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'")
1294                    .build(),
1295            )
1296            .parameter(
1297                Parameter::builder("rewrite")
1298                    .param_type(ParameterType::String)
1299                    .required(false)
1300                    .description("Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'")
1301                    .build(),
1302            )
1303            .parameter(
1304                Parameter::builder("path")
1305                    .param_type(ParameterType::String)
1306                    .required(true)
1307                    .description("File or directory to search/rewrite")
1308                    .build(),
1309            )
1310            .parameter(
1311                Parameter::builder("lang")
1312                    .param_type(ParameterType::String)
1313                    .required(false)
1314                    .description("Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)")
1315                    .build(),
1316            )
1317            .build()
1318    }
1319
1320    async fn execute(&self, args: Value) -> crate::Result<Value> {
1321        ensure_binary("ast-grep", &self.workspace_root).await?;
1322
1323        let action = args["action"].as_str()
1324            .ok_or_else(|| crate::PawanError::Tool("action required (search or rewrite)".into()))?;
1325        let pattern = args["pattern"].as_str()
1326            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
1327        let path = args["path"].as_str()
1328            .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
1329
1330        let mut cmd_args: Vec<String> = vec!["run".into()];
1331
1332        // Language
1333        if let Some(lang) = args["lang"].as_str() {
1334            cmd_args.push("--lang".into());
1335            cmd_args.push(lang.into());
1336        }
1337
1338        // Pattern
1339        cmd_args.push("--pattern".into());
1340        cmd_args.push(pattern.into());
1341
1342        match action {
1343            "search" => {
1344                // Search mode: just find and report matches
1345                cmd_args.push(path.into());
1346            }
1347            "rewrite" => {
1348                let rewrite = args["rewrite"].as_str()
1349                    .ok_or_else(|| crate::PawanError::Tool("rewrite pattern required for action=rewrite".into()))?;
1350                cmd_args.push("--rewrite".into());
1351                cmd_args.push(rewrite.into());
1352                cmd_args.push("--update-all".into());
1353                cmd_args.push(path.into());
1354            }
1355            _ => {
1356                return Err(crate::PawanError::Tool(
1357                    format!("Unknown action: {}. Use 'search' or 'rewrite'", action),
1358                ));
1359            }
1360        }
1361
1362        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
1363        let (stdout, stderr, success) = run_cmd("ast-grep", &cmd_refs, &self.workspace_root).await
1364            .map_err(crate::PawanError::Tool)?;
1365
1366        // Count matches from output
1367        let match_count = stdout.lines()
1368            .filter(|l| l.starts_with('/') || l.contains("│"))
1369            .count();
1370
1371        Ok(json!({
1372            "success": success,
1373            "action": action,
1374            "matches": match_count,
1375            "output": stdout,
1376            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1377        }))
1378    }
1379}
1380
1381// ─── LSP (rust-analyzer powered code intelligence) ──────────────────────────
1382
1383pub struct LspTool {
1384    workspace_root: PathBuf,
1385}
1386
1387impl LspTool {
1388    pub fn new(workspace_root: PathBuf) -> Self {
1389        Self { workspace_root }
1390    }
1391}
1392
1393#[async_trait]
1394impl Tool for LspTool {
1395    fn name(&self) -> &str { "lsp" }
1396
1397    fn description(&self) -> &str {
1398        "LSP code intelligence via rust-analyzer. Provides type-aware code understanding \
1399         that grep/ast-grep can't: diagnostics without cargo check, structural search with \
1400         type info, symbol extraction, and analysis stats. Actions: diagnostics (find errors), \
1401         search (structural pattern search), ssr (structural search+replace with types), \
1402         symbols (parse file symbols), analyze (project-wide type stats)."
1403    }
1404
1405    fn parameters_schema(&self) -> Value {
1406        json!({
1407            "type": "object",
1408            "properties": {
1409                "action": {
1410                    "type": "string",
1411                    "enum": ["diagnostics", "search", "ssr", "symbols", "analyze"],
1412                    "description": "diagnostics: find errors/warnings in project. \
1413                                    search: structural pattern search (e.g. '$a.foo($b)'). \
1414                                    ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
1415                                    symbols: parse file and list symbols. \
1416                                    analyze: project-wide type analysis stats."
1417                },
1418                "pattern": {
1419                    "type": "string",
1420                    "description": "For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'"
1421                },
1422                "path": {
1423                    "type": "string",
1424                    "description": "Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols."
1425                },
1426                "severity": {
1427                    "type": "string",
1428                    "enum": ["error", "warning", "info", "hint"],
1429                    "description": "Minimum severity for diagnostics (default: warning)"
1430                }
1431            },
1432            "required": ["action"]
1433        })
1434    }
1435
1436    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
1437        use thulp_core::{Parameter, ParameterType};
1438        thulp_core::ToolDefinition::builder(self.name())
1439            .description(self.description())
1440            .parameter(
1441                Parameter::builder("action")
1442                    .param_type(ParameterType::String)
1443                    .required(true)
1444                    .description("diagnostics: find errors/warnings in project. \
1445                                  search: structural pattern search (e.g. '$a.foo($b)'). \
1446                                  ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
1447                                  symbols: parse file and list symbols. \
1448                                  analyze: project-wide type analysis stats.")
1449                    .build(),
1450            )
1451            .parameter(
1452                Parameter::builder("pattern")
1453                    .param_type(ParameterType::String)
1454                    .required(false)
1455                    .description("For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'")
1456                    .build(),
1457            )
1458            .parameter(
1459                Parameter::builder("path")
1460                    .param_type(ParameterType::String)
1461                    .required(false)
1462                    .description("Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols.")
1463                    .build(),
1464            )
1465            .parameter(
1466                Parameter::builder("severity")
1467                    .param_type(ParameterType::String)
1468                    .required(false)
1469                    .description("Minimum severity for diagnostics (default: warning)")
1470                    .build(),
1471            )
1472            .build()
1473    }
1474
1475    async fn execute(&self, args: Value) -> crate::Result<Value> {
1476        ensure_binary("rust-analyzer", &self.workspace_root).await?;
1477
1478        let action = args["action"].as_str()
1479            .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
1480
1481        let timeout_dur = std::time::Duration::from_secs(60);
1482
1483        match action {
1484            "diagnostics" => {
1485                let path = args["path"].as_str()
1486                    .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1487                let mut cmd_args = vec!["diagnostics", path];
1488                if let Some(sev) = args["severity"].as_str() {
1489                    cmd_args.extend(["--severity", sev]);
1490                }
1491                let result = tokio::time::timeout(timeout_dur,
1492                    run_cmd("rust-analyzer", &cmd_args, &self.workspace_root)
1493                ).await;
1494                match result {
1495                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1496                        "success": success,
1497                        "diagnostics": stdout,
1498                        "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1499                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1500                    })),
1501                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1502                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer diagnostics timed out (60s)".into())),
1503                }
1504            }
1505            "search" => {
1506                let pattern = args["pattern"].as_str()
1507                    .ok_or_else(|| crate::PawanError::Tool("pattern required for search".into()))?;
1508                let result = tokio::time::timeout(timeout_dur,
1509                    run_cmd("rust-analyzer", &["search", pattern], &self.workspace_root)
1510                ).await;
1511                match result {
1512                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1513                        "success": success,
1514                        "matches": stdout,
1515                        "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1516                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1517                    })),
1518                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1519                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer search timed out (60s)".into())),
1520                }
1521            }
1522            "ssr" => {
1523                let pattern = args["pattern"].as_str()
1524                    .ok_or_else(|| crate::PawanError::Tool(
1525                        "pattern required for ssr (format: '$a.unwrap() ==>> $a?')".into()
1526                    ))?;
1527                let result = tokio::time::timeout(timeout_dur,
1528                    run_cmd("rust-analyzer", &["ssr", pattern], &self.workspace_root)
1529                ).await;
1530                match result {
1531                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1532                        "success": success,
1533                        "output": stdout,
1534                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1535                    })),
1536                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1537                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer ssr timed out (60s)".into())),
1538                }
1539            }
1540            "symbols" => {
1541                // Parse stdin and list symbols — pipe file content to rust-analyzer symbols
1542                let path = args["path"].as_str()
1543                    .ok_or_else(|| crate::PawanError::Tool("path required for symbols".into()))?;
1544                let full_path = if std::path::Path::new(path).is_absolute() {
1545                    PathBuf::from(path)
1546                } else {
1547                    self.workspace_root.join(path)
1548                };
1549                let content = tokio::fs::read_to_string(&full_path).await
1550                    .map_err(|e| crate::PawanError::Tool(format!("Failed to read {}: {}", path, e)))?;
1551
1552                let mut child = tokio::process::Command::new("rust-analyzer")
1553                    .arg("symbols")
1554                    .stdin(Stdio::piped())
1555                    .stdout(Stdio::piped())
1556                    .stderr(Stdio::piped())
1557                    .spawn()
1558                    .map_err(|e| crate::PawanError::Tool(format!("Failed to spawn rust-analyzer: {}", e)))?;
1559
1560                // Write file content to stdin
1561                if let Some(mut stdin) = child.stdin.take() {
1562                    use tokio::io::AsyncWriteExt;
1563                    let _ = stdin.write_all(content.as_bytes()).await;
1564                    drop(stdin);
1565                }
1566
1567                let output = child.wait_with_output().await
1568                    .map_err(|e| crate::PawanError::Tool(format!("rust-analyzer symbols failed: {}", e)))?;
1569
1570                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1571                Ok(json!({
1572                    "success": output.status.success(),
1573                    "symbols": stdout,
1574                    "count": stdout.lines().filter(|l| !l.is_empty()).count()
1575                }))
1576            }
1577            "analyze" => {
1578                let path = args["path"].as_str()
1579                    .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1580                let result = tokio::time::timeout(timeout_dur,
1581                    run_cmd("rust-analyzer", &["analysis-stats", "--skip-inference", path], &self.workspace_root)
1582                ).await;
1583                match result {
1584                    Ok(Ok((stdout, stderr, success))) => Ok(json!({
1585                        "success": success,
1586                        "stats": stdout,
1587                        "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1588                    })),
1589                    Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1590                    Err(_) => Err(crate::PawanError::Tool("rust-analyzer analysis-stats timed out (60s)".into())),
1591                }
1592            }
1593            _ => Err(crate::PawanError::Tool(
1594                format!("Unknown action: {action}. Use diagnostics/search/ssr/symbols/analyze")
1595            )),
1596        }
1597    }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603    use tempfile::TempDir;
1604
1605    #[test]
1606    fn test_binary_exists_cargo() {
1607        assert!(binary_exists("cargo"));
1608    }
1609
1610    #[test]
1611    fn test_binary_exists_nonexistent() {
1612        assert!(!binary_exists("nonexistent_binary_xyz_123"));
1613    }
1614
1615    #[test]
1616    fn test_mise_package_name_mapping() {
1617        assert_eq!(mise_package_name("rg"), "ripgrep");
1618        assert_eq!(mise_package_name("fd"), "fd");
1619        assert_eq!(mise_package_name("sg"), "ast-grep");
1620        assert_eq!(mise_package_name("erd"), "erdtree");
1621        assert_eq!(mise_package_name("unknown"), "unknown");
1622    }
1623
1624    #[tokio::test]
1625    async fn test_ast_grep_tool_schema() {
1626        let tmp = TempDir::new().unwrap();
1627        let tool = AstGrepTool::new(tmp.path().to_path_buf());
1628        assert_eq!(tool.name(), "ast_grep");
1629        let schema = tool.parameters_schema();
1630        assert!(schema["properties"]["action"].is_object());
1631        assert!(schema["properties"]["pattern"].is_object());
1632    }
1633
1634    #[tokio::test]
1635    async fn test_lsp_tool_schema() {
1636        let tmp = TempDir::new().unwrap();
1637        let tool = LspTool::new(tmp.path().to_path_buf());
1638        assert_eq!(tool.name(), "lsp");
1639        let schema = tool.parameters_schema();
1640        assert!(schema["properties"]["action"].is_object());
1641    }
1642
1643    #[tokio::test]
1644    async fn test_erd_tool_schema() {
1645        let tmp = TempDir::new().unwrap();
1646        let tool = ErdTool::new(tmp.path().to_path_buf());
1647        assert_eq!(tool.name(), "tree");
1648        let schema = tool.parameters_schema();
1649        assert!(schema["properties"]["disk_usage"].is_object());
1650        assert!(schema["properties"]["layout"].is_object());
1651    }
1652
1653    #[tokio::test]
1654    async fn test_mise_tool_schema() {
1655        let tmp = TempDir::new().unwrap();
1656        let tool = MiseTool::new(tmp.path().to_path_buf());
1657        assert_eq!(tool.name(), "mise");
1658        let schema = tool.parameters_schema();
1659        assert!(schema["properties"]["action"].is_object());
1660        assert!(schema["properties"]["task"].is_object());
1661    }
1662
1663    // --- Tool name + description coverage ---
1664
1665    #[tokio::test]
1666    async fn test_rg_tool_basics() {
1667        let tmp = TempDir::new().unwrap();
1668        let tool = RipgrepTool::new(tmp.path().into());
1669        assert_eq!(tool.name(), "rg");
1670        assert!(!tool.description().is_empty());
1671        let schema = tool.parameters_schema();
1672        assert!(schema["required"].as_array().unwrap().contains(&json!("pattern")));
1673    }
1674
1675    #[tokio::test]
1676    async fn test_fd_tool_basics() {
1677        let tmp = TempDir::new().unwrap();
1678        let tool = FdTool::new(tmp.path().into());
1679        assert_eq!(tool.name(), "fd");
1680        assert!(!tool.description().is_empty());
1681        let schema = tool.parameters_schema();
1682        assert!(schema["required"].as_array().unwrap().contains(&json!("pattern")));
1683    }
1684
1685    #[tokio::test]
1686    async fn test_sd_tool_basics() {
1687        let tmp = TempDir::new().unwrap();
1688        let tool = SdTool::new(tmp.path().into());
1689        assert_eq!(tool.name(), "sd");
1690        assert!(!tool.description().is_empty());
1691        let schema = tool.parameters_schema();
1692        let required = schema["required"].as_array().unwrap();
1693        assert!(required.contains(&json!("find")));
1694        assert!(required.contains(&json!("replace")));
1695    }
1696
1697    #[tokio::test]
1698    async fn test_zoxide_tool_basics() {
1699        let tmp = TempDir::new().unwrap();
1700        let tool = ZoxideTool::new(tmp.path().into());
1701        assert_eq!(tool.name(), "z");
1702        assert!(!tool.description().is_empty());
1703        let schema = tool.parameters_schema();
1704        assert!(schema["required"].as_array().unwrap().contains(&json!("action")));
1705    }
1706
1707    #[tokio::test]
1708    async fn test_native_glob_tool_basics() {
1709        let tmp = TempDir::new().unwrap();
1710        let tool = GlobSearchTool::new(tmp.path().into());
1711        assert_eq!(tool.name(), "glob_search");
1712        let schema = tool.parameters_schema();
1713        assert!(schema["required"].as_array().unwrap().contains(&json!("pattern")));
1714    }
1715
1716    #[tokio::test]
1717    async fn test_native_grep_tool_basics() {
1718        let tmp = TempDir::new().unwrap();
1719        let tool = GrepSearchTool::new(tmp.path().into());
1720        assert_eq!(tool.name(), "grep_search");
1721        let schema = tool.parameters_schema();
1722        assert!(schema["required"].as_array().unwrap().contains(&json!("pattern")));
1723    }
1724
1725    // --- mise_package_name edge cases ---
1726
1727    #[test]
1728    fn test_mise_package_name_all_aliases() {
1729        assert_eq!(mise_package_name("ast-grep"), "ast-grep");
1730        assert_eq!(mise_package_name("bat"), "bat");
1731        assert_eq!(mise_package_name("delta"), "delta");
1732        assert_eq!(mise_package_name("jq"), "jq");
1733        assert_eq!(mise_package_name("yq"), "yq");
1734    }
1735
1736    // --- ErdTool (tree) fallback behavior ---
1737
1738    #[tokio::test]
1739    async fn test_tree_tool_runs_without_crash() {
1740        let tmp = TempDir::new().unwrap();
1741        std::fs::create_dir(tmp.path().join("sub")).unwrap();
1742        std::fs::write(tmp.path().join("sub/a.rs"), "code").unwrap();
1743        std::fs::write(tmp.path().join("b.txt"), "text").unwrap();
1744
1745        let tool = ErdTool::new(tmp.path().into());
1746        // Should succeed with the built-in fallback (no erd binary needed)
1747        let result = tool.execute(json!({})).await;
1748        assert!(result.is_ok(), "tree tool should work with fallback: {:?}", result.err());
1749        let val = result.unwrap();
1750        assert!(val["tree"].is_string() || val["output"].is_string(),
1751                "Should produce tree output");
1752    }
1753}
1754