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