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