Skip to main content

astrid_tools/
read_file.rs

1//! Read file tool — reads a file with line numbers (cat -n style).
2
3use std::fmt::Write;
4
5use crate::{BuiltinTool, ToolContext, ToolError, ToolResult};
6use serde_json::Value;
7
8/// Default maximum lines to read.
9const DEFAULT_LINE_LIMIT: usize = 2000;
10/// Maximum line length before truncation.
11const MAX_LINE_LENGTH: usize = 2000;
12
13/// Built-in tool for reading files.
14pub struct ReadFileTool;
15
16#[async_trait::async_trait]
17impl BuiltinTool for ReadFileTool {
18    fn name(&self) -> &'static str {
19        "read_file"
20    }
21
22    fn description(&self) -> &'static str {
23        "Reads a file from the filesystem. Returns contents with line numbers (cat -n format). \
24         Default reads up to 2000 lines. Use offset and limit for large files. \
25         Lines longer than 2000 characters are truncated."
26    }
27
28    fn input_schema(&self) -> Value {
29        serde_json::json!({
30            "type": "object",
31            "properties": {
32                "file_path": {
33                    "type": "string",
34                    "description": "Absolute path to the file to read"
35                },
36                "offset": {
37                    "type": "integer",
38                    "description": "Line number to start reading from (1-based). Only provide for large files."
39                },
40                "limit": {
41                    "type": "integer",
42                    "description": "Number of lines to read. Only provide for large files."
43                }
44            },
45            "required": ["file_path"]
46        })
47    }
48
49    async fn execute(&self, args: Value, _ctx: &ToolContext) -> ToolResult {
50        let file_path = args
51            .get("file_path")
52            .and_then(Value::as_str)
53            .ok_or_else(|| ToolError::InvalidArguments("file_path is required".into()))?;
54
55        let offset = args
56            .get("offset")
57            .and_then(Value::as_u64)
58            .map(|v| usize::try_from(v).unwrap_or(usize::MAX));
59
60        let limit = args
61            .get("limit")
62            .and_then(Value::as_u64)
63            .map_or(DEFAULT_LINE_LIMIT, |v| {
64                usize::try_from(v).unwrap_or(usize::MAX)
65            });
66
67        let path = std::path::Path::new(file_path);
68        if !path.exists() {
69            return Err(ToolError::PathNotFound(file_path.to_string()));
70        }
71
72        // Binary detection: read first 8KB and check for null bytes
73        let raw = tokio::fs::read(path).await?;
74        let check_len = raw.len().min(8192);
75        if raw[..check_len].contains(&0) {
76            return Err(ToolError::ExecutionFailed(format!(
77                "{file_path} appears to be a binary file"
78            )));
79        }
80
81        let content = String::from_utf8(raw)
82            .map_err(|_| ToolError::ExecutionFailed(format!("{file_path} is not valid UTF-8")))?;
83
84        let lines: Vec<&str> = content.lines().collect();
85        let total_lines = lines.len();
86
87        // Apply offset (1-based)
88        let start = offset.map_or(0, |o| o.saturating_sub(1));
89        let end = start.saturating_add(limit).min(total_lines);
90
91        if start >= total_lines {
92            return Ok(format!(
93                "(file has {total_lines} lines, offset {start} is past end)"
94            ));
95        }
96
97        let mut output = String::new();
98        for (idx, &line) in lines[start..end].iter().enumerate() {
99            // Safety: start and idx are bounded by total_lines, +1 for 1-based display
100            #[allow(clippy::arithmetic_side_effects)]
101            let line_num = start + idx + 1;
102            let display_line = if line.len() > MAX_LINE_LENGTH {
103                &line[..MAX_LINE_LENGTH]
104            } else {
105                line
106            };
107            let _ = writeln!(output, "{line_num:>6}\t{display_line}");
108        }
109
110        if end < total_lines {
111            let _ = write!(
112                output,
113                "\n(showing lines {}-{} of {total_lines}; use offset/limit for more)",
114                start.saturating_add(1),
115                end
116            );
117        }
118
119        Ok(output)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use std::io::Write as IoWrite;
127    use tempfile::NamedTempFile;
128
129    fn ctx() -> ToolContext {
130        ToolContext::new(std::env::temp_dir())
131    }
132
133    #[tokio::test]
134    async fn test_read_file_basic() {
135        let mut f = NamedTempFile::new().unwrap();
136        writeln!(f, "line one").unwrap();
137        writeln!(f, "line two").unwrap();
138        writeln!(f, "line three").unwrap();
139
140        let result = ReadFileTool
141            .execute(
142                serde_json::json!({"file_path": f.path().to_str().unwrap()}),
143                &ctx(),
144            )
145            .await
146            .unwrap();
147
148        assert!(result.contains("line one"));
149        assert!(result.contains("line two"));
150        assert!(result.contains("line three"));
151        assert!(result.contains("     1\t"));
152        assert!(result.contains("     2\t"));
153        assert!(result.contains("     3\t"));
154    }
155
156    #[tokio::test]
157    async fn test_read_file_not_found() {
158        let result = ReadFileTool
159            .execute(
160                serde_json::json!({"file_path": "/tmp/astrid_nonexistent_12345.txt"}),
161                &ctx(),
162            )
163            .await;
164
165        assert!(result.is_err());
166        assert!(matches!(result.unwrap_err(), ToolError::PathNotFound(_)));
167    }
168
169    #[tokio::test]
170    async fn test_read_file_with_offset_and_limit() {
171        let mut f = NamedTempFile::new().unwrap();
172        for i in 1..=20 {
173            writeln!(f, "line {i}").unwrap();
174        }
175
176        let result = ReadFileTool
177            .execute(
178                serde_json::json!({
179                    "file_path": f.path().to_str().unwrap(),
180                    "offset": 5,
181                    "limit": 3
182                }),
183                &ctx(),
184            )
185            .await
186            .unwrap();
187
188        assert!(result.contains("     5\t"));
189        assert!(result.contains("line 5"));
190        assert!(result.contains("line 7"));
191        assert!(!result.contains("line 8"));
192    }
193
194    #[tokio::test]
195    async fn test_read_binary_file() {
196        let mut f = NamedTempFile::new().unwrap();
197        f.write_all(&[0x00, 0x01, 0x02, 0xFF]).unwrap();
198
199        let result = ReadFileTool
200            .execute(
201                serde_json::json!({"file_path": f.path().to_str().unwrap()}),
202                &ctx(),
203            )
204            .await;
205
206        assert!(result.is_err());
207        let err = result.unwrap_err();
208        assert!(err.to_string().contains("binary file"));
209    }
210
211    #[tokio::test]
212    async fn test_read_file_missing_arg() {
213        let result = ReadFileTool.execute(serde_json::json!({}), &ctx()).await;
214
215        assert!(result.is_err());
216        assert!(matches!(
217            result.unwrap_err(),
218            ToolError::InvalidArguments(_)
219        ));
220    }
221}