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: "Read a UTF-8 text file under the workspace. Returns the file contents."
36                .into(),
37            parameters: json!({
38                "type": "object",
39                "properties": {
40                    "path": {"type": "string", "description": "Path relative to the workspace root"}
41                },
42                "required": ["path"]
43            }),
44        }
45    }
46
47    async fn execute(&self, args: Value) -> Result<String> {
48        let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
49            name: "read_file".into(),
50            message: "missing `path`".into(),
51        })?;
52        let abs = resolve_within(&self.root, path)?;
53        let bytes = tokio::fs::read(&abs).await.map_err(|e| Error::Tool {
54            name: "read_file".into(),
55            message: format!("{}: {e}", abs.display()),
56        })?;
57        if bytes.len() > self.max_bytes {
58            return Err(Error::Tool {
59                name: "read_file".into(),
60                message: format!(
61                    "file too large: {} bytes (max {})",
62                    bytes.len(),
63                    self.max_bytes
64                ),
65            });
66        }
67        String::from_utf8(bytes).map_err(|e| Error::Tool {
68            name: "read_file".into(),
69            message: format!("not utf-8: {e}"),
70        })
71    }
72}
73
74#[derive(Debug, Clone)]
75pub struct WriteFile {
76    pub root: PathBuf,
77}
78
79impl WriteFile {
80    pub fn new(root: impl Into<PathBuf>) -> Self {
81        Self { root: root.into() }
82    }
83}
84
85#[async_trait]
86impl Tool for WriteFile {
87    fn spec(&self) -> ToolSpec {
88        ToolSpec {
89            name: "write_file".into(),
90            description: "Write/overwrite a UTF-8 text file under the workspace. Parent directories are created.".into(),
91            parameters: json!({
92                "type": "object",
93                "properties": {
94                    "path": {"type": "string", "description": "Path relative to the workspace root"},
95                    "contents": {"type": "string", "description": "Full new contents of the file"}
96                },
97                "required": ["path", "contents"]
98            }),
99        }
100    }
101
102    async fn execute(&self, args: Value) -> Result<String> {
103        let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
104            name: "write_file".into(),
105            message: "missing `path`".into(),
106        })?;
107        let contents = args["contents"]
108            .as_str()
109            .ok_or_else(|| Error::BadToolArgs {
110                name: "write_file".into(),
111                message: "missing `contents`".into(),
112            })?;
113        let abs = resolve_within(&self.root, path)?;
114        if let Some(parent) = abs.parent() {
115            tokio::fs::create_dir_all(parent)
116                .await
117                .map_err(|e| Error::Tool {
118                    name: "write_file".into(),
119                    message: format!("mkdir {}: {e}", parent.display()),
120                })?;
121        }
122        tokio::fs::write(&abs, contents)
123            .await
124            .map_err(|e| Error::Tool {
125                name: "write_file".into(),
126                message: format!("{}: {e}", abs.display()),
127            })?;
128        Ok(format!("wrote {} bytes to {}", contents.len(), path))
129    }
130}
131
132#[derive(Debug, Clone)]
133pub struct ListDir {
134    pub root: PathBuf,
135}
136
137impl ListDir {
138    pub fn new(root: impl Into<PathBuf>) -> Self {
139        Self { root: root.into() }
140    }
141}
142
143#[async_trait]
144impl Tool for ListDir {
145    fn spec(&self) -> ToolSpec {
146        ToolSpec {
147            name: "list_dir".into(),
148            description: "List entries of a directory under the workspace. Returns one path per line, `/` suffix for dirs.".into(),
149            parameters: json!({
150                "type": "object",
151                "properties": {
152                    "path": {"type": "string", "description": "Directory relative to the workspace root", "default": "."}
153                }
154            }),
155        }
156    }
157
158    async fn execute(&self, args: Value) -> Result<String> {
159        let path = args["path"].as_str().unwrap_or(".");
160        let abs = resolve_within(&self.root, path)?;
161        let mut entries = tokio::fs::read_dir(&abs).await.map_err(|e| Error::Tool {
162            name: "list_dir".into(),
163            message: format!("{}: {e}", abs.display()),
164        })?;
165        let mut lines = Vec::new();
166        while let Some(entry) = entries.next_entry().await.map_err(|e| Error::Tool {
167            name: "list_dir".into(),
168            message: e.to_string(),
169        })? {
170            let name = entry.file_name().to_string_lossy().to_string();
171            let kind = entry.file_type().await.ok();
172            let suffix = if kind.is_some_and(|k| k.is_dir()) {
173                "/"
174            } else {
175                ""
176            };
177            lines.push(format!("{name}{suffix}"));
178        }
179        lines.sort();
180        Ok(lines.join("\n"))
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use tempfile::TempDir;
188
189    #[tokio::test]
190    async fn write_then_read_roundtrip() {
191        let tmp = TempDir::new().unwrap();
192        let w = WriteFile::new(tmp.path());
193        let r = ReadFile::new(tmp.path());
194        w.execute(json!({"path":"hello.txt","contents":"world"}))
195            .await
196            .unwrap();
197        let got = r.execute(json!({"path":"hello.txt"})).await.unwrap();
198        assert_eq!(got, "world");
199    }
200
201    #[tokio::test]
202    async fn write_creates_parent_dirs() {
203        let tmp = TempDir::new().unwrap();
204        WriteFile::new(tmp.path())
205            .execute(json!({"path":"a/b/c.txt","contents":"x"}))
206            .await
207            .unwrap();
208        assert!(tmp.path().join("a/b/c.txt").exists());
209    }
210
211    #[tokio::test]
212    async fn list_dir_sorts_and_marks_dirs() {
213        let tmp = TempDir::new().unwrap();
214        std::fs::create_dir(tmp.path().join("sub")).unwrap();
215        std::fs::write(tmp.path().join("a.txt"), "x").unwrap();
216        let out = ListDir::new(tmp.path())
217            .execute(json!({"path":"."}))
218            .await
219            .unwrap();
220        assert_eq!(out, "a.txt\nsub/");
221    }
222
223    #[tokio::test]
224    async fn rejects_escape() {
225        let tmp = TempDir::new().unwrap();
226        let r = ReadFile::new(tmp.path());
227        let err = r
228            .execute(json!({"path":"../etc/passwd"}))
229            .await
230            .unwrap_err();
231        assert!(matches!(err, Error::BadToolArgs { .. }));
232    }
233}