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 async fn execute(&self, args: Value) -> crate::Result<Value> {
153 ensure_binary("rg", &self.workspace_root).await?;
154 let pattern = args["pattern"].as_str()
155 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
156 let search_path = args["path"].as_str().unwrap_or(".");
157 let max_count = args["max_count"].as_u64().unwrap_or(20);
158 let context = args["context"].as_u64().unwrap_or(0);
159
160 let max_count_str = max_count.to_string();
161 let ctx_str = context.to_string();
162 let mut cmd_args = vec![
163 "--line-number", "--no-heading", "--color", "never",
164 "--max-count", &max_count_str,
165 ];
166 if context > 0 {
167 cmd_args.extend_from_slice(&["--context", &ctx_str]);
168 }
169 if let Some(t) = args["type_filter"].as_str() {
170 cmd_args.extend_from_slice(&["--type", t]);
171 }
172 if args["fixed_strings"].as_bool().unwrap_or(false) {
173 cmd_args.push("--fixed-strings");
174 }
175 if args["case_insensitive"].as_bool().unwrap_or(false) {
176 cmd_args.push("-i");
177 }
178 if args["invert"].as_bool().unwrap_or(false) {
179 cmd_args.push("--invert-match");
180 }
181 if args["hidden"].as_bool().unwrap_or(false) {
182 cmd_args.push("--hidden");
183 }
184 let depth_str = args["max_depth"].as_u64().map(|d| d.to_string());
185 if let Some(ref ds) = depth_str {
186 cmd_args.push("--max-depth");
187 cmd_args.push(ds);
188 }
189 cmd_args.push(pattern);
190 cmd_args.push(search_path);
191
192 let cwd = if std::path::Path::new(search_path).is_absolute() {
193 std::path::PathBuf::from("/")
194 } else {
195 self.workspace_root.clone()
196 };
197
198 let (stdout, stderr, success) = run_cmd("rg", &cmd_args, &cwd).await
199 .map_err(crate::PawanError::Tool)?;
200
201 let match_count = stdout.lines().filter(|l| !l.is_empty()).count();
202
203 Ok(json!({
204 "matches": stdout.lines().take(100).collect::<Vec<_>>().join("\n"),
205 "match_count": match_count,
206 "success": success || match_count > 0,
207 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
208 }))
209 }
210}
211
212pub struct FdTool {
222 workspace_root: PathBuf,
223}
224
225impl FdTool {
226 pub fn new(workspace_root: PathBuf) -> Self {
227 Self { workspace_root }
228 }
229}
230
231#[async_trait]
232impl Tool for FdTool {
233 fn name(&self) -> &str { "fd" }
234
235 fn description(&self) -> &str {
236 "fd — fast file finder. Finds files and directories by name pattern. \
237 Much faster than bash find. Use for locating files, exploring project structure."
238 }
239
240 fn parameters_schema(&self) -> Value {
241 json!({
242 "type": "object",
243 "properties": {
244 "pattern": { "type": "string", "description": "Search pattern (regex by default)" },
245 "path": { "type": "string", "description": "Directory to search in (default: workspace root)" },
246 "extension": { "type": "string", "description": "Filter by extension: rs, py, js, toml, md" },
247 "type_filter": { "type": "string", "description": "f=file, d=directory, l=symlink" },
248 "max_depth": { "type": "integer", "description": "Max directory depth" },
249 "max_results": { "type": "integer", "description": "Max results to return (default: 50, prevents context flooding)" },
250 "hidden": { "type": "boolean", "description": "Include hidden files (default: false)" }
251 },
252 "required": ["pattern"]
253 })
254 }
255
256 async fn execute(&self, args: Value) -> crate::Result<Value> {
257 ensure_binary("fd", &self.workspace_root).await?;
258 let pattern = args["pattern"].as_str()
259 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
260
261 let mut cmd_args: Vec<String> = vec!["--color".into(), "never".into()];
262 if let Some(ext) = args["extension"].as_str() {
263 cmd_args.push("-e".into()); cmd_args.push(ext.into());
264 }
265 if let Some(t) = args["type_filter"].as_str() {
266 cmd_args.push("-t".into()); cmd_args.push(t.into());
267 }
268 if let Some(d) = args["max_depth"].as_u64() {
269 cmd_args.push("--max-depth".into()); cmd_args.push(d.to_string());
270 }
271 if args["hidden"].as_bool().unwrap_or(false) {
272 cmd_args.push("--hidden".into());
273 }
274 cmd_args.push(pattern.into());
275 if let Some(p) = args["path"].as_str() {
276 cmd_args.push(p.into());
277 }
278
279 let cmd_args_ref: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
280 let (stdout, stderr, _) = run_cmd("fd", &cmd_args_ref, &self.workspace_root).await
281 .map_err(crate::PawanError::Tool)?;
282
283 let max_results = args["max_results"].as_u64().unwrap_or(50) as usize;
284 let all_files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
285 let total = all_files.len();
286 let files: Vec<&str> = all_files.into_iter().take(max_results).collect();
287 let truncated = total > max_results;
288
289 Ok(json!({
290 "files": files,
291 "count": files.len(),
292 "total_found": total,
293 "truncated": truncated,
294 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
295 }))
296 }
297}
298
299pub struct SdTool {
309 workspace_root: PathBuf,
310}
311
312impl SdTool {
313 pub fn new(workspace_root: PathBuf) -> Self {
314 Self { workspace_root }
315 }
316}
317
318#[async_trait]
319impl Tool for SdTool {
320 fn name(&self) -> &str { "sd" }
321
322 fn description(&self) -> &str {
323 "sd — fast find-and-replace across files. Like sed but simpler syntax and faster. \
324 Use for bulk renaming, refactoring imports, changing patterns across entire codebase. \
325 Modifies files in-place."
326 }
327
328 fn parameters_schema(&self) -> Value {
329 json!({
330 "type": "object",
331 "properties": {
332 "find": { "type": "string", "description": "Pattern to find (regex)" },
333 "replace": { "type": "string", "description": "Replacement string" },
334 "path": { "type": "string", "description": "File or directory to process" },
335 "fixed_strings": { "type": "boolean", "description": "Treat find as literal, not regex (default: false)" }
336 },
337 "required": ["find", "replace", "path"]
338 })
339 }
340
341 async fn execute(&self, args: Value) -> crate::Result<Value> {
342 ensure_binary("sd", &self.workspace_root).await?;
343 let find = args["find"].as_str()
344 .ok_or_else(|| crate::PawanError::Tool("find required".into()))?;
345 let replace = args["replace"].as_str()
346 .ok_or_else(|| crate::PawanError::Tool("replace required".into()))?;
347 let path = args["path"].as_str()
348 .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
349
350 let mut cmd_args = vec![];
351 if args["fixed_strings"].as_bool().unwrap_or(false) {
352 cmd_args.push("-F");
353 }
354 cmd_args.extend_from_slice(&[find, replace, path]);
355
356 let (stdout, stderr, success) = run_cmd("sd", &cmd_args, &self.workspace_root).await
357 .map_err(crate::PawanError::Tool)?;
358
359 Ok(json!({
360 "success": success,
361 "output": stdout,
362 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
363 }))
364 }
365}
366
367pub struct ErdTool {
370 workspace_root: PathBuf,
371}
372
373impl ErdTool {
374 pub fn new(workspace_root: PathBuf) -> Self {
375 Self { workspace_root }
376 }
377}
378
379#[async_trait]
380impl Tool for ErdTool {
381 fn name(&self) -> &str { "tree" }
382
383 fn description(&self) -> &str {
384 "erdtree (erd) — fast filesystem tree with disk usage, file counts, and metadata. \
385 Use to map project structure, find large files/dirs, count lines of code, \
386 audit disk usage, or get a flat file listing. Much faster than find + du."
387 }
388
389 fn parameters_schema(&self) -> Value {
390 json!({
391 "type": "object",
392 "properties": {
393 "path": { "type": "string", "description": "Root directory (default: workspace root)" },
394 "depth": { "type": "integer", "description": "Max traversal depth (default: 3)" },
395 "pattern": { "type": "string", "description": "Filter by glob pattern (e.g. '*.rs', 'Cargo*')" },
396 "sort": {
397 "type": "string",
398 "enum": ["name", "size", "type"],
399 "description": "Sort entries by name, size, or file type (default: name)"
400 },
401 "disk_usage": {
402 "type": "string",
403 "enum": ["physical", "logical", "line", "word", "block"],
404 "description": "Disk usage mode: physical (bytes on disk), logical (file size), line (line count), word (word count). Default: physical."
405 },
406 "layout": {
407 "type": "string",
408 "enum": ["regular", "inverted", "flat", "iflat"],
409 "description": "Output layout: regular (root at bottom), inverted (root at top), flat (paths only), iflat (flat + root at top). Default: inverted."
410 },
411 "long": { "type": "boolean", "description": "Show extended metadata: permissions, owner, group, timestamps (default: false)" },
412 "hidden": { "type": "boolean", "description": "Show hidden (dot) files (default: false)" },
413 "dirs_only": { "type": "boolean", "description": "Only show directories, not files (default: false)" },
414 "human": { "type": "boolean", "description": "Human-readable sizes like 4.2M instead of bytes (default: true)" },
415 "icons": { "type": "boolean", "description": "Show file type icons (default: false)" },
416 "no_ignore": { "type": "boolean", "description": "Don't respect .gitignore (default: false)" },
417 "suppress_size": { "type": "boolean", "description": "Hide disk usage column (default: false)" }
418 }
419 })
420 }
421
422 async fn execute(&self, args: Value) -> crate::Result<Value> {
423 if !binary_exists("erd") {
425 if !auto_install("erd", &self.workspace_root).await || !binary_exists("erd") {
426 let path = args["path"].as_str().unwrap_or(".");
427 let depth = args["depth"].as_u64().unwrap_or(3).to_string();
428 let cmd_args = vec![".", path, "--max-depth", &depth, "--color", "never"];
429 let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
430 .unwrap_or(("(erd and fd not available)".into(), String::new(), false));
431 return Ok(json!({ "tree": stdout, "tool": "fd-fallback" }));
432 }
433 }
434
435 let path = args["path"].as_str().unwrap_or(".");
436 let depth_str = args["depth"].as_u64().unwrap_or(3).to_string();
437
438 let mut cmd_args: Vec<String> = vec![
439 "--level".into(), depth_str,
440 "--no-config".into(),
441 "--color".into(), "none".into(),
442 ];
443
444 if let Some(du) = args["disk_usage"].as_str() {
446 cmd_args.extend(["--disk-usage".into(), du.into()]);
447 }
448
449 let layout = args["layout"].as_str().unwrap_or("inverted");
451 cmd_args.extend(["--layout".into(), layout.into()]);
452
453 if let Some(sort) = args["sort"].as_str() {
455 cmd_args.extend(["--sort".into(), sort.into()]);
456 }
457
458 if let Some(p) = args["pattern"].as_str() {
460 cmd_args.extend(["--pattern".into(), p.into()]);
461 }
462
463 if args["long"].as_bool().unwrap_or(false) { cmd_args.push("--long".into()); }
465 if args["hidden"].as_bool().unwrap_or(false) { cmd_args.push("--hidden".into()); }
466 if args["dirs_only"].as_bool().unwrap_or(false) { cmd_args.push("--dirs-only".into()); }
467 if args["human"].as_bool().unwrap_or(true) { cmd_args.push("--human".into()); }
468 if args["icons"].as_bool().unwrap_or(false) { cmd_args.push("--icons".into()); }
469 if args["no_ignore"].as_bool().unwrap_or(false) { cmd_args.push("--no-ignore".into()); }
470 if args["suppress_size"].as_bool().unwrap_or(false) { cmd_args.push("--suppress-size".into()); }
471
472 cmd_args.push(path.into());
473
474 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
475 let (stdout, stderr, success) = run_cmd("erd", &cmd_refs, &self.workspace_root).await
476 .map_err(crate::PawanError::Tool)?;
477
478 Ok(json!({
479 "success": success,
480 "tree": stdout,
481 "tool": "erd",
482 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
483 }))
484 }
485}
486
487pub struct GrepSearchTool {
490 workspace_root: PathBuf,
491}
492
493impl GrepSearchTool {
494 pub fn new(workspace_root: PathBuf) -> Self {
495 Self { workspace_root }
496 }
497}
498
499#[async_trait]
500impl Tool for GrepSearchTool {
501 fn name(&self) -> &str { "grep_search" }
502
503 fn description(&self) -> &str {
504 "Search for a pattern in files using ripgrep. Returns file paths and matching lines. \
505 Prefer this over bash grep — it's faster and respects .gitignore."
506 }
507
508 fn parameters_schema(&self) -> Value {
509 json!({
510 "type": "object",
511 "properties": {
512 "pattern": { "type": "string", "description": "Search pattern (regex)" },
513 "path": { "type": "string", "description": "Path to search (default: workspace)" },
514 "include": { "type": "string", "description": "Glob to include (e.g. '*.rs')" }
515 },
516 "required": ["pattern"]
517 })
518 }
519
520 async fn execute(&self, args: Value) -> crate::Result<Value> {
521 let pattern = args["pattern"].as_str()
522 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
523 let path = args["path"].as_str().unwrap_or(".");
524
525 let mut cmd_args = vec!["--line-number", "--no-heading", "--color", "never", "--max-count", "30"];
526 let include;
527 if let Some(glob) = args["include"].as_str() {
528 include = format!("--glob={}", glob);
529 cmd_args.push(&include);
530 }
531 cmd_args.push(pattern);
532 cmd_args.push(path);
533
534 let cwd = if std::path::Path::new(path).is_absolute() {
535 std::path::PathBuf::from("/")
536 } else {
537 self.workspace_root.clone()
538 };
539
540 let (stdout, _, _) = run_cmd("rg", &cmd_args, &cwd).await
541 .map_err(crate::PawanError::Tool)?;
542
543 let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).take(50).collect();
544
545 Ok(json!({
546 "results": lines.join("\n"),
547 "count": lines.len()
548 }))
549 }
550}
551
552pub struct GlobSearchTool {
555 workspace_root: PathBuf,
556}
557
558impl GlobSearchTool {
559 pub fn new(workspace_root: PathBuf) -> Self {
560 Self { workspace_root }
561 }
562}
563
564#[async_trait]
565impl Tool for GlobSearchTool {
566 fn name(&self) -> &str { "glob_search" }
567
568 fn description(&self) -> &str {
569 "Find files by glob pattern. Returns list of matching file paths. \
570 Uses fd under the hood — fast, respects .gitignore."
571 }
572
573 fn parameters_schema(&self) -> Value {
574 json!({
575 "type": "object",
576 "properties": {
577 "pattern": { "type": "string", "description": "Glob pattern (e.g. '*.rs', 'test_*')" },
578 "path": { "type": "string", "description": "Directory to search (default: workspace)" }
579 },
580 "required": ["pattern"]
581 })
582 }
583
584 async fn execute(&self, args: Value) -> crate::Result<Value> {
585 let pattern = args["pattern"].as_str()
586 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
587 let path = args["path"].as_str().unwrap_or(".");
588
589 let cmd_args = vec!["--glob", pattern, "--color", "never", path];
590 let (stdout, _, _) = run_cmd("fd", &cmd_args, &self.workspace_root).await
591 .map_err(crate::PawanError::Tool)?;
592
593 let files: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
594
595 Ok(json!({
596 "files": files,
597 "count": files.len()
598 }))
599 }
600}
601
602pub struct MiseTool {
605 workspace_root: PathBuf,
606}
607
608impl MiseTool {
609 pub fn new(workspace_root: PathBuf) -> Self {
610 Self { workspace_root }
611 }
612}
613
614#[async_trait]
615impl Tool for MiseTool {
616 fn name(&self) -> &str { "mise" }
617
618 fn description(&self) -> &str {
619 "mise — polyglot tool manager, environment manager, and task runner. Replaces asdf, nvm, \
620 pyenv, direnv, make, and npm scripts. Three powers: (1) install/manage any dev tool or \
621 language runtime, (2) manage per-project env vars, (3) run/watch project tasks. \
622 Pawan should use this to self-install any missing CLI tool (erd, ast-grep, fd, rg, etc)."
623 }
624
625 fn parameters_schema(&self) -> Value {
626 json!({
627 "type": "object",
628 "properties": {
629 "action": {
630 "type": "string",
631 "enum": [
632 "install", "uninstall", "upgrade", "list", "use", "search",
633 "exec", "run", "tasks", "env", "outdated", "prune",
634 "doctor", "self-update", "trust", "watch"
635 ],
636 "description": "Tool management: install, uninstall, upgrade, list, use, search, outdated, prune. \
637 Execution: exec (run with tool env), run (run a task), watch (rerun task on file change). \
638 Environment: env (show/set env vars). Tasks: tasks (list/manage tasks). \
639 Maintenance: doctor, self-update, trust, prune."
640 },
641 "tool": {
642 "type": "string",
643 "description": "Tool name with optional version. Examples: 'erdtree', 'node@22', 'python@3.12', \
644 'ast-grep', 'ripgrep', 'fd', 'sd', 'bat', 'delta', 'jq', 'yq', 'go', 'bun', 'deno'"
645 },
646 "task": {
647 "type": "string",
648 "description": "Task name for run/watch/tasks actions (defined in mise.toml or .mise/tasks/)"
649 },
650 "args": {
651 "type": "string",
652 "description": "Additional arguments (space-separated). For exec: command to run. For run: task args."
653 },
654 "global": {
655 "type": "boolean",
656 "description": "Apply globally (--global flag) instead of project-local. Default: false."
657 }
658 },
659 "required": ["action"]
660 })
661 }
662
663 async fn execute(&self, args: Value) -> crate::Result<Value> {
664 let mise_bin = if binary_exists("mise") {
665 "mise".to_string()
666 } else {
667 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".into());
668 let local = format!("{}/.local/bin/mise", home);
669 if std::path::Path::new(&local).exists() { local } else {
670 return Err(crate::PawanError::Tool(
671 "mise not found. Install: curl https://mise.run | sh".into()
672 ));
673 }
674 };
675
676 let action = args["action"].as_str()
677 .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
678 let global = args["global"].as_bool().unwrap_or(false);
679
680 let cmd_args: Vec<String> = match action {
681 "install" => {
682 let tool = args["tool"].as_str()
683 .ok_or_else(|| crate::PawanError::Tool("tool required for install".into()))?;
684 vec!["install".into(), tool.into(), "-y".into()]
685 }
686 "uninstall" => {
687 let tool = args["tool"].as_str()
688 .ok_or_else(|| crate::PawanError::Tool("tool required for uninstall".into()))?;
689 vec!["uninstall".into(), tool.into()]
690 }
691 "upgrade" => {
692 let mut v = vec!["upgrade".into()];
693 if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
694 v
695 }
696 "list" => vec!["ls".into()],
697 "search" => {
698 let tool = args["tool"].as_str()
699 .ok_or_else(|| crate::PawanError::Tool("tool required for search".into()))?;
700 vec!["registry".into(), tool.into()]
701 }
702 "use" => {
703 let tool = args["tool"].as_str()
704 .ok_or_else(|| crate::PawanError::Tool("tool required for use".into()))?;
705 let mut v = vec!["use".into()];
706 if global { v.push("--global".into()); }
707 v.push(tool.into());
708 v
709 }
710 "outdated" => {
711 let mut v = vec!["outdated".into()];
712 if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
713 v
714 }
715 "prune" => {
716 let mut v = vec!["prune".into(), "-y".into()];
717 if let Some(tool) = args["tool"].as_str() { v.push(tool.into()); }
718 v
719 }
720 "exec" => {
721 let tool = args["tool"].as_str()
722 .ok_or_else(|| crate::PawanError::Tool("tool required for exec".into()))?;
723 let extra = args["args"].as_str().unwrap_or("");
724 let mut v = vec!["exec".into(), tool.into(), "--".into()];
725 if !extra.is_empty() {
726 v.extend(extra.split_whitespace().map(|s| s.to_string()));
727 }
728 v
729 }
730 "run" => {
731 let task = args["task"].as_str()
732 .ok_or_else(|| crate::PawanError::Tool("task required for run".into()))?;
733 let mut v = vec!["run".into(), task.into()];
734 if let Some(extra) = args["args"].as_str() {
735 v.push("--".into());
736 v.extend(extra.split_whitespace().map(|s| s.to_string()));
737 }
738 v
739 }
740 "watch" => {
741 let task = args["task"].as_str()
742 .ok_or_else(|| crate::PawanError::Tool("task required for watch".into()))?;
743 let mut v = vec!["watch".into(), task.into()];
744 if let Some(extra) = args["args"].as_str() {
745 v.push("--".into());
746 v.extend(extra.split_whitespace().map(|s| s.to_string()));
747 }
748 v
749 }
750 "tasks" => vec!["tasks".into(), "ls".into()],
751 "env" => vec!["env".into()],
752 "doctor" => vec!["doctor".into()],
753 "self-update" => vec!["self-update".into(), "-y".into()],
754 "trust" => {
755 let mut v = vec!["trust".into()];
756 if let Some(extra) = args["args"].as_str() { v.push(extra.into()); }
757 v
758 }
759 _ => return Err(crate::PawanError::Tool(
760 format!("Unknown action: {action}. See tool description for available actions.")
761 )),
762 };
763
764 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
765 let (stdout, stderr, success) = run_cmd(&mise_bin, &cmd_refs, &self.workspace_root).await
766 .map_err(crate::PawanError::Tool)?;
767
768 Ok(json!({
769 "success": success,
770 "action": action,
771 "output": stdout,
772 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
773 }))
774 }
775}
776
777pub struct ZoxideTool {
780 workspace_root: PathBuf,
781}
782
783impl ZoxideTool {
784 pub fn new(workspace_root: PathBuf) -> Self {
785 Self { workspace_root }
786 }
787}
788
789#[async_trait]
790impl Tool for ZoxideTool {
791 fn name(&self) -> &str { "z" }
792
793 fn description(&self) -> &str {
794 "zoxide — smart directory jumper. Learns from your cd history. \
795 Use 'query' to find a directory by fuzzy match (e.g. 'pawan' finds /opt/pawan). \
796 Use 'add' to teach it a new path. Use 'list' to see known paths."
797 }
798
799 fn parameters_schema(&self) -> Value {
800 json!({
801 "type": "object",
802 "properties": {
803 "action": { "type": "string", "description": "query, add, or list" },
804 "path": { "type": "string", "description": "Path or search term" }
805 },
806 "required": ["action"]
807 })
808 }
809
810 async fn execute(&self, args: Value) -> crate::Result<Value> {
811 let action = args["action"].as_str()
812 .ok_or_else(|| crate::PawanError::Tool("action required (query/add/list)".into()))?;
813
814 let cmd_args: Vec<String> = match action {
815 "query" => {
816 let path = args["path"].as_str()
817 .ok_or_else(|| crate::PawanError::Tool("path/search term required for query".into()))?;
818 vec!["query".into(), path.into()]
819 }
820 "add" => {
821 let path = args["path"].as_str()
822 .ok_or_else(|| crate::PawanError::Tool("path required for add".into()))?;
823 vec!["add".into(), path.into()]
824 }
825 "list" => vec!["query".into(), "--list".into()],
826 _ => return Err(crate::PawanError::Tool(format!("Unknown action: {}. Use query/add/list", action))),
827 };
828
829 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
830 let (stdout, stderr, success) = run_cmd("zoxide", &cmd_refs, &self.workspace_root).await
831 .map_err(crate::PawanError::Tool)?;
832
833 Ok(json!({
834 "success": success,
835 "result": stdout.trim(),
836 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
837 }))
838 }
839}
840
841pub struct AstGrepTool {
844 workspace_root: PathBuf,
845}
846
847impl AstGrepTool {
848 pub fn new(workspace_root: PathBuf) -> Self {
849 Self { workspace_root }
850 }
851}
852
853#[async_trait]
854impl Tool for AstGrepTool {
855 fn name(&self) -> &str { "ast_grep" }
856
857 fn description(&self) -> &str {
858 "ast-grep — structural code search and rewrite using AST patterns. \
859 Unlike regex, this matches code by syntax tree structure. Use $NAME for \
860 single-node wildcards, $$$ARGS for variadic (multiple nodes). \
861 Actions: 'search' finds matches, 'rewrite' transforms them in-place. \
862 Examples: pattern='fn $NAME($$$ARGS)' finds all functions. \
863 pattern='$EXPR.unwrap()' rewrite='$EXPR?' replaces unwrap with ?. \
864 Supports: rust, python, javascript, typescript, go, c, cpp, java."
865 }
866
867 fn parameters_schema(&self) -> Value {
868 json!({
869 "type": "object",
870 "properties": {
871 "action": {
872 "type": "string",
873 "enum": ["search", "rewrite"],
874 "description": "search: find matching code. rewrite: transform matching code in-place."
875 },
876 "pattern": {
877 "type": "string",
878 "description": "AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'"
879 },
880 "rewrite": {
881 "type": "string",
882 "description": "Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'"
883 },
884 "path": {
885 "type": "string",
886 "description": "File or directory to search/rewrite"
887 },
888 "lang": {
889 "type": "string",
890 "description": "Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)"
891 }
892 },
893 "required": ["action", "pattern", "path"]
894 })
895 }
896
897 async fn execute(&self, args: Value) -> crate::Result<Value> {
898 ensure_binary("ast-grep", &self.workspace_root).await?;
899
900 let action = args["action"].as_str()
901 .ok_or_else(|| crate::PawanError::Tool("action required (search or rewrite)".into()))?;
902 let pattern = args["pattern"].as_str()
903 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
904 let path = args["path"].as_str()
905 .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
906
907 let mut cmd_args: Vec<String> = vec!["run".into()];
908
909 if let Some(lang) = args["lang"].as_str() {
911 cmd_args.push("--lang".into());
912 cmd_args.push(lang.into());
913 }
914
915 cmd_args.push("--pattern".into());
917 cmd_args.push(pattern.into());
918
919 match action {
920 "search" => {
921 cmd_args.push(path.into());
923 }
924 "rewrite" => {
925 let rewrite = args["rewrite"].as_str()
926 .ok_or_else(|| crate::PawanError::Tool("rewrite pattern required for action=rewrite".into()))?;
927 cmd_args.push("--rewrite".into());
928 cmd_args.push(rewrite.into());
929 cmd_args.push("--update-all".into());
930 cmd_args.push(path.into());
931 }
932 _ => {
933 return Err(crate::PawanError::Tool(
934 format!("Unknown action: {}. Use 'search' or 'rewrite'", action),
935 ));
936 }
937 }
938
939 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
940 let (stdout, stderr, success) = run_cmd("ast-grep", &cmd_refs, &self.workspace_root).await
941 .map_err(crate::PawanError::Tool)?;
942
943 let match_count = stdout.lines()
945 .filter(|l| l.starts_with('/') || l.contains("│"))
946 .count();
947
948 Ok(json!({
949 "success": success,
950 "action": action,
951 "matches": match_count,
952 "output": stdout,
953 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
954 }))
955 }
956}
957
958pub struct LspTool {
961 workspace_root: PathBuf,
962}
963
964impl LspTool {
965 pub fn new(workspace_root: PathBuf) -> Self {
966 Self { workspace_root }
967 }
968}
969
970#[async_trait]
971impl Tool for LspTool {
972 fn name(&self) -> &str { "lsp" }
973
974 fn description(&self) -> &str {
975 "LSP code intelligence via rust-analyzer. Provides type-aware code understanding \
976 that grep/ast-grep can't: diagnostics without cargo check, structural search with \
977 type info, symbol extraction, and analysis stats. Actions: diagnostics (find errors), \
978 search (structural pattern search), ssr (structural search+replace with types), \
979 symbols (parse file symbols), analyze (project-wide type stats)."
980 }
981
982 fn parameters_schema(&self) -> Value {
983 json!({
984 "type": "object",
985 "properties": {
986 "action": {
987 "type": "string",
988 "enum": ["diagnostics", "search", "ssr", "symbols", "analyze"],
989 "description": "diagnostics: find errors/warnings in project. \
990 search: structural pattern search (e.g. '$a.foo($b)'). \
991 ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
992 symbols: parse file and list symbols. \
993 analyze: project-wide type analysis stats."
994 },
995 "pattern": {
996 "type": "string",
997 "description": "For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'"
998 },
999 "path": {
1000 "type": "string",
1001 "description": "Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols."
1002 },
1003 "severity": {
1004 "type": "string",
1005 "enum": ["error", "warning", "info", "hint"],
1006 "description": "Minimum severity for diagnostics (default: warning)"
1007 }
1008 },
1009 "required": ["action"]
1010 })
1011 }
1012
1013 async fn execute(&self, args: Value) -> crate::Result<Value> {
1014 ensure_binary("rust-analyzer", &self.workspace_root).await?;
1015
1016 let action = args["action"].as_str()
1017 .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
1018
1019 let timeout_dur = std::time::Duration::from_secs(60);
1020
1021 match action {
1022 "diagnostics" => {
1023 let path = args["path"].as_str()
1024 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1025 let mut cmd_args = vec!["diagnostics", path];
1026 if let Some(sev) = args["severity"].as_str() {
1027 cmd_args.extend(["--severity", sev]);
1028 }
1029 let result = tokio::time::timeout(timeout_dur,
1030 run_cmd("rust-analyzer", &cmd_args, &self.workspace_root)
1031 ).await;
1032 match result {
1033 Ok(Ok((stdout, stderr, success))) => Ok(json!({
1034 "success": success,
1035 "diagnostics": stdout,
1036 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1037 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1038 })),
1039 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1040 Err(_) => Err(crate::PawanError::Tool("rust-analyzer diagnostics timed out (60s)".into())),
1041 }
1042 }
1043 "search" => {
1044 let pattern = args["pattern"].as_str()
1045 .ok_or_else(|| crate::PawanError::Tool("pattern required for search".into()))?;
1046 let result = tokio::time::timeout(timeout_dur,
1047 run_cmd("rust-analyzer", &["search", pattern], &self.workspace_root)
1048 ).await;
1049 match result {
1050 Ok(Ok((stdout, stderr, success))) => Ok(json!({
1051 "success": success,
1052 "matches": stdout,
1053 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
1054 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1055 })),
1056 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1057 Err(_) => Err(crate::PawanError::Tool("rust-analyzer search timed out (60s)".into())),
1058 }
1059 }
1060 "ssr" => {
1061 let pattern = args["pattern"].as_str()
1062 .ok_or_else(|| crate::PawanError::Tool(
1063 "pattern required for ssr (format: '$a.unwrap() ==>> $a?')".into()
1064 ))?;
1065 let result = tokio::time::timeout(timeout_dur,
1066 run_cmd("rust-analyzer", &["ssr", pattern], &self.workspace_root)
1067 ).await;
1068 match result {
1069 Ok(Ok((stdout, stderr, success))) => Ok(json!({
1070 "success": success,
1071 "output": stdout,
1072 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1073 })),
1074 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1075 Err(_) => Err(crate::PawanError::Tool("rust-analyzer ssr timed out (60s)".into())),
1076 }
1077 }
1078 "symbols" => {
1079 let path = args["path"].as_str()
1081 .ok_or_else(|| crate::PawanError::Tool("path required for symbols".into()))?;
1082 let full_path = if std::path::Path::new(path).is_absolute() {
1083 PathBuf::from(path)
1084 } else {
1085 self.workspace_root.join(path)
1086 };
1087 let content = tokio::fs::read_to_string(&full_path).await
1088 .map_err(|e| crate::PawanError::Tool(format!("Failed to read {}: {}", path, e)))?;
1089
1090 let mut child = tokio::process::Command::new("rust-analyzer")
1091 .arg("symbols")
1092 .stdin(Stdio::piped())
1093 .stdout(Stdio::piped())
1094 .stderr(Stdio::piped())
1095 .spawn()
1096 .map_err(|e| crate::PawanError::Tool(format!("Failed to spawn rust-analyzer: {}", e)))?;
1097
1098 if let Some(mut stdin) = child.stdin.take() {
1100 use tokio::io::AsyncWriteExt;
1101 let _ = stdin.write_all(content.as_bytes()).await;
1102 drop(stdin);
1103 }
1104
1105 let output = child.wait_with_output().await
1106 .map_err(|e| crate::PawanError::Tool(format!("rust-analyzer symbols failed: {}", e)))?;
1107
1108 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1109 Ok(json!({
1110 "success": output.status.success(),
1111 "symbols": stdout,
1112 "count": stdout.lines().filter(|l| !l.is_empty()).count()
1113 }))
1114 }
1115 "analyze" => {
1116 let path = args["path"].as_str()
1117 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
1118 let result = tokio::time::timeout(timeout_dur,
1119 run_cmd("rust-analyzer", &["analysis-stats", "--skip-inference", path], &self.workspace_root)
1120 ).await;
1121 match result {
1122 Ok(Ok((stdout, stderr, success))) => Ok(json!({
1123 "success": success,
1124 "stats": stdout,
1125 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
1126 })),
1127 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
1128 Err(_) => Err(crate::PawanError::Tool("rust-analyzer analysis-stats timed out (60s)".into())),
1129 }
1130 }
1131 _ => Err(crate::PawanError::Tool(
1132 format!("Unknown action: {action}. Use diagnostics/search/ssr/symbols/analyze")
1133 )),
1134 }
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use tempfile::TempDir;
1142
1143 #[test]
1144 fn test_binary_exists_cargo() {
1145 assert!(binary_exists("cargo"));
1146 }
1147
1148 #[test]
1149 fn test_binary_exists_nonexistent() {
1150 assert!(!binary_exists("nonexistent_binary_xyz_123"));
1151 }
1152
1153 #[test]
1154 fn test_mise_package_name_mapping() {
1155 assert_eq!(mise_package_name("rg"), "ripgrep");
1156 assert_eq!(mise_package_name("fd"), "fd");
1157 assert_eq!(mise_package_name("sg"), "ast-grep");
1158 assert_eq!(mise_package_name("erd"), "erdtree");
1159 assert_eq!(mise_package_name("unknown"), "unknown");
1160 }
1161
1162 #[tokio::test]
1163 async fn test_ast_grep_tool_schema() {
1164 let tmp = TempDir::new().unwrap();
1165 let tool = AstGrepTool::new(tmp.path().to_path_buf());
1166 assert_eq!(tool.name(), "ast_grep");
1167 let schema = tool.parameters_schema();
1168 assert!(schema["properties"]["action"].is_object());
1169 assert!(schema["properties"]["pattern"].is_object());
1170 }
1171
1172 #[tokio::test]
1173 async fn test_lsp_tool_schema() {
1174 let tmp = TempDir::new().unwrap();
1175 let tool = LspTool::new(tmp.path().to_path_buf());
1176 assert_eq!(tool.name(), "lsp");
1177 let schema = tool.parameters_schema();
1178 assert!(schema["properties"]["action"].is_object());
1179 }
1180
1181 #[tokio::test]
1182 async fn test_erd_tool_schema() {
1183 let tmp = TempDir::new().unwrap();
1184 let tool = ErdTool::new(tmp.path().to_path_buf());
1185 assert_eq!(tool.name(), "tree");
1186 let schema = tool.parameters_schema();
1187 assert!(schema["properties"]["disk_usage"].is_object());
1188 assert!(schema["properties"]["layout"].is_object());
1189 }
1190
1191 #[tokio::test]
1192 async fn test_mise_tool_schema() {
1193 let tmp = TempDir::new().unwrap();
1194 let tool = MiseTool::new(tmp.path().to_path_buf());
1195 assert_eq!(tool.name(), "mise");
1196 let schema = tool.parameters_schema();
1197 assert!(schema["properties"]["action"].is_object());
1198 assert!(schema["properties"]["task"].is_object());
1199 }
1200}
1201