1use crate::context::ToolContext;
7use crate::toolset::{ToolOutcome, ToolSet};
8use async_trait::async_trait;
9use oharness_core::message::{Content, ToolOutput};
10use oharness_core::ToolSpec;
11use serde::Deserialize;
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::sync::OnceLock;
15
16const MAX_READ_BYTES: u64 = 1024 * 1024; pub struct FsToolSet {
20 specs: Vec<ToolSpec>,
21}
22
23impl Default for FsToolSet {
24 fn default() -> Self {
25 Self::new()
26 }
27}
28
29impl FsToolSet {
30 pub fn new() -> Self {
31 Self {
32 specs: vec![
33 ToolSpec {
34 name: "fs_read".to_string(),
35 description: "Read a UTF-8 text file relative to the workspace root. \
36 Returns the file's contents (max 1MiB)."
37 .to_string(),
38 input_schema: read_schema(),
39 },
40 ToolSpec {
41 name: "fs_write".to_string(),
42 description: "Write UTF-8 text to a file relative to the workspace root. \
43 Overwrites if the file exists; creates parent directories \
44 as needed."
45 .to_string(),
46 input_schema: write_schema(),
47 },
48 ToolSpec {
49 name: "fs_list".to_string(),
50 description: "List entries in a directory relative to the workspace root."
51 .to_string(),
52 input_schema: list_schema(),
53 },
54 ],
55 }
56 }
57}
58
59#[async_trait]
60impl ToolSet for FsToolSet {
61 fn specs(&self) -> &[ToolSpec] {
62 &self.specs
63 }
64
65 async fn execute(&self, name: &str, input: Value, ctx: &ToolContext) -> ToolOutcome {
66 if ctx.cancellation.is_cancelled() {
67 return ToolOutcome::Cancelled;
68 }
69 let root = ctx
70 .workspace_path()
71 .map(Path::to_path_buf)
72 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
73
74 match name {
75 "fs_read" => do_read(input, &root).await,
76 "fs_write" => do_write(input, &root).await,
77 "fs_list" => do_list(input, &root).await,
78 other => ToolOutcome::error(format!("unknown fs tool `{other}`"), false),
79 }
80 }
81}
82
83async fn do_read(input: Value, root: &Path) -> ToolOutcome {
84 #[derive(Deserialize)]
85 struct ReadInput {
86 path: String,
87 }
88 let parsed: ReadInput = match serde_json::from_value(input) {
89 Ok(v) => v,
90 Err(e) => return ToolOutcome::error(format!("invalid fs_read input: {e}"), false),
91 };
92
93 let resolved = match resolve(root, &parsed.path) {
94 Ok(p) => p,
95 Err(e) => return ToolOutcome::error(e, false),
96 };
97
98 let metadata = match tokio::fs::metadata(&resolved).await {
99 Ok(m) => m,
100 Err(e) => return ToolOutcome::error(format!("fs_read stat: {e}"), true),
101 };
102 if !metadata.is_file() {
103 return ToolOutcome::error(format!("fs_read: `{}` is not a file", parsed.path), false);
104 }
105 if metadata.len() > MAX_READ_BYTES {
106 return ToolOutcome::error(
107 format!(
108 "fs_read: `{}` is {} bytes; max {MAX_READ_BYTES}",
109 parsed.path,
110 metadata.len()
111 ),
112 false,
113 );
114 }
115
116 match tokio::fs::read_to_string(&resolved).await {
117 Ok(contents) => ToolOutcome::Success(ToolOutput {
118 content: vec![Content::Text { text: contents }],
119 truncated: false,
120 }),
121 Err(e) => ToolOutcome::error(format!("fs_read: {e}"), true),
122 }
123}
124
125async fn do_write(input: Value, root: &Path) -> ToolOutcome {
126 #[derive(Deserialize)]
127 struct WriteInput {
128 path: String,
129 content: String,
130 }
131 let parsed: WriteInput = match serde_json::from_value(input) {
132 Ok(v) => v,
133 Err(e) => return ToolOutcome::error(format!("invalid fs_write input: {e}"), false),
134 };
135
136 let resolved = match resolve(root, &parsed.path) {
137 Ok(p) => p,
138 Err(e) => return ToolOutcome::error(e, false),
139 };
140
141 if let Some(parent) = resolved.parent() {
142 if let Err(e) = tokio::fs::create_dir_all(parent).await {
143 return ToolOutcome::error(format!("fs_write mkdir: {e}"), true);
144 }
145 }
146
147 match tokio::fs::write(&resolved, parsed.content.as_bytes()).await {
148 Ok(()) => ToolOutcome::success_text(format!(
149 "wrote {} bytes to {}",
150 parsed.content.len(),
151 parsed.path
152 )),
153 Err(e) => ToolOutcome::error(format!("fs_write: {e}"), true),
154 }
155}
156
157async fn do_list(input: Value, root: &Path) -> ToolOutcome {
158 #[derive(Deserialize)]
159 struct ListInput {
160 #[serde(default = "default_dot")]
161 path: String,
162 }
163 fn default_dot() -> String {
164 ".".to_string()
165 }
166 let parsed: ListInput = match serde_json::from_value(input) {
167 Ok(v) => v,
168 Err(e) => return ToolOutcome::error(format!("invalid fs_list input: {e}"), false),
169 };
170
171 let resolved = match resolve(root, &parsed.path) {
172 Ok(p) => p,
173 Err(e) => return ToolOutcome::error(e, false),
174 };
175
176 let mut entries = match tokio::fs::read_dir(&resolved).await {
177 Ok(r) => r,
178 Err(e) => return ToolOutcome::error(format!("fs_list: {e}"), true),
179 };
180
181 let mut names: Vec<String> = Vec::new();
182 while let Ok(Some(entry)) = entries.next_entry().await {
183 let name = entry.file_name().to_string_lossy().into_owned();
184 let is_dir = entry
185 .file_type()
186 .await
187 .map(|ft| ft.is_dir())
188 .unwrap_or(false);
189 names.push(if is_dir { format!("{name}/") } else { name });
190 }
191 names.sort();
192
193 ToolOutcome::success_text(names.join("\n"))
194}
195
196fn resolve(root: &Path, rel: &str) -> Result<PathBuf, String> {
199 let candidate = root.join(rel);
200 let normalized = normalize(&candidate);
203 if !normalized.starts_with(root) {
204 return Err(format!(
205 "path `{rel}` escapes workspace root `{}`",
206 root.display()
207 ));
208 }
209 Ok(normalized)
210}
211
212fn normalize(p: &Path) -> PathBuf {
214 let mut out = PathBuf::new();
215 for component in p.components() {
216 match component {
217 std::path::Component::ParentDir => {
218 out.pop();
219 }
220 std::path::Component::CurDir => {}
221 c => out.push(c.as_os_str()),
222 }
223 }
224 out
225}
226
227fn read_schema() -> Value {
228 static S: OnceLock<Value> = OnceLock::new();
229 S.get_or_init(|| {
230 json!({
231 "type": "object",
232 "required": ["path"],
233 "properties": {
234 "path": {"type": "string", "description": "File path relative to workspace root."}
235 },
236 "additionalProperties": false
237 })
238 })
239 .clone()
240}
241
242fn write_schema() -> Value {
243 static S: OnceLock<Value> = OnceLock::new();
244 S.get_or_init(|| {
245 json!({
246 "type": "object",
247 "required": ["path", "content"],
248 "properties": {
249 "path": {"type": "string", "description": "File path relative to workspace root."},
250 "content": {"type": "string", "description": "UTF-8 text to write."}
251 },
252 "additionalProperties": false
253 })
254 })
255 .clone()
256}
257
258fn list_schema() -> Value {
259 static S: OnceLock<Value> = OnceLock::new();
260 S.get_or_init(|| {
261 json!({
262 "type": "object",
263 "properties": {
264 "path": {"type": "string", "description": "Directory path relative to workspace root (default `.`)."}
265 },
266 "additionalProperties": false
267 })
268 })
269 .clone()
270}