1use super::{Tool, ToolResult};
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use std::path::Path;
8use tokio::fs;
9
10pub struct TreeTool;
12
13impl Default for TreeTool {
14 fn default() -> Self {
15 Self::new()
16 }
17}
18
19impl TreeTool {
20 pub fn new() -> Self {
21 Self
22 }
23}
24
25#[async_trait]
26impl Tool for TreeTool {
27 fn id(&self) -> &str {
28 "tree"
29 }
30
31 fn name(&self) -> &str {
32 "Directory Tree"
33 }
34
35 fn description(&self) -> &str {
36 "tree(path: string, depth?: int, show_hidden?: bool, show_size?: bool) - Display a tree view of directory structure. Great for understanding project layout."
37 }
38
39 fn parameters(&self) -> Value {
40 json!({
41 "type": "object",
42 "properties": {
43 "path": {
44 "type": "string",
45 "description": "The root directory to display"
46 },
47 "depth": {
48 "type": "integer",
49 "description": "Maximum depth to traverse (default: 3)",
50 "default": 3
51 },
52 "show_hidden": {
53 "type": "boolean",
54 "description": "Show hidden files (default: false)",
55 "default": false
56 },
57 "show_size": {
58 "type": "boolean",
59 "description": "Show file sizes (default: false)",
60 "default": false
61 },
62 "gitignore": {
63 "type": "boolean",
64 "description": "Respect .gitignore rules (default: true)",
65 "default": true
66 }
67 },
68 "required": ["path"],
69 "example": {
70 "path": "src/",
71 "depth": 2,
72 "show_size": true
73 }
74 })
75 }
76
77 async fn execute(&self, args: Value) -> Result<ToolResult> {
78 let path = match args["path"].as_str() {
79 Some(p) => p,
80 None => {
81 return Ok(ToolResult::structured_error(
82 "INVALID_ARGUMENT",
83 "tree",
84 "path is required",
85 Some(vec!["path"]),
86 Some(json!({"path": "src/"})),
87 ));
88 }
89 };
90 let max_depth = args["depth"].as_u64().unwrap_or(3) as usize;
91 let show_hidden = args["show_hidden"].as_bool().unwrap_or(false);
92 let show_size = args["show_size"].as_bool().unwrap_or(false);
93 let respect_gitignore = args["gitignore"].as_bool().unwrap_or(true);
94
95 let mut output = Vec::new();
96 let root_path = Path::new(path);
97
98 output.push(format!(
100 "{}/",
101 root_path.file_name().unwrap_or_default().to_string_lossy()
102 ));
103
104 let mut file_count = 0;
105 let mut dir_count = 0;
106
107 build_tree(
109 root_path,
110 "",
111 0,
112 max_depth,
113 show_hidden,
114 show_size,
115 respect_gitignore,
116 &mut output,
117 &mut file_count,
118 &mut dir_count,
119 )
120 .await?;
121
122 output.push(String::new());
123 output.push(format!("{} directories, {} files", dir_count, file_count));
124
125 Ok(ToolResult::success(output.join("\n"))
126 .with_metadata("directories", json!(dir_count))
127 .with_metadata("files", json!(file_count)))
128 }
129}
130
131struct TreeEntry {
133 name: String,
134 path: std::path::PathBuf,
135 is_dir: bool,
136 size: u64,
137}
138
139async fn build_tree(
141 path: &Path,
142 prefix: &str,
143 depth: usize,
144 max_depth: usize,
145 show_hidden: bool,
146 show_size: bool,
147 respect_gitignore: bool,
148 output: &mut Vec<String>,
149 file_count: &mut usize,
150 dir_count: &mut usize,
151) -> Result<()> {
152 if depth >= max_depth {
153 return Ok(());
154 }
155
156 let mut entries: Vec<TreeEntry> = Vec::new();
158
159 let mut dir = fs::read_dir(path)
160 .await
161 .with_context(|| format!("Failed to read directory: {}", path.display()))?;
162
163 loop {
164 match dir.next_entry().await {
165 Ok(Some(entry)) => {
166 let name = entry.file_name().to_string_lossy().to_string();
167
168 if !show_hidden && name.starts_with('.') {
170 continue;
171 }
172
173 if respect_gitignore {
175 let skip_dirs = [
176 "node_modules",
177 "target",
178 ".git",
179 "__pycache__",
180 ".venv",
181 "dist",
182 ".next",
183 "vendor",
184 ];
185 if skip_dirs.contains(&name.as_str()) {
186 continue;
187 }
188 }
189
190 let file_type = match entry.file_type().await {
191 Ok(ft) => ft,
192 Err(e) => {
193 tracing::warn!(path = %entry.path().display(), error = %e, "Failed to get file type, skipping");
194 continue;
195 }
196 };
197
198 let size = if show_size {
199 entry.metadata().await.map(|m| m.len()).unwrap_or(0)
200 } else {
201 0
202 };
203
204 entries.push(TreeEntry {
205 name,
206 path: entry.path(),
207 is_dir: file_type.is_dir(),
208 size,
209 });
210 }
211 Ok(None) => break, Err(e) => {
213 tracing::warn!(path = %path.display(), error = %e, "Error reading directory entry, continuing");
214 continue;
215 }
216 }
217 }
218
219 entries.sort_by(|a, b| match (a.is_dir, b.is_dir) {
221 (true, false) => std::cmp::Ordering::Less,
222 (false, true) => std::cmp::Ordering::Greater,
223 _ => a.name.cmp(&b.name),
224 });
225
226 let total = entries.len();
227 for (idx, entry) in entries.iter().enumerate() {
228 let is_last = idx == total - 1;
229 let connector = if is_last { "└── " } else { "├── " };
230
231 let mut line = format!("{}{}", prefix, connector);
232
233 if entry.is_dir {
234 *dir_count += 1;
235 line.push_str(&format!("{}/", entry.name));
236 } else {
237 *file_count += 1;
238 if show_size {
239 let size = format_size(entry.size);
240 line.push_str(&format!("{} ({})", entry.name, size));
241 } else {
242 line.push_str(&entry.name);
243 }
244 }
245
246 output.push(line);
247
248 if entry.is_dir {
250 let new_prefix = format!("{}{}", prefix, if is_last { " " } else { "│ " });
251 Box::pin(build_tree(
252 &entry.path,
253 &new_prefix,
254 depth + 1,
255 max_depth,
256 show_hidden,
257 show_size,
258 respect_gitignore,
259 output,
260 file_count,
261 dir_count,
262 ))
263 .await?;
264 }
265 }
266
267 Ok(())
268}
269
270fn format_size(bytes: u64) -> String {
272 const KB: u64 = 1024;
273 const MB: u64 = KB * 1024;
274 const GB: u64 = MB * 1024;
275
276 if bytes >= GB {
277 format!("{:.1}G", bytes as f64 / GB as f64)
278 } else if bytes >= MB {
279 format!("{:.1}M", bytes as f64 / MB as f64)
280 } else if bytes >= KB {
281 format!("{:.1}K", bytes as f64 / KB as f64)
282 } else {
283 format!("{}B", bytes)
284 }
285}
286
287pub struct FileInfoTool;
289
290impl Default for FileInfoTool {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296impl FileInfoTool {
297 pub fn new() -> Self {
298 Self
299 }
300}
301
302#[async_trait]
303impl Tool for FileInfoTool {
304 fn id(&self) -> &str {
305 "fileinfo"
306 }
307
308 fn name(&self) -> &str {
309 "File Info"
310 }
311
312 fn description(&self) -> &str {
313 "fileinfo(path: string) - Get detailed information about a file: size, type, permissions, line count, encoding detection, and language."
314 }
315
316 fn parameters(&self) -> Value {
317 json!({
318 "type": "object",
319 "properties": {
320 "path": {
321 "type": "string",
322 "description": "The path to the file to inspect"
323 }
324 },
325 "required": ["path"],
326 "example": {
327 "path": "src/main.rs"
328 }
329 })
330 }
331
332 async fn execute(&self, args: Value) -> Result<ToolResult> {
333 let path = match args["path"].as_str() {
334 Some(p) => p,
335 None => {
336 return Ok(ToolResult::structured_error(
337 "INVALID_ARGUMENT",
338 "fileinfo",
339 "path is required",
340 Some(vec!["path"]),
341 Some(json!({"path": "src/main.rs"})),
342 ));
343 }
344 };
345
346 let path_obj = Path::new(path);
347 let metadata = fs::metadata(path).await?;
348
349 let mut info = Vec::new();
350
351 info.push(format!("Path: {}", path));
353 info.push(format!(
354 "Size: {} ({} bytes)",
355 format_size(metadata.len()),
356 metadata.len()
357 ));
358
359 let file_type = if metadata.is_dir() {
361 "directory"
362 } else if metadata.is_symlink() {
363 "symlink"
364 } else {
365 "file"
366 };
367 info.push(format!("Type: {}", file_type));
368
369 #[cfg(unix)]
371 {
372 use std::os::unix::fs::PermissionsExt;
373 let mode = metadata.permissions().mode();
374 info.push(format!("Permissions: {:o}", mode & 0o777));
375 }
376
377 if let Ok(modified) = metadata.modified()
379 && let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH)
380 {
381 let secs = duration.as_secs();
382 info.push(format!("Modified: {} seconds since epoch", secs));
383 }
384
385 if metadata.is_file() {
387 if let Some(ext) = path_obj.extension() {
389 let lang = match ext.to_str().unwrap_or("") {
390 "rs" => "Rust",
391 "py" => "Python",
392 "js" => "JavaScript",
393 "ts" => "TypeScript",
394 "tsx" => "TypeScript (React)",
395 "jsx" => "JavaScript (React)",
396 "go" => "Go",
397 "java" => "Java",
398 "c" | "h" => "C",
399 "cpp" | "hpp" | "cc" | "cxx" => "C++",
400 "rb" => "Ruby",
401 "php" => "PHP",
402 "swift" => "Swift",
403 "kt" | "kts" => "Kotlin",
404 "scala" => "Scala",
405 "cs" => "C#",
406 "md" => "Markdown",
407 "json" => "JSON",
408 "yaml" | "yml" => "YAML",
409 "toml" => "TOML",
410 "xml" => "XML",
411 "html" => "HTML",
412 "css" => "CSS",
413 "scss" | "sass" => "SCSS/Sass",
414 "sql" => "SQL",
415 "sh" | "bash" | "zsh" => "Shell",
416 _ => "Unknown",
417 };
418 info.push(format!("Language: {}", lang));
419 }
420
421 if let Ok(content) = fs::read_to_string(path).await {
423 let lines = content.lines().count();
424 let chars = content.chars().count();
425 let words = content.split_whitespace().count();
426
427 info.push(format!("Lines: {}", lines));
428 info.push(format!("Words: {}", words));
429 info.push(format!("Characters: {}", chars));
430
431 info.push("Encoding: UTF-8 (text)".to_string());
433 } else {
434 info.push("Encoding: Binary or non-UTF-8".to_string());
435 }
436 }
437
438 Ok(ToolResult::success(info.join("\n")))
439 }
440}
441
442pub struct HeadTailTool;
444
445impl Default for HeadTailTool {
446 fn default() -> Self {
447 Self::new()
448 }
449}
450
451impl HeadTailTool {
452 pub fn new() -> Self {
453 Self
454 }
455}
456
457#[async_trait]
458impl Tool for HeadTailTool {
459 fn id(&self) -> &str {
460 "headtail"
461 }
462
463 fn name(&self) -> &str {
464 "Head/Tail"
465 }
466
467 fn description(&self) -> &str {
468 "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."
469 }
470
471 fn parameters(&self) -> Value {
472 json!({
473 "type": "object",
474 "properties": {
475 "path": {
476 "type": "string",
477 "description": "The path to the file"
478 },
479 "head": {
480 "type": "integer",
481 "description": "Number of lines from the beginning (default: 10)",
482 "default": 10
483 },
484 "tail": {
485 "type": "integer",
486 "description": "Number of lines from the end (default: 0, set to show tail)",
487 "default": 0
488 }
489 },
490 "required": ["path"],
491 "example": {
492 "path": "src/main.rs",
493 "head": 20,
494 "tail": 10
495 }
496 })
497 }
498
499 async fn execute(&self, args: Value) -> Result<ToolResult> {
500 let path = match args["path"].as_str() {
501 Some(p) => p,
502 None => {
503 return Ok(ToolResult::structured_error(
504 "INVALID_ARGUMENT",
505 "headtail",
506 "path is required",
507 Some(vec!["path"]),
508 Some(json!({"path": "src/main.rs", "head": 10})),
509 ));
510 }
511 };
512 let head_lines = args["head"].as_u64().unwrap_or(10) as usize;
513 let tail_lines = args["tail"].as_u64().unwrap_or(0) as usize;
514
515 let content = fs::read_to_string(path).await?;
516 let lines: Vec<&str> = content.lines().collect();
517 let total_lines = lines.len();
518
519 let mut output = Vec::new();
520 output.push(format!("=== {} ({} lines total) ===", path, total_lines));
521 output.push(String::new());
522
523 if head_lines > 0 {
525 output.push(format!(
526 "--- First {} lines ---",
527 head_lines.min(total_lines)
528 ));
529 for (i, line) in lines.iter().take(head_lines).enumerate() {
530 output.push(format!("{:4} | {}", i + 1, line));
531 }
532 }
533
534 let head_end = head_lines;
536 let tail_start = total_lines.saturating_sub(tail_lines);
537
538 if tail_lines > 0 && tail_start > head_end {
539 output.push(String::new());
540 output.push(format!("... ({} lines omitted) ...", tail_start - head_end));
541 output.push(String::new());
542 output.push(format!(
543 "--- Last {} lines ---",
544 tail_lines.min(total_lines)
545 ));
546 for (i, line) in lines.iter().skip(tail_start).enumerate() {
547 output.push(format!("{:4} | {}", tail_start + i + 1, line));
548 }
549 } else if tail_lines > 0 && tail_start <= head_end {
550 if head_end < total_lines {
552 for (i, line) in lines.iter().skip(head_end).enumerate() {
553 output.push(format!("{:4} | {}", head_end + i + 1, line));
554 }
555 }
556 }
557
558 Ok(ToolResult::success(output.join("\n")).with_metadata("total_lines", json!(total_lines)))
559 }
560}
561
562pub struct DiffTool;
564
565impl Default for DiffTool {
566 fn default() -> Self {
567 Self::new()
568 }
569}
570
571impl DiffTool {
572 pub fn new() -> Self {
573 Self
574 }
575}
576
577#[async_trait]
578impl Tool for DiffTool {
579 fn id(&self) -> &str {
580 "diff"
581 }
582
583 fn name(&self) -> &str {
584 "Diff"
585 }
586
587 fn description(&self) -> &str {
588 "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."
589 }
590
591 fn parameters(&self) -> Value {
592 json!({
593 "type": "object",
594 "properties": {
595 "file1": {
596 "type": "string",
597 "description": "First file to compare (or file for git diff)"
598 },
599 "file2": {
600 "type": "string",
601 "description": "Second file to compare"
602 },
603 "git": {
604 "type": "boolean",
605 "description": "Show git diff for uncommitted changes (default: false)",
606 "default": false
607 },
608 "staged": {
609 "type": "boolean",
610 "description": "Show git diff for staged changes (default: false)",
611 "default": false
612 },
613 "context": {
614 "type": "integer",
615 "description": "Lines of context around changes (default: 3)",
616 "default": 3
617 }
618 },
619 "example": {
620 "git": true,
621 "file1": "src/main.rs"
622 }
623 })
624 }
625
626 async fn execute(&self, args: Value) -> Result<ToolResult> {
627 let git_mode = args["git"].as_bool().unwrap_or(false);
628 let staged = args["staged"].as_bool().unwrap_or(false);
629 let context = args["context"].as_u64().unwrap_or(3);
630
631 if git_mode {
632 let mut cmd = tokio::process::Command::new("git");
634 cmd.arg("diff");
635
636 if staged {
637 cmd.arg("--staged");
638 }
639
640 cmd.arg(format!("-U{}", context));
641
642 if let Some(file) = args["file1"].as_str() {
643 cmd.arg("--").arg(file);
644 }
645
646 let output = cmd.output().await?;
647
648 if output.status.success() {
649 let diff = String::from_utf8_lossy(&output.stdout);
650 if diff.is_empty() {
651 Ok(ToolResult::success("No changes detected"))
652 } else {
653 Ok(ToolResult::success(diff.to_string()))
654 }
655 } else {
656 let error = String::from_utf8_lossy(&output.stderr);
657 Ok(ToolResult::error(format!("Git diff failed: {}", error)))
658 }
659 } else {
660 let file1 = match args["file1"].as_str() {
662 Some(f) => f,
663 None => {
664 return Ok(ToolResult::structured_error(
665 "INVALID_ARGUMENT",
666 "diff",
667 "file1 is required for file comparison (or use git=true)",
668 Some(vec!["file1"]),
669 Some(json!({"file1": "old.txt", "file2": "new.txt"})),
670 ));
671 }
672 };
673 let file2 = match args["file2"].as_str() {
674 Some(f) => f,
675 None => {
676 return Ok(ToolResult::structured_error(
677 "INVALID_ARGUMENT",
678 "diff",
679 "file2 is required for file comparison",
680 Some(vec!["file2"]),
681 Some(json!({"file1": file1, "file2": "new.txt"})),
682 ));
683 }
684 };
685
686 let output = tokio::process::Command::new("diff")
688 .arg("-u")
689 .arg(format!("--label={}", file1))
690 .arg(format!("--label={}", file2))
691 .arg(file1)
692 .arg(file2)
693 .output()
694 .await?;
695
696 let diff = String::from_utf8_lossy(&output.stdout);
697 if diff.is_empty() && output.status.success() {
698 Ok(ToolResult::success("Files are identical"))
699 } else {
700 Ok(ToolResult::success(diff.to_string()))
701 }
702 }
703 }
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709 use std::path::PathBuf;
710
711 #[tokio::test]
712 async fn test_build_tree_propagates_read_dir_error() {
713 let non_existent = PathBuf::from("/nonexistent/path/that/does/not/exist");
715 let mut output = Vec::new();
716 let mut file_count = 0;
717 let mut dir_count = 0;
718
719 let result = build_tree(
720 &non_existent,
721 "",
722 0,
723 3,
724 false,
725 false,
726 true,
727 &mut output,
728 &mut file_count,
729 &mut dir_count,
730 )
731 .await;
732
733 assert!(result.is_err(), "Expected error for non-existent directory");
735
736 let err = result.unwrap_err();
738 let err_msg = err.to_string();
739 assert!(
740 err_msg.contains("Failed to read directory"),
741 "Error should contain context message, got: {err_msg}"
742 );
743 assert!(
744 err_msg.contains("/nonexistent/path/that/does/not/exist"),
745 "Error should contain the path, got: {err_msg}"
746 );
747 }
748
749 #[tokio::test]
750 async fn test_build_tree_no_partial_output_on_error() {
751 let non_existent = PathBuf::from("/another/nonexistent/path");
753 let mut output = Vec::new();
754 let mut file_count = 0;
755 let mut dir_count = 0;
756
757 let initial_output_len = output.len();
758 let initial_file_count = file_count;
759 let initial_dir_count = dir_count;
760
761 let result = build_tree(
762 &non_existent,
763 "",
764 0,
765 3,
766 false,
767 false,
768 true,
769 &mut output,
770 &mut file_count,
771 &mut dir_count,
772 )
773 .await;
774
775 assert!(result.is_err());
777
778 assert_eq!(
780 output.len(),
781 initial_output_len,
782 "Output should not be modified on error"
783 );
784 assert_eq!(
785 file_count, initial_file_count,
786 "File count should not be modified on error"
787 );
788 assert_eq!(
789 dir_count, initial_dir_count,
790 "Dir count should not be modified on error"
791 );
792 }
793
794 #[tokio::test]
795 async fn test_build_tree_success_with_temp_dir() {
796 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
798 let temp_path = temp_dir.path();
799
800 tokio::fs::create_dir(temp_path.join("subdir"))
802 .await
803 .expect("Failed to create subdir");
804 tokio::fs::write(temp_path.join("file1.txt"), "content")
805 .await
806 .expect("Failed to write file1");
807 tokio::fs::write(temp_path.join("subdir").join("file2.txt"), "content")
808 .await
809 .expect("Failed to write file2");
810
811 let mut output = Vec::new();
812 let mut file_count = 0;
813 let mut dir_count = 0;
814
815 let result = build_tree(
816 temp_path,
817 "",
818 0,
819 3,
820 false,
821 false,
822 false, &mut output,
824 &mut file_count,
825 &mut dir_count,
826 )
827 .await;
828
829 assert!(
831 result.is_ok(),
832 "Expected success for valid directory: {:?}",
833 result
834 );
835
836 assert!(
838 file_count >= 2,
839 "Should have found at least 2 files, found: {}",
840 file_count
841 );
842 assert!(
843 dir_count >= 1,
844 "Should have found at least 1 directory, found: {}",
845 dir_count
846 );
847 assert!(!output.is_empty(), "Output should not be empty");
848 }
849}