astrid_tools/
read_file.rs1use std::fmt::Write;
4
5use crate::{BuiltinTool, ToolContext, ToolError, ToolResult};
6use serde_json::Value;
7
8const DEFAULT_LINE_LIMIT: usize = 2000;
10const MAX_LINE_LENGTH: usize = 2000;
12
13pub 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 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 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 #[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}