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