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