1use crate::tool::diff::build_unified_line_diff;
2use crate::tool::{Tool, ToolResult, ToolSchema};
3use async_trait::async_trait;
4use serde::Serialize;
5use serde_json::{Value, json};
6use std::path::{Component, Path, PathBuf};
7use tokio::process::Command;
8
9pub struct FsRead;
10pub struct FsWrite {
11 workspace_root: PathBuf,
12}
13pub struct FsList;
14pub struct FsGlob;
15pub struct FsGrep;
16
17#[derive(Debug, Serialize)]
18struct FileWriteSummary {
19 added_lines: usize,
20 removed_lines: usize,
21}
22
23#[derive(Debug, Serialize)]
24struct FileWriteOutput {
25 path: String,
26 applied: bool,
27 summary: FileWriteSummary,
28 diff: String,
29}
30
31#[derive(Debug, Serialize)]
32struct FileReadOutput {
33 path: String,
34 bytes: usize,
35 lines: usize,
36 start: usize,
37 end: usize,
38 total_lines: usize,
39 content: String,
40}
41
42#[derive(Debug, Serialize)]
43struct ListOutput {
44 path: String,
45 count: usize,
46 entries: Vec<String>,
47}
48
49#[derive(Debug, Serialize)]
50struct GlobOutput {
51 pattern: String,
52 count: usize,
53 matches: Vec<String>,
54}
55
56#[derive(Debug, Serialize)]
57struct GrepOutput {
58 path: String,
59 pattern: String,
60 include: Option<String>,
61 count: usize,
62 shown_count: usize,
63 truncated: bool,
64 has_errors: bool,
65 matches: Vec<GrepMatch>,
66}
67
68#[derive(Debug, Serialize)]
69struct GrepMatch {
70 path: String,
71 line_number: usize,
72 line: String,
73}
74
75#[async_trait]
76impl Tool for FsRead {
77 fn schema(&self) -> ToolSchema {
78 ToolSchema {
79 name: "read".to_string(),
80 description: "Read a UTF-8 text file".to_string(),
81 capability: Some("read".to_string()),
82 mutating: Some(false),
83 parameters: json!({
84 "type": "object",
85 "properties": {
86 "path": {"type": "string"},
87 "start": {"type": "integer", "minimum": 0, "default": 0},
88 "end": {"type": "integer", "minimum": -1, "default": -1}
89 },
90 "required": ["path"]
91 }),
92 }
93 }
94
95 async fn execute(&self, args: Value) -> ToolResult {
96 let path = args
97 .get("path")
98 .and_then(|v| v.as_str())
99 .unwrap_or_default();
100 let start = args.get("start").and_then(|v| v.as_i64()).unwrap_or(0);
101 let end = args.get("end").and_then(|v| v.as_i64()).unwrap_or(-1);
102
103 if start < 0 {
104 return ToolResult::error("start must be >= 0".to_string());
105 }
106 if end < -1 {
107 return ToolResult::error("end must be >= -1".to_string());
108 }
109
110 let content = match std::fs::read_to_string(path) {
111 Ok(text) => text,
112 Err(err) => return ToolResult::error(err.to_string()),
113 };
114
115 let line_chunks: Vec<&str> = content.split_inclusive('\n').collect();
116 let total_lines = line_chunks.len();
117
118 let start = usize::try_from(start).unwrap_or(0).min(total_lines);
119 let end = if end == -1 {
120 total_lines
121 } else {
122 usize::try_from(end).unwrap_or(total_lines).min(total_lines)
123 };
124
125 if start > end {
126 return ToolResult::error("start must be less than or equal to end".to_string());
127 }
128
129 let content = line_chunks[start..end].join("");
130 let output = FileReadOutput {
131 path: path.to_string(),
132 bytes: content.len(),
133 lines: end.saturating_sub(start),
134 start,
135 end,
136 total_lines,
137 content,
138 };
139 ToolResult::ok_json_serializable("ok", &output)
140 }
141}
142
143impl FsWrite {
144 pub fn new(workspace_root: PathBuf) -> Self {
145 Self { workspace_root }
146 }
147}
148
149#[async_trait]
150impl Tool for FsWrite {
151 fn schema(&self) -> ToolSchema {
152 ToolSchema {
153 name: "write".to_string(),
154 description: "Write UTF-8 text to file".to_string(),
155 capability: Some("write".to_string()),
156 mutating: Some(true),
157 parameters: json!({
158 "type": "object",
159 "properties": {
160 "path": {"type": "string"},
161 "content": {"type": "string"}
162 },
163 "required": ["path", "content"]
164 }),
165 }
166 }
167
168 async fn execute(&self, args: Value) -> ToolResult {
169 let raw_path = args
170 .get("path")
171 .and_then(|v| v.as_str())
172 .unwrap_or_default()
173 .to_string();
174 let path = PathBuf::from(&raw_path);
175 let content = args
176 .get("content")
177 .and_then(|v| v.as_str())
178 .unwrap_or_default();
179
180 let target = match resolve_workspace_target(&self.workspace_root, &path) {
181 Ok(path) => path,
182 Err(err) => return ToolResult::error(err),
183 };
184
185 if let Some(parent) = target.parent()
186 && let Err(err) = std::fs::create_dir_all(parent)
187 {
188 return ToolResult::error(err.to_string());
189 }
190
191 let before = if target.exists() {
192 match std::fs::read_to_string(&target) {
193 Ok(text) => text,
194 Err(err) => {
195 return ToolResult::error(format!(
196 "failed to read existing file before write: {err}"
197 ));
198 }
199 }
200 } else {
201 String::new()
202 };
203
204 match std::fs::write(target, content) {
205 Ok(_) => {
206 let diff = build_unified_line_diff(before.as_str(), content, &raw_path);
207 let output = FileWriteOutput {
208 path: raw_path,
209 applied: before != content,
210 summary: FileWriteSummary {
211 added_lines: diff.added_lines,
212 removed_lines: diff.removed_lines,
213 },
214 diff: diff.unified,
215 };
216
217 ToolResult::ok_json_serializable("ok", &output)
218 }
219 Err(err) => ToolResult::error(err.to_string()),
220 }
221 }
222}
223
224#[async_trait]
225impl Tool for FsList {
226 fn schema(&self) -> ToolSchema {
227 ToolSchema {
228 name: "list".to_string(),
229 description: "List directory entries".to_string(),
230 capability: Some("list".to_string()),
231 mutating: Some(false),
232 parameters: json!({
233 "type": "object",
234 "properties": {"path": {"type": "string"}},
235 "required": ["path"]
236 }),
237 }
238 }
239
240 async fn execute(&self, args: Value) -> ToolResult {
241 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
242 match std::fs::read_dir(path) {
243 Ok(entries) => {
244 let mut entries_list = Vec::new();
245 for entry in entries.flatten() {
246 entries_list.push(entry.path().display().to_string());
247 }
248 let output = ListOutput {
249 path: path.to_string(),
250 count: entries_list.len(),
251 entries: entries_list,
252 };
253 ToolResult::ok_json_serializable("ok", &output)
254 }
255 Err(err) => ToolResult::error(err.to_string()),
256 }
257 }
258}
259
260#[async_trait]
261impl Tool for FsGlob {
262 fn schema(&self) -> ToolSchema {
263 ToolSchema {
264 name: "glob".to_string(),
265 description: "Glob files".to_string(),
266 capability: Some("glob".to_string()),
267 mutating: Some(false),
268 parameters: json!({
269 "type": "object",
270 "properties": {"pattern": {"type": "string"}},
271 "required": ["pattern"]
272 }),
273 }
274 }
275
276 async fn execute(&self, args: Value) -> ToolResult {
277 let pattern = args
278 .get("pattern")
279 .and_then(|v| v.as_str())
280 .unwrap_or_default();
281 let mut matches = Vec::new();
282 match glob::glob(pattern) {
283 Ok(paths) => {
284 for p in paths.flatten() {
285 matches.push(p.display().to_string());
286 }
287 let output = GlobOutput {
288 pattern: pattern.to_string(),
289 count: matches.len(),
290 matches,
291 };
292 ToolResult::ok_json_serializable("ok", &output)
293 }
294 Err(err) => ToolResult::error(err.to_string()),
295 }
296 }
297}
298
299#[async_trait]
300impl Tool for FsGrep {
301 fn schema(&self) -> ToolSchema {
302 ToolSchema {
303 name: "grep".to_string(),
304 description: "Search regex in files recursively".to_string(),
305 capability: Some("grep".to_string()),
306 mutating: Some(false),
307 parameters: json!({
308 "type": "object",
309 "properties": {
310 "path": {"type": "string"},
311 "pattern": {"type": "string"},
312 "include": {"type": "string"}
313 },
314 "required": ["pattern"]
315 }),
316 }
317 }
318
319 async fn execute(&self, args: Value) -> ToolResult {
320 let root = PathBuf::from(args.get("path").and_then(|v| v.as_str()).unwrap_or("."));
321 let pattern = args
322 .get("pattern")
323 .and_then(|v| v.as_str())
324 .unwrap_or_default();
325 let include = args
326 .get("include")
327 .and_then(|v| v.as_str())
328 .map(ToOwned::to_owned);
329
330 let mut command = Command::new("rg");
331 command
332 .arg("--json")
333 .arg("--hidden")
334 .arg("--no-messages")
335 .arg("--color")
336 .arg("never")
337 .arg("--regexp")
338 .arg(pattern);
339 if let Some(include) = include.as_deref() {
340 command.arg("--glob").arg(include);
341 }
342 command.arg(&root);
343
344 let output = match command.output().await {
345 Ok(output) => output,
346 Err(err) => {
347 return ToolResult::error(format!("failed to run rg: {err}"));
348 }
349 };
350
351 let all_results = parse_rg_json_matches(&output.stdout);
352
353 let exit_code = output.status.code().unwrap_or_default();
354 let has_errors = exit_code == 2;
356
357 if exit_code != 0 && exit_code != 1 {
359 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
360 return if stderr.is_empty() {
361 ToolResult::error(format!("rg exited with code {exit_code}"))
362 } else {
363 ToolResult::error(stderr)
364 };
365 }
366
367 let total_count = all_results.len();
368 let limit = 100;
369 let truncated = total_count > limit;
370 let results = if truncated {
371 all_results.into_iter().take(limit).collect()
372 } else {
373 all_results
374 };
375 let shown_count = results.len();
376
377 let output = GrepOutput {
378 path: root.display().to_string(),
379 pattern: pattern.to_string(),
380 include,
381 count: total_count,
382 shown_count,
383 truncated,
384 has_errors,
385 matches: results,
386 };
387
388 ToolResult::ok_json_serializable("ok", &output)
389 }
390}
391
392fn parse_rg_json_matches(stdout: &[u8]) -> Vec<GrepMatch> {
393 if stdout.is_empty() {
394 return Vec::new();
395 }
396
397 String::from_utf8_lossy(stdout)
398 .lines()
399 .filter_map(parse_rg_match_line)
400 .collect()
401}
402
403fn parse_rg_match_line(line: &str) -> Option<GrepMatch> {
404 let event: Value = serde_json::from_str(line).ok()?;
405 let event_type = event.get("type")?.as_str()?;
406
407 if event_type != "match" {
408 return None;
409 }
410
411 let data = event.get("data")?;
412 let path = extract_rg_field(data, "path", "text")?;
413 let line_number = data
414 .get("line_number")
415 .and_then(|v| v.as_u64())
416 .and_then(|v| usize::try_from(v).ok())?;
417 let line = extract_rg_field(data, "lines", "text")
418 .unwrap_or_default()
419 .trim_end_matches('\n')
420 .to_string();
421
422 Some(GrepMatch {
423 path,
424 line_number,
425 line,
426 })
427}
428
429fn extract_rg_field(data: &Value, outer: &str, inner: &str) -> Option<String> {
430 data.get(outer)
431 .and_then(|v| v.get(inner))
432 .and_then(|v| v.as_str())
433 .map(|s| s.to_string())
434}
435
436pub(crate) fn to_workspace_target(workspace_root: &Path, path: &Path) -> PathBuf {
437 if path.is_absolute() {
438 path.to_path_buf()
439 } else {
440 workspace_root.join(path)
441 }
442}
443
444pub(crate) fn resolve_workspace_target(
445 workspace_root: &Path,
446 path: &Path,
447) -> Result<PathBuf, String> {
448 if path
449 .components()
450 .any(|component| matches!(component, Component::ParentDir))
451 {
452 return Err("path must not contain parent directory traversal".to_string());
453 }
454
455 let workspace_root = std::fs::canonicalize(workspace_root)
456 .map_err(|err| format!("failed to resolve workspace root: {err}"))?;
457 let target = to_workspace_target(&workspace_root, path);
458
459 let checked_target = if target.exists() {
460 std::fs::canonicalize(&target)
461 .map_err(|err| format!("failed to resolve target path: {err}"))?
462 } else {
463 let parent = target
464 .parent()
465 .ok_or_else(|| "target path has no parent directory".to_string())?;
466 let canonical_parent = std::fs::canonicalize(parent)
467 .map_err(|err| format!("failed to resolve target parent: {err}"))?;
468 let file_name = target
469 .file_name()
470 .ok_or_else(|| "target path has no file name".to_string())?;
471 canonical_parent.join(file_name)
472 };
473
474 if !checked_target.starts_with(&workspace_root) {
475 return Err("path is outside workspace".to_string());
476 }
477
478 Ok(target)
479}