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 side_effect_class(&self) -> crate::tools::ToolSideEffect {
51 crate::tools::ToolSideEffect::ReadOnly
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 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
181 crate::tools::ToolSideEffect::Mutating
182 }
183
184 async fn execute(&self, args: Value) -> Result<String> {
185 let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
186 name: "write_file".into(),
187 message: "missing `path`".into(),
188 })?;
189 let contents = args["contents"]
190 .as_str()
191 .ok_or_else(|| Error::BadToolArgs {
192 name: "write_file".into(),
193 message: "missing `contents`".into(),
194 })?;
195 let abs = resolve_within(&self.root, path)?;
196 if let Some(parent) = abs.parent() {
197 tokio::fs::create_dir_all(parent)
198 .await
199 .map_err(|e| Error::Tool {
200 name: "write_file".into(),
201 message: format!("mkdir {}: {e}", parent.display()),
202 })?;
203 }
204 tokio::fs::write(&abs, contents)
205 .await
206 .map_err(|e| Error::Tool {
207 name: "write_file".into(),
208 message: format!("{}: {e}", abs.display()),
209 })?;
210 Ok(format!("wrote {} bytes to {}", contents.len(), path))
211 }
212}
213
214#[derive(Debug, Clone)]
215pub struct ListDir {
216 pub root: PathBuf,
217}
218
219impl ListDir {
220 pub fn new(root: impl Into<PathBuf>) -> Self {
221 Self { root: root.into() }
222 }
223}
224
225#[async_trait]
226impl Tool for ListDir {
227 fn spec(&self) -> ToolSpec {
228 ToolSpec {
229 name: "list_dir".into(),
230 description: "List entries of a directory under the workspace. Returns one path per line, `/` suffix for dirs.".into(),
231 parameters: json!({
232 "type": "object",
233 "properties": {
234 "path": {"type": "string", "description": "Directory relative to the workspace root", "default": "."}
235 }
236 }),
237 }
238 }
239
240 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
241 crate::tools::ToolSideEffect::ReadOnly
242 }
243 async fn execute(&self, args: Value) -> Result<String> {
244 let path = args["path"].as_str().unwrap_or(".");
245 let abs = resolve_within(&self.root, path)?;
246 let mut entries = tokio::fs::read_dir(&abs).await.map_err(|e| Error::Tool {
247 name: "list_dir".into(),
248 message: format!("{}: {e}", abs.display()),
249 })?;
250 let mut lines = Vec::new();
251 while let Some(entry) = entries.next_entry().await.map_err(|e| Error::Tool {
252 name: "list_dir".into(),
253 message: e.to_string(),
254 })? {
255 let name = entry.file_name().to_string_lossy().to_string();
256 let kind = entry.file_type().await.ok();
257 let suffix = if kind.is_some_and(|k| k.is_dir()) {
258 "/"
259 } else {
260 ""
261 };
262 lines.push(format!("{name}{suffix}"));
263 }
264 lines.sort();
265 Ok(lines.join("\n"))
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use tempfile::TempDir;
273
274 #[tokio::test]
275 async fn write_then_read_roundtrip() {
276 let tmp = TempDir::new().unwrap();
277 let w = WriteFile::new(tmp.path());
278 let r = ReadFile::new(tmp.path());
279 w.execute(json!({"path":"hello.txt","contents":"world"}))
280 .await
281 .unwrap();
282 let got = r.execute(json!({"path":"hello.txt"})).await.unwrap();
283 assert_eq!(got, "world");
284 }
285
286 #[tokio::test]
287 async fn write_creates_parent_dirs() {
288 let tmp = TempDir::new().unwrap();
289 WriteFile::new(tmp.path())
290 .execute(json!({"path":"a/b/c.txt","contents":"x"}))
291 .await
292 .unwrap();
293 assert!(tmp.path().join("a/b/c.txt").exists());
294 }
295
296 #[tokio::test]
297 async fn list_dir_sorts_and_marks_dirs() {
298 let tmp = TempDir::new().unwrap();
299 std::fs::create_dir(tmp.path().join("sub")).unwrap();
300 std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
301 let out = ListDir::new(tmp.path())
302 .execute(json!({"path":"."}))
303 .await
304 .unwrap();
305 assert_eq!(out, "a.txt\nsub/");
306 }
307
308 #[tokio::test]
309 async fn rejects_escape() {
310 let tmp = TempDir::new().unwrap();
311 let r = ReadFile::new(tmp.path());
312 let err = r
313 .execute(json!({"path":"../etc/passwd"}))
314 .await
315 .unwrap_err();
316 assert!(matches!(err, Error::BadToolArgs { .. }));
317 }
318 #[tokio::test]
320 async fn read_file_with_line_range() {
321 let tmp = TempDir::new().unwrap();
322 std::fs::write(
323 tmp.path().join("test.txt"),
324 "line1
325line2
326line3
327line4
328line5
329",
330 )
331 .unwrap();
332 let r = ReadFile::new(tmp.path());
333 let got = r
334 .execute(json!({"path":"test.txt", "start_line": 2, "end_line": 3}))
335 .await
336 .unwrap();
337 assert!(got.starts_with(
339 "# range: lines 2-3 of 5
340"
341 ));
342 assert!(got.contains("line2"));
343 assert!(got.contains("line3"));
344 assert!(!got.contains("line1"));
345 assert!(!got.contains("line4"));
346 assert!(!got.contains("line5"));
347 }
348
349 #[tokio::test]
350 async fn read_file_without_range_returns_full() {
351 let tmp = TempDir::new().unwrap();
352 std::fs::write(
353 tmp.path().join("test.txt"),
354 "line1
355line2
356line3",
357 )
358 .unwrap();
359 let r = ReadFile::new(tmp.path());
360 let got = r.execute(json!({"path":"test.txt"})).await.unwrap();
361 assert!(!got.starts_with("# range:"));
363 assert_eq!(
364 got,
365 "line1
366line2
367line3"
368 );
369 }
370
371 #[tokio::test]
372 async fn read_file_invalid_range_start_greater_than_end() {
373 let tmp = TempDir::new().unwrap();
374 std::fs::write(
375 tmp.path().join("test.txt"),
376 "line1
377line2
378line3
379",
380 )
381 .unwrap();
382 let r = ReadFile::new(tmp.path());
383 let err = r
384 .execute(json!({"path":"test.txt", "start_line": 10, "end_line": 5}))
385 .await
386 .unwrap_err();
387 assert!(matches!(err, Error::BadToolArgs { .. }));
388 let err_msg = format!("{:?}", err);
389 assert!(err_msg.contains("start_line") && err_msg.contains("end_line"));
390 }
391}