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