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: "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}