1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::path::Path;
8use tokio::fs;
9
10pub struct TreeTool;
12
13impl TreeTool {
14 pub fn new() -> Self {
15 Self
16 }
17}
18
19#[async_trait]
20impl Tool for TreeTool {
21 fn id(&self) -> &str {
22 "tree"
23 }
24
25 fn name(&self) -> &str {
26 "Directory Tree"
27 }
28
29 fn description(&self) -> &str {
30 "tree(path: string, depth?: int, show_hidden?: bool, show_size?: bool) - Display a tree view of directory structure. Great for understanding project layout."
31 }
32
33 fn parameters(&self) -> Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "path": {
38 "type": "string",
39 "description": "The root directory to display"
40 },
41 "depth": {
42 "type": "integer",
43 "description": "Maximum depth to traverse (default: 3)",
44 "default": 3
45 },
46 "show_hidden": {
47 "type": "boolean",
48 "description": "Show hidden files (default: false)",
49 "default": false
50 },
51 "show_size": {
52 "type": "boolean",
53 "description": "Show file sizes (default: false)",
54 "default": false
55 },
56 "gitignore": {
57 "type": "boolean",
58 "description": "Respect .gitignore rules (default: true)",
59 "default": true
60 }
61 },
62 "required": ["path"],
63 "example": {
64 "path": "src/",
65 "depth": 2,
66 "show_size": true
67 }
68 })
69 }
70
71 async fn execute(&self, args: Value) -> Result<ToolResult> {
72 let path = match args["path"].as_str() {
73 Some(p) => p,
74 None => {
75 return Ok(ToolResult::structured_error(
76 "INVALID_ARGUMENT",
77 "tree",
78 "path is required",
79 Some(vec!["path"]),
80 Some(json!({"path": "src/"})),
81 ));
82 }
83 };
84 let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
85 let show_hidden = args["show_hidden"].as_bool().unwrap_or(false);
86 let show_size = args["show_size"].as_bool().unwrap_or(false);
87 let respect_gitignore = args["gitignore"].as_bool().unwrap_or(true);
88
89 let mut output = Vec::new();
90 let root_path = Path::new(path);
91
92 output.push(format!("{}/", root_path.file_name().unwrap_or_default().to_string_lossy()));
94
95 let mut file_count = 0;
96 let mut dir_count = 0;
97
98 build_tree(
100 root_path,
101 "",
102 0,
103 max_depth,
104 show_hidden,
105 show_size,
106 respect_gitignore,
107 &mut output,
108 &mut file_count,
109 &mut dir_count,
110 ).await?;
111
112 output.push(String::new());
113 output.push(format!("{} directories, {} files", dir_count, file_count));
114
115 Ok(ToolResult::success(output.join("\n"))
116 .with_metadata("directories", json!(dir_count))
117 .with_metadata("files", json!(file_count)))
118 }
119}
120
121struct TreeEntry {
123 name: String,
124 path: std::path::PathBuf,
125 is_dir: bool,
126 size: u64,
127}
128
129async fn build_tree(
131 path: &Path,
132 prefix: &str,
133 depth: usize,
134 max_depth: usize,
135 show_hidden: bool,
136 show_size: bool,
137 respect_gitignore: bool,
138 output: &mut Vec<String>,
139 file_count: &mut usize,
140 dir_count: &mut usize,
141) -> Result<()> {
142 if depth >= max_depth {
143 return Ok(());
144 }
145
146 let mut entries: Vec<TreeEntry> = Vec::new();
148
149 let mut dir = match fs::read_dir(path).await {
150 Ok(d) => d,
151 Err(_) => return Ok(()),
152 };
153
154 while let Ok(Some(entry)) = dir.next_entry().await {
155 let name = entry.file_name().to_string_lossy().to_string();
156
157 if !show_hidden && name.starts_with('.') {
159 continue;
160 }
161
162 if respect_gitignore {
164 let skip_dirs = ["node_modules", "target", ".git", "__pycache__", ".venv", "dist", ".next", "vendor"];
165 if skip_dirs.contains(&name.as_str()) {
166 continue;
167 }
168 }
169
170 let file_type = match entry.file_type().await {
171 Ok(ft) => ft,
172 Err(_) => continue,
173 };
174
175 let size = if show_size {
176 entry.metadata().await.map(|m| m.len()).unwrap_or(0)
177 } else {
178 0
179 };
180
181 entries.push(TreeEntry {
182 name,
183 path: entry.path(),
184 is_dir: file_type.is_dir(),
185 size,
186 });
187 }
188
189 entries.sort_by(|a, b| {
191 match (a.is_dir, b.is_dir) {
192 (true, false) => std::cmp::Ordering::Less,
193 (false, true) => std::cmp::Ordering::Greater,
194 _ => a.name.cmp(&b.name),
195 }
196 });
197
198 let total = entries.len();
199 for (idx, entry) in entries.iter().enumerate() {
200 let is_last = idx == total - 1;
201 let connector = if is_last { "└── " } else { "├── " };
202
203 let mut line = format!("{}{}", prefix, connector);
204
205 if entry.is_dir {
206 *dir_count += 1;
207 line.push_str(&format!("{}/", entry.name));
208 } else {
209 *file_count += 1;
210 if show_size {
211 let size = format_size(entry.size);
212 line.push_str(&format!("{} ({})", entry.name, size));
213 } else {
214 line.push_str(&entry.name);
215 }
216 }
217
218 output.push(line);
219
220 if entry.is_dir {
222 let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
223 Box::pin(build_tree(
224 &entry.path,
225 &new_prefix,
226 depth + 1,
227 max_depth,
228 show_hidden,
229 show_size,
230 respect_gitignore,
231 output,
232 file_count,
233 dir_count,
234 )).await?;
235 }
236 }
237
238 Ok(())
239}
240
241fn format_size(bytes: u64) -> String {
243 const KB: u64 = 1024;
244 const MB: u64 = KB * 1024;
245 const GB: u64 = MB * 1024;
246
247 if bytes >= GB {
248 format!("{:.1}G", bytes as f64 / GB as f64)
249 } else if bytes >= MB {
250 format!("{:.1}M", bytes as f64 / MB as f64)
251 } else if bytes >= KB {
252 format!("{:.1}K", bytes as f64 / KB as f64)
253 } else {
254 format!("{}B", bytes)
255 }
256}
257
258pub struct FileInfoTool;
260
261impl FileInfoTool {
262 pub fn new() -> Self {
263 Self
264 }
265}
266
267#[async_trait]
268impl Tool for FileInfoTool {
269 fn id(&self) -> &str {
270 "fileinfo"
271 }
272
273 fn name(&self) -> &str {
274 "File Info"
275 }
276
277 fn description(&self) -> &str {
278 "fileinfo(path: string) - Get detailed information about a file: size, type, permissions, line count, encoding detection, and language."
279 }
280
281 fn parameters(&self) -> Value {
282 json!({
283 "type": "object",
284 "properties": {
285 "path": {
286 "type": "string",
287 "description": "The path to the file to inspect"
288 }
289 },
290 "required": ["path"],
291 "example": {
292 "path": "src/main.rs"
293 }
294 })
295 }
296
297 async fn execute(&self, args: Value) -> Result<ToolResult> {
298 let path = match args["path"].as_str() {
299 Some(p) => p,
300 None => {
301 return Ok(ToolResult::structured_error(
302 "INVALID_ARGUMENT",
303 "fileinfo",
304 "path is required",
305 Some(vec!["path"]),
306 Some(json!({"path": "src/main.rs"})),
307 ));
308 }
309 };
310
311 let path_obj = Path::new(path);
312 let metadata = fs::metadata(path).await?;
313
314 let mut info = Vec::new();
315
316 info.push(format!("Path: {}", path));
318 info.push(format!("Size: {} ({} bytes)", format_size(metadata.len()), metadata.len()));
319
320 let file_type = if metadata.is_dir() {
322 "directory"
323 } else if metadata.is_symlink() {
324 "symlink"
325 } else {
326 "file"
327 };
328 info.push(format!("Type: {}", file_type));
329
330 #[cfg(unix)]
332 {
333 use std::os::unix::fs::PermissionsExt;
334 let mode = metadata.permissions().mode();
335 info.push(format!("Permissions: {:o}", mode & 0o777));
336 }
337
338 if let Ok(modified) = metadata.modified() {
340 if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
341 let secs = duration.as_secs();
342 info.push(format!("Modified: {} seconds since epoch", secs));
343 }
344 }
345
346 if metadata.is_file() {
348 if let Some(ext) = path_obj.extension() {
350 let lang = match ext.to_str().unwrap_or("") {
351 "rs" => "Rust",
352 "py" => "Python",
353 "js" => "JavaScript",
354 "ts" => "TypeScript",
355 "tsx" => "TypeScript (React)",
356 "jsx" => "JavaScript (React)",
357 "go" => "Go",
358 "java" => "Java",
359 "c" | "h" => "C",
360 "cpp" | "hpp" | "cc" | "cxx" => "C++",
361 "rb" => "Ruby",
362 "php" => "PHP",
363 "swift" => "Swift",
364 "kt" | "kts" => "Kotlin",
365 "scala" => "Scala",
366 "cs" => "C#",
367 "md" => "Markdown",
368 "json" => "JSON",
369 "yaml" | "yml" => "YAML",
370 "toml" => "TOML",
371 "xml" => "XML",
372 "html" => "HTML",
373 "css" => "CSS",
374 "scss" | "sass" => "SCSS/Sass",
375 "sql" => "SQL",
376 "sh" | "bash" | "zsh" => "Shell",
377 _ => "Unknown",
378 };
379 info.push(format!("Language: {}", lang));
380 }
381
382 if let Ok(content) = fs::read_to_string(path).await {
384 let lines = content.lines().count();
385 let chars = content.chars().count();
386 let words = content.split_whitespace().count();
387
388 info.push(format!("Lines: {}", lines));
389 info.push(format!("Words: {}", words));
390 info.push(format!("Characters: {}", chars));
391
392 info.push("Encoding: UTF-8 (text)".to_string());
394 } else {
395 info.push("Encoding: Binary or non-UTF-8".to_string());
396 }
397 }
398
399 Ok(ToolResult::success(info.join("\n")))
400 }
401}
402
403pub struct HeadTailTool;
405
406impl HeadTailTool {
407 pub fn new() -> Self {
408 Self
409 }
410}
411
412#[async_trait]
413impl Tool for HeadTailTool {
414 fn id(&self) -> &str {
415 "headtail"
416 }
417
418 fn name(&self) -> &str {
419 "Head/Tail"
420 }
421
422 fn description(&self) -> &str {
423 "headtail(path: string, head?: int, tail?: int) - Quickly peek at the beginning and/or end of a file. Useful for understanding file structure without reading the entire file."
424 }
425
426 fn parameters(&self) -> Value {
427 json!({
428 "type": "object",
429 "properties": {
430 "path": {
431 "type": "string",
432 "description": "The path to the file"
433 },
434 "head": {
435 "type": "integer",
436 "description": "Number of lines from the beginning (default: 10)",
437 "default": 10
438 },
439 "tail": {
440 "type": "integer",
441 "description": "Number of lines from the end (default: 0, set to show tail)",
442 "default": 0
443 }
444 },
445 "required": ["path"],
446 "example": {
447 "path": "src/main.rs",
448 "head": 20,
449 "tail": 10
450 }
451 })
452 }
453
454 async fn execute(&self, args: Value) -> Result<ToolResult> {
455 let path = match args["path"].as_str() {
456 Some(p) => p,
457 None => {
458 return Ok(ToolResult::structured_error(
459 "INVALID_ARGUMENT",
460 "headtail",
461 "path is required",
462 Some(vec!["path"]),
463 Some(json!({"path": "src/main.rs", "head": 10})),
464 ));
465 }
466 };
467 let head_lines = args["head"].as_u64().unwrap_or(10) as usize;
468 let tail_lines = args["tail"].as_u64().unwrap_or(0) as usize;
469
470 let content = fs::read_to_string(path).await?;
471 let lines: Vec<&str> = content.lines().collect();
472 let total_lines = lines.len();
473
474 let mut output = Vec::new();
475 output.push(format!("=== {} ({} lines total) ===", path, total_lines));
476 output.push(String::new());
477
478 if head_lines > 0 {
480 output.push(format!("--- First {} lines ---", head_lines.min(total_lines)));
481 for (i, line) in lines.iter().take(head_lines).enumerate() {
482 output.push(format!("{:4} | {}", i + 1, line));
483 }
484 }
485
486 let head_end = head_lines;
488 let tail_start = total_lines.saturating_sub(tail_lines);
489
490 if tail_lines > 0 && tail_start > head_end {
491 output.push(String::new());
492 output.push(format!("... ({} lines omitted) ...", tail_start - head_end));
493 output.push(String::new());
494 output.push(format!("--- Last {} lines ---", tail_lines.min(total_lines)));
495 for (i, line) in lines.iter().skip(tail_start).enumerate() {
496 output.push(format!("{:4} | {}", tail_start + i + 1, line));
497 }
498 } else if tail_lines > 0 && tail_start <= head_end {
499 if head_end < total_lines {
501 for (i, line) in lines.iter().skip(head_end).enumerate() {
502 output.push(format!("{:4} | {}", head_end + i + 1, line));
503 }
504 }
505 }
506
507 Ok(ToolResult::success(output.join("\n"))
508 .with_metadata("total_lines", json!(total_lines)))
509 }
510}
511
512pub struct DiffTool;
514
515impl DiffTool {
516 pub fn new() -> Self {
517 Self
518 }
519}
520
521#[async_trait]
522impl Tool for DiffTool {
523 fn id(&self) -> &str {
524 "diff"
525 }
526
527 fn name(&self) -> &str {
528 "Diff"
529 }
530
531 fn description(&self) -> &str {
532 "diff(file1?: string, file2?: string, git?: bool, staged?: bool) - Compare two files or show git changes. Use git=true for uncommitted changes, staged=true for staged changes."
533 }
534
535 fn parameters(&self) -> Value {
536 json!({
537 "type": "object",
538 "properties": {
539 "file1": {
540 "type": "string",
541 "description": "First file to compare (or file for git diff)"
542 },
543 "file2": {
544 "type": "string",
545 "description": "Second file to compare"
546 },
547 "git": {
548 "type": "boolean",
549 "description": "Show git diff for uncommitted changes (default: false)",
550 "default": false
551 },
552 "staged": {
553 "type": "boolean",
554 "description": "Show git diff for staged changes (default: false)",
555 "default": false
556 },
557 "context": {
558 "type": "integer",
559 "description": "Lines of context around changes (default: 3)",
560 "default": 3
561 }
562 },
563 "example": {
564 "git": true,
565 "file1": "src/main.rs"
566 }
567 })
568 }
569
570 async fn execute(&self, args: Value) -> Result<ToolResult> {
571 let git_mode = args["git"].as_bool().unwrap_or(false);
572 let staged = args["staged"].as_bool().unwrap_or(false);
573 let context = args["context"].as_u64().unwrap_or(3);
574
575 if git_mode {
576 let mut cmd = tokio::process::Command::new("git");
578 cmd.arg("diff");
579
580 if staged {
581 cmd.arg("--staged");
582 }
583
584 cmd.arg(format!("-U{}", context));
585
586 if let Some(file) = args["file1"].as_str() {
587 cmd.arg("--").arg(file);
588 }
589
590 let output = cmd.output().await?;
591
592 if output.status.success() {
593 let diff = String::from_utf8_lossy(&output.stdout);
594 if diff.is_empty() {
595 Ok(ToolResult::success("No changes detected"))
596 } else {
597 Ok(ToolResult::success(diff.to_string()))
598 }
599 } else {
600 let error = String::from_utf8_lossy(&output.stderr);
601 Ok(ToolResult::error(format!("Git diff failed: {}", error)))
602 }
603 } else {
604 let file1 = match args["file1"].as_str() {
606 Some(f) => f,
607 None => {
608 return Ok(ToolResult::structured_error(
609 "INVALID_ARGUMENT",
610 "diff",
611 "file1 is required for file comparison (or use git=true)",
612 Some(vec!["file1"]),
613 Some(json!({"file1": "old.txt", "file2": "new.txt"})),
614 ));
615 }
616 };
617 let file2 = match args["file2"].as_str() {
618 Some(f) => f,
619 None => {
620 return Ok(ToolResult::structured_error(
621 "INVALID_ARGUMENT",
622 "diff",
623 "file2 is required for file comparison",
624 Some(vec!["file2"]),
625 Some(json!({"file1": file1, "file2": "new.txt"})),
626 ));
627 }
628 };
629
630 let output = tokio::process::Command::new("diff")
632 .arg("-u")
633 .arg(format!("--label={}", file1))
634 .arg(format!("--label={}", file2))
635 .arg(file1)
636 .arg(file2)
637 .output()
638 .await?;
639
640 let diff = String::from_utf8_lossy(&output.stdout);
641 if diff.is_empty() && output.status.success() {
642 Ok(ToolResult::success("Files are identical"))
643 } else {
644 Ok(ToolResult::success(diff.to_string()))
645 }
646 }
647 }
648}