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