Skip to main content

codetether_agent/tool/
file_extras.rs

1//! Additional file tools: tree, fileinfo, headtail, diff
2
3use 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
10/// Tree view of directory structure
11pub 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        // Add root directory
99        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 recursively
108        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
131/// Entry with resolved metadata for sorting
132struct TreeEntry {
133    name: String,
134    path: std::path::PathBuf,
135    is_dir: bool,
136    size: u64,
137}
138
139/// Helper function to build tree recursively
140async 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    // Read directory and collect entries with their metadata
157    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                // Skip hidden files unless requested
169                if !show_hidden && name.starts_with('.') {
170                    continue;
171                }
172
173                // Skip common ignored directories
174                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, // End of directory
212            Err(e) => {
213                tracing::warn!(path = %path.display(), error = %e, "Error reading directory entry, continuing");
214                continue;
215            }
216        }
217    }
218
219    // Sort entries: directories first, then files, alphabetically
220    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        // Recurse into directories
249        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
270/// Format file size in human-readable form
271fn 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
287/// File information tool - get metadata about a file
288pub 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        // Basic info
352        info.push(format!("Path: {}", path));
353        info.push(format!(
354            "Size: {} ({} bytes)",
355            format_size(metadata.len()),
356            metadata.len()
357        ));
358
359        // File type
360        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        // Permissions (Unix)
370        #[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        // Modified time
378        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        // For files, get additional info
386        if metadata.is_file() {
387            // Detect language from extension
388            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            // Try to read and count lines
422            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                // Check if it looks like UTF-8 text
432                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
442/// Head/Tail tool - quickly peek at file beginning or end
443pub 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        // Head
524        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        // Check if there's a gap between head and tail
535        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            // Overlap or contiguous - just show everything
551            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
562/// Diff tool - compare files or show git diff
563pub 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            // Git diff mode
633            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            // File comparison mode
661            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            // Use system diff command for better output
687            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        // Use a path that doesn't exist to trigger an error
714        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        // Should return an error, not Ok(())
734        assert!(result.is_err(), "Expected error for non-existent directory");
735
736        // Error message should contain context about the path
737        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        // Use a path that doesn't exist
752        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        // Verify error was returned
776        assert!(result.is_err());
777
778        // Verify output was not modified (no partial tree building)
779        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        // Create a temporary directory structure
797        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
798        let temp_path = temp_dir.path();
799
800        // Create some files and directories
801        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, // Don't respect gitignore for this test
823            &mut output,
824            &mut file_count,
825            &mut dir_count,
826        )
827        .await;
828
829        // Should succeed
830        assert!(
831            result.is_ok(),
832            "Expected success for valid directory: {:?}",
833            result
834        );
835
836        // Should have found files and directories
837        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}