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