1use std::path::PathBuf;
2
3use crate::error::RuntimeError;
4use crate::stream::StreamFrame;
5use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
6use crate::value::Value;
7
8pub struct FsRead;
9
10impl Tool for FsRead {
11 fn name(&self) -> &str {
12 "fs.read"
13 }
14
15 fn tier(&self) -> Tier {
16 Tier::Zero
17 }
18
19 fn description(&self) -> Option<&str> {
20 Some(
21 "Read a UTF-8 text file. Use `offset` (1-indexed) + `limit` to fetch a slice of a large \
22 file. Alternatively pass `anchor` (literal substring) to jump to the first line \
23 containing it and return `context` lines on each side (default 5). Explicit \
24 offset/limit take precedence over anchor.",
25 )
26 }
27
28 fn input_schema(&self) -> serde_json::Value {
29 serde_json::json!({
30 "type": "object",
31 "properties": {
32 "path": {"type": "string"},
33 "offset": {"type": "integer", "description": "1-indexed start line."},
34 "limit": {"type": "integer", "description": "Maximum lines to return."},
35 "anchor": {"type": "string", "description": "Literal substring; when set, defaults offset to the matched line."},
36 "context": {"type": "integer", "description": "Lines around the anchor (default 5)."}
37 },
38 "required": ["path"]
39 })
40 }
41
42 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
43 Box::pin(async move {
44 let path = extract_path(&args, "path", 0)?;
45 let mut offset = extract_optional_int(&args, "offset")?;
46 let mut limit = extract_optional_int(&args, "limit")?;
47 let anchor = match args.named("anchor") {
48 Some(Value::Str(s)) if !s.is_empty() => Some(s.clone()),
49 Some(Value::Unit) | None => None,
50 Some(other) => {
51 return Err(RuntimeError::TypeMismatch {
52 expected: "string anchor".into(),
53 actual: other.kind_name().into(),
54 });
55 }
56 };
57 let context: usize = match args.named("context") {
58 Some(Value::Int(n)) if *n >= 0 => (*n as usize).min(200),
59 _ => 5,
60 };
61 let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
62 RuntimeError::ToolFailed(format!("fs.read({}): {e}", path.display()))
63 })?;
64 let canonical = canonicalize_or_owned(&path);
65 ctx.note_read(&canonical);
66 let anchor_line = if let Some(needle) = anchor.as_deref() {
67 let mut hit: Option<usize> = None;
68 for (idx, line) in content.split_inclusive('\n').enumerate() {
69 if line.contains(needle) {
70 hit = Some(idx + 1);
71 break;
72 }
73 }
74 match hit {
75 Some(l) => Some(l),
76 None => {
77 return Err(RuntimeError::ToolFailed(format!(
78 "fs.read({}): anchor `{needle}` not found",
79 path.display()
80 )));
81 }
82 }
83 } else {
84 None
85 };
86 if let Some(anchor_line) = anchor_line {
87 if offset.is_none() {
88 offset = Some((anchor_line.saturating_sub(context).max(1)) as i64);
89 }
90 if limit.is_none() {
91 limit = Some((context * 2 + 1) as i64);
92 }
93 }
94 if offset.is_none() && limit.is_none() {
95 let budget = ctx.tool_output_budget;
96 let bounded = crate::tools::tool_output::bounded_text_prefix(&content, budget);
97 if bounded == content.len() {
98 return Ok(Value::Str(content));
99 }
100 return truncated_read_value(&content[..bounded], &content, bounded, ctx);
101 }
102 let start_line = offset.unwrap_or(1).max(1) as usize;
103 let total_lines = content.split_inclusive('\n').count();
104 let out = slice_lines(&content, offset, limit, &path);
105 if start_line.saturating_sub(1) >= total_lines {
106 return Ok(Value::Str(out));
107 }
108 let bounded =
109 crate::tools::tool_output::bounded_text_prefix(&out, ctx.tool_output_budget);
110 if bounded == out.len() {
111 return Ok(Value::Str(out));
112 }
113 let body_end = absolute_byte_at(&content, start_line, 0).saturating_add(
114 bounded.saturating_sub(out.find('\n').map_or(0, |index| index + 1)),
115 );
116 truncated_read_value(&out[..bounded], &content, body_end, ctx)
117 })
118 }
119}
120
121fn truncated_read_value(
122 head: &str,
123 full: &str,
124 next_offset: usize,
125 ctx: &ToolCtx,
126) -> Result<Value, RuntimeError> {
127 let output_id = ctx
128 .output_store
129 .as_deref()
130 .and_then(|store| store.register("fs_read", full))
131 .ok_or_else(|| {
132 RuntimeError::ToolFailed(
133 "fs.read: output exceeded budget, but no output_id is available for output.read"
134 .into(),
135 )
136 })?;
137 Ok(Value::Struct(vec![
138 ("content".into(), Value::Str(head.to_string())),
139 ("truncated".into(), Value::Bool(true)),
140 (
141 "total_lines".into(),
142 Value::Int(full.split_inclusive('\n').count() as i64),
143 ),
144 ("total_bytes".into(), Value::Int(full.len() as i64)),
145 ("output_id".into(), Value::Str(output_id)),
146 (
147 "next".into(),
148 Value::Struct(vec![
149 ("mode".into(), Value::Str("bytes".into())),
150 ("offset".into(), Value::Int(next_offset as i64)),
151 ("has_more".into(), Value::Bool(next_offset < full.len())),
152 ]),
153 ),
154 ]))
155}
156
157fn canonicalize_or_owned(path: &std::path::Path) -> std::path::PathBuf {
158 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
159}
160
161fn absolute_byte_at(content: &str, line: usize, byte_in_line: usize) -> usize {
162 let lines: Vec<&str> = content.split_inclusive('\n').collect();
163 let index = line.saturating_sub(1).min(lines.len());
164 lines[..index].iter().map(|line| line.len()).sum::<usize>() + byte_in_line
165}
166
167fn slice_lines(
168 content: &str,
169 offset: Option<i64>,
170 limit: Option<i64>,
171 path: &std::path::Path,
172) -> String {
173 let lines: Vec<&str> = content.split_inclusive('\n').collect();
174 let total = lines.len();
175 let start_line = offset.unwrap_or(1).max(1) as usize;
176 let start_idx = start_line.saturating_sub(1);
177 if start_idx >= total {
178 return format!(
179 "[fs.read({}): offset={start_line} exceeds file length {total}. File has {total} line(s).]",
180 path.display()
181 );
182 }
183 let take = match limit {
184 Some(n) if n > 0 => n as usize,
185 _ => total.saturating_sub(start_idx),
186 };
187 let end_idx = (start_idx + take).min(total);
188 let body: String = lines[start_idx..end_idx].concat();
189 let end_line = end_idx;
190 format!(
191 "[fs.read({}): lines {start_line}-{end_line} of {total}]\n{body}",
192 path.display()
193 )
194}
195
196fn extract_optional_int(args: &ToolArgs, name: &str) -> Result<Option<i64>, RuntimeError> {
197 match args.named(name) {
198 None => Ok(None),
199 Some(Value::Int(n)) => Ok(Some(*n)),
200 Some(other) => Err(RuntimeError::TypeMismatch {
201 expected: "integer".into(),
202 actual: other.kind_name().into(),
203 }),
204 }
205}
206
207fn extract_optional_positive_usize(
208 args: &ToolArgs,
209 name: &str,
210) -> Result<Option<usize>, RuntimeError> {
211 match args.named(name) {
212 None | Some(Value::Unit) => Ok(None),
213 Some(Value::Int(n)) if *n > 0 => Ok(Some(*n as usize)),
214 Some(Value::Int(_)) => Err(RuntimeError::TypeMismatch {
215 expected: format!("positive integer {name}"),
216 actual: "non-positive integer".into(),
217 }),
218 Some(other) => Err(RuntimeError::TypeMismatch {
219 expected: format!("positive integer {name}"),
220 actual: other.kind_name().into(),
221 }),
222 }
223}
224
225pub struct FsWrite;
226
227impl Tool for FsWrite {
228 fn name(&self) -> &str {
229 "fs.write"
230 }
231
232 fn tier(&self) -> Tier {
233 Tier::Two
234 }
235
236 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
237 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
238 }
239
240 fn description(&self) -> Option<&str> {
241 Some(
242 "Create a new file or rewrite an existing one from scratch. \
243 Provide BOTH `path` and `content` — never emit an empty {} input. \
244 \
245 PREFER fs.edit INSTEAD when: (a) the file already exists and you \
246 only want to change part of it, (b) the file is longer than ~200 \
247 lines, or (c) the intended content would exceed 4KB. Repeatedly \
248 regenerating a large file via fs.write tends to fail — use \
249 fs.read + fs.edit to apply targeted str_replace edits. \
250 \
251 Example (new file): \
252 {\"path\":\"index.html\",\"content\":\"<html>...</html>\"}",
253 )
254 }
255
256 fn input_schema(&self) -> serde_json::Value {
257 serde_json::json!({
258 "type": "object",
259 "properties": {
260 "path": {"type": "string", "description": "Target file path."},
261 "content": {"type": "string", "description": "UTF-8 text to write."}
262 },
263 "required": ["path", "content"]
264 })
265 }
266
267 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
268 Box::pin(async move {
269 let path = extract_path(&args, "path", 0)?;
270 let content = extract_string(&args, "content", 1)?;
271 let mut approved = false;
272 if let Err(e) = ctx.fs_access.check_write(&path) {
273 match request_fs_write_approval(ctx, &path, &e.to_string()).await {
274 Some(true) => approved = true,
275 Some(false) => {
276 return Err(RuntimeError::ToolFailed(format!(
277 "fs.write({}): user denied the write operation",
278 path.display()
279 )));
280 }
281 None => {
282 return Err(RuntimeError::ToolFailed(format!(
283 "fs.write({}): {e}",
284 path.display()
285 )));
286 }
287 }
288 }
289 let old_content = tokio::fs::read_to_string(&path).await.ok();
290 tokio::fs::write(&path, content.as_bytes())
291 .await
292 .map_err(|e| {
293 RuntimeError::ToolFailed(format!("fs.write({}): {e}", path.display()))
294 })?;
295 let canonical = canonicalize_or_owned(&path);
296 ctx.note_read(&canonical);
297 let path_str = path.display().to_string();
298 let diff_patch = match &old_content {
299 Some(old) => unified_diff_preview(&path_str, old, &content),
300 None => format!("+++ {path_str}\n{content}"),
301 };
302 if let Some(tx) = &ctx.stream_tx {
303 let _ = tx.send(StreamFrame::DiffPreview {
304 title: path_str,
305 old_content: None,
306 new_content: None,
307 unified_diff: Some(diff_patch.clone()),
308 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
309 });
310 }
311 Ok(Value::Struct(vec![
312 ("path".into(), Value::Path(path)),
313 (
314 "approval".into(),
315 Value::Str(if approved { "approved" } else { "auto" }.into()),
316 ),
317 ("diff".into(), Value::Str(diff_patch)),
318 ]))
319 })
320 }
321}
322
323fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
324 let value = match args.named(name) {
325 Some(v) => v,
326 None => args.positional(pos)?,
327 };
328 match value {
329 Value::Str(s) => Ok(s.clone()),
330 other => Err(RuntimeError::TypeMismatch {
331 expected: "string".into(),
332 actual: other.kind_name().into(),
333 }),
334 }
335}
336
337pub struct FsEdit;
338
339impl Tool for FsEdit {
340 fn name(&self) -> &str {
341 "fs.edit"
342 }
343
344 fn tier(&self) -> Tier {
345 Tier::Two
346 }
347
348 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
349 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
350 }
351
352 fn description(&self) -> Option<&str> {
353 Some(
354 "Replace an exact text snippet in a file. Preferred over fs.write for changing part of \
355 an existing file. `old_string` must match VERBATIM (whitespace + newlines) and, by \
356 default, appear exactly once — if it matches multiple times the error tells you how \
357 to disambiguate. Use `start_line` (1-based) to require the match to start on a \
358 specific line. Use `replace_all: true` to change every occurrence. \
359 Example: {\"path\":\"a.rs\",\"old_string\":\"fn foo() {}\",\"new_string\":\"fn foo() { println!(\\\"hi\\\"); }\"}",
360 )
361 }
362
363 fn input_schema(&self) -> serde_json::Value {
364 serde_json::json!({
365 "type": "object",
366 "properties": {
367 "path": {"type": "string", "description": "Target file path."},
368 "old_string": {"type": "string", "description": "Exact text to find. Match is literal, not regex."},
369 "new_string": {"type": "string", "description": "Replacement text. May be empty to delete."},
370 "start_line": {"type": "integer", "minimum": 1, "description": "Require old_string to start on this 1-based line."},
371 "replace_all": {"type": "boolean", "description": "Replace every occurrence. Default false (unique match required)."}
372 },
373 "required": ["path", "old_string", "new_string"]
374 })
375 }
376
377 fn preview_call<'a>(
378 &'a self,
379 args: &'a ToolArgs,
380 _ctx: &'a ToolCtx,
381 ) -> BoxFut<'a, Option<String>> {
382 Box::pin(async move {
383 let path = extract_path(args, "path", 0).ok()?;
384 let old_string = extract_string(args, "old_string", 1).ok()?;
385 let new_string = extract_string(args, "new_string", 2).ok()?;
386 let start_line = extract_optional_positive_usize(args, "start_line").ok()?;
387 let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
388 let content = tokio::fs::read_to_string(&path).await.ok()?;
389 let match_lines = find_match_lines(&content, &old_string);
390 let selected = match_lines
391 .iter()
392 .enumerate()
393 .filter(|(_, line)| start_line.is_none_or(|start| **line == start))
394 .map(|(index, _)| index)
395 .collect::<Vec<_>>();
396 let updated = if let Some(&first) = selected.first() {
397 let mut result = content.clone();
398 if replace_all {
399 for index in selected.into_iter().rev() {
400 let start = content.match_indices(&old_string).nth(index)?.0;
401 result.replace_range(start..start + old_string.len(), &new_string);
402 }
403 result
404 } else {
405 let start = content.match_indices(&old_string).nth(first)?.0;
406 result.replace_range(start..start + old_string.len(), &new_string);
407 result
408 }
409 } else {
410 content.clone()
411 };
412 if updated == content {
413 return None;
414 }
415 Some(unified_diff_preview(
416 &path.display().to_string(),
417 &content,
418 &updated,
419 ))
420 })
421 }
422
423 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
424 Box::pin(async move {
425 let path = extract_path(&args, "path", 0)?;
426 let old_string = extract_string(&args, "old_string", 1)?;
427 let new_string = extract_string(&args, "new_string", 2)?;
428 let start_line = extract_optional_positive_usize(&args, "start_line")?;
429 let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
430 let mut approved = false;
431 if let Err(e) = ctx.fs_access.check_write(&path) {
432 match request_fs_write_approval(ctx, &path, &e.to_string()).await {
433 Some(true) => approved = true,
434 Some(false) => {
435 return Err(RuntimeError::ToolFailed(format!(
436 "fs.edit({}): user denied the write operation",
437 path.display()
438 )));
439 }
440 None => {
441 return Err(RuntimeError::ToolFailed(format!(
442 "fs.edit({}): {e}",
443 path.display()
444 )));
445 }
446 }
447 }
448 let canonical = canonicalize_or_owned(&path);
449 if ctx.read_files.is_some() && !ctx.has_read(&canonical) {
450 return Err(RuntimeError::ToolFailed(format!(
451 "fs.edit({}): file has not been read in this session. Call fs.read({}) first so the model works on current content.",
452 path.display(),
453 path.display()
454 )));
455 }
456 if old_string == new_string {
457 return Err(RuntimeError::ToolFailed(format!(
458 "fs.edit({}): old_string equals new_string — edit would be a no-op",
459 path.display()
460 )));
461 }
462 if old_string.is_empty() {
463 return Err(RuntimeError::ToolFailed(format!(
464 "fs.edit({}): old_string is empty — refusing to insert at every byte boundary",
465 path.display()
466 )));
467 }
468 let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
469 RuntimeError::ToolFailed(format!("fs.edit({}): {e}", path.display()))
470 })?;
471 let match_lines = find_match_lines(&content, &old_string);
472 let match_lines: Vec<usize> = match_lines
473 .into_iter()
474 .filter(|line| start_line.is_none_or(|start| *line == start))
475 .collect();
476 if match_lines.is_empty() {
477 let similar = similar_line_hint(&content, &old_string);
478 let snippet: String = old_string.chars().take(60).collect();
479 return Err(RuntimeError::ToolFailed(format!(
480 "fs.edit({}): old_string not found. First 60 chars searched: {snippet:?}. {similar}",
481 path.display()
482 )));
483 }
484 if !replace_all && match_lines.len() > 1 {
485 let sample: Vec<String> = match_lines
486 .iter()
487 .take(3)
488 .map(|n| format!("line {n}"))
489 .collect();
490 return Err(RuntimeError::ToolFailed(format!(
491 "fs.edit({}): old_string matches {} times ({}). Add surrounding context so it is unique, or pass replace_all=true.",
492 path.display(),
493 match_lines.len(),
494 sample.join(", ")
495 )));
496 }
497 let match_offsets = find_match_offsets(&content, &old_string)
498 .into_iter()
499 .zip(find_match_lines(&content, &old_string))
500 .filter(|(_, line)| start_line.is_none_or(|start| *line == start))
501 .map(|(offset, _)| offset)
502 .collect::<Vec<_>>();
503 let mut updated = content.clone();
504 let offsets = if replace_all {
505 match_offsets
506 } else {
507 match_offsets.into_iter().take(1).collect()
508 };
509 for offset in offsets.into_iter().rev() {
510 updated.replace_range(offset..offset + old_string.len(), &new_string);
511 }
512 tokio::fs::write(&path, updated.as_bytes())
513 .await
514 .map_err(|e| {
515 RuntimeError::ToolFailed(format!(
516 "fs.edit({}): write failed: {e}",
517 path.display()
518 ))
519 })?;
520 let path_str = path.display().to_string();
521 let diff_patch = unified_diff_preview(&path_str, &content, &updated);
522 if let Some(tx) = &ctx.stream_tx {
523 let _ = tx.send(StreamFrame::DiffPreview {
524 title: path_str,
525 old_content: None,
526 new_content: None,
527 unified_diff: Some(diff_patch.clone()),
528 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
529 });
530 }
531 let replaced = if replace_all { match_lines.len() } else { 1 };
532 let first_line = match_lines[0];
533 Ok(Value::Struct(vec![
534 (
535 "summary".into(),
536 Value::Str(format!(
537 "[fs.edit({}): replaced {replaced} occurrence(s), first at line {first_line}]",
538 path.display()
539 )),
540 ),
541 (
542 "approval".into(),
543 Value::Str(if approved { "approved" } else { "auto" }.into()),
544 ),
545 ("path".into(), Value::Str(path.display().to_string())),
546 ("diff".into(), Value::Str(diff_patch)),
547 ]))
548 })
549 }
550}
551
552pub(crate) fn unified_diff_preview(path: &str, before: &str, after: &str) -> String {
553 use similar::{ChangeTag, TextDiff};
554 let diff = TextDiff::from_lines(before, after);
555 let mut out = format!("--- {path}\n+++ {path}\n");
556 for (shown, hunk) in diff
557 .unified_diff()
558 .context_radius(3)
559 .iter_hunks()
560 .enumerate()
561 {
562 if shown >= 4 {
563 out.push_str("... (truncated) ...\n");
564 break;
565 }
566 out.push_str(&hunk.header().to_string());
567 out.push('\n');
568 for change in hunk.iter_changes() {
569 let sign = match change.tag() {
570 ChangeTag::Delete => '-',
571 ChangeTag::Insert => '+',
572 ChangeTag::Equal => ' ',
573 };
574 let line = change.value();
575 let trimmed = line.strip_suffix('\n').unwrap_or(line);
576 out.push(sign);
577 out.push_str(trimmed);
578 out.push('\n');
579 }
580 }
581 if out.chars().count() > 4000 {
582 let head: String = out.chars().take(4000).collect();
583 format!("{head}\n... (preview truncated at 4000 chars) ...")
584 } else {
585 out
586 }
587}
588
589fn find_match_offsets(content: &str, needle: &str) -> Vec<usize> {
590 content
591 .match_indices(needle)
592 .map(|(offset, _)| offset)
593 .collect()
594}
595
596fn find_match_lines(content: &str, needle: &str) -> Vec<usize> {
597 let mut out = Vec::new();
598 let mut cursor = 0usize;
599 while let Some(pos) = content[cursor..].find(needle) {
600 let abs = cursor + pos;
601 let line = content[..abs].bytes().filter(|b| *b == b'\n').count() + 1;
602 out.push(line);
603 cursor = abs + needle.len().max(1);
604 if needle.is_empty() {
605 break;
606 }
607 }
608 out
609}
610
611fn similar_line_hint(content: &str, needle: &str) -> String {
612 let first_needle_line = needle.lines().next().unwrap_or("").trim();
613 if first_needle_line.is_empty() {
614 return "No similar lines to suggest.".into();
615 }
616 let needle_tokens: Vec<&str> = first_needle_line
617 .split(|c: char| !c.is_alphanumeric() && c != '_')
618 .filter(|t| !t.is_empty())
619 .collect();
620 if needle_tokens.is_empty() {
621 return "No similar lines to suggest.".into();
622 }
623 let mut scored: Vec<(usize, usize, &str)> = Vec::new();
624 for (i, line) in content.lines().enumerate() {
625 let mut hits = 0usize;
626 for tok in &needle_tokens {
627 if line.contains(tok) {
628 hits += 1;
629 }
630 }
631 if hits > 0 {
632 scored.push((hits, i + 1, line));
633 }
634 }
635 scored.sort_by_key(|x| std::cmp::Reverse(x.0));
636 scored.truncate(3);
637 if scored.is_empty() {
638 "No similar lines found — perhaps whitespace differs or the file was already edited.".into()
639 } else {
640 let joined = scored
641 .iter()
642 .map(|(_, n, l)| format!(" line {n}: {}", l.trim_end()))
643 .collect::<Vec<_>>()
644 .join("\n");
645 format!("Similar lines in file:\n{joined}")
646 }
647}
648
649pub struct FsList;
650
651impl Tool for FsList {
652 fn name(&self) -> &str {
653 "fs.list"
654 }
655
656 fn tier(&self) -> Tier {
657 Tier::Zero
658 }
659
660 fn description(&self) -> Option<&str> {
661 Some("List the entries of a directory. Returns a list of {name, kind} structs.")
662 }
663
664 fn input_schema(&self) -> serde_json::Value {
665 serde_json::json!({
666 "type": "object",
667 "properties": {
668 "path": {"type": "string", "description": "Directory path to list."}
669 },
670 "required": ["path"]
671 })
672 }
673
674 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
675 Box::pin(async move {
676 let path = extract_path(&args, "path", 0)?;
677 let mut rd = tokio::fs::read_dir(&path).await.map_err(|e| {
678 RuntimeError::ToolFailed(format!("fs.list({}): {e}", path.display()))
679 })?;
680 let mut entries = Vec::new();
681 while let Some(entry) = rd
682 .next_entry()
683 .await
684 .map_err(|e| RuntimeError::ToolFailed(format!("fs.list next_entry: {e}")))?
685 {
686 entries.push(Value::Path(entry.path()));
687 }
688 entries.sort_by(|a, b| match (a, b) {
689 (Value::Path(a), Value::Path(b)) => a.cmp(b),
690 _ => std::cmp::Ordering::Equal,
691 });
692 Ok(Value::List(entries))
693 })
694 }
695}
696
697fn extract_path(args: &ToolArgs, name: &str, pos: usize) -> Result<PathBuf, RuntimeError> {
698 let value = match args.named(name) {
699 Some(v) => v,
700 None => args.positional(pos)?,
701 };
702 match value {
703 Value::Path(p) => Ok(p.clone()),
704 Value::Str(s) => Ok(PathBuf::from(s)),
705 other => Err(RuntimeError::TypeMismatch {
706 expected: "path or string".into(),
707 actual: other.kind_name().into(),
708 }),
709 }
710}
711
712fn path_in_workspace(path: &std::path::Path) -> bool {
713 let abs = if path.is_absolute() {
714 path.to_path_buf()
715 } else {
716 match std::env::current_dir() {
717 Ok(cwd) => cwd.join(path),
718 Err(_) => return false,
719 }
720 };
721 let Ok(cwd) = std::env::current_dir() else {
722 return false;
723 };
724 abs.starts_with(&cwd)
725}
726
727fn workspace_approval_level(
728 args: &ToolArgs,
729 fallback: crate::tool::ApprovalLevel,
730) -> crate::tool::ApprovalLevel {
731 match extract_path(args, "path", 0) {
732 Ok(p) if path_in_workspace(&p) => crate::tool::ApprovalLevel::Auto,
733 _ => fallback,
734 }
735}
736
737pub struct FsGrep;
738
739impl Tool for FsGrep {
740 fn name(&self) -> &str {
741 "fs.grep"
742 }
743
744 fn tier(&self) -> Tier {
745 Tier::One
746 }
747
748 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
749 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
750 }
751
752 fn description(&self) -> Option<&str> {
753 Some(
754 "Search files under `path` for a regex `pattern` (like ripgrep). Returns matches \
755 grouped by file with `context_lines` before + after each match. Honors .gitignore \
756 and hidden-file rules by default. Params: pattern (regex, required), path (dir or \
757 file, default cwd), context_lines (int 0..=10, default 3), case_sensitive (bool, \
758 default false), limit (int, default 50 matches, max 200). Use this INSTEAD of \
759 bash.exec + rg — it's faster and returns structured results.",
760 )
761 }
762
763 fn input_schema(&self) -> serde_json::Value {
764 serde_json::json!({
765 "type": "object",
766 "properties": {
767 "pattern": {"type": "string"},
768 "path": {"type": "string"},
769 "context_lines": {"type": "integer", "default": 3},
770 "case_sensitive": {"type": "boolean", "default": false},
771 "limit": {"type": "integer", "default": 50}
772 },
773 "required": ["pattern"]
774 })
775 }
776
777 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
778 Box::pin(async move { fs_grep_impl(args).await })
779 }
780}
781
782async fn fs_grep_impl(args: ToolArgs) -> ToolResult {
783 let pattern = extract_string(&args, "pattern", 0)?;
784 if pattern.is_empty() {
785 return Err(RuntimeError::ToolFailed("fs.grep: empty pattern".into()));
786 }
787 let base_path: std::path::PathBuf = match args.named("path") {
788 Some(Value::Str(s)) => std::path::PathBuf::from(s),
789 Some(Value::Path(p)) => p.clone(),
790 Some(other) => {
791 return Err(RuntimeError::TypeMismatch {
792 expected: "path or string".into(),
793 actual: other.kind_name().into(),
794 });
795 }
796 None => std::env::current_dir()
797 .map_err(|e| RuntimeError::ToolFailed(format!("fs.grep: cwd: {e}")))?,
798 };
799 let context_lines: usize = match args.named("context_lines") {
800 Some(Value::Int(n)) if *n >= 0 => (*n as usize).min(10),
801 _ => 3,
802 };
803 let case_sensitive = matches!(args.named("case_sensitive"), Some(Value::Bool(true)));
804 let limit: usize = match args.named("limit") {
805 Some(Value::Int(n)) if *n > 0 => (*n as usize).min(200),
806 _ => 50,
807 };
808 let re = regex::RegexBuilder::new(&pattern)
809 .case_insensitive(!case_sensitive)
810 .build()
811 .map_err(|e| RuntimeError::ToolFailed(format!("fs.grep: invalid regex: {e}")))?;
812 let walker = ignore::WalkBuilder::new(&base_path).build();
813 let mut hits: Vec<Value> = Vec::new();
814 for entry in walker {
815 if hits.len() >= limit {
816 break;
817 }
818 let entry = match entry {
819 Ok(e) => e,
820 Err(_) => continue,
821 };
822 if entry.file_type().is_none_or(|ft| !ft.is_file()) {
823 continue;
824 }
825 let contents = match tokio::fs::read_to_string(entry.path()).await {
826 Ok(c) => c,
827 Err(_) => continue,
828 };
829 let lines: Vec<&str> = contents.lines().collect();
830 for (idx, line) in lines.iter().enumerate() {
831 if !re.is_match(line) {
832 continue;
833 }
834 let before_start = idx.saturating_sub(context_lines);
835 let after_end = (idx + context_lines + 1).min(lines.len());
836 let before: Vec<Value> = lines[before_start..idx]
837 .iter()
838 .map(|s| Value::Str((*s).to_string()))
839 .collect();
840 let after: Vec<Value> = lines[idx + 1..after_end]
841 .iter()
842 .map(|s| Value::Str((*s).to_string()))
843 .collect();
844 hits.push(Value::Struct(vec![
845 (
846 "file".into(),
847 Value::Str(entry.path().display().to_string()),
848 ),
849 ("line".into(), Value::Int((idx + 1) as i64)),
850 ("before".into(), Value::List(before)),
851 ("match".into(), Value::Str((*line).to_string())),
852 ("after".into(), Value::List(after)),
853 ]));
854 if hits.len() >= limit {
855 break;
856 }
857 }
858 }
859 Ok(Value::List(hits))
860}
861
862async fn request_fs_write_approval(
863 ctx: &ToolCtx,
864 path: &std::path::Path,
865 reason: &str,
866) -> Option<bool> {
867 use crate::tool::ApprovalLevel;
868 let Some(approval) = &ctx.approval else {
869 return None;
870 };
871 let run_id = ctx.flow_run_id.clone()?;
872 let id = format!("fs_write_{}", uuid::Uuid::now_v7());
873 let pending = crate::session::PendingApproval {
874 tool_use_id: id.clone(),
875 tool_name: "fs.write (sandboxed)".to_string(),
876 args_preview: format!("path={}", path.display()),
877 preview: Some(reason.to_string()),
878 level: ApprovalLevel::Dangerous,
879 run_id,
880 emitted_at: chrono::Utc::now(),
881 bypass_auto_ceiling: false,
882 };
883 let rx = approval.request(pending);
884 let run_id_for_emit = ctx.flow_run_id.clone();
885 if let (Some(sink), Some(rid)) = (ctx.events.as_ref(), run_id_for_emit) {
886 sink.emit(crate::event::Event::ToolPendingApproval {
887 run_id: rid,
888 tool_use_id: id,
889 tool_name: "fs.write".into(),
890 args_preview: path.display().to_string(),
891 level: "dangerous".into(),
892 preview: Some(reason.to_string()),
893 });
894 }
895 match rx.await {
896 Ok(crate::session::ApprovalDecision::Approve) => Some(true),
897 _ => Some(false),
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904 use tempfile::TempDir;
905
906 #[tokio::test]
907 async fn fs_read_returns_file_content() {
908 let dir = TempDir::new().unwrap();
909 let path = dir.path().join("hello.txt");
910 tokio::fs::write(&path, b"hi from atman").await.unwrap();
911
912 let ctx = ToolCtx::new();
913 let args = ToolArgs {
914 positional: vec![Value::Path(path)],
915 named: vec![],
916 };
917 let v = FsRead.call(args, &ctx).await.unwrap();
918 assert!(matches!(v, Value::Str(s) if s == "hi from atman"));
919 }
920
921 fn field<'a>(fields: &'a [(String, Value)], name: &str) -> &'a Value {
922 fields
923 .iter()
924 .find_map(|(field, value)| (field == name).then_some(value))
925 .unwrap_or_else(|| panic!("missing {name}"))
926 }
927
928 fn oversized_read_parts(value: Value) -> (String, String, usize) {
929 let Value::Struct(fields) = value else {
930 panic!("expected bounded fs.read struct");
931 };
932 let names: Vec<&str> = fields.iter().map(|(name, _)| name.as_str()).collect();
933 assert_eq!(
934 names,
935 vec![
936 "content",
937 "truncated",
938 "total_lines",
939 "total_bytes",
940 "output_id",
941 "next",
942 ]
943 );
944 assert!(matches!(field(&fields, "truncated"), Value::Bool(true)));
945 assert!(matches!(field(&fields, "total_lines"), Value::Int(_)));
946 assert!(matches!(field(&fields, "total_bytes"), Value::Int(_)));
947 let content = match field(&fields, "content") {
948 Value::Str(content) => content.clone(),
949 _ => panic!("expected content string"),
950 };
951 let output_id = match field(&fields, "output_id") {
952 Value::Str(output_id) => output_id.clone(),
953 _ => panic!("expected output_id string"),
954 };
955 let Value::Struct(next) = field(&fields, "next") else {
956 panic!("expected next struct");
957 };
958 let next_names: Vec<&str> = next.iter().map(|(name, _)| name.as_str()).collect();
959 assert_eq!(next_names, vec!["mode", "offset", "has_more"]);
960 assert!(matches!(field(next, "mode"), Value::Str(mode) if mode == "bytes"));
961 assert!(matches!(field(next, "has_more"), Value::Bool(true)));
962 let offset = match field(next, "offset") {
963 Value::Int(offset) => *offset as usize,
964 _ => panic!("expected byte offset"),
965 };
966 (content, output_id, offset)
967 }
968
969 #[tokio::test]
970 async fn fs_read_oversized_utf8_reassembles_with_output_read_bytes() {
971 let dir = TempDir::new().unwrap();
972 let path = dir.path().join("long.txt");
973 let original = "你好世界🚀".repeat(100);
974 tokio::fs::write(&path, &original).await.unwrap();
975 let budget = crate::tools::tool_output::ToolOutputBudget {
976 max_lines: 4,
977 max_bytes: 40,
978 max_line_bytes: 20,
979 };
980 let mut ctx = ToolCtx::new().with_session_dir(dir.path().to_path_buf());
981 ctx.tool_output_budget = budget;
982
983 let result = FsRead
984 .call(
985 ToolArgs {
986 positional: vec![Value::Path(path)],
987 named: vec![],
988 },
989 &ctx,
990 )
991 .await
992 .unwrap();
993 let (mut assembled, output_id, mut offset) = oversized_read_parts(result);
994 assert_eq!(offset, assembled.len());
995 loop {
996 let page = crate::tools::tool_output::OutputRead
997 .call(
998 ToolArgs {
999 positional: vec![],
1000 named: vec![
1001 ("output_id".into(), Value::Str(output_id.clone())),
1002 ("byte_offset".into(), Value::Int(offset as i64)),
1003 ("byte_limit".into(), Value::Int(40)),
1004 ],
1005 },
1006 &ctx,
1007 )
1008 .await
1009 .unwrap();
1010 let Value::Struct(fields) = page else {
1011 panic!("expected output.read page");
1012 };
1013 assert!(matches!(field(&fields, "mode"), Value::Str(mode) if mode == "bytes"));
1014 assert!(
1015 matches!(field(&fields, "offset"), Value::Int(value) if *value == offset as i64)
1016 );
1017 let content = match field(&fields, "content") {
1018 Value::Str(content) => content,
1019 _ => panic!("expected output.read content"),
1020 };
1021 assembled.push_str(content);
1022 let has_more = matches!(field(&fields, "has_more"), Value::Bool(true));
1023 if !has_more {
1024 break;
1025 }
1026 let next_offset = match field(&fields, "next_offset") {
1027 Value::Int(value) => *value as usize,
1028 _ => panic!("expected output.read next_offset"),
1029 };
1030 assert!(next_offset > offset);
1031 offset = next_offset;
1032 }
1033 assert_eq!(assembled, original);
1034 }
1035
1036 #[tokio::test]
1037 async fn fs_read_oversized_file_reassembles_with_output_read_lines() {
1038 let dir = TempDir::new().unwrap();
1039 let path = dir.path().join("lines.txt");
1040 let original = "first\nsecond\nthird\n";
1041 tokio::fs::write(&path, original).await.unwrap();
1042 let budget = crate::tools::tool_output::ToolOutputBudget {
1043 max_lines: 1,
1044 max_bytes: 64,
1045 max_line_bytes: 64,
1046 };
1047 let mut ctx = ToolCtx::new().with_session_dir(dir.path().to_path_buf());
1048 ctx.tool_output_budget = budget;
1049
1050 let result = FsRead
1051 .call(
1052 ToolArgs {
1053 positional: vec![Value::Path(path)],
1054 named: vec![],
1055 },
1056 &ctx,
1057 )
1058 .await
1059 .unwrap();
1060 let (_, output_id, _) = oversized_read_parts(result);
1061 let store = ctx.output_store.as_deref().unwrap();
1062 let mut assembled = String::new();
1063 let mut offset = 0;
1064 loop {
1065 let page = store.read_lines(&output_id, offset, 1, budget).unwrap();
1066 assembled.push_str(&page.content);
1067 if !page.has_more {
1068 break;
1069 }
1070 offset = page.next_offset;
1071 }
1072 assert_eq!(assembled, original);
1073 }
1074
1075 #[tokio::test]
1076 async fn fs_read_oversized_explicit_slice_registers_full_file() {
1077 let dir = TempDir::new().unwrap();
1078 let path = dir.path().join("limited.txt");
1079 let original = "one\ntwo\nthree\n";
1080 tokio::fs::write(&path, original).await.unwrap();
1081 let budget = crate::tools::tool_output::ToolOutputBudget {
1082 max_lines: 2,
1083 max_bytes: 12,
1084 max_line_bytes: 12,
1085 };
1086 let mut ctx = ToolCtx::new().with_session_dir(dir.path().to_path_buf());
1087 ctx.tool_output_budget = budget;
1088 let result = FsRead
1089 .call(
1090 ToolArgs {
1091 positional: vec![Value::Path(path)],
1092 named: vec![
1093 ("offset".into(), Value::Int(2)),
1094 ("limit".into(), Value::Int(100)),
1095 ],
1096 },
1097 &ctx,
1098 )
1099 .await
1100 .unwrap();
1101 let (head, output_id, offset) = oversized_read_parts(result);
1102 assert!(!head.is_empty());
1103 assert!(offset <= original.len());
1104 let mut assembled = String::new();
1105 let mut page_offset = 0;
1106 loop {
1107 let page = ctx
1108 .output_store
1109 .as_deref()
1110 .unwrap()
1111 .read_bytes(&output_id, page_offset, original.len(), budget)
1112 .unwrap();
1113 assert_eq!(page.offset, page_offset);
1114 assembled.push_str(&page.content);
1115 if !page.has_more {
1116 break;
1117 }
1118 assert!(page.next_offset > page_offset);
1119 page_offset = page.next_offset;
1120 }
1121 assert_eq!(assembled, original);
1122 }
1123
1124 #[tokio::test]
1125 async fn fs_read_oversized_without_output_store_returns_clear_error() {
1126 let dir = TempDir::new().unwrap();
1127 let path = dir.path().join("long.txt");
1128 tokio::fs::write(&path, "x".repeat(100)).await.unwrap();
1129 let mut ctx = ToolCtx::new();
1130 ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
1131 max_lines: 1,
1132 max_bytes: 10,
1133 max_line_bytes: 10,
1134 };
1135
1136 let error = FsRead
1137 .call(
1138 ToolArgs {
1139 positional: vec![Value::Path(path)],
1140 named: vec![],
1141 },
1142 &ctx,
1143 )
1144 .await
1145 .unwrap_err();
1146 assert!(
1147 matches!(error, RuntimeError::ToolFailed(message) if message.contains("no output_id is available for output.read"))
1148 );
1149 }
1150
1151 #[tokio::test]
1152 async fn fs_read_offset_past_end_keeps_diagnostic_under_tiny_budget() {
1153 let dir = TempDir::new().unwrap();
1154 let path = dir.path().join("short.txt");
1155 tokio::fs::write(&path, "one\n").await.unwrap();
1156 let mut ctx = ToolCtx::new().with_session_dir(dir.path().to_path_buf());
1157 ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
1158 max_lines: 1,
1159 max_bytes: 1,
1160 max_line_bytes: 1,
1161 };
1162
1163 let result = FsRead
1164 .call(
1165 ToolArgs {
1166 positional: vec![Value::Path(path)],
1167 named: vec![("offset".into(), Value::Int(20))],
1168 },
1169 &ctx,
1170 )
1171 .await
1172 .unwrap();
1173 assert!(matches!(
1174 result,
1175 Value::Str(message) if message.contains("offset=20 exceeds file length 1")
1176 ));
1177 }
1178
1179 #[tokio::test]
1180 async fn fs_read_accepts_string_path() {
1181 let dir = TempDir::new().unwrap();
1182 let path = dir.path().join("x.txt");
1183 tokio::fs::write(&path, b"ok").await.unwrap();
1184
1185 let ctx = ToolCtx::new();
1186 let args = ToolArgs {
1187 positional: vec![Value::Str(path.to_string_lossy().into())],
1188 named: vec![],
1189 };
1190 let v = FsRead.call(args, &ctx).await.unwrap();
1191 assert!(matches!(v, Value::Str(s) if s == "ok"));
1192 }
1193
1194 #[tokio::test]
1195 async fn fs_read_missing_file_returns_tool_failed() {
1196 let dir = TempDir::new().unwrap();
1197 let ctx = ToolCtx::new();
1198 let args = ToolArgs {
1199 positional: vec![Value::Path(dir.path().join("nope"))],
1200 named: vec![],
1201 };
1202 let err = FsRead.call(args, &ctx).await.unwrap_err();
1203 assert!(matches!(err, RuntimeError::ToolFailed(_)));
1204 }
1205
1206 #[tokio::test]
1207 async fn fs_list_returns_sorted_paths() {
1208 let dir = TempDir::new().unwrap();
1209 tokio::fs::write(dir.path().join("b.txt"), b"")
1210 .await
1211 .unwrap();
1212 tokio::fs::write(dir.path().join("a.txt"), b"")
1213 .await
1214 .unwrap();
1215
1216 let ctx = ToolCtx::new();
1217 let args = ToolArgs {
1218 positional: vec![Value::Path(dir.path().to_path_buf())],
1219 named: vec![],
1220 };
1221 let v = FsList.call(args, &ctx).await.unwrap();
1222 if let Value::List(items) = v {
1223 assert_eq!(items.len(), 2);
1224 if let Value::Path(p) = &items[0] {
1225 assert!(p.ends_with("a.txt"));
1226 } else {
1227 panic!("expected path");
1228 }
1229 } else {
1230 panic!("expected list");
1231 }
1232 }
1233
1234 #[tokio::test]
1235 async fn missing_positional_is_missing_arg_error() {
1236 let ctx = ToolCtx::new();
1237 let args = ToolArgs::default();
1238 let err = FsRead.call(args, &ctx).await.unwrap_err();
1239 assert!(matches!(err, RuntimeError::MissingArg(_)));
1240 }
1241
1242 #[tokio::test]
1243 async fn fs_read_offset_limit_returns_slice_with_header() {
1244 let dir = TempDir::new().unwrap();
1245 let path = dir.path().join("multi.txt");
1246 tokio::fs::write(&path, b"line1\nline2\nline3\nline4\nline5\n")
1247 .await
1248 .unwrap();
1249 let ctx = ToolCtx::new();
1250 let args = ToolArgs {
1251 positional: vec![Value::Path(path.clone())],
1252 named: vec![
1253 ("offset".into(), Value::Int(2)),
1254 ("limit".into(), Value::Int(2)),
1255 ],
1256 };
1257 let v = FsRead.call(args, &ctx).await.unwrap();
1258 let s = match v {
1259 Value::Str(s) => s,
1260 _ => panic!(),
1261 };
1262 assert!(s.contains("lines 2-3 of 5"), "header missing: {s}");
1263 assert!(s.contains("line2\nline3"), "body wrong: {s}");
1264 assert!(!s.contains("line1"));
1265 assert!(!s.contains("line4"));
1266 }
1267
1268 #[tokio::test]
1269 async fn fs_read_offset_past_end_reports_bounds() {
1270 let dir = TempDir::new().unwrap();
1271 let path = dir.path().join("short.txt");
1272 tokio::fs::write(&path, b"only\n").await.unwrap();
1273 let ctx = ToolCtx::new();
1274 let args = ToolArgs {
1275 positional: vec![Value::Path(path)],
1276 named: vec![("offset".into(), Value::Int(99))],
1277 };
1278 let v = FsRead.call(args, &ctx).await.unwrap();
1279 let s = match v {
1280 Value::Str(s) => s,
1281 _ => panic!(),
1282 };
1283 assert!(s.contains("offset=99 exceeds"), "expected bounds msg: {s}");
1284 }
1285
1286 #[tokio::test]
1287 async fn fs_edit_unique_match_replaces_and_returns_diff_summary() {
1288 let dir = TempDir::new().unwrap();
1289 let path = dir.path().join("code.rs");
1290 tokio::fs::write(&path, b"fn foo() {}\nfn bar() {}\n")
1291 .await
1292 .unwrap();
1293 let ctx = ToolCtx::new();
1294 let args = ToolArgs {
1295 positional: vec![],
1296 named: vec![
1297 ("path".into(), Value::Path(path.clone())),
1298 ("old_string".into(), Value::Str("fn foo() {}".into())),
1299 (
1300 "new_string".into(),
1301 Value::Str("fn foo() { println!(\"hi\"); }".into()),
1302 ),
1303 ],
1304 };
1305 let v = FsEdit.call(args, &ctx).await.unwrap();
1306 let s = match v {
1307 Value::Struct(fields) => fields
1308 .into_iter()
1309 .find(|(k, _)| k == "summary")
1310 .map(|(_, v)| match v {
1311 Value::Str(s) => s,
1312 _ => panic!(),
1313 })
1314 .unwrap(),
1315 _ => panic!(),
1316 };
1317 assert!(s.contains("replaced 1 occurrence"), "summary: {s}");
1318 assert!(s.contains("line 1"));
1319 let updated = tokio::fs::read_to_string(&path).await.unwrap();
1320 assert!(updated.starts_with("fn foo() { println!(\"hi\"); }\n"));
1321 assert!(updated.contains("fn bar() {}"));
1322 }
1323
1324 #[tokio::test]
1325 async fn fs_edit_missing_match_returns_similar_lines_hint() {
1326 let dir = TempDir::new().unwrap();
1327 let path = dir.path().join("code.rs");
1328 tokio::fs::write(&path, b"fn foo() {}\nfn baz() {}\n")
1329 .await
1330 .unwrap();
1331 let ctx = ToolCtx::new();
1332 let args = ToolArgs {
1333 positional: vec![],
1334 named: vec![
1335 ("path".into(), Value::Path(path)),
1336 ("old_string".into(), Value::Str("fn bar() {}".into())),
1337 ("new_string".into(), Value::Str("changed".into())),
1338 ],
1339 };
1340 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1341 let msg = format!("{err}");
1342 assert!(msg.contains("not found"), "msg: {msg}");
1343 assert!(
1344 msg.contains("line 1") || msg.contains("line 2"),
1345 "msg: {msg}"
1346 );
1347 }
1348
1349 #[tokio::test]
1350 async fn fs_edit_ambiguous_match_reports_locations() {
1351 let dir = TempDir::new().unwrap();
1352 let path = dir.path().join("code.rs");
1353 tokio::fs::write(&path, b"TODO\nline\nTODO\n")
1354 .await
1355 .unwrap();
1356 let ctx = ToolCtx::new();
1357 let args = ToolArgs {
1358 positional: vec![],
1359 named: vec![
1360 ("path".into(), Value::Path(path)),
1361 ("old_string".into(), Value::Str("TODO".into())),
1362 ("new_string".into(), Value::Str("DONE".into())),
1363 ],
1364 };
1365 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1366 let msg = format!("{err}");
1367 assert!(msg.contains("matches 2 times"), "msg: {msg}");
1368 assert!(msg.contains("line 1"));
1369 assert!(msg.contains("line 3"));
1370 assert!(msg.contains("replace_all=true"));
1371 }
1372
1373 #[tokio::test]
1374 async fn fs_edit_replace_all_replaces_every_occurrence() {
1375 let dir = TempDir::new().unwrap();
1376 let path = dir.path().join("code.rs");
1377 tokio::fs::write(&path, b"TODO\nTODO\nTODO\n")
1378 .await
1379 .unwrap();
1380 let ctx = ToolCtx::new();
1381 let args = ToolArgs {
1382 positional: vec![],
1383 named: vec![
1384 ("path".into(), Value::Path(path.clone())),
1385 ("old_string".into(), Value::Str("TODO".into())),
1386 ("new_string".into(), Value::Str("DONE".into())),
1387 ("replace_all".into(), Value::Bool(true)),
1388 ],
1389 };
1390 FsEdit.call(args, &ctx).await.unwrap();
1391 let after = tokio::fs::read_to_string(&path).await.unwrap();
1392 assert_eq!(after, "DONE\nDONE\nDONE\n");
1393 }
1394
1395 #[tokio::test]
1396 async fn fs_edit_noop_rejected() {
1397 let dir = TempDir::new().unwrap();
1398 let path = dir.path().join("code.rs");
1399 tokio::fs::write(&path, b"same\n").await.unwrap();
1400 let ctx = ToolCtx::new();
1401 let args = ToolArgs {
1402 positional: vec![],
1403 named: vec![
1404 ("path".into(), Value::Path(path)),
1405 ("old_string".into(), Value::Str("same".into())),
1406 ("new_string".into(), Value::Str("same".into())),
1407 ],
1408 };
1409 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1410 assert!(format!("{err}").contains("no-op"));
1411 }
1412
1413 #[tokio::test]
1414 async fn fs_edit_requires_prior_read_when_tracker_present() {
1415 let dir = TempDir::new().unwrap();
1416 let path = dir.path().join("code.rs");
1417 tokio::fs::write(&path, b"foo\n").await.unwrap();
1418 let ctx = ToolCtx::new().with_read_files(std::sync::Arc::new(std::sync::Mutex::new(
1419 std::collections::HashSet::new(),
1420 )));
1421 let args = ToolArgs {
1422 positional: vec![],
1423 named: vec![
1424 ("path".into(), Value::Path(path)),
1425 ("old_string".into(), Value::Str("foo".into())),
1426 ("new_string".into(), Value::Str("bar".into())),
1427 ],
1428 };
1429 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1430 assert!(format!("{err}").contains("has not been read"));
1431 }
1432
1433 #[tokio::test]
1434 async fn fs_edit_allowed_after_fs_read() {
1435 let dir = TempDir::new().unwrap();
1436 let path = dir.path().join("code.rs");
1437 tokio::fs::write(&path, b"foo\n").await.unwrap();
1438 let tracker = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1439 let ctx = ToolCtx::new().with_read_files(tracker);
1440 let read_args = ToolArgs {
1441 positional: vec![Value::Path(path.clone())],
1442 named: vec![],
1443 };
1444 FsRead.call(read_args, &ctx).await.unwrap();
1445 let edit_args = ToolArgs {
1446 positional: vec![],
1447 named: vec![
1448 ("path".into(), Value::Path(path.clone())),
1449 ("old_string".into(), Value::Str("foo".into())),
1450 ("new_string".into(), Value::Str("bar".into())),
1451 ],
1452 };
1453 FsEdit.call(edit_args, &ctx).await.unwrap();
1454 }
1455
1456 #[tokio::test]
1457 async fn fs_edit_new_string_containing_old_string_does_not_loop() {
1458 let dir = TempDir::new().unwrap();
1459 let path = dir.path().join("code.rs");
1460 tokio::fs::write(&path, b"foo bar\n").await.unwrap();
1461 let ctx = ToolCtx::new();
1462 let args = ToolArgs {
1463 positional: vec![],
1464 named: vec![
1465 ("path".into(), Value::Path(path.clone())),
1466 ("old_string".into(), Value::Str("foo".into())),
1467 ("new_string".into(), Value::Str("foo foo".into())),
1468 ],
1469 };
1470 FsEdit.call(args, &ctx).await.unwrap();
1471 let after = tokio::fs::read_to_string(&path).await.unwrap();
1472 assert_eq!(after, "foo foo bar\n");
1473 }
1474
1475 #[tokio::test]
1476 async fn fs_read_without_offset_limit_is_backward_compatible() {
1477 let dir = TempDir::new().unwrap();
1478 let path = dir.path().join("plain.txt");
1479 tokio::fs::write(&path, b"one\ntwo\n").await.unwrap();
1480 let ctx = ToolCtx::new();
1481 let args = ToolArgs {
1482 positional: vec![Value::Path(path)],
1483 named: vec![],
1484 };
1485 let v = FsRead.call(args, &ctx).await.unwrap();
1486 assert!(matches!(v, Value::Str(s) if s == "one\ntwo\n"));
1487 }
1488
1489 #[tokio::test]
1490 async fn fs_read_anchor_jumps_to_matched_line_with_context() {
1491 let dir = TempDir::new().unwrap();
1492 let path = dir.path().join("story.txt");
1493 let body = (1..=20)
1494 .map(|i| format!("line {i}"))
1495 .collect::<Vec<_>>()
1496 .join("\n");
1497 tokio::fs::write(&path, body.as_bytes()).await.unwrap();
1498 let ctx = ToolCtx::new();
1499 let args = ToolArgs {
1500 positional: vec![Value::Path(path)],
1501 named: vec![
1502 ("anchor".into(), Value::Str("line 10".into())),
1503 ("context".into(), Value::Int(2)),
1504 ],
1505 };
1506 let v = FsRead.call(args, &ctx).await.unwrap();
1507 let text = match v {
1508 Value::Str(s) => s,
1509 other => panic!("expected string, got {other:?}"),
1510 };
1511 assert!(text.contains("lines 8-12"), "header was: {text}");
1512 assert!(text.contains("line 10"));
1513 assert!(!text.contains("line 7"), "context boundary respected");
1514 }
1515
1516 #[tokio::test]
1517 async fn fs_read_anchor_missing_reports_error() {
1518 let dir = TempDir::new().unwrap();
1519 let path = dir.path().join("body.txt");
1520 tokio::fs::write(&path, b"only this line\n").await.unwrap();
1521 let ctx = ToolCtx::new();
1522 let args = ToolArgs {
1523 positional: vec![Value::Path(path)],
1524 named: vec![("anchor".into(), Value::Str("nope".into()))],
1525 };
1526 let err = FsRead.call(args, &ctx).await.unwrap_err();
1527 assert!(format!("{err}").contains("anchor `nope` not found"));
1528 }
1529
1530 #[tokio::test]
1531 async fn fs_grep_finds_matches_with_context() {
1532 let dir = TempDir::new().unwrap();
1533 let file_a = dir.path().join("a.txt");
1534 tokio::fs::write(&file_a, b"foo\nhello world\nbar\nbaz\n")
1535 .await
1536 .unwrap();
1537 let ctx = ToolCtx::new();
1538 let args = ToolArgs {
1539 positional: Vec::new(),
1540 named: vec![
1541 ("pattern".into(), Value::Str("world".into())),
1542 (
1543 "path".into(),
1544 Value::Str(dir.path().to_string_lossy().to_string()),
1545 ),
1546 ("context_lines".into(), Value::Int(1)),
1547 ],
1548 };
1549 let out = FsGrep.call(args, &ctx).await.unwrap();
1550 let items = match out {
1551 Value::List(v) => v,
1552 other => panic!("expected list, got {other:?}"),
1553 };
1554 assert_eq!(items.len(), 1);
1555 let fields = match &items[0] {
1556 Value::Struct(f) => f.clone(),
1557 other => panic!("expected struct, got {other:?}"),
1558 };
1559 let matched = fields.iter().find(|(k, _)| k == "match").unwrap();
1560 assert!(matches!(&matched.1, Value::Str(s) if s == "hello world"));
1561 let line = fields.iter().find(|(k, _)| k == "line").unwrap();
1562 assert!(matches!(line.1, Value::Int(2)));
1563 }
1564
1565 #[tokio::test]
1566 async fn fs_grep_case_insensitive_by_default() {
1567 let dir = TempDir::new().unwrap();
1568 tokio::fs::write(dir.path().join("b.txt"), b"HELLO World")
1569 .await
1570 .unwrap();
1571 let ctx = ToolCtx::new();
1572 let args = ToolArgs {
1573 positional: Vec::new(),
1574 named: vec![
1575 ("pattern".into(), Value::Str("hello".into())),
1576 (
1577 "path".into(),
1578 Value::Str(dir.path().to_string_lossy().to_string()),
1579 ),
1580 ],
1581 };
1582 let out = FsGrep.call(args, &ctx).await.unwrap();
1583 let n = match out {
1584 Value::List(v) => v.len(),
1585 _ => 0,
1586 };
1587 assert_eq!(n, 1);
1588 }
1589
1590 #[tokio::test]
1591 async fn fs_write_blocked_outside_workspace() {
1592 let workspace = TempDir::new().unwrap();
1593 let policy = crate::fs_access::FsAccessPolicy::workspace_write(workspace.path().into());
1594 let ctx = ToolCtx::new().with_fs_access(policy);
1595 let args = ToolArgs {
1600 positional: vec![],
1601 named: vec![
1602 ("path".into(), Value::Path(PathBuf::from("/etc/atman-evil"))),
1603 ("content".into(), Value::Str("pwned".into())),
1604 ],
1605 };
1606 let err = FsWrite.call(args, &ctx).await.unwrap_err();
1607 let msg = format!("{err}");
1608 assert!(msg.contains("outside workspace"), "got: {msg}");
1609 assert!(!std::path::Path::new("/etc/atman-evil").exists());
1610 }
1611
1612 #[tokio::test]
1613 async fn fs_write_permitted_inside_workspace() {
1614 let workspace = TempDir::new().unwrap();
1615 let policy = crate::fs_access::FsAccessPolicy::workspace_write(workspace.path().into());
1616 let ctx = ToolCtx::new().with_fs_access(policy);
1617 let target = workspace.path().join("nested/deeper/note.txt");
1618 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1619 let args = ToolArgs {
1620 positional: vec![],
1621 named: vec![
1622 ("path".into(), Value::Path(target.clone())),
1623 ("content".into(), Value::Str("ok".into())),
1624 ],
1625 };
1626 FsWrite.call(args, &ctx).await.unwrap();
1627 assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "ok");
1628 }
1629
1630 #[tokio::test]
1631 async fn fs_write_permitted_under_danger_full_access() {
1632 let outside = TempDir::new().unwrap();
1633 let ctx =
1634 ToolCtx::new().with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
1635 let target = outside.path().join("wide.txt");
1636 let args = ToolArgs {
1637 positional: vec![],
1638 named: vec![
1639 ("path".into(), Value::Path(target.clone())),
1640 ("content".into(), Value::Str("go".into())),
1641 ],
1642 };
1643 FsWrite.call(args, &ctx).await.unwrap();
1644 assert!(target.exists());
1645 }
1646
1647 #[tokio::test]
1648 async fn fs_grep_respects_gitignore() {
1649 let dir = TempDir::new().unwrap();
1650 tokio::fs::write(dir.path().join(".ignore"), b"skip.txt\n")
1651 .await
1652 .unwrap();
1653 tokio::fs::write(dir.path().join("skip.txt"), b"needle")
1654 .await
1655 .unwrap();
1656 tokio::fs::write(dir.path().join("keep.txt"), b"needle")
1657 .await
1658 .unwrap();
1659 let ctx = ToolCtx::new();
1660 let args = ToolArgs {
1661 positional: Vec::new(),
1662 named: vec![
1663 ("pattern".into(), Value::Str("needle".into())),
1664 (
1665 "path".into(),
1666 Value::Str(dir.path().to_string_lossy().to_string()),
1667 ),
1668 ],
1669 };
1670 let out = FsGrep.call(args, &ctx).await.unwrap();
1671 let items = match out {
1672 Value::List(v) => v,
1673 _ => panic!("list"),
1674 };
1675 assert_eq!(items.len(), 1, "gitignored file should be skipped");
1676 }
1677}