Skip to main content

atman_runtime/tools/
fs.rs

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        // /etc sits outside every workspace and the tempdir whitelist on
1596        // all supported platforms. The boundary check must reject before
1597        // tokio::fs::write is ever invoked, so the file is never touched
1598        // even if the test happened to run as root.
1599        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}