opendev_tools_impl/file_read/
mod.rs1mod binary;
4mod suggestions;
5
6use std::collections::HashMap;
7
8use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
9
10use crate::path_utils::{is_sensitive_file, resolve_file_path, validate_path_access};
11
12use binary::is_binary_file;
13use suggestions::file_not_found_message;
14
15#[derive(Debug)]
17pub struct FileReadTool;
18
19impl FileReadTool {
20 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
22
23 const DEFAULT_MAX_LINES: usize = 2000;
25
26 const MAX_LINE_LENGTH: usize = 2000;
28
29 const MAX_OUTPUT_BYTES: usize = 50 * 1024;
31
32 fn read_directory(
34 path: &std::path::Path,
35 display_path: &str,
36 offset: usize,
37 limit: usize,
38 ) -> ToolResult {
39 let entries = match std::fs::read_dir(path) {
40 Ok(rd) => rd,
41 Err(e) => return ToolResult::fail(format!("Failed to read directory: {e}")),
42 };
43
44 let mut names: Vec<String> = Vec::new();
45 for entry in entries {
46 let entry = match entry {
47 Ok(e) => e,
48 Err(e) => return ToolResult::fail(format!("Failed to read directory entry: {e}")),
49 };
50 let name = entry.file_name().to_string_lossy().into_owned();
51 let is_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
52 if is_dir {
53 names.push(format!("{name}/"));
54 } else {
55 names.push(name);
56 }
57 }
58 names.sort();
59
60 let total = names.len();
61 let start = if offset > 0 { offset - 1 } else { 0 };
62 let end = (start + limit).min(total);
63
64 let mut output = format!("Directory: {display_path}\n");
65 if total == 0 {
66 output.push_str("(empty directory)\n");
67 } else {
68 for (i, name) in names[start..end].iter().enumerate() {
69 let idx = start + i + 1;
70 output.push_str(&format!("{idx:>6}\t{name}\n"));
71 }
72 }
73
74 let mut metadata = HashMap::new();
75 metadata.insert("total_entries".into(), serde_json::json!(total));
76 metadata.insert(
77 "entries_shown".into(),
78 serde_json::json!(end.saturating_sub(start)),
79 );
80 metadata.insert("is_directory".into(), serde_json::json!(true));
81
82 ToolResult::ok_with_metadata(output, metadata)
83 }
84}
85
86#[async_trait::async_trait]
87impl BaseTool for FileReadTool {
88 fn name(&self) -> &str {
89 "read_file"
90 }
91
92 fn description(&self) -> &str {
93 "Read the contents of a file or list directory entries. Supports line ranges, \
94 detects binary files, and suggests similar filenames on not-found errors."
95 }
96
97 fn parameter_schema(&self) -> serde_json::Value {
98 serde_json::json!({
99 "type": "object",
100 "properties": {
101 "file_path": {
102 "type": "string",
103 "description": "Absolute path to the file to read"
104 },
105 "offset": {
106 "type": "integer",
107 "description": "Line number to start reading from (1-based)"
108 },
109 "limit": {
110 "type": "integer",
111 "description": "Maximum number of lines to read"
112 }
113 },
114 "required": ["file_path"]
115 })
116 }
117
118 async fn execute(
119 &self,
120 args: HashMap<String, serde_json::Value>,
121 ctx: &ToolContext,
122 ) -> ToolResult {
123 let file_path = match args.get("file_path").and_then(|v| v.as_str()) {
124 Some(p) => p,
125 None => return ToolResult::fail("file_path is required"),
126 };
127
128 let offset = args
129 .get("offset")
130 .and_then(|v| v.as_u64())
131 .map(|v| v as usize)
132 .unwrap_or(1);
133
134 let limit = args
135 .get("limit")
136 .and_then(|v| v.as_u64())
137 .map(|v| v as usize)
138 .unwrap_or(Self::DEFAULT_MAX_LINES);
139
140 let path = resolve_file_path(file_path, &ctx.working_dir);
141
142 if let Err(msg) = validate_path_access(&path, &ctx.working_dir) {
143 return ToolResult::fail(msg);
144 }
145
146 if !path.exists() {
147 return ToolResult::fail(file_not_found_message(file_path, &path));
148 }
149
150 if path.is_dir() {
152 return Self::read_directory(&path, file_path, offset, limit);
153 }
154
155 if !path.is_file() {
156 return ToolResult::fail(format!("Not a file: {file_path}"));
157 }
158
159 match std::fs::metadata(&path) {
161 Ok(meta) => {
162 if meta.len() > Self::MAX_FILE_SIZE {
163 return ToolResult::fail(format!(
164 "File too large: {} bytes (max {} bytes)",
165 meta.len(),
166 Self::MAX_FILE_SIZE
167 ));
168 }
169 }
170 Err(e) => return ToolResult::fail(format!("Cannot read file metadata: {e}")),
171 }
172
173 match std::fs::read(&path) {
175 Ok(bytes) => {
176 if is_binary_file(&path, &bytes) {
177 return ToolResult::fail(format!(
178 "Binary file detected: {file_path} ({} bytes). Use a specialized tool for binary files.",
179 bytes.len()
180 ));
181 }
182
183 let content = String::from_utf8_lossy(&bytes);
184 let lines: Vec<&str> = content.lines().collect();
185 let total_lines = lines.len();
186
187 let start = if offset > 0 { offset - 1 } else { 0 };
189 let end = (start + limit).min(total_lines);
190
191 if start >= total_lines {
192 return ToolResult::fail(format!(
193 "Offset {offset} is beyond end of file ({total_lines} lines)"
194 ));
195 }
196
197 let mut output = String::new();
198 let mut output_bytes: usize = 0;
199 let mut lines_emitted: usize = 0;
200 let mut byte_truncated = false;
201
202 for (i, line) in lines[start..end].iter().enumerate() {
203 let line_num = start + i + 1;
204 let truncated_line = if line.len() > Self::MAX_LINE_LENGTH {
205 format!("{}...", &line[..Self::MAX_LINE_LENGTH])
206 } else {
207 line.to_string()
208 };
209 let formatted = format!("{line_num:>6}\t{truncated_line}\n");
210 let line_bytes = formatted.len();
211
212 if output_bytes + line_bytes > Self::MAX_OUTPUT_BYTES {
213 byte_truncated = true;
214 break;
215 }
216
217 output.push_str(&formatted);
218 output_bytes += line_bytes;
219 lines_emitted += 1;
220 }
221
222 let next_offset = start + lines_emitted + 1;
224 let has_more = next_offset <= total_lines;
225
226 if byte_truncated {
227 let remaining = end - start - lines_emitted;
228 output.push_str(&format!(
229 "\n[...truncated: {remaining} more lines not shown (output exceeded {} KB limit). \
230 Use offset={next_offset} to continue reading.]\n",
231 Self::MAX_OUTPUT_BYTES / 1024
232 ));
233 } else if end < total_lines {
234 output.push_str(&format!(
236 "\n[{} more lines below. Use offset={next_offset} to continue reading.]\n",
237 total_lines - end
238 ));
239 }
240
241 if let Some(reason) = is_sensitive_file(&path) {
243 output.insert_str(
244 0,
245 &format!(
246 "WARNING: This is a {reason}. Do NOT include its contents \
247 in responses, commits, or logs. Treat all values as secrets.\n\n"
248 ),
249 );
250 }
251
252 let mut metadata = HashMap::new();
253 metadata.insert("total_lines".into(), serde_json::json!(total_lines));
254 metadata.insert("lines_shown".into(), serde_json::json!(lines_emitted));
255 if has_more {
256 metadata.insert("next_offset".into(), serde_json::json!(next_offset));
257 }
258 if byte_truncated {
259 metadata.insert("truncated".into(), serde_json::json!(true));
260 }
261
262 ToolResult::ok_with_metadata(output, metadata)
263 }
264 Err(e) => ToolResult::fail(format!("Failed to read file: {e}")),
265 }
266 }
267}
268
269#[cfg(test)]
270mod tests;