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 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}