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