1use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use super::{resolve_within, Tool};
12use crate::error::{Error, Result};
13use crate::llm::ToolSpec;
14
15#[derive(Debug, Clone)]
16pub struct ReadFile {
17 pub root: PathBuf,
18 pub max_bytes: usize,
19}
20
21impl ReadFile {
22 pub fn new(root: impl Into<PathBuf>) -> Self {
23 Self {
24 root: root.into(),
25 max_bytes: 256 * 1024,
26 }
27 }
28}
29
30#[async_trait]
31impl Tool for ReadFile {
32 fn spec(&self) -> ToolSpec {
33 ToolSpec {
34 name: "read_file".into(),
35 description:
36 "Read a UTF-8 text file under the workspace. Optionally return a line range."
37 .to_string(),
38 parameters: json!({
39 "type": "object",
40 "properties": {
41 "path": {"type": "string", "description": "Path relative to the workspace root"},
42 "start_line": {"type": "integer", "description": "Optional 1-indexed inclusive start line. If end_line is set but not start_line, defaults to 1."},
43 "end_line": {"type": "integer", "description": "Optional 1-indexed inclusive end line. If start_line is set but not end_line, defaults to last line."}
44 },
45 "required": ["path"]
46 }),
47 }
48 }
49
50 fn is_readonly(&self) -> bool {
51 true
52 }
53
54 async fn execute(&self, args: Value) -> Result<String> {
55 let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
56 name: "read_file".into(),
57 message: "missing `path`".into(),
58 })?;
59 let abs = resolve_within(&self.root, path)?;
60 let bytes = tokio::fs::read(&abs).await.map_err(|e| Error::Tool {
61 name: "read_file".into(),
62 message: format!("{}: {e}", abs.display()),
63 })?;
64 if bytes.len() > self.max_bytes {
65 return Err(Error::Tool {
66 name: "read_file".into(),
67 message: format!(
68 "file too large: {} bytes (max {})",
69 bytes.len(),
70 self.max_bytes
71 ),
72 });
73 }
74 let content = String::from_utf8(bytes).map_err(|e| Error::Tool {
75 name: "read_file".into(),
76 message: format!("not utf-8: {e}"),
77 })?;
78
79 let start_line = args["start_line"].as_u64();
81 let end_line = args["end_line"].as_u64();
82
83 if start_line.is_none() && end_line.is_none() {
85 return Ok(content);
86 }
87
88 let total_lines = content.lines().count();
90 if total_lines == 0 {
91 return Ok(content); }
93
94 let start = match start_line {
96 Some(0) => {
97 return Err(Error::BadToolArgs {
98 name: "read_file".to_string(),
99 message: "start_line must be >= 1 (1-indexed)".to_string(),
100 });
101 }
102 Some(n) => n as usize,
103 None => 1,
104 };
105
106 let end = match end_line {
107 Some(0) => {
108 return Err(Error::BadToolArgs {
109 name: "read_file".to_string(),
110 message: "end_line must be >= 1 (1-indexed)".to_string(),
111 });
112 }
113 Some(n) => n as usize,
114 None => total_lines,
115 };
116
117 if start > end {
119 return Err(Error::BadToolArgs {
120 name: "read_file".to_string(),
121 message: format!("start_line ({}) must be <= end_line ({})", start, end),
122 });
123 }
124
125 let start = start.min(total_lines);
127 let end = end.min(total_lines);
128
129 if start_line.is_some() && start > total_lines {
131 return Err(Error::BadToolArgs {
132 name: "read_file".to_string(),
133 message: format!("start_line {} exceeds total lines {}", start, total_lines),
134 });
135 }
136
137 let slice: String = content
139 .lines()
140 .skip(start - 1)
141 .take(end - start + 1)
142 .collect::<Vec<_>>()
143 .join("\n");
144
145 Ok(format!(
146 "# range: lines {}-{} of {}\n{}",
147 start, end, total_lines, slice
148 ))
149 }
150}
151
152#[derive(Debug, Clone)]
153pub struct WriteFile {
154 pub root: PathBuf,
155}
156
157impl WriteFile {
158 pub fn new(root: impl Into<PathBuf>) -> Self {
159 Self { root: root.into() }
160 }
161}
162
163#[async_trait]
164impl Tool for WriteFile {
165 fn spec(&self) -> ToolSpec {
166 ToolSpec {
167 name: "write_file".into(),
168 description: "Write/overwrite a UTF-8 text file under the workspace. Parent directories are created.".into(),
169 parameters: json!({
170 "type": "object",
171 "properties": {
172 "path": {"type": "string", "description": "Path relative to the workspace root"},
173 "contents": {"type": "string", "description": "Full new contents of the file"}
174 },
175 "required": ["path", "contents"]
176 }),
177 }
178 }
179
180 async fn execute(&self, args: Value) -> Result<String> {
181 let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
182 name: "write_file".into(),
183 message: "missing `path`".into(),
184 })?;
185 let contents = args["contents"]
186 .as_str()
187 .ok_or_else(|| Error::BadToolArgs {
188 name: "write_file".into(),
189 message: "missing `contents`".into(),
190 })?;
191 let abs = resolve_within(&self.root, path)?;
192 if let Some(parent) = abs.parent() {
193 tokio::fs::create_dir_all(parent)
194 .await
195 .map_err(|e| Error::Tool {
196 name: "write_file".into(),
197 message: format!("mkdir {}: {e}", parent.display()),
198 })?;
199 }
200 tokio::fs::write(&abs, contents)
201 .await
202 .map_err(|e| Error::Tool {
203 name: "write_file".into(),
204 message: format!("{}: {e}", abs.display()),
205 })?;
206 Ok(format!("wrote {} bytes to {}", contents.len(), path))
207 }
208}
209
210#[derive(Debug, Clone)]
211pub struct ListDir {
212 pub root: PathBuf,
213}
214
215impl ListDir {
216 pub fn new(root: impl Into<PathBuf>) -> Self {
217 Self { root: root.into() }
218 }
219}
220
221#[async_trait]
222impl Tool for ListDir {
223 fn spec(&self) -> ToolSpec {
224 ToolSpec {
225 name: "list_dir".into(),
226 description: "List entries of a directory under the workspace. Returns one path per line, `/` suffix for dirs.".into(),
227 parameters: json!({
228 "type": "object",
229 "properties": {
230 "path": {"type": "string", "description": "Directory relative to the workspace root", "default": "."}
231 }
232 }),
233 }
234 }
235
236 fn is_readonly(&self) -> bool {
237 true
238 }
239 async fn execute(&self, args: Value) -> Result<String> {
240 let path = args["path"].as_str().unwrap_or(".");
241 let abs = resolve_within(&self.root, path)?;
242 let mut entries = tokio::fs::read_dir(&abs).await.map_err(|e| Error::Tool {
243 name: "list_dir".into(),
244 message: format!("{}: {e}", abs.display()),
245 })?;
246 let mut lines = Vec::new();
247 while let Some(entry) = entries.next_entry().await.map_err(|e| Error::Tool {
248 name: "list_dir".into(),
249 message: e.to_string(),
250 })? {
251 let name = entry.file_name().to_string_lossy().to_string();
252 let kind = entry.file_type().await.ok();
253 let suffix = if kind.is_some_and(|k| k.is_dir()) {
254 "/"
255 } else {
256 ""
257 };
258 lines.push(format!("{name}{suffix}"));
259 }
260 lines.sort();
261 Ok(lines.join("\n"))
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use tempfile::TempDir;
269
270 #[tokio::test]
271 async fn write_then_read_roundtrip() {
272 let tmp = TempDir::new().unwrap();
273 let w = WriteFile::new(tmp.path());
274 let r = ReadFile::new(tmp.path());
275 w.execute(json!({"path":"hello.txt","contents":"world"}))
276 .await
277 .unwrap();
278 let got = r.execute(json!({"path":"hello.txt"})).await.unwrap();
279 assert_eq!(got, "world");
280 }
281
282 #[tokio::test]
283 async fn write_creates_parent_dirs() {
284 let tmp = TempDir::new().unwrap();
285 WriteFile::new(tmp.path())
286 .execute(json!({"path":"a/b/c.txt","contents":"x"}))
287 .await
288 .unwrap();
289 assert!(tmp.path().join("a/b/c.txt").exists());
290 }
291
292 #[tokio::test]
293 async fn list_dir_sorts_and_marks_dirs() {
294 let tmp = TempDir::new().unwrap();
295 std::fs::create_dir(tmp.path().join("sub")).unwrap();
296 std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
297 let out = ListDir::new(tmp.path())
298 .execute(json!({"path":"."}))
299 .await
300 .unwrap();
301 assert_eq!(out, "a.txt\nsub/");
302 }
303
304 #[tokio::test]
305 async fn rejects_escape() {
306 let tmp = TempDir::new().unwrap();
307 let r = ReadFile::new(tmp.path());
308 let err = r
309 .execute(json!({"path":"../etc/passwd"}))
310 .await
311 .unwrap_err();
312 assert!(matches!(err, Error::BadToolArgs { .. }));
313 }
314 #[tokio::test]
316 async fn read_file_with_line_range() {
317 let tmp = TempDir::new().unwrap();
318 std::fs::write(
319 tmp.path().join("test.txt"),
320 "line1
321line2
322line3
323line4
324line5
325",
326 )
327 .unwrap();
328 let r = ReadFile::new(tmp.path());
329 let got = r
330 .execute(json!({"path":"test.txt", "start_line": 2, "end_line": 3}))
331 .await
332 .unwrap();
333 assert!(got.starts_with(
335 "# range: lines 2-3 of 5
336"
337 ));
338 assert!(got.contains("line2"));
339 assert!(got.contains("line3"));
340 assert!(!got.contains("line1"));
341 assert!(!got.contains("line4"));
342 assert!(!got.contains("line5"));
343 }
344
345 #[tokio::test]
346 async fn read_file_without_range_returns_full() {
347 let tmp = TempDir::new().unwrap();
348 std::fs::write(
349 tmp.path().join("test.txt"),
350 "line1
351line2
352line3",
353 )
354 .unwrap();
355 let r = ReadFile::new(tmp.path());
356 let got = r.execute(json!({"path":"test.txt"})).await.unwrap();
357 assert!(!got.starts_with("# range:"));
359 assert_eq!(
360 got,
361 "line1
362line2
363line3"
364 );
365 }
366
367 #[tokio::test]
368 async fn read_file_invalid_range_start_greater_than_end() {
369 let tmp = TempDir::new().unwrap();
370 std::fs::write(
371 tmp.path().join("test.txt"),
372 "line1
373line2
374line3
375",
376 )
377 .unwrap();
378 let r = ReadFile::new(tmp.path());
379 let err = r
380 .execute(json!({"path":"test.txt", "start_line": 10, "end_line": 5}))
381 .await
382 .unwrap_err();
383 assert!(matches!(err, Error::BadToolArgs { .. }));
384 let err_msg = format!("{:?}", err);
385 assert!(err_msg.contains("start_line") && err_msg.contains("end_line"));
386 }
387}