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