git_iris/agents/tools/
file_read.rs

1//! File reading tool
2//!
3//! Simple file reading capability with support for partial reads (head/tail).
4//! This is more efficient than using `code_search` when you need the actual content.
5
6use anyhow::Result;
7use rig::completion::ToolDefinition;
8use rig::tool::Tool;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::Path;
12
13use crate::define_tool_error;
14
15use super::common::{get_current_repo, parameters_schema};
16
17define_tool_error!(FileReadError);
18
19/// File reading tool for accessing file contents directly
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct FileRead;
22
23impl FileRead {
24    /// Maximum lines to return by default (to avoid overwhelming context)
25    const DEFAULT_MAX_LINES: usize = 500;
26
27    /// Line number column width (supports files up to 999,999 lines)
28    const LINE_NUM_WIDTH: usize = 6;
29
30    /// Check if file appears to be binary
31    fn is_binary(content: &[u8]) -> bool {
32        let check_size = content.len().min(8192);
33        content[..check_size].contains(&0)
34    }
35
36    /// Check if file extension indicates binary
37    fn is_binary_extension(path: &str) -> bool {
38        const BINARY_EXTENSIONS: &[&str] = &[
39            ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp", ".pdf", ".zip", ".tar",
40            ".gz", ".rar", ".7z", ".exe", ".dll", ".so", ".dylib", ".bin", ".wasm", ".ttf", ".otf",
41            ".woff", ".woff2", ".mp3", ".mp4", ".wav", ".sqlite", ".db", ".pyc", ".class", ".o",
42            ".a",
43        ];
44        let path_lower = path.to_lowercase();
45        BINARY_EXTENSIONS
46            .iter()
47            .any(|ext| path_lower.ends_with(ext))
48    }
49
50    /// List directory contents when user accidentally reads a directory
51    #[allow(clippy::cast_precision_loss, clippy::as_conversions)] // Fine for human-readable file sizes
52    fn list_directory(dir_path: &Path, display_path: &str) -> Result<String, FileReadError> {
53        let mut output = String::new();
54        output.push_str(&format!("=== {} is a directory ===\n\n", display_path));
55        output.push_str("Contents:\n\n");
56
57        let mut entries: Vec<_> = fs::read_dir(dir_path)
58            .map_err(|e| FileReadError(format!("Cannot read directory: {e}")))?
59            .filter_map(std::result::Result::ok)
60            .collect();
61
62        // Sort: directories first, then files, alphabetically within each group
63        entries.sort_by(|a, b| {
64            let a_is_dir = a.path().is_dir();
65            let b_is_dir = b.path().is_dir();
66            match (a_is_dir, b_is_dir) {
67                (true, false) => std::cmp::Ordering::Less,
68                (false, true) => std::cmp::Ordering::Greater,
69                _ => a.file_name().cmp(&b.file_name()),
70            }
71        });
72
73        for entry in entries {
74            let name = entry.file_name();
75            let name_str = name.to_string_lossy();
76            let path = entry.path();
77
78            if path.is_dir() {
79                output.push_str(&format!("  📁 {}/\n", name_str));
80            } else {
81                // Get file size if available
82                let size_str = if let Ok(meta) = path.metadata() {
83                    let size = meta.len();
84                    if size < 1024 {
85                        format!("{} B", size)
86                    } else if size < 1024 * 1024 {
87                        format!("{:.1} KB", size as f64 / 1024.0)
88                    } else {
89                        format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
90                    }
91                } else {
92                    String::new()
93                };
94                output.push_str(&format!("  📄 {}  ({})\n", name_str, size_str));
95            }
96        }
97
98        output.push_str("\nUse file_read with a specific file path to read contents.\n");
99        Ok(output)
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
104pub struct FileReadArgs {
105    /// Path to the file to read (relative to repo root)
106    pub path: String,
107    /// Starting line number (1-indexed, default: 1)
108    #[serde(default)]
109    pub start_line: Option<usize>,
110    /// Number of lines to read (default: 500, max: 1000)
111    #[serde(default)]
112    pub num_lines: Option<usize>,
113}
114
115impl Tool for FileRead {
116    const NAME: &'static str = "file_read";
117    type Error = FileReadError;
118    type Args = FileReadArgs;
119    type Output = String;
120
121    async fn definition(&self, _: String) -> ToolDefinition {
122        ToolDefinition {
123            name: "file_read".to_string(),
124            description: "Read file contents directly. Use start_line and num_lines for partial reads on large files. Returns line-numbered content.".to_string(),
125            parameters: parameters_schema::<FileReadArgs>(),
126        }
127    }
128
129    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
130        let repo = get_current_repo().map_err(FileReadError::from)?;
131        let repo_path = repo.repo_path();
132
133        // Reject absolute paths - all paths must be relative to repo root
134        if Path::new(&args.path).is_absolute() {
135            return Err(FileReadError(
136                "Absolute paths not allowed. Use paths relative to repository root.".into(),
137            ));
138        }
139
140        // Join path to repo root
141        let file_path = repo_path.join(&args.path);
142
143        // Check file exists before canonicalization
144        if !file_path.exists() {
145            return Err(FileReadError(format!("File not found: {}", args.path)));
146        }
147
148        // Canonicalize both paths to resolve symlinks and .. components
149        let canonical_file = file_path
150            .canonicalize()
151            .map_err(|e| FileReadError(format!("Cannot resolve path: {e}")))?;
152        let canonical_repo = repo_path
153            .canonicalize()
154            .map_err(|e| FileReadError(format!("Cannot resolve repo path: {e}")))?;
155
156        // Security: verify resolved path is within repository bounds
157        if !canonical_file.starts_with(&canonical_repo) {
158            return Err(FileReadError("Path escapes repository boundaries".into()));
159        }
160
161        // If it's a directory, return a helpful listing instead of an error
162        if canonical_file.is_dir() {
163            return Self::list_directory(&canonical_file, &args.path);
164        }
165
166        if !canonical_file.is_file() {
167            return Err(FileReadError(format!("Not a file: {}", args.path)));
168        }
169
170        // Check for binary extension
171        if Self::is_binary_extension(&args.path) {
172            return Ok(format!(
173                "[Binary file: {} - content not displayed]",
174                args.path
175            ));
176        }
177
178        // Read the file (use canonical path for actual read)
179        let content = fs::read(&canonical_file).map_err(|e| FileReadError(e.to_string()))?;
180
181        // Check for binary content
182        if Self::is_binary(&content) {
183            return Ok(format!(
184                "[Binary file detected: {} - content not displayed]",
185                args.path
186            ));
187        }
188
189        // Convert to string
190        let content_str = String::from_utf8(content).map_err(|e| FileReadError(e.to_string()))?;
191
192        let lines: Vec<&str> = content_str.lines().collect();
193        let total_lines = lines.len();
194
195        // Calculate range
196        let start = args.start_line.unwrap_or(1).saturating_sub(1); // Convert to 0-indexed
197        let max_lines = args.num_lines.unwrap_or(Self::DEFAULT_MAX_LINES).min(1000);
198        let end = (start + max_lines).min(total_lines);
199
200        // Build output with line numbers
201        let mut output = String::new();
202        output.push_str(&format!(
203            "=== {} ({} total lines) ===\n",
204            args.path, total_lines
205        ));
206
207        if start > 0 || end < total_lines {
208            output.push_str(&format!(
209                "Showing lines {}-{} of {}\n",
210                start + 1,
211                end,
212                total_lines
213            ));
214        }
215        output.push('\n');
216
217        for (i, line) in lines.iter().enumerate().skip(start).take(end - start) {
218            output.push_str(&format!(
219                "{:>width$}│ {}\n",
220                i + 1,
221                line,
222                width = Self::LINE_NUM_WIDTH
223            ));
224        }
225
226        if end < total_lines {
227            output.push_str(&format!(
228                "\n... {} more lines (use start_line={} to continue)\n",
229                total_lines - end,
230                end + 1
231            ));
232        }
233
234        Ok(output)
235    }
236}