1use super::Tool;
7use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10use std::process::Stdio;
11
12pub(crate) fn binary_exists(name: &str) -> bool {
16 which::which(name).is_ok()
17}
18
19pub(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
35pub(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 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
87pub(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
105pub(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
125pub 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
312pub 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
456pub 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
559pub 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 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
792pub 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
896pub 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#[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}