Skip to main content

recursive/tools/
fs.rs

1//! Filesystem tools: `read_file`, `write_file`, `list_dir`.
2//!
3//! All paths are sandboxed to a workspace root. Reads/writes outside the
4//! root are rejected at the tool layer, so the model can't (accidentally
5//! or otherwise) touch the rest of the disk.
6
7use 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        // Parse optional line range parameters
80        let start_line = args["start_line"].as_u64();
81        let end_line = args["end_line"].as_u64();
82
83        // If no range specified, return full content
84        if start_line.is_none() && end_line.is_none() {
85            return Ok(content);
86        }
87
88        // Count total lines
89        let total_lines = content.lines().count();
90        if total_lines == 0 {
91            return Ok(content); // Empty file, return as-is
92        }
93
94        // Validate and clamp line numbers (1-indexed)
95        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        // Validate start <= end
118        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        // Clamp to valid range
126        let start = start.min(total_lines);
127        let end = end.min(total_lines);
128
129        // Check if start exceeds total lines
130        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        // Extract the requested slice (1-indexed, inclusive)
138        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    // Tests for line range support (goal-26)
319    #[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        // Should include range header and the sliced content
338        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        // Should NOT have range header when no range specified
362        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}