Skip to main content

pawan/tools/
native_search.rs

1//! Native CLI search tool wrappers — rg, fd, sd, erd.
2//!
3//! Helper functions and tool structs that wrap CLI binaries for search tasks.
4//! Also includes structured-output wrappers: GrepSearchTool (rg) and GlobSearchTool (fd).
5
6use super::Tool;
7use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10use std::process::Stdio;
11
12// ─── helpers ────────────────────────────────────────────────────────────────
13
14/// Check if a CLI binary is available in PATH.
15pub(crate) fn binary_exists(name: &str) -> bool {
16    which::which(name).is_ok()
17}
18
19/// Map tool binary names to their mise package names for auto-install.
20pub(crate) fn mise_package_name(binary: &str) -> &str {
21    match binary {
22        "erd" => "erdtree",
23        "sg" | "ast-grep" => "ast-grep",
24        "rg" => "ripgrep",
25        "fd" => "fd",
26        "sd" => "sd",
27        "bat" => "bat",
28        "delta" => "delta",
29        "jq" => "jq",
30        "yq" => "yq",
31        other => other,
32    }
33}
34
35/// Try to auto-install a missing tool via mise. Returns true if install succeeded.
36pub(crate) async fn auto_install(binary: &str, cwd: &std::path::Path) -> bool {
37    let mise_bin = if binary_exists("mise") {
38        "mise".to_string()
39    } else {
40        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
41        let local = format!("{}/.local/bin/mise", home);
42        if std::path::Path::new(&local).exists() { local } else { return false; }
43    };
44
45    let pkg = mise_package_name(binary);
46    tracing::info!(binary = binary, package = pkg, "Auto-installing missing tool via mise");
47
48    let result = tokio::process::Command::new(&mise_bin)
49        .args(["install", pkg, "-y"])
50        .current_dir(cwd)
51        .stdout(Stdio::piped())
52        .stderr(Stdio::piped())
53        .output()
54        .await;
55
56    match result {
57        Ok(output) if output.status.success() => {
58            // Also run `mise use --global` to make it available
59            let _ = tokio::process::Command::new(&mise_bin)
60                .args(["use", "--global", pkg])
61                .current_dir(cwd)
62                .output()
63                .await;
64            tracing::info!(binary = binary, "Auto-install succeeded");
65            true
66        }
67        Ok(output) => {
68            let stderr = String::from_utf8_lossy(&output.stderr);
69            tracing::warn!(binary = binary, stderr = %stderr, "Auto-install failed");
70            false
71        }
72        Err(e) => {
73            tracing::warn!(binary = binary, error = %e, "Auto-install failed to run mise");
74            false
75        }
76    }
77}
78
79/// Ensure a binary is available, auto-installing via mise if needed.
80pub(crate) async fn ensure_binary(name: &str, cwd: &std::path::Path) -> Result<(), crate::PawanError> {
81    if binary_exists(name) { return Ok(()); }
82    if auto_install(name, cwd).await && binary_exists(name) { return Ok(()); }
83    Err(crate::PawanError::Tool(format!(
84        "{} not found and auto-install failed. Install manually: mise install {}",
85        name, mise_package_name(name)
86    )))
87}
88
89/// Execute a command and capture stdout, stderr, and success status.
90pub(crate) async fn run_cmd(cmd: &str, args: &[&str], cwd: &std::path::Path) -> Result<(String, String, bool), String> {
91    let output = tokio::process::Command::new(cmd)
92        .args(args)
93        .current_dir(cwd)
94        .stdout(Stdio::piped())
95        .stderr(Stdio::piped())
96        .output()
97        .await
98        .map_err(|e| format!("Failed to run {}: {}", cmd, e))?;
99
100    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
101    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
102    Ok((stdout, stderr, output.status.success()))
103}
104
105// ─── ripgrep (rg) ───────────────────────────────────────────────────────────
106
107/// Tool for fast text search using ripgrep.
108pub struct RipgrepTool {
109    workspace_root: PathBuf,
110}
111
112impl RipgrepTool {
113    pub fn new(workspace_root: PathBuf) -> Self {
114        Self { workspace_root }
115    }
116}
117
118#[async_trait]
119impl Tool for RipgrepTool {
120    fn name(&self) -> &str { "rg" }
121
122    fn description(&self) -> &str {
123        "ripgrep — blazing fast regex search across files. Returns matching lines with file paths \
124         and line numbers. Use for finding code patterns, function definitions, imports, usages. \
125         Much faster than bash grep. Supports --type for language filtering (rust, py, js, go)."
126    }
127
128    fn parameters_schema(&self) -> Value {
129        json!({
130            "type": "object",
131            "properties": {
132                "pattern": { "type": "string", "description": "Regex pattern to search for" },
133                "path": { "type": "string", "description": "Path to search in (default: workspace root)" },
134                "type_filter": { "type": "string", "description": "File type filter: rust, py, js, go, ts, c, cpp, java, toml, md" },
135                "max_count": { "type": "integer", "description": "Max matches per file (default: 20)" },
136                "context": { "type": "integer", "description": "Lines of context around each match (default: 0)" },
137                "fixed_strings": { "type": "boolean", "description": "Treat pattern as literal string, not regex" },
138                "case_insensitive": { "type": "boolean", "description": "Case insensitive search (default: false)" },
139                "invert": { "type": "boolean", "description": "Invert match: show lines that do NOT match (default: false)" },
140                "hidden": { "type": "boolean", "description": "Search hidden files and directories (default: false)" },
141                "max_depth": { "type": "integer", "description": "Max directory depth to search (default: unlimited)" }
142            },
143            "required": ["pattern"]
144        })
145    }
146
147    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
148        use thulp_core::{Parameter, ParameterType};
149        thulp_core::ToolDefinition::builder(self.name())
150            .description(self.description())
151            .parameter(
152                Parameter::builder("pattern")
153                    .param_type(ParameterType::String)
154                    .required(true)
155                    .description("Regex pattern to search for")
156                    .build(),
157            )
158            .parameter(
159                Parameter::builder("path")
160                    .param_type(ParameterType::String)
161                    .required(false)
162                    .description("Path to search in (default: workspace root)")
163                    .build(),
164            )
165            .parameter(
166                Parameter::builder("type_filter")
167                    .param_type(ParameterType::String)
168                    .required(false)
169                    .description("File type filter: rust, py, js, go, ts, c, cpp, java, toml, md")
170                    .build(),
171            )
172            .parameter(
173                Parameter::builder("max_count")
174                    .param_type(ParameterType::Integer)
175                    .required(false)
176                    .description("Max matches per file (default: 20)")
177                    .build(),
178            )
179            .parameter(
180                Parameter::builder("context")
181                    .param_type(ParameterType::Integer)
182                    .required(false)
183                    .description("Lines of context around each match (default: 0)")
184                    .build(),
185            )
186            .parameter(
187                Parameter::builder("fixed_strings")
188                    .param_type(ParameterType::Boolean)
189                    .required(false)
190                    .description("Treat pattern as literal string, not regex")
191                    .build(),
192            )
193            .parameter(
194                Parameter::builder("case_insensitive")
195                    .param_type(ParameterType::Boolean)
196                    .required(false)
197                    .description("Case insensitive search (default: false)")
198                    .build(),
199            )
200            .parameter(
201                Parameter::builder("invert")
202                    .param_type(ParameterType::Boolean)
203                    .required(false)
204                    .description("Invert match: show lines that do NOT match (default: false)")
205                    .build(),
206            )
207            .parameter(
208                Parameter::builder("hidden")
209                    .param_type(ParameterType::Boolean)
210                    .required(false)
211                    .description("Search hidden files and directories (default: false)")
212                    .build(),
213            )
214            .parameter(
215                Parameter::builder("max_depth")
216                    .param_type(ParameterType::Integer)
217                    .required(false)
218                    .description("Max directory depth to search (default: unlimited)")
219                    .build(),
220            )
221            .build()
222    }
223
224    async fn execute(&self, args: Value) -> crate::Result<Value> {
225        ensure_binary("rg", &self.workspace_root).await?;
226        let pattern = args["pattern"].as_str()
227            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
228        let search_path = args["path"].as_str().unwrap_or(".");
229        let max_count = args["max_count"].as_u64().unwrap_or(20);
230        let context = args["context"].as_u64().unwrap_or(0);
231
232        let max_count_str = max_count.to_string();
233        let ctx_str = context.to_string();
234        let mut cmd_args = vec![
235            "--line-number", "--no-heading", "--color", "never",
236            "--max-count", &max_count_str,
237        ];
238        if context > 0 {
239            cmd_args.extend_from_slice(&["--context", &ctx_str]);
240        }
241        if let Some(t) = args["type_filter"].as_str() {
242            cmd_args.extend_from_slice(&["--type", t]);
243        }
244        if args["fixed_strings"].as_bool().unwrap_or(false) {
245            cmd_args.push("--fixed-strings");
246        }
247        if args["case_insensitive"].as_bool().unwrap_or(false) {
248            cmd_args.push("-i");
249        }
250        if args["invert"].as_bool().unwrap_or(false) {
251            cmd_args.push("--invert-match");
252        }
253        if args["hidden"].as_bool().unwrap_or(false) {
254            cmd_args.push("--hidden");
255        }
256        let depth_str = args["max_depth"].as_u64().map(|d| d.to_string());
257        if let Some(ref ds) = depth_str {
258            cmd_args.push("--max-depth");
259            cmd_args.push(ds);
260        }
261        cmd_args.push(pattern);
262        cmd_args.push(search_path);
263
264        let cwd = if std::path::Path::new(search_path).is_absolute() {
265            std::path::PathBuf::from("/")
266        } else {
267            self.workspace_root.clone()
268        };
269
270        let (stdout, stderr, success) = run_cmd("rg", &cmd_args, &cwd).await
271            .map_err(crate::PawanError::Tool)?;
272
273        let match_count = stdout.lines().filter(|l| !l.is_empty()).count();
274
275        Ok(json!({
276            "matches": stdout.lines().take(100).collect::<Vec<_>>().join("\n"),
277            "match_count": match_count,
278            "success": success || match_count > 0,
279            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
280        }))
281    }
282}
283
284// ─── fd (fast find) ─────────────────────────────────────────────────────────
285
286/// Tool for fast file search using fd.
287pub struct FdTool {
288    workspace_root: PathBuf,
289}
290
291impl FdTool {
292    pub fn new(workspace_root: PathBuf) -> Self {
293        Self { workspace_root }
294    }
295}
296
297#[async_trait]
298impl Tool for FdTool {
299    fn name(&self) -> &str { "fd" }
300
301    fn description(&self) -> &str {
302        "fd — fast file finder. Finds files and directories by name pattern. \
303         Much faster than bash find. Use for locating files, exploring project structure."
304    }
305
306    fn parameters_schema(&self) -> Value {
307        json!({
308            "type": "object",
309            "properties": {
310                "pattern": { "type": "string", "description": "Search pattern (regex by default)" },
311                "path": { "type": "string", "description": "Directory to search in (default: workspace root)" },
312                "extension": { "type": "string", "description": "Filter by extension: rs, py, js, toml, md" },
313                "type_filter": { "type": "string", "description": "f=file, d=directory, l=symlink" },
314                "max_depth": { "type": "integer", "description": "Max directory depth" },
315                "max_results": { "type": "integer", "description": "Max results to return (default: 50, prevents context flooding)" },
316                "hidden": { "type": "boolean", "description": "Include hidden files (default: false)" }
317            },
318            "required": ["pattern"]
319        })
320    }
321
322    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
323        use thulp_core::{Parameter, ParameterType};
324        thulp_core::ToolDefinition::builder(self.name())
325            .description(self.description())
326            .parameter(
327                Parameter::builder("pattern")
328                    .param_type(ParameterType::String)
329                    .required(true)
330                    .description("Search pattern (regex by default)")
331                    .build(),
332            )
333            .parameter(
334                Parameter::builder("path")
335                    .param_type(ParameterType::String)
336                    .required(false)
337                    .description("Directory to search in (default: workspace root)")
338                    .build(),
339            )
340            .parameter(
341                Parameter::builder("extension")
342                    .param_type(ParameterType::String)
343                    .required(false)
344                    .description("Filter by extension: rs, py, js, toml, md")
345                    .build(),
346            )
347            .parameter(
348                Parameter::builder("type_filter")
349                    .param_type(ParameterType::String)
350                    .required(false)
351                    .description("f=file, d=directory, l=symlink")
352                    .build(),
353            )
354            .parameter(
355                Parameter::builder("max_depth")
356                    .param_type(ParameterType::Integer)
357                    .required(false)
358                    .description("Max directory depth")
359                    .build(),
360            )
361            .parameter(
362                Parameter::builder("max_results")
363                    .param_type(ParameterType::Integer)
364                    .required(false)
365                    .description("Max results to return (default: 50, prevents context flooding)")
366                    .build(),
367            )
368            .parameter(
369                Parameter::builder("hidden")
370                    .param_type(ParameterType::Boolean)
371                    .required(false)
372                    .description("Include hidden files (default: false)")
373                    .build(),
374            )
375            .build()
376    }
377
378    async fn execute(&self, args: Value) -> crate::Result<Value> {
379        ensure_binary("fd", &self.workspace_root).await?;
380        let pattern = args["pattern"].as_str()
381            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
382
383        let mut cmd_args: Vec<String> = vec!["--color".into(), "never".into()];
384        if let Some(ext) = args["extension"].as_str() {
385            cmd_args.push("-e".into()); cmd_args.push(ext.into());
386        }
387        if let Some(t) = args["type_filter"].as_str() {
388            cmd_args.push("-t".into()); cmd_args.push(t.into());
389        }
390        if let Some(d) = args["max_depth"].as_u64() {
391            cmd_args.push("--max-depth".into()); cmd_args.push(d.to_string());
392        }
393        if args["hidden"].as_bool().unwrap_or(false) {
394            cmd_args.push("--hidden".into());
395        }
396        cmd_args.push(pattern.into());
397        if let Some(p) = args["path"].as_str() {
398            cmd_args.push(p.into());
399        }
400
401        let cmd_args_ref: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
402        let (stdout, stderr, _) = run_cmd("fd", &cmd_args_ref, &self.workspace_root).await
403            .map_err(crate::PawanError::Tool)?;
404
405        let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
406        let all_files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
407        let total = all_files.len();
408        let files: Vec<&str> = all_files.into_iter().take(max_results).collect();
409        let truncated = total > max_results;
410
411        Ok(json!({
412            "files": files,
413            "count": files.len(),
414            "total_found": total,
415            "truncated": truncated,
416            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
417        }))
418    }
419}
420
421// ─── sd (fast sed) ──────────────────────────────────────────────────────────
422
423/// Tool for fast text replacement using sd.
424pub struct SdTool {
425    workspace_root: PathBuf,
426}
427
428impl SdTool {
429    pub fn new(workspace_root: PathBuf) -> Self {
430        Self { workspace_root }
431    }
432}
433
434#[async_trait]
435impl Tool for SdTool {
436    fn name(&self) -> &str { "sd" }
437
438    fn description(&self) -> &str {
439        "sd — fast find-and-replace across files. Like sed but simpler syntax and faster. \
440         Use for bulk renaming, refactoring imports, changing patterns across entire codebase. \
441         Modifies files in-place."
442    }
443
444    fn parameters_schema(&self) -> Value {
445        json!({
446            "type": "object",
447            "properties": {
448                "find": { "type": "string", "description": "Pattern to find (regex)" },
449                "replace": { "type": "string", "description": "Replacement string" },
450                "path": { "type": "string", "description": "File or directory to process" },
451                "fixed_strings": { "type": "boolean", "description": "Treat find as literal, not regex (default: false)" }
452            },
453            "required": ["find", "replace", "path"]
454        })
455    }
456
457    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
458        use thulp_core::{Parameter, ParameterType};
459        thulp_core::ToolDefinition::builder(self.name())
460            .description(self.description())
461            .parameter(
462                Parameter::builder("find")
463                    .param_type(ParameterType::String)
464                    .required(true)
465                    .description("Pattern to find (regex)")
466                    .build(),
467            )
468            .parameter(
469                Parameter::builder("replace")
470                    .param_type(ParameterType::String)
471                    .required(true)
472                    .description("Replacement string")
473                    .build(),
474            )
475            .parameter(
476                Parameter::builder("path")
477                    .param_type(ParameterType::String)
478                    .required(true)
479                    .description("File or directory to process")
480                    .build(),
481            )
482            .parameter(
483                Parameter::builder("fixed_strings")
484                    .param_type(ParameterType::Boolean)
485                    .required(false)
486                    .description("Treat find as literal, not regex (default: false)")
487                    .build(),
488            )
489            .build()
490    }
491
492    async fn execute(&self, args: Value) -> crate::Result<Value> {
493        ensure_binary("sd", &self.workspace_root).await?;
494        let find = args["find"].as_str()
495            .ok_or_else(|| crate::PawanError::Tool("find required".into()))?;
496        let replace = args["replace"].as_str()
497            .ok_or_else(|| crate::PawanError::Tool("replace required".into()))?;
498        let path = args["path"].as_str()
499            .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
500
501        let mut cmd_args = vec![];
502        if args["fixed_strings"].as_bool().unwrap_or(false) {
503            cmd_args.push("-F");
504        }
505        cmd_args.extend_from_slice(&[find, replace, path]);
506
507        let (stdout, stderr, success) = run_cmd("sd", &cmd_args, &self.workspace_root).await
508            .map_err(crate::PawanError::Tool)?;
509
510        Ok(json!({
511            "success": success,
512            "output": stdout,
513            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
514        }))
515    }
516}
517
518// ─── erdtree (erd) ──────────────────────────────────────────────────────────
519
520pub struct ErdTool {
521    workspace_root: PathBuf,
522}
523
524impl ErdTool {
525    pub fn new(workspace_root: PathBuf) -> Self {
526        Self { workspace_root }
527    }
528}
529
530#[async_trait]
531impl Tool for ErdTool {
532    fn name(&self) -> &str { "tree" }
533
534    fn description(&self) -> &str {
535        "erdtree (erd) — fast filesystem tree with disk usage, file counts, and metadata. \
536         Use to map project structure, find large files/dirs, count lines of code, \
537         audit disk usage, or get a flat file listing. Much faster than find + du."
538    }
539
540    fn parameters_schema(&self) -> Value {
541        json!({
542            "type": "object",
543            "properties": {
544                "path": { "type": "string", "description": "Root directory (default: workspace root)" },
545                "depth": { "type": "integer", "description": "Max traversal depth (default: 3)" },
546                "pattern": { "type": "string", "description": "Filter by glob pattern (e.g. '*.rs', 'Cargo*')" },
547                "sort": {
548                    "type": "string",
549                    "enum": ["name", "size", "type"],
550                    "description": "Sort entries by name, size, or file type (default: name)"
551                },
552                "disk_usage": {
553                    "type": "string",
554                    "enum": ["physical", "logical", "line", "word", "block"],
555                    "description": "Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical."
556                },
557                "layout": {
558                    "type": "string",
559                    "enum": ["regular", "inverted", "flat", "iflat"],
560                    "description": "Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted."
561                },
562                "long": { "type": "boolean", "description": "Show extended metadata: permissions, owner, group, timestamps (default: false)" },
563                "hidden": { "type": "boolean", "description": "Show hidden (dot) files (default: false)" },
564                "dirs_only": { "type": "boolean", "description": "Only show directories, not files (default: false)" },
565                "human": { "type": "boolean", "description": "Human-readable sizes like 4.2M instead of bytes (default: true)" },
566                "icons": { "type": "boolean", "description": "Show file type icons (default: false)" },
567                "no_ignore": { "type": "boolean", "description": "Don't respect .gitignore (default: false)" },
568                "suppress_size": { "type": "boolean", "description": "Hide disk usage column (default: false)" }
569            }
570        })
571    }
572
573    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
574        use thulp_core::{Parameter, ParameterType};
575        thulp_core::ToolDefinition::builder(self.name())
576            .description(self.description())
577            .parameter(
578                Parameter::builder("path")
579                    .param_type(ParameterType::String)
580                    .required(false)
581                    .description("Root directory (default: workspace root)")
582                    .build(),
583            )
584            .parameter(
585                Parameter::builder("depth")
586                    .param_type(ParameterType::Integer)
587                    .required(false)
588                    .description("Max traversal depth (default: 3)")
589                    .build(),
590            )
591            .parameter(
592                Parameter::builder("pattern")
593                    .param_type(ParameterType::String)
594                    .required(false)
595                    .description("Filter by glob pattern (e.g. '*.rs', 'Cargo*')")
596                    .build(),
597            )
598            .parameter(
599                Parameter::builder("sort")
600                    .param_type(ParameterType::String)
601                    .required(false)
602                    .description("Sort entries by name, size, or file type (default: name)")
603                    .build(),
604            )
605            .parameter(
606                Parameter::builder("disk_usage")
607                    .param_type(ParameterType::String)
608                    .required(false)
609                    .description("Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical.")
610                    .build(),
611            )
612            .parameter(
613                Parameter::builder("layout")
614                    .param_type(ParameterType::String)
615                    .required(false)
616                    .description("Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted.")
617                    .build(),
618            )
619            .parameter(
620                Parameter::builder("long")
621                    .param_type(ParameterType::Boolean)
622                    .required(false)
623                    .description("Show extended metadata: permissions, owner, group, timestamps (default: false)")
624                    .build(),
625            )
626            .parameter(
627                Parameter::builder("hidden")
628                    .param_type(ParameterType::Boolean)
629                    .required(false)
630                    .description("Show hidden (dot) files (default: false)")
631                    .build(),
632            )
633            .parameter(
634                Parameter::builder("dirs_only")
635                    .param_type(ParameterType::Boolean)
636                    .required(false)
637                    .description("Only show directories, not files (default: false)")
638                    .build(),
639            )
640            .parameter(
641                Parameter::builder("human")
642                    .param_type(ParameterType::Boolean)
643                    .required(false)
644                    .description("Human-readable sizes like 4.2M instead of bytes (default: true)")
645                    .build(),
646            )
647            .parameter(
648                Parameter::builder("icons")
649                    .param_type(ParameterType::Boolean)
650                    .required(false)
651                    .description("Show file type icons (default: false)")
652                    .build(),
653            )
654            .parameter(
655                Parameter::builder("no_ignore")
656                    .param_type(ParameterType::Boolean)
657                    .required(false)
658                    .description("Don't respect .gitignore (default: false)")
659                    .build(),
660            )
661            .parameter(
662                Parameter::builder("suppress_size")
663                    .param_type(ParameterType::Boolean)
664                    .required(false)
665                    .description("Hide disk usage column (default: false)")
666                    .build(),
667            )
668            .build()
669    }
670
671    async fn execute(&self, args: Value) -> crate::Result<Value> {
672        // Auto-install erd if missing; fall back to fd if mise unavailable
673        if !binary_exists("erd")
674            && (!auto_install("erd", &self.workspace_root).await || !binary_exists("erd")) {
675                let path = args["path"].as_str().unwrap_or(".");
676                let depth = args["depth"].as_u64().unwrap_or(3).to_string();
677                let cmd_args = vec![".", path, "--max-depth", &depth, "--color", "never"];
678                let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
679                    .unwrap_or(("(erd and fd not available)".into(), String::new(), false));
680                return Ok(json!({ "tree": stdout, "tool": "fd-fallback" }));
681            }
682
683        let path = args["path"].as_str().unwrap_or(".");
684        let depth_str = args["depth"].as_u64().unwrap_or(3).to_string();
685
686        let mut cmd_args: Vec<String> = vec![
687            "--level".into(), depth_str,
688            "--no-config".into(),
689            "--color".into(), "none".into(),
690        ];
691
692        if let Some(du) = args["disk_usage"].as_str() {
693            cmd_args.extend(["--disk-usage".into(), du.into()]);
694        }
695
696        let layout = args["layout"].as_str().unwrap_or("inverted");
697        cmd_args.extend(["--layout".into(), layout.into()]);
698
699        if let Some(sort) = args["sort"].as_str() {
700            cmd_args.extend(["--sort".into(), sort.into()]);
701        }
702
703        if let Some(p) = args["pattern"].as_str() {
704            cmd_args.extend(["--pattern".into(), p.into()]);
705        }
706
707        if args["long"].as_bool().unwrap_or(false) { cmd_args.push("--long".into()); }
708        if args["hidden"].as_bool().unwrap_or(false) { cmd_args.push("--hidden".into()); }
709        if args["dirs_only"].as_bool().unwrap_or(false) { cmd_args.push("--dirs-only".into()); }
710        if args["human"].as_bool().unwrap_or(true) { cmd_args.push("--human".into()); }
711        if args["icons"].as_bool().unwrap_or(false) { cmd_args.push("--icons".into()); }
712        if args["no_ignore"].as_bool().unwrap_or(false) { cmd_args.push("--no-ignore".into()); }
713        if args["suppress_size"].as_bool().unwrap_or(false) { cmd_args.push("--suppress-size".into()); }
714
715        cmd_args.push(path.into());
716
717        let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
718        let (stdout, stderr, success) = run_cmd("erd", &cmd_refs, &self.workspace_root).await
719            .map_err(crate::PawanError::Tool)?;
720
721        Ok(json!({
722            "success": success,
723            "tree": stdout,
724            "tool": "erd",
725            "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
726        }))
727    }
728}
729
730// ─── grep_search (rg wrapper for structured output) ─────────────────────────
731
732pub struct GrepSearchTool {
733    workspace_root: PathBuf,
734}
735
736impl GrepSearchTool {
737    pub fn new(workspace_root: PathBuf) -> Self {
738        Self { workspace_root }
739    }
740}
741
742#[async_trait]
743impl Tool for GrepSearchTool {
744    fn name(&self) -> &str { "grep_search" }
745
746    fn description(&self) -> &str {
747        "Search for a pattern in files using ripgrep. Returns file paths and matching lines. \
748         Prefer this over bash grep — it's faster and respects .gitignore."
749    }
750
751    fn parameters_schema(&self) -> Value {
752        json!({
753            "type": "object",
754            "properties": {
755                "pattern": { "type": "string", "description": "Search pattern (regex)" },
756                "path": { "type": "string", "description": "Path to search (default: workspace)" },
757                "include": { "type": "string", "description": "Glob to include (e.g. '*.rs')" }
758            },
759            "required": ["pattern"]
760        })
761    }
762
763    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
764        use thulp_core::{Parameter, ParameterType};
765        thulp_core::ToolDefinition::builder(self.name())
766            .description(self.description())
767            .parameter(
768                Parameter::builder("pattern")
769                    .param_type(ParameterType::String)
770                    .required(true)
771                    .description("Search pattern (regex)")
772                    .build(),
773            )
774            .parameter(
775                Parameter::builder("path")
776                    .param_type(ParameterType::String)
777                    .required(false)
778                    .description("Path to search (default: workspace)")
779                    .build(),
780            )
781            .parameter(                Parameter::builder("include")
782                    .param_type(ParameterType::String)
783                    .required(false)
784                    .description("Glob to include (e.g. '*.rs')")
785                    .build(),
786            )
787            .build()
788    }
789
790    async fn execute(&self, args: Value) -> crate::Result<Value> {
791        let pattern = args["pattern"].as_str()
792            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
793        let path = args["path"].as_str().unwrap_or(".");
794
795        let mut cmd_args = vec!["--line-number", "--no-heading", "--color", "never", "--max-count", "30"];
796        let include;
797        if let Some(glob) = args["include"].as_str() {
798            include = format!("--glob={}", glob);
799            cmd_args.push(&include);
800        }
801        cmd_args.push(pattern);
802        cmd_args.push(path);
803
804        let cwd = if std::path::Path::new(path).is_absolute() {
805            std::path::PathBuf::from("/")
806        } else {
807            self.workspace_root.clone()
808        };
809
810        let (stdout, _, _) = run_cmd("rg", &cmd_args, &cwd).await
811            .map_err(crate::PawanError::Tool)?;
812
813        let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).take(50).collect();
814
815        Ok(json!({
816            "results": lines.join("\n"),
817            "count": lines.len()
818        }))
819    }
820}
821
822// ─── glob_search (fd wrapper) ───────────────────────────────────────────────
823
824pub struct GlobSearchTool {
825    workspace_root: PathBuf,
826}
827
828impl GlobSearchTool {
829    pub fn new(workspace_root: PathBuf) -> Self {
830        Self { workspace_root }
831    }
832}
833
834#[async_trait]
835impl Tool for GlobSearchTool {
836    fn name(&self) -> &str { "glob_search" }
837
838    fn description(&self) -> &str {
839        "Find files by glob pattern. Returns list of matching file paths. \
840         Uses fd under the hood — fast, respects .gitignore."
841    }
842
843    fn parameters_schema(&self) -> Value {
844        json!({
845            "type": "object",
846            "properties": {
847                "pattern": { "type": "string", "description": "Glob pattern (e.g. '*.rs', 'test_*')" },
848                "path": { "type": "string", "description": "Directory to search (default: workspace)" }
849            },
850            "required": ["pattern"]
851        })
852    }
853
854    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
855        use thulp_core::{Parameter, ParameterType};
856        thulp_core::ToolDefinition::builder(self.name())
857            .description(self.description())
858            .parameter(
859                Parameter::builder("pattern")
860                    .param_type(ParameterType::String)
861                    .required(true)
862                    .description("Glob pattern (e.g. '*.rs', 'test_*')")
863                    .build(),
864            )
865            .parameter(
866                Parameter::builder("path")
867                    .param_type(ParameterType::String)
868                    .required(false)
869                    .description("Directory to search (default: workspace)")
870                    .build(),
871            )
872            .build()
873    }
874
875    async fn execute(&self, args: Value) -> crate::Result<Value> {
876        let pattern = args["pattern"].as_str()
877            .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
878        let path = args["path"].as_str().unwrap_or(".");
879
880        let cmd_args = vec!["--glob", pattern, "--color", "never", path];
881        let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
882            .map_err(crate::PawanError::Tool)?;
883
884        let files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
885
886        Ok(json!({
887            "files": files,
888            "count": files.len()
889        }))
890    }
891}
892
893// ─── tests ──────────────────────────────────────────────────────────────────
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898    use tempfile::TempDir;
899
900    #[test]
901    fn test_binary_exists_cargo() {
902        assert!(binary_exists("cargo"));
903    }
904
905    #[test]
906    fn test_binary_exists_nonexistent() {
907        assert!(!binary_exists("nonexistent_binary_xyz_123"));
908    }
909
910    #[test]
911    fn test_mise_package_name_mapping() {
912        assert_eq!(mise_package_name("rg"), "ripgrep");
913        assert_eq!(mise_package_name("fd"), "fd");
914        assert_eq!(mise_package_name("sg"), "ast-grep");
915        assert_eq!(mise_package_name("erd"), "erdtree");
916        assert_eq!(mise_package_name("unknown"), "unknown");
917    }
918
919    #[test]
920    fn test_mise_package_name_all_aliases() {
921        assert_eq!(mise_package_name("ast-grep"), "ast-grep");
922        assert_eq!(mise_package_name("bat"), "bat");
923        assert_eq!(mise_package_name("delta"), "delta");
924        assert_eq!(mise_package_name("jq"), "jq");
925        assert_eq!(mise_package_name("yq"), "yq");
926    }
927
928    #[test]
929    fn test_mise_package_name_is_case_sensitive() {
930        assert_eq!(mise_package_name("RG"), "RG");
931        assert_eq!(mise_package_name("Fd"), "Fd");
932        assert_eq!(mise_package_name("AST-GREP"), "AST-GREP");
933    }
934
935    #[test]
936    fn test_mise_package_name_passes_through_arbitrary_names() {
937        assert_eq!(mise_package_name("foo"), "foo");
938        assert_eq!(mise_package_name(""), "");
939        assert_eq!(mise_package_name("some-random-tool_v2"), "some-random-tool_v2");
940    }
941
942    #[tokio::test]
943    async fn test_rg_tool_basics() {
944        let tmp = TempDir::new().unwrap();
945        let tool = RipgrepTool::new(tmp.path().into());
946        assert_eq!(tool.name(), "rg");
947        assert!(!tool.description().is_empty());
948        let schema = tool.parameters_schema();
949        assert!(schema["required"].as_array().unwrap().contains(&serde_json::json!("pattern")));
950    }
951
952    #[tokio::test]
953    async fn test_fd_tool_basics() {
954        let tmp = TempDir::new().unwrap();
955        let tool = FdTool::new(tmp.path().into());
956        assert_eq!(tool.name(), "fd");
957        assert!(!tool.description().is_empty());
958        let schema = tool.parameters_schema();
959        assert!(schema["required"].as_array().unwrap().contains(&serde_json::json!("pattern")));
960    }
961
962    #[tokio::test]
963    async fn test_sd_tool_basics() {
964        let tmp = TempDir::new().unwrap();
965        let tool = SdTool::new(tmp.path().into());
966        assert_eq!(tool.name(), "sd");
967        assert!(!tool.description().is_empty());
968        let schema = tool.parameters_schema();
969        let required = schema["required"].as_array().unwrap();
970        assert!(required.contains(&serde_json::json!("find")));
971        assert!(required.contains(&serde_json::json!("replace")));
972    }
973
974    #[tokio::test]
975    async fn test_erd_tool_schema() {
976        let tmp = TempDir::new().unwrap();
977        let tool = ErdTool::new(tmp.path().to_path_buf());
978        assert_eq!(tool.name(), "tree");
979        let schema = tool.parameters_schema();
980        assert!(schema["properties"]["disk_usage"].is_object());
981        assert!(schema["properties"]["layout"].is_object());
982    }
983
984    #[tokio::test]
985    async fn test_native_glob_tool_basics() {
986        let tmp = TempDir::new().unwrap();
987        let tool = GlobSearchTool::new(tmp.path().into());
988        assert_eq!(tool.name(), "glob_search");
989        let schema = tool.parameters_schema();
990        assert!(schema["required"].as_array().unwrap().contains(&serde_json::json!("pattern")));
991    }
992
993    #[tokio::test]
994    async fn test_native_grep_tool_basics() {
995        let tmp = TempDir::new().unwrap();
996        let tool = GrepSearchTool::new(tmp.path().into());
997        assert_eq!(tool.name(), "grep_search");
998        let schema = tool.parameters_schema();
999        assert!(schema["required"].as_array().unwrap().contains(&serde_json::json!("pattern")));
1000    }
1001
1002    #[tokio::test]
1003    async fn test_tree_tool_runs_without_crash() {
1004        let tmp = TempDir::new().unwrap();
1005        std::fs::create_dir(tmp.path().join("sub")).unwrap();
1006        std::fs::write(tmp.path().join("sub/a.rs"), "code").unwrap();
1007        std::fs::write(tmp.path().join("b.txt"), "text").unwrap();
1008
1009        let tool = ErdTool::new(tmp.path().into());
1010        let result = tool.execute(serde_json::json!({})).await;
1011        assert!(result.is_ok(), "tree tool should work with fallback: {:?}", result.err());
1012        let val = result.unwrap();
1013        assert!(val["tree"].is_string() || val["output"].is_string(),
1014                "Should produce tree output");
1015    }
1016
1017    #[tokio::test]
1018    async fn test_grep_search_finds_pattern_in_files() {
1019        let tmp = TempDir::new().unwrap();
1020        std::fs::write(
1021            tmp.path().join("alpha.rs"),
1022            "fn main() {\n    println!(\"unique_marker_abc\");\n}\n",
1023        )
1024        .unwrap();
1025        std::fs::write(tmp.path().join("beta.rs"), "fn unrelated() {}\n").unwrap();
1026
1027        let tool = GrepSearchTool::new(tmp.path().into());
1028        let result = tool
1029            .execute(serde_json::json!({ "pattern": "unique_marker_abc" }))
1030            .await
1031            .unwrap();
1032
1033        let count = result["count"].as_u64().unwrap();
1034        assert!(count >= 1, "should find at least one match, got {}", count);
1035        let results = result["results"].as_str().unwrap();
1036        assert!(results.contains("unique_marker_abc"));
1037        assert!(results.contains("alpha.rs"));
1038    }
1039
1040    #[tokio::test]
1041    async fn test_grep_search_returns_empty_on_no_match() {
1042        let tmp = TempDir::new().unwrap();
1043        std::fs::write(tmp.path().join("x.rs"), "fn main() {}\n").unwrap();
1044
1045        let tool = GrepSearchTool::new(tmp.path().into());
1046        let result = tool
1047            .execute(serde_json::json!({ "pattern": "definitely_not_in_any_file_9f8e7d6c" }))
1048            .await
1049            .unwrap();
1050
1051        assert_eq!(result["count"], 0);
1052        assert_eq!(result["results"].as_str().unwrap(), "");
1053    }
1054
1055    #[tokio::test]
1056    async fn test_glob_search_finds_files_by_extension() {
1057        let tmp = TempDir::new().unwrap();
1058        std::fs::write(tmp.path().join("one.rs"), "").unwrap();
1059        std::fs::write(tmp.path().join("two.rs"), "").unwrap();
1060        std::fs::write(tmp.path().join("ignored.txt"), "").unwrap();
1061
1062        let tool = GlobSearchTool::new(tmp.path().into());
1063        let result = tool
1064            .execute(serde_json::json!({ "pattern": "*.rs" }))
1065            .await
1066            .unwrap();
1067
1068        let debug = format!("{}", result);
1069        assert!(debug.contains("one.rs") || debug.contains("two.rs"),
1070            "should find at least one .rs file, got: {}", debug);
1071        assert!(!debug.contains("ignored.txt"), "should not match .txt, got: {}", debug);
1072    }
1073
1074    #[tokio::test]
1075    async fn test_ripgrep_case_insensitive_flag() {
1076        let tmp = TempDir::new().unwrap();
1077        std::fs::write(
1078            tmp.path().join("x.txt"),
1079            "Hello World\nhello world\nHELLO WORLD\n",
1080        )
1081        .unwrap();
1082
1083        let tool = RipgrepTool::new(tmp.path().into());
1084
1085        let case_sensitive = tool
1086            .execute(serde_json::json!({ "pattern": "hello world" }))
1087            .await
1088            .unwrap();
1089        let cs_debug = format!("{}", case_sensitive);
1090
1091        let case_insensitive = tool
1092            .execute(serde_json::json!({ "pattern": "hello world", "case_insensitive": true }))
1093            .await
1094            .unwrap();
1095        let ci_debug = format!("{}", case_insensitive);
1096
1097        assert!(
1098            ci_debug.len() > cs_debug.len(),
1099            "case_insensitive should find more matches.\nCS: {}\nCI: {}",
1100            cs_debug, ci_debug
1101        );
1102    }
1103
1104    #[tokio::test]
1105    async fn test_ripgrep_fixed_strings_treats_regex_chars_literally() {
1106        let tmp = TempDir::new().unwrap();
1107        std::fs::write(tmp.path().join("x.txt"), "a.b\naxb\n").unwrap();
1108
1109        let tool = RipgrepTool::new(tmp.path().into());
1110
1111        let regex_mode = tool
1112            .execute(serde_json::json!({ "pattern": "a.b" }))
1113            .await
1114            .unwrap();
1115        let regex_debug = format!("{}", regex_mode);
1116
1117        let fixed_mode = tool
1118            .execute(serde_json::json!({ "pattern": "a.b", "fixed_strings": true }))
1119            .await
1120            .unwrap();
1121        let fixed_debug = format!("{}", fixed_mode);
1122
1123        assert!(
1124            regex_debug.contains("axb") || regex_debug.len() > fixed_debug.len(),
1125            "regex mode should match more.\nregex: {}\nfixed: {}",
1126            regex_debug, fixed_debug
1127        );
1128    }
1129
1130    #[tokio::test]
1131    async fn test_fd_finds_files_by_extension_filter() {
1132        if !binary_exists("fd") {
1133            eprintln!("skipping fd test — fd binary not on PATH");
1134            return;
1135        }
1136        let tmp = TempDir::new().unwrap();
1137        std::fs::write(tmp.path().join("keep.rs"), "").unwrap();
1138        std::fs::write(tmp.path().join("keep_too.rs"), "").unwrap();
1139        std::fs::write(tmp.path().join("skip.txt"), "").unwrap();
1140        std::fs::write(tmp.path().join("skip.md"), "").unwrap();
1141
1142        let tool = FdTool::new(tmp.path().into());
1143        let result = tool
1144            .execute(serde_json::json!({
1145                "pattern": ".",
1146                "extension": "rs"
1147            }))
1148            .await
1149            .unwrap();
1150
1151        let files = result["files"].as_array().unwrap();
1152        let file_list: Vec<&str> = files.iter().filter_map(|v| v.as_str()).collect();
1153        for f in &file_list {
1154            assert!(f.ends_with(".rs"), "extension filter leaked non-.rs file: {}", f);
1155        }
1156        assert!(
1157            file_list.iter().any(|f| f.contains("keep")),
1158            "expected to find keep.rs, got: {:?}", file_list
1159        );
1160    }
1161
1162    #[tokio::test]
1163    async fn test_fd_max_results_truncation() {
1164        if !binary_exists("fd") {
1165            eprintln!("skipping fd test — fd binary not on PATH");
1166            return;
1167        }
1168        let tmp = TempDir::new().unwrap();
1169        for i in 0..15 {
1170            std::fs::write(tmp.path().join(format!("file{i:02}.log")), "").unwrap();
1171        }
1172
1173        let tool = FdTool::new(tmp.path().into());
1174        let result = tool
1175            .execute(serde_json::json!({
1176                "pattern": ".",
1177                "extension": "log",
1178                "max_results": 5,
1179            }))
1180            .await
1181            .unwrap();
1182
1183        let count = result["count"].as_u64().unwrap();
1184        let total = result["total_found"].as_u64().unwrap();
1185        let truncated = result["truncated"].as_bool().unwrap();
1186        assert_eq!(count, 5, "max_results=5 must cap returned files");
1187        assert_eq!(total, 15, "total_found must reflect all 15 matches");
1188        assert!(truncated, "truncated flag must be true when total > max");
1189    }
1190
1191    #[tokio::test]
1192    async fn test_fd_empty_result_has_correct_shape() {
1193        if !binary_exists("fd") {
1194            eprintln!("skipping fd test — fd binary not on PATH");
1195            return;
1196        }
1197        let tmp = TempDir::new().unwrap();
1198        std::fs::write(tmp.path().join("only.txt"), "").unwrap();
1199
1200        let tool = FdTool::new(tmp.path().into());
1201        let result = tool
1202            .execute(serde_json::json!({
1203                "pattern": "definitely_nothing_matches_xyz_abc_9999"
1204            }))
1205            .await
1206            .unwrap();
1207
1208        assert_eq!(result["count"].as_u64().unwrap(), 0);
1209        assert_eq!(result["total_found"].as_u64().unwrap(), 0);
1210        assert_eq!(result["truncated"].as_bool().unwrap(), false);
1211        assert!(result["files"].as_array().unwrap().is_empty());
1212    }
1213
1214    #[tokio::test]
1215    async fn test_rg_missing_pattern_returns_error() {
1216        if !binary_exists("rg") {
1217            eprintln!("skipping rg test — rg binary not on PATH");
1218            return;
1219        }
1220        let tmp = TempDir::new().unwrap();
1221        let tool = RipgrepTool::new(tmp.path().into());
1222        let result = tool.execute(serde_json::json!({})).await;
1223        assert!(result.is_err(), "missing pattern must return Err, got: {:?}", result);
1224        let err_msg = format!("{}", result.unwrap_err());
1225        assert!(err_msg.contains("pattern"), "error must mention 'pattern', got: {}", err_msg);
1226    }
1227
1228    #[tokio::test]
1229    async fn test_sd_missing_required_params_returns_error() {
1230        if !binary_exists("sd") {
1231            eprintln!("skipping sd test — sd binary not on PATH");
1232            return;
1233        }
1234        let tmp = TempDir::new().unwrap();
1235        let tool = SdTool::new(tmp.path().into());
1236
1237        let r1 = tool.execute(serde_json::json!({"replace": "new", "path": "f"})).await;
1238        assert!(r1.is_err(), "missing find must return Err");
1239        assert!(format!("{}", r1.unwrap_err()).contains("find"));
1240
1241        let r2 = tool.execute(serde_json::json!({"find": "old", "path": "f"})).await;
1242        assert!(r2.is_err(), "missing replace must return Err");
1243        assert!(format!("{}", r2.unwrap_err()).contains("replace"));
1244
1245        let r3 = tool.execute(serde_json::json!({"find": "old", "replace": "new"})).await;
1246        assert!(r3.is_err(), "missing path must return Err");
1247        assert!(format!("{}", r3.unwrap_err()).contains("path"));
1248    }
1249
1250    #[tokio::test]
1251    async fn test_ripgrep_max_count_caps_matches() {
1252        if !binary_exists("rg") {
1253            eprintln!("skipping rg test — rg binary not on PATH");
1254            return;
1255        }
1256        let tmp = TempDir::new().unwrap();
1257        let mut content = String::new();
1258        for _ in 0..10 {
1259            content.push_str("MATCH_TOKEN\n");
1260        }
1261        std::fs::write(tmp.path().join("many.txt"), content).unwrap();
1262
1263        let tool = RipgrepTool::new(tmp.path().into());
1264        let result = tool
1265            .execute(serde_json::json!({
1266                "pattern": "MATCH_TOKEN",
1267                "max_count": 3,
1268            }))
1269            .await
1270            .unwrap();
1271
1272        let match_count = result["match_count"].as_u64().unwrap();
1273        assert!(match_count <= 3, "max_count=3 must limit results, got {}", match_count);
1274        assert!(match_count >= 1, "should find at least one match");
1275    }
1276
1277    #[tokio::test]
1278    async fn test_glob_search_missing_pattern_returns_error() {
1279        let tmp = TempDir::new().unwrap();
1280        let tool = GlobSearchTool::new(tmp.path().into());
1281        let err = tool
1282            .execute(serde_json::json!({}))
1283            .await
1284            .expect_err("glob_search without pattern must error");
1285        let msg = format!("{}", err);
1286        assert!(msg.contains("pattern required"), "error message should say 'pattern required', got: {}", msg);
1287    }
1288
1289    #[tokio::test]
1290    async fn test_grep_search_missing_pattern_returns_error() {
1291        let tmp = TempDir::new().unwrap();
1292        let tool = GrepSearchTool::new(tmp.path().into());
1293        let err = tool
1294            .execute(serde_json::json!({}))
1295            .await
1296            .expect_err("grep_search without pattern must error");
1297        let msg = format!("{}", err);
1298        assert!(msg.contains("pattern required"), "error message should say 'pattern required', got: {}", msg);
1299    }
1300
1301    #[tokio::test]
1302    async fn test_glob_search_non_string_pattern_returns_error() {
1303        let tmp = TempDir::new().unwrap();
1304        let tool = GlobSearchTool::new(tmp.path().into());
1305        let err = tool
1306            .execute(serde_json::json!({ "pattern": 42 }))
1307            .await
1308            .expect_err("glob_search with numeric pattern must error");
1309        let msg = format!("{}", err);
1310        assert!(msg.contains("pattern required"), "got: {}", msg);
1311    }
1312}