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