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