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 return Ok(Value::Str(content));
96 }
97 let out = slice_lines(&content, offset, limit, &path);
98 Ok(Value::Str(out))
99 })
100 }
101}
102
103fn canonicalize_or_owned(path: &std::path::Path) -> std::path::PathBuf {
104 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
105}
106
107fn slice_lines(
108 content: &str,
109 offset: Option<i64>,
110 limit: Option<i64>,
111 path: &std::path::Path,
112) -> String {
113 let lines: Vec<&str> = content.split_inclusive('\n').collect();
114 let total = lines.len();
115 let start_line = offset.unwrap_or(1).max(1) as usize;
116 let start_idx = start_line.saturating_sub(1);
117 if start_idx >= total {
118 return format!(
119 "[fs.read({}): offset={start_line} exceeds file length {total}. File has {total} line(s).]",
120 path.display()
121 );
122 }
123 let take = match limit {
124 Some(n) if n > 0 => n as usize,
125 _ => total.saturating_sub(start_idx),
126 };
127 let end_idx = (start_idx + take).min(total);
128 let body: String = lines[start_idx..end_idx].concat();
129 let end_line = end_idx;
130 format!(
131 "[fs.read({}): lines {start_line}-{end_line} of {total}]\n{body}",
132 path.display()
133 )
134}
135
136fn extract_optional_int(args: &ToolArgs, name: &str) -> Result<Option<i64>, RuntimeError> {
137 match args.named(name) {
138 None => Ok(None),
139 Some(Value::Int(n)) => Ok(Some(*n)),
140 Some(other) => Err(RuntimeError::TypeMismatch {
141 expected: "integer".into(),
142 actual: other.kind_name().into(),
143 }),
144 }
145}
146
147pub struct FsWrite;
148
149impl Tool for FsWrite {
150 fn name(&self) -> &str {
151 "fs.write"
152 }
153
154 fn tier(&self) -> Tier {
155 Tier::Two
156 }
157
158 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
159 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
160 }
161
162 fn description(&self) -> Option<&str> {
163 Some(
164 "Create a new file or rewrite an existing one from scratch. \
165 Provide BOTH `path` and `content` — never emit an empty {} input. \
166 \
167 PREFER fs.edit INSTEAD when: (a) the file already exists and you \
168 only want to change part of it, (b) the file is longer than ~200 \
169 lines, or (c) the intended content would exceed 4KB. Repeatedly \
170 regenerating a large file via fs.write tends to fail — use \
171 fs.read + fs.edit to apply targeted str_replace edits. \
172 \
173 Example (new file): \
174 {\"path\":\"index.html\",\"content\":\"<html>...</html>\"}",
175 )
176 }
177
178 fn input_schema(&self) -> serde_json::Value {
179 serde_json::json!({
180 "type": "object",
181 "properties": {
182 "path": {"type": "string", "description": "Target file path."},
183 "content": {"type": "string", "description": "UTF-8 text to write."}
184 },
185 "required": ["path", "content"]
186 })
187 }
188
189 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
190 Box::pin(async move {
191 let path = extract_path(&args, "path", 0)?;
192 let content = extract_string(&args, "content", 1)?;
193 let mut approved = false;
194 if let Err(e) = ctx.fs_access.check_write(&path) {
195 match request_fs_write_approval(ctx, &path, &e.to_string()).await {
196 Some(true) => approved = true,
197 Some(false) => {
198 return Err(RuntimeError::ToolFailed(format!(
199 "fs.write({}): user denied the write operation",
200 path.display()
201 )));
202 }
203 None => {
204 return Err(RuntimeError::ToolFailed(format!(
205 "fs.write({}): {e}",
206 path.display()
207 )));
208 }
209 }
210 }
211 let old_content = tokio::fs::read_to_string(&path).await.ok();
212 tokio::fs::write(&path, content.as_bytes())
213 .await
214 .map_err(|e| {
215 RuntimeError::ToolFailed(format!("fs.write({}): {e}", path.display()))
216 })?;
217 let canonical = canonicalize_or_owned(&path);
218 ctx.note_read(&canonical);
219 let path_str = path.display().to_string();
220 let diff_patch = match &old_content {
221 Some(old) => unified_diff_preview(&path_str, old, &content),
222 None => format!("+++ {path_str}\n{content}"),
223 };
224 if let Some(tx) = &ctx.stream_tx {
225 let _ = tx.send(StreamFrame::DiffPreview {
226 title: path_str,
227 old_content: None,
228 new_content: None,
229 unified_diff: Some(diff_patch.clone()),
230 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
231 });
232 }
233 Ok(Value::Struct(vec![
234 ("path".into(), Value::Path(path)),
235 (
236 "approval".into(),
237 Value::Str(if approved { "approved" } else { "auto" }.into()),
238 ),
239 ("diff".into(), Value::Str(diff_patch)),
240 ]))
241 })
242 }
243}
244
245fn extract_string(args: &ToolArgs, name: &str, pos: usize) -> Result<String, RuntimeError> {
246 let value = match args.named(name) {
247 Some(v) => v,
248 None => args.positional(pos)?,
249 };
250 match value {
251 Value::Str(s) => Ok(s.clone()),
252 other => Err(RuntimeError::TypeMismatch {
253 expected: "string".into(),
254 actual: other.kind_name().into(),
255 }),
256 }
257}
258
259pub struct FsEdit;
260
261impl Tool for FsEdit {
262 fn name(&self) -> &str {
263 "fs.edit"
264 }
265
266 fn tier(&self) -> Tier {
267 Tier::Two
268 }
269
270 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
271 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
272 }
273
274 fn description(&self) -> Option<&str> {
275 Some(
276 "Replace an exact text snippet in a file. Preferred over fs.write for changing part of \
277 an existing file. `old_string` must match VERBATIM (whitespace + newlines) and, by \
278 default, appear exactly once — if it matches multiple times the error tells you how \
279 to disambiguate. Use `replace_all: true` to change every occurrence. \
280 Example: {\"path\":\"a.rs\",\"old_string\":\"fn foo() {}\",\"new_string\":\"fn foo() { println!(\\\"hi\\\"); }\"}",
281 )
282 }
283
284 fn input_schema(&self) -> serde_json::Value {
285 serde_json::json!({
286 "type": "object",
287 "properties": {
288 "path": {"type": "string", "description": "Target file path."},
289 "old_string": {"type": "string", "description": "Exact text to find. Match is literal, not regex."},
290 "new_string": {"type": "string", "description": "Replacement text. May be empty to delete."},
291 "replace_all": {"type": "boolean", "description": "Replace every occurrence. Default false (unique match required)."}
292 },
293 "required": ["path", "old_string", "new_string"]
294 })
295 }
296
297 fn preview_call<'a>(
298 &'a self,
299 args: &'a ToolArgs,
300 _ctx: &'a ToolCtx,
301 ) -> BoxFut<'a, Option<String>> {
302 Box::pin(async move {
303 let path = extract_path(args, "path", 0).ok()?;
304 let old_string = extract_string(args, "old_string", 1).ok()?;
305 let new_string = extract_string(args, "new_string", 2).ok()?;
306 let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
307 let content = tokio::fs::read_to_string(&path).await.ok()?;
308 let updated = if replace_all {
309 content.replace(&old_string, &new_string)
310 } else {
311 content.replacen(&old_string, &new_string, 1)
312 };
313 if updated == content {
314 return None;
315 }
316 Some(unified_diff_preview(
317 &path.display().to_string(),
318 &content,
319 &updated,
320 ))
321 })
322 }
323
324 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
325 Box::pin(async move {
326 let path = extract_path(&args, "path", 0)?;
327 let old_string = extract_string(&args, "old_string", 1)?;
328 let new_string = extract_string(&args, "new_string", 2)?;
329 let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
330 let mut approved = false;
331 if let Err(e) = ctx.fs_access.check_write(&path) {
332 match request_fs_write_approval(ctx, &path, &e.to_string()).await {
333 Some(true) => approved = true,
334 Some(false) => {
335 return Err(RuntimeError::ToolFailed(format!(
336 "fs.edit({}): user denied the write operation",
337 path.display()
338 )));
339 }
340 None => {
341 return Err(RuntimeError::ToolFailed(format!(
342 "fs.edit({}): {e}",
343 path.display()
344 )));
345 }
346 }
347 }
348 let canonical = canonicalize_or_owned(&path);
349 if ctx.read_files.is_some() && !ctx.has_read(&canonical) {
350 return Err(RuntimeError::ToolFailed(format!(
351 "fs.edit({}): file has not been read in this session. Call fs.read({}) first so the model works on current content.",
352 path.display(),
353 path.display()
354 )));
355 }
356 if old_string == new_string {
357 return Err(RuntimeError::ToolFailed(format!(
358 "fs.edit({}): old_string equals new_string — edit would be a no-op",
359 path.display()
360 )));
361 }
362 if old_string.is_empty() {
363 return Err(RuntimeError::ToolFailed(format!(
364 "fs.edit({}): old_string is empty — refusing to insert at every byte boundary",
365 path.display()
366 )));
367 }
368 let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
369 RuntimeError::ToolFailed(format!("fs.edit({}): {e}", path.display()))
370 })?;
371 let match_lines = find_match_lines(&content, &old_string);
372 if match_lines.is_empty() {
373 let similar = similar_line_hint(&content, &old_string);
374 let snippet: String = old_string.chars().take(60).collect();
375 return Err(RuntimeError::ToolFailed(format!(
376 "fs.edit({}): old_string not found. First 60 chars searched: {snippet:?}. {similar}",
377 path.display()
378 )));
379 }
380 if !replace_all && match_lines.len() > 1 {
381 let sample: Vec<String> = match_lines
382 .iter()
383 .take(3)
384 .map(|n| format!("line {n}"))
385 .collect();
386 return Err(RuntimeError::ToolFailed(format!(
387 "fs.edit({}): old_string matches {} times ({}). Add surrounding context so it is unique, or pass replace_all=true.",
388 path.display(),
389 match_lines.len(),
390 sample.join(", ")
391 )));
392 }
393 let updated = if replace_all {
394 content.replace(&old_string, &new_string)
395 } else {
396 content.replacen(&old_string, &new_string, 1)
397 };
398 tokio::fs::write(&path, updated.as_bytes())
399 .await
400 .map_err(|e| {
401 RuntimeError::ToolFailed(format!(
402 "fs.edit({}): write failed: {e}",
403 path.display()
404 ))
405 })?;
406 let path_str = path.display().to_string();
407 let diff_patch = unified_diff_preview(&path_str, &content, &updated);
408 if let Some(tx) = &ctx.stream_tx {
409 let _ = tx.send(StreamFrame::DiffPreview {
410 title: path_str,
411 old_content: None,
412 new_content: None,
413 unified_diff: Some(diff_patch.clone()),
414 run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
415 });
416 }
417 let replaced = if replace_all { match_lines.len() } else { 1 };
418 let first_line = match_lines[0];
419 Ok(Value::Struct(vec![
420 (
421 "summary".into(),
422 Value::Str(format!(
423 "[fs.edit({}): replaced {replaced} occurrence(s), first at line {first_line}]",
424 path.display()
425 )),
426 ),
427 (
428 "approval".into(),
429 Value::Str(if approved { "approved" } else { "auto" }.into()),
430 ),
431 ("path".into(), Value::Str(path.display().to_string())),
432 ("diff".into(), Value::Str(diff_patch)),
433 ]))
434 })
435 }
436}
437
438pub(crate) fn unified_diff_preview(path: &str, before: &str, after: &str) -> String {
439 use similar::{ChangeTag, TextDiff};
440 let diff = TextDiff::from_lines(before, after);
441 let mut out = format!("--- {path}\n+++ {path}\n");
442 for (shown, hunk) in diff
443 .unified_diff()
444 .context_radius(3)
445 .iter_hunks()
446 .enumerate()
447 {
448 if shown >= 4 {
449 out.push_str("... (truncated) ...\n");
450 break;
451 }
452 out.push_str(&hunk.header().to_string());
453 out.push('\n');
454 for change in hunk.iter_changes() {
455 let sign = match change.tag() {
456 ChangeTag::Delete => '-',
457 ChangeTag::Insert => '+',
458 ChangeTag::Equal => ' ',
459 };
460 let line = change.value();
461 let trimmed = line.strip_suffix('\n').unwrap_or(line);
462 out.push(sign);
463 out.push_str(trimmed);
464 out.push('\n');
465 }
466 }
467 if out.chars().count() > 4000 {
468 let head: String = out.chars().take(4000).collect();
469 format!("{head}\n... (preview truncated at 4000 chars) ...")
470 } else {
471 out
472 }
473}
474
475fn find_match_lines(content: &str, needle: &str) -> Vec<usize> {
476 let mut out = Vec::new();
477 let mut cursor = 0usize;
478 while let Some(pos) = content[cursor..].find(needle) {
479 let abs = cursor + pos;
480 let line = content[..abs].bytes().filter(|b| *b == b'\n').count() + 1;
481 out.push(line);
482 cursor = abs + needle.len().max(1);
483 if needle.is_empty() {
484 break;
485 }
486 }
487 out
488}
489
490fn similar_line_hint(content: &str, needle: &str) -> String {
491 let first_needle_line = needle.lines().next().unwrap_or("").trim();
492 if first_needle_line.is_empty() {
493 return "No similar lines to suggest.".into();
494 }
495 let needle_tokens: Vec<&str> = first_needle_line
496 .split(|c: char| !c.is_alphanumeric() && c != '_')
497 .filter(|t| !t.is_empty())
498 .collect();
499 if needle_tokens.is_empty() {
500 return "No similar lines to suggest.".into();
501 }
502 let mut scored: Vec<(usize, usize, &str)> = Vec::new();
503 for (i, line) in content.lines().enumerate() {
504 let mut hits = 0usize;
505 for tok in &needle_tokens {
506 if line.contains(tok) {
507 hits += 1;
508 }
509 }
510 if hits > 0 {
511 scored.push((hits, i + 1, line));
512 }
513 }
514 scored.sort_by_key(|x| std::cmp::Reverse(x.0));
515 scored.truncate(3);
516 if scored.is_empty() {
517 "No similar lines found — perhaps whitespace differs or the file was already edited.".into()
518 } else {
519 let joined = scored
520 .iter()
521 .map(|(_, n, l)| format!(" line {n}: {}", l.trim_end()))
522 .collect::<Vec<_>>()
523 .join("\n");
524 format!("Similar lines in file:\n{joined}")
525 }
526}
527
528pub struct FsList;
529
530impl Tool for FsList {
531 fn name(&self) -> &str {
532 "fs.list"
533 }
534
535 fn tier(&self) -> Tier {
536 Tier::Zero
537 }
538
539 fn description(&self) -> Option<&str> {
540 Some("List the entries of a directory. Returns a list of {name, kind} structs.")
541 }
542
543 fn input_schema(&self) -> serde_json::Value {
544 serde_json::json!({
545 "type": "object",
546 "properties": {
547 "path": {"type": "string", "description": "Directory path to list."}
548 },
549 "required": ["path"]
550 })
551 }
552
553 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
554 Box::pin(async move {
555 let path = extract_path(&args, "path", 0)?;
556 let mut rd = tokio::fs::read_dir(&path).await.map_err(|e| {
557 RuntimeError::ToolFailed(format!("fs.list({}): {e}", path.display()))
558 })?;
559 let mut entries = Vec::new();
560 while let Some(entry) = rd
561 .next_entry()
562 .await
563 .map_err(|e| RuntimeError::ToolFailed(format!("fs.list next_entry: {e}")))?
564 {
565 entries.push(Value::Path(entry.path()));
566 }
567 entries.sort_by(|a, b| match (a, b) {
568 (Value::Path(a), Value::Path(b)) => a.cmp(b),
569 _ => std::cmp::Ordering::Equal,
570 });
571 Ok(Value::List(entries))
572 })
573 }
574}
575
576fn extract_path(args: &ToolArgs, name: &str, pos: usize) -> Result<PathBuf, RuntimeError> {
577 let value = match args.named(name) {
578 Some(v) => v,
579 None => args.positional(pos)?,
580 };
581 match value {
582 Value::Path(p) => Ok(p.clone()),
583 Value::Str(s) => Ok(PathBuf::from(s)),
584 other => Err(RuntimeError::TypeMismatch {
585 expected: "path or string".into(),
586 actual: other.kind_name().into(),
587 }),
588 }
589}
590
591fn path_in_workspace(path: &std::path::Path) -> bool {
592 let abs = if path.is_absolute() {
593 path.to_path_buf()
594 } else {
595 match std::env::current_dir() {
596 Ok(cwd) => cwd.join(path),
597 Err(_) => return false,
598 }
599 };
600 let Ok(cwd) = std::env::current_dir() else {
601 return false;
602 };
603 abs.starts_with(&cwd)
604}
605
606fn workspace_approval_level(
607 args: &ToolArgs,
608 fallback: crate::tool::ApprovalLevel,
609) -> crate::tool::ApprovalLevel {
610 match extract_path(args, "path", 0) {
611 Ok(p) if path_in_workspace(&p) => crate::tool::ApprovalLevel::Auto,
612 _ => fallback,
613 }
614}
615
616pub struct FsGrep;
617
618impl Tool for FsGrep {
619 fn name(&self) -> &str {
620 "fs.grep"
621 }
622
623 fn tier(&self) -> Tier {
624 Tier::One
625 }
626
627 fn approval_level(&self, args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
628 workspace_approval_level(args, ApprovalLevel::from_tier(self.tier()))
629 }
630
631 fn description(&self) -> Option<&str> {
632 Some(
633 "Search files under `path` for a regex `pattern` (like ripgrep). Returns matches \
634 grouped by file with `context_lines` before + after each match. Honors .gitignore \
635 and hidden-file rules by default. Params: pattern (regex, required), path (dir or \
636 file, default cwd), context_lines (int 0..=10, default 3), case_sensitive (bool, \
637 default false), limit (int, default 50 matches, max 200). Use this INSTEAD of \
638 bash.exec + rg — it's faster and returns structured results.",
639 )
640 }
641
642 fn input_schema(&self) -> serde_json::Value {
643 serde_json::json!({
644 "type": "object",
645 "properties": {
646 "pattern": {"type": "string"},
647 "path": {"type": "string"},
648 "context_lines": {"type": "integer", "default": 3},
649 "case_sensitive": {"type": "boolean", "default": false},
650 "limit": {"type": "integer", "default": 50}
651 },
652 "required": ["pattern"]
653 })
654 }
655
656 fn call<'a>(&'a self, args: ToolArgs, _ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
657 Box::pin(async move { fs_grep_impl(args).await })
658 }
659}
660
661async fn fs_grep_impl(args: ToolArgs) -> ToolResult {
662 let pattern = extract_string(&args, "pattern", 0)?;
663 if pattern.is_empty() {
664 return Err(RuntimeError::ToolFailed("fs.grep: empty pattern".into()));
665 }
666 let base_path: std::path::PathBuf = match args.named("path") {
667 Some(Value::Str(s)) => std::path::PathBuf::from(s),
668 Some(Value::Path(p)) => p.clone(),
669 Some(other) => {
670 return Err(RuntimeError::TypeMismatch {
671 expected: "path or string".into(),
672 actual: other.kind_name().into(),
673 });
674 }
675 None => std::env::current_dir()
676 .map_err(|e| RuntimeError::ToolFailed(format!("fs.grep: cwd: {e}")))?,
677 };
678 let context_lines: usize = match args.named("context_lines") {
679 Some(Value::Int(n)) if *n >= 0 => (*n as usize).min(10),
680 _ => 3,
681 };
682 let case_sensitive = matches!(args.named("case_sensitive"), Some(Value::Bool(true)));
683 let limit: usize = match args.named("limit") {
684 Some(Value::Int(n)) if *n > 0 => (*n as usize).min(200),
685 _ => 50,
686 };
687 let re = regex::RegexBuilder::new(&pattern)
688 .case_insensitive(!case_sensitive)
689 .build()
690 .map_err(|e| RuntimeError::ToolFailed(format!("fs.grep: invalid regex: {e}")))?;
691 let walker = ignore::WalkBuilder::new(&base_path).build();
692 let mut hits: Vec<Value> = Vec::new();
693 for entry in walker {
694 if hits.len() >= limit {
695 break;
696 }
697 let entry = match entry {
698 Ok(e) => e,
699 Err(_) => continue,
700 };
701 if entry.file_type().is_none_or(|ft| !ft.is_file()) {
702 continue;
703 }
704 let contents = match tokio::fs::read_to_string(entry.path()).await {
705 Ok(c) => c,
706 Err(_) => continue,
707 };
708 let lines: Vec<&str> = contents.lines().collect();
709 for (idx, line) in lines.iter().enumerate() {
710 if !re.is_match(line) {
711 continue;
712 }
713 let before_start = idx.saturating_sub(context_lines);
714 let after_end = (idx + context_lines + 1).min(lines.len());
715 let before: Vec<Value> = lines[before_start..idx]
716 .iter()
717 .map(|s| Value::Str((*s).to_string()))
718 .collect();
719 let after: Vec<Value> = lines[idx + 1..after_end]
720 .iter()
721 .map(|s| Value::Str((*s).to_string()))
722 .collect();
723 hits.push(Value::Struct(vec![
724 (
725 "file".into(),
726 Value::Str(entry.path().display().to_string()),
727 ),
728 ("line".into(), Value::Int((idx + 1) as i64)),
729 ("before".into(), Value::List(before)),
730 ("match".into(), Value::Str((*line).to_string())),
731 ("after".into(), Value::List(after)),
732 ]));
733 if hits.len() >= limit {
734 break;
735 }
736 }
737 }
738 Ok(Value::List(hits))
739}
740
741async fn request_fs_write_approval(
742 ctx: &ToolCtx,
743 path: &std::path::Path,
744 reason: &str,
745) -> Option<bool> {
746 use crate::tool::ApprovalLevel;
747 let Some(approval) = &ctx.approval else {
748 return None;
749 };
750 let run_id = ctx.flow_run_id.clone()?;
751 let id = format!("fs_write_{}", uuid::Uuid::now_v7());
752 let pending = crate::session::PendingApproval {
753 tool_use_id: id.clone(),
754 tool_name: "fs.write (sandboxed)".to_string(),
755 args_preview: format!("path={}", path.display()),
756 preview: Some(reason.to_string()),
757 level: ApprovalLevel::Dangerous,
758 run_id,
759 emitted_at: chrono::Utc::now(),
760 bypass_auto_ceiling: false,
761 };
762 let rx = approval.request(pending);
763 let run_id_for_emit = ctx.flow_run_id.clone();
764 if let (Some(sink), Some(rid)) = (ctx.events.as_ref(), run_id_for_emit) {
765 sink.emit(crate::event::Event::ToolPendingApproval {
766 run_id: rid,
767 tool_use_id: id,
768 tool_name: "fs.write".into(),
769 args_preview: path.display().to_string(),
770 level: "dangerous".into(),
771 preview: Some(reason.to_string()),
772 });
773 }
774 match rx.await {
775 Ok(crate::session::ApprovalDecision::Approve) => Some(true),
776 _ => Some(false),
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783 use tempfile::TempDir;
784
785 #[tokio::test]
786 async fn fs_read_returns_file_content() {
787 let dir = TempDir::new().unwrap();
788 let path = dir.path().join("hello.txt");
789 tokio::fs::write(&path, b"hi from atman").await.unwrap();
790
791 let ctx = ToolCtx::new();
792 let args = ToolArgs {
793 positional: vec![Value::Path(path)],
794 named: vec![],
795 };
796 let v = FsRead.call(args, &ctx).await.unwrap();
797 assert!(matches!(v, Value::Str(s) if s == "hi from atman"));
798 }
799
800 #[tokio::test]
801 async fn fs_read_accepts_string_path() {
802 let dir = TempDir::new().unwrap();
803 let path = dir.path().join("x.txt");
804 tokio::fs::write(&path, b"ok").await.unwrap();
805
806 let ctx = ToolCtx::new();
807 let args = ToolArgs {
808 positional: vec![Value::Str(path.to_string_lossy().into())],
809 named: vec![],
810 };
811 let v = FsRead.call(args, &ctx).await.unwrap();
812 assert!(matches!(v, Value::Str(s) if s == "ok"));
813 }
814
815 #[tokio::test]
816 async fn fs_read_missing_file_returns_tool_failed() {
817 let dir = TempDir::new().unwrap();
818 let ctx = ToolCtx::new();
819 let args = ToolArgs {
820 positional: vec![Value::Path(dir.path().join("nope"))],
821 named: vec![],
822 };
823 let err = FsRead.call(args, &ctx).await.unwrap_err();
824 assert!(matches!(err, RuntimeError::ToolFailed(_)));
825 }
826
827 #[tokio::test]
828 async fn fs_list_returns_sorted_paths() {
829 let dir = TempDir::new().unwrap();
830 tokio::fs::write(dir.path().join("b.txt"), b"")
831 .await
832 .unwrap();
833 tokio::fs::write(dir.path().join("a.txt"), b"")
834 .await
835 .unwrap();
836
837 let ctx = ToolCtx::new();
838 let args = ToolArgs {
839 positional: vec![Value::Path(dir.path().to_path_buf())],
840 named: vec![],
841 };
842 let v = FsList.call(args, &ctx).await.unwrap();
843 if let Value::List(items) = v {
844 assert_eq!(items.len(), 2);
845 if let Value::Path(p) = &items[0] {
846 assert!(p.ends_with("a.txt"));
847 } else {
848 panic!("expected path");
849 }
850 } else {
851 panic!("expected list");
852 }
853 }
854
855 #[tokio::test]
856 async fn missing_positional_is_missing_arg_error() {
857 let ctx = ToolCtx::new();
858 let args = ToolArgs::default();
859 let err = FsRead.call(args, &ctx).await.unwrap_err();
860 assert!(matches!(err, RuntimeError::MissingArg(_)));
861 }
862
863 #[tokio::test]
864 async fn fs_read_offset_limit_returns_slice_with_header() {
865 let dir = TempDir::new().unwrap();
866 let path = dir.path().join("multi.txt");
867 tokio::fs::write(&path, b"line1\nline2\nline3\nline4\nline5\n")
868 .await
869 .unwrap();
870 let ctx = ToolCtx::new();
871 let args = ToolArgs {
872 positional: vec![Value::Path(path.clone())],
873 named: vec![
874 ("offset".into(), Value::Int(2)),
875 ("limit".into(), Value::Int(2)),
876 ],
877 };
878 let v = FsRead.call(args, &ctx).await.unwrap();
879 let s = match v {
880 Value::Str(s) => s,
881 _ => panic!(),
882 };
883 assert!(s.contains("lines 2-3 of 5"), "header missing: {s}");
884 assert!(s.contains("line2\nline3"), "body wrong: {s}");
885 assert!(!s.contains("line1"));
886 assert!(!s.contains("line4"));
887 }
888
889 #[tokio::test]
890 async fn fs_read_offset_past_end_reports_bounds() {
891 let dir = TempDir::new().unwrap();
892 let path = dir.path().join("short.txt");
893 tokio::fs::write(&path, b"only\n").await.unwrap();
894 let ctx = ToolCtx::new();
895 let args = ToolArgs {
896 positional: vec![Value::Path(path)],
897 named: vec![("offset".into(), Value::Int(99))],
898 };
899 let v = FsRead.call(args, &ctx).await.unwrap();
900 let s = match v {
901 Value::Str(s) => s,
902 _ => panic!(),
903 };
904 assert!(s.contains("offset=99 exceeds"), "expected bounds msg: {s}");
905 }
906
907 #[tokio::test]
908 async fn fs_edit_unique_match_replaces_and_returns_diff_summary() {
909 let dir = TempDir::new().unwrap();
910 let path = dir.path().join("code.rs");
911 tokio::fs::write(&path, b"fn foo() {}\nfn bar() {}\n")
912 .await
913 .unwrap();
914 let ctx = ToolCtx::new();
915 let args = ToolArgs {
916 positional: vec![],
917 named: vec![
918 ("path".into(), Value::Path(path.clone())),
919 ("old_string".into(), Value::Str("fn foo() {}".into())),
920 (
921 "new_string".into(),
922 Value::Str("fn foo() { println!(\"hi\"); }".into()),
923 ),
924 ],
925 };
926 let v = FsEdit.call(args, &ctx).await.unwrap();
927 let s = match v {
928 Value::Struct(fields) => fields
929 .into_iter()
930 .find(|(k, _)| k == "summary")
931 .map(|(_, v)| match v {
932 Value::Str(s) => s,
933 _ => panic!(),
934 })
935 .unwrap(),
936 _ => panic!(),
937 };
938 assert!(s.contains("replaced 1 occurrence"), "summary: {s}");
939 assert!(s.contains("line 1"));
940 let updated = tokio::fs::read_to_string(&path).await.unwrap();
941 assert!(updated.starts_with("fn foo() { println!(\"hi\"); }\n"));
942 assert!(updated.contains("fn bar() {}"));
943 }
944
945 #[tokio::test]
946 async fn fs_edit_missing_match_returns_similar_lines_hint() {
947 let dir = TempDir::new().unwrap();
948 let path = dir.path().join("code.rs");
949 tokio::fs::write(&path, b"fn foo() {}\nfn baz() {}\n")
950 .await
951 .unwrap();
952 let ctx = ToolCtx::new();
953 let args = ToolArgs {
954 positional: vec![],
955 named: vec![
956 ("path".into(), Value::Path(path)),
957 ("old_string".into(), Value::Str("fn bar() {}".into())),
958 ("new_string".into(), Value::Str("changed".into())),
959 ],
960 };
961 let err = FsEdit.call(args, &ctx).await.unwrap_err();
962 let msg = format!("{err}");
963 assert!(msg.contains("not found"), "msg: {msg}");
964 assert!(
965 msg.contains("line 1") || msg.contains("line 2"),
966 "msg: {msg}"
967 );
968 }
969
970 #[tokio::test]
971 async fn fs_edit_ambiguous_match_reports_locations() {
972 let dir = TempDir::new().unwrap();
973 let path = dir.path().join("code.rs");
974 tokio::fs::write(&path, b"TODO\nline\nTODO\n")
975 .await
976 .unwrap();
977 let ctx = ToolCtx::new();
978 let args = ToolArgs {
979 positional: vec![],
980 named: vec![
981 ("path".into(), Value::Path(path)),
982 ("old_string".into(), Value::Str("TODO".into())),
983 ("new_string".into(), Value::Str("DONE".into())),
984 ],
985 };
986 let err = FsEdit.call(args, &ctx).await.unwrap_err();
987 let msg = format!("{err}");
988 assert!(msg.contains("matches 2 times"), "msg: {msg}");
989 assert!(msg.contains("line 1"));
990 assert!(msg.contains("line 3"));
991 assert!(msg.contains("replace_all=true"));
992 }
993
994 #[tokio::test]
995 async fn fs_edit_replace_all_replaces_every_occurrence() {
996 let dir = TempDir::new().unwrap();
997 let path = dir.path().join("code.rs");
998 tokio::fs::write(&path, b"TODO\nTODO\nTODO\n")
999 .await
1000 .unwrap();
1001 let ctx = ToolCtx::new();
1002 let args = ToolArgs {
1003 positional: vec![],
1004 named: vec![
1005 ("path".into(), Value::Path(path.clone())),
1006 ("old_string".into(), Value::Str("TODO".into())),
1007 ("new_string".into(), Value::Str("DONE".into())),
1008 ("replace_all".into(), Value::Bool(true)),
1009 ],
1010 };
1011 FsEdit.call(args, &ctx).await.unwrap();
1012 let after = tokio::fs::read_to_string(&path).await.unwrap();
1013 assert_eq!(after, "DONE\nDONE\nDONE\n");
1014 }
1015
1016 #[tokio::test]
1017 async fn fs_edit_noop_rejected() {
1018 let dir = TempDir::new().unwrap();
1019 let path = dir.path().join("code.rs");
1020 tokio::fs::write(&path, b"same\n").await.unwrap();
1021 let ctx = ToolCtx::new();
1022 let args = ToolArgs {
1023 positional: vec![],
1024 named: vec![
1025 ("path".into(), Value::Path(path)),
1026 ("old_string".into(), Value::Str("same".into())),
1027 ("new_string".into(), Value::Str("same".into())),
1028 ],
1029 };
1030 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1031 assert!(format!("{err}").contains("no-op"));
1032 }
1033
1034 #[tokio::test]
1035 async fn fs_edit_requires_prior_read_when_tracker_present() {
1036 let dir = TempDir::new().unwrap();
1037 let path = dir.path().join("code.rs");
1038 tokio::fs::write(&path, b"foo\n").await.unwrap();
1039 let ctx = ToolCtx::new().with_read_files(std::sync::Arc::new(std::sync::Mutex::new(
1040 std::collections::HashSet::new(),
1041 )));
1042 let args = ToolArgs {
1043 positional: vec![],
1044 named: vec![
1045 ("path".into(), Value::Path(path)),
1046 ("old_string".into(), Value::Str("foo".into())),
1047 ("new_string".into(), Value::Str("bar".into())),
1048 ],
1049 };
1050 let err = FsEdit.call(args, &ctx).await.unwrap_err();
1051 assert!(format!("{err}").contains("has not been read"));
1052 }
1053
1054 #[tokio::test]
1055 async fn fs_edit_allowed_after_fs_read() {
1056 let dir = TempDir::new().unwrap();
1057 let path = dir.path().join("code.rs");
1058 tokio::fs::write(&path, b"foo\n").await.unwrap();
1059 let tracker = std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1060 let ctx = ToolCtx::new().with_read_files(tracker);
1061 let read_args = ToolArgs {
1062 positional: vec![Value::Path(path.clone())],
1063 named: vec![],
1064 };
1065 FsRead.call(read_args, &ctx).await.unwrap();
1066 let edit_args = ToolArgs {
1067 positional: vec![],
1068 named: vec![
1069 ("path".into(), Value::Path(path.clone())),
1070 ("old_string".into(), Value::Str("foo".into())),
1071 ("new_string".into(), Value::Str("bar".into())),
1072 ],
1073 };
1074 FsEdit.call(edit_args, &ctx).await.unwrap();
1075 }
1076
1077 #[tokio::test]
1078 async fn fs_edit_new_string_containing_old_string_does_not_loop() {
1079 let dir = TempDir::new().unwrap();
1080 let path = dir.path().join("code.rs");
1081 tokio::fs::write(&path, b"foo bar\n").await.unwrap();
1082 let ctx = ToolCtx::new();
1083 let args = ToolArgs {
1084 positional: vec![],
1085 named: vec![
1086 ("path".into(), Value::Path(path.clone())),
1087 ("old_string".into(), Value::Str("foo".into())),
1088 ("new_string".into(), Value::Str("foo foo".into())),
1089 ],
1090 };
1091 FsEdit.call(args, &ctx).await.unwrap();
1092 let after = tokio::fs::read_to_string(&path).await.unwrap();
1093 assert_eq!(after, "foo foo bar\n");
1094 }
1095
1096 #[tokio::test]
1097 async fn fs_read_without_offset_limit_is_backward_compatible() {
1098 let dir = TempDir::new().unwrap();
1099 let path = dir.path().join("plain.txt");
1100 tokio::fs::write(&path, b"one\ntwo\n").await.unwrap();
1101 let ctx = ToolCtx::new();
1102 let args = ToolArgs {
1103 positional: vec![Value::Path(path)],
1104 named: vec![],
1105 };
1106 let v = FsRead.call(args, &ctx).await.unwrap();
1107 assert!(matches!(v, Value::Str(s) if s == "one\ntwo\n"));
1108 }
1109
1110 #[tokio::test]
1111 async fn fs_read_anchor_jumps_to_matched_line_with_context() {
1112 let dir = TempDir::new().unwrap();
1113 let path = dir.path().join("story.txt");
1114 let body = (1..=20)
1115 .map(|i| format!("line {i}"))
1116 .collect::<Vec<_>>()
1117 .join("\n");
1118 tokio::fs::write(&path, body.as_bytes()).await.unwrap();
1119 let ctx = ToolCtx::new();
1120 let args = ToolArgs {
1121 positional: vec![Value::Path(path)],
1122 named: vec![
1123 ("anchor".into(), Value::Str("line 10".into())),
1124 ("context".into(), Value::Int(2)),
1125 ],
1126 };
1127 let v = FsRead.call(args, &ctx).await.unwrap();
1128 let text = match v {
1129 Value::Str(s) => s,
1130 other => panic!("expected string, got {other:?}"),
1131 };
1132 assert!(text.contains("lines 8-12"), "header was: {text}");
1133 assert!(text.contains("line 10"));
1134 assert!(!text.contains("line 7"), "context boundary respected");
1135 }
1136
1137 #[tokio::test]
1138 async fn fs_read_anchor_missing_reports_error() {
1139 let dir = TempDir::new().unwrap();
1140 let path = dir.path().join("body.txt");
1141 tokio::fs::write(&path, b"only this line\n").await.unwrap();
1142 let ctx = ToolCtx::new();
1143 let args = ToolArgs {
1144 positional: vec![Value::Path(path)],
1145 named: vec![("anchor".into(), Value::Str("nope".into()))],
1146 };
1147 let err = FsRead.call(args, &ctx).await.unwrap_err();
1148 assert!(format!("{err}").contains("anchor `nope` not found"));
1149 }
1150
1151 #[tokio::test]
1152 async fn fs_grep_finds_matches_with_context() {
1153 let dir = TempDir::new().unwrap();
1154 let file_a = dir.path().join("a.txt");
1155 tokio::fs::write(&file_a, b"foo\nhello world\nbar\nbaz\n")
1156 .await
1157 .unwrap();
1158 let ctx = ToolCtx::new();
1159 let args = ToolArgs {
1160 positional: Vec::new(),
1161 named: vec![
1162 ("pattern".into(), Value::Str("world".into())),
1163 (
1164 "path".into(),
1165 Value::Str(dir.path().to_string_lossy().to_string()),
1166 ),
1167 ("context_lines".into(), Value::Int(1)),
1168 ],
1169 };
1170 let out = FsGrep.call(args, &ctx).await.unwrap();
1171 let items = match out {
1172 Value::List(v) => v,
1173 other => panic!("expected list, got {other:?}"),
1174 };
1175 assert_eq!(items.len(), 1);
1176 let fields = match &items[0] {
1177 Value::Struct(f) => f.clone(),
1178 other => panic!("expected struct, got {other:?}"),
1179 };
1180 let matched = fields.iter().find(|(k, _)| k == "match").unwrap();
1181 assert!(matches!(&matched.1, Value::Str(s) if s == "hello world"));
1182 let line = fields.iter().find(|(k, _)| k == "line").unwrap();
1183 assert!(matches!(line.1, Value::Int(2)));
1184 }
1185
1186 #[tokio::test]
1187 async fn fs_grep_case_insensitive_by_default() {
1188 let dir = TempDir::new().unwrap();
1189 tokio::fs::write(dir.path().join("b.txt"), b"HELLO World")
1190 .await
1191 .unwrap();
1192 let ctx = ToolCtx::new();
1193 let args = ToolArgs {
1194 positional: Vec::new(),
1195 named: vec![
1196 ("pattern".into(), Value::Str("hello".into())),
1197 (
1198 "path".into(),
1199 Value::Str(dir.path().to_string_lossy().to_string()),
1200 ),
1201 ],
1202 };
1203 let out = FsGrep.call(args, &ctx).await.unwrap();
1204 let n = match out {
1205 Value::List(v) => v.len(),
1206 _ => 0,
1207 };
1208 assert_eq!(n, 1);
1209 }
1210
1211 #[tokio::test]
1212 async fn fs_write_blocked_outside_workspace() {
1213 let workspace = TempDir::new().unwrap();
1214 let policy = crate::fs_access::FsAccessPolicy::workspace_write(workspace.path().into());
1215 let ctx = ToolCtx::new().with_fs_access(policy);
1216 let args = ToolArgs {
1221 positional: vec![],
1222 named: vec![
1223 ("path".into(), Value::Path(PathBuf::from("/etc/atman-evil"))),
1224 ("content".into(), Value::Str("pwned".into())),
1225 ],
1226 };
1227 let err = FsWrite.call(args, &ctx).await.unwrap_err();
1228 let msg = format!("{err}");
1229 assert!(msg.contains("outside workspace"), "got: {msg}");
1230 assert!(!std::path::Path::new("/etc/atman-evil").exists());
1231 }
1232
1233 #[tokio::test]
1234 async fn fs_write_permitted_inside_workspace() {
1235 let workspace = TempDir::new().unwrap();
1236 let policy = crate::fs_access::FsAccessPolicy::workspace_write(workspace.path().into());
1237 let ctx = ToolCtx::new().with_fs_access(policy);
1238 let target = workspace.path().join("nested/deeper/note.txt");
1239 std::fs::create_dir_all(target.parent().unwrap()).unwrap();
1240 let args = ToolArgs {
1241 positional: vec![],
1242 named: vec![
1243 ("path".into(), Value::Path(target.clone())),
1244 ("content".into(), Value::Str("ok".into())),
1245 ],
1246 };
1247 FsWrite.call(args, &ctx).await.unwrap();
1248 assert_eq!(tokio::fs::read_to_string(&target).await.unwrap(), "ok");
1249 }
1250
1251 #[tokio::test]
1252 async fn fs_write_permitted_under_danger_full_access() {
1253 let outside = TempDir::new().unwrap();
1254 let ctx =
1255 ToolCtx::new().with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
1256 let target = outside.path().join("wide.txt");
1257 let args = ToolArgs {
1258 positional: vec![],
1259 named: vec![
1260 ("path".into(), Value::Path(target.clone())),
1261 ("content".into(), Value::Str("go".into())),
1262 ],
1263 };
1264 FsWrite.call(args, &ctx).await.unwrap();
1265 assert!(target.exists());
1266 }
1267
1268 #[tokio::test]
1269 async fn fs_grep_respects_gitignore() {
1270 let dir = TempDir::new().unwrap();
1271 tokio::fs::write(dir.path().join(".ignore"), b"skip.txt\n")
1272 .await
1273 .unwrap();
1274 tokio::fs::write(dir.path().join("skip.txt"), b"needle")
1275 .await
1276 .unwrap();
1277 tokio::fs::write(dir.path().join("keep.txt"), b"needle")
1278 .await
1279 .unwrap();
1280 let ctx = ToolCtx::new();
1281 let args = ToolArgs {
1282 positional: Vec::new(),
1283 named: vec![
1284 ("pattern".into(), Value::Str("needle".into())),
1285 (
1286 "path".into(),
1287 Value::Str(dir.path().to_string_lossy().to_string()),
1288 ),
1289 ],
1290 };
1291 let out = FsGrep.call(args, &ctx).await.unwrap();
1292 let items = match out {
1293 Value::List(v) => v,
1294 _ => panic!("list"),
1295 };
1296 assert_eq!(items.len(), 1, "gitignored file should be skipped");
1297 }
1298}