Skip to main content

codetether_agent/tool/
file_extras.rs

1//! Additional file tools: tree, fileinfo, headtail, diff
2
3use 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
11/// Tree view of directory structure
12pub 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        // Add root directory
100        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 recursively
109        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
132/// Entry with resolved metadata for sorting
133struct TreeEntry {
134    name: String,
135    path: std::path::PathBuf,
136    is_dir: bool,
137    size: u64,
138}
139
140/// Helper function to build tree recursively
141async 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    // Read directory and collect entries with their metadata
158    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                // Skip hidden files unless requested
170                if !show_hidden && name.starts_with('.') {
171                    continue;
172                }
173
174                // Skip common ignored directories
175                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, // End of directory
201            Err(e) => {
202                tracing::warn!(path = %path.display(), error = %e, "Error reading directory entry, continuing");
203                continue;
204            }
205        }
206    }
207
208    // Sort entries: directories first, then files, alphabetically
209    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        // Recurse into directories
238        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
259/// Format file size in human-readable form
260fn 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
276/// File information tool - get metadata about a file
277pub 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        // Basic info
341        info.push(format!("Path: {}", path));
342        info.push(format!(
343            "Size: {} ({} bytes)",
344            format_size(metadata.len()),
345            metadata.len()
346        ));
347
348        // File type
349        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        // Permissions (Unix)
359        #[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        // Modified time
367        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        // For files, get additional info
375        if metadata.is_file() {
376            // Detect language from extension
377            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            // Try to read and count lines
411            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                // Check if it looks like UTF-8 text
421                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
431/// Head/Tail tool - quickly peek at file beginning or end
432pub 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        // Head
513        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        // Check if there's a gap between head and tail
524        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            // Overlap or contiguous - just show everything
540            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
551/// Diff tool - compare files or show git diff
552pub 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            // Git diff mode
622            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            // File comparison mode
650            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            // Use system diff command for better output
676            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        // Use a path that doesn't exist to trigger an error
703        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        // Should return an error, not Ok(())
723        assert!(result.is_err(), "Expected error for non-existent directory");
724
725        // Error message should contain context about the path
726        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        // Use a path that doesn't exist
741        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        // Verify error was returned
765        assert!(result.is_err());
766
767        // Verify output was not modified (no partial tree building)
768        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        // Create a temporary directory structure
786        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
787        let temp_path = temp_dir.path();
788
789        // Create some files and directories
790        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, // Don't respect gitignore for this test
812            &mut output,
813            &mut file_count,
814            &mut dir_count,
815        )
816        .await;
817
818        // Should succeed
819        assert!(
820            result.is_ok(),
821            "Expected success for valid directory: {:?}",
822            result
823        );
824
825        // Should have found files and directories
826        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}