Skip to main content

opendev_tools_impl/file_read/
mod.rs

1//! Read file tool — reads file contents with optional line ranges and binary detection.
2
3mod 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/// Tool for reading file contents.
16#[derive(Debug)]
17pub struct FileReadTool;
18
19impl FileReadTool {
20    /// Maximum file size we'll read (10 MB).
21    const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024;
22
23    /// Maximum number of lines to return by default.
24    const DEFAULT_MAX_LINES: usize = 2000;
25
26    /// Maximum line length before truncation.
27    const MAX_LINE_LENGTH: usize = 2000;
28
29    /// Maximum output size in bytes (50 KB) to prevent context bloat.
30    const MAX_OUTPUT_BYTES: usize = 50 * 1024;
31
32    /// Read directory entries, sorted alphabetically with `/` suffix for subdirs.
33    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        // Directory reading: list entries with optional pagination
151        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        // Check file size
160        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        // Check for binary content
174        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                // Apply offset (1-based) and limit
188                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                // Calculate the next offset for follow-up reads.
223                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                    // Lines were limited by the limit param, hint the next offset.
235                    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                // Warn if the file is potentially sensitive.
242                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;