git_iris/agents/tools/
file_read.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct FileRead;
22
23impl FileRead {
24 const DEFAULT_MAX_LINES: usize = 500;
26
27 const LINE_NUM_WIDTH: usize = 6;
29
30 fn is_binary(content: &[u8]) -> bool {
32 let check_size = content.len().min(8192);
33 content[..check_size].contains(&0)
34 }
35
36 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 #[allow(clippy::cast_precision_loss, clippy::as_conversions)] 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 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 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 pub path: String,
107 #[serde(default)]
109 pub start_line: Option<usize>,
110 #[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 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 let file_path = repo_path.join(&args.path);
142
143 if !file_path.exists() {
145 return Err(FileReadError(format!("File not found: {}", args.path)));
146 }
147
148 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 if !canonical_file.starts_with(&canonical_repo) {
158 return Err(FileReadError("Path escapes repository boundaries".into()));
159 }
160
161 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 if Self::is_binary_extension(&args.path) {
172 return Ok(format!(
173 "[Binary file: {} - content not displayed]",
174 args.path
175 ));
176 }
177
178 let content = fs::read(&canonical_file).map_err(|e| FileReadError(e.to_string()))?;
180
181 if Self::is_binary(&content) {
183 return Ok(format!(
184 "[Binary file detected: {} - content not displayed]",
185 args.path
186 ));
187 }
188
189 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 let start = args.start_line.unwrap_or(1).saturating_sub(1); 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 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}