1use std::path::{Path, PathBuf};
16
17use async_trait::async_trait;
18
19use crate::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
20use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
22
23use super::super::ctx::{ExecContext, ProgressEvent};
24use super::ToolExecutor;
25use super::path_safety::{relative_within, resolve_path_safe};
26
27fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
31 ToolDefinition {
32 name: name.to_string(),
33 description: description.to_string(),
34 input_schema,
35 }
36}
37
38const MAX_READ_AGGREGATE_CHARS: usize = crate::constants::MAX_RESPONSE_CHARS;
44
45pub struct ReadFileTool;
48
49#[async_trait]
50impl ToolExecutor for ReadFileTool {
51 fn name(&self) -> &'static str {
52 "read_file"
53 }
54
55 fn schema(&self) -> ToolDefinition {
56 defn(
57 "read_file",
58 "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory or the call is rejected.",
59 serde_json::json!({
60 "type": "object",
61 "properties": {
62 "path": { "type": "string", "description": "File to read (single)." },
63 "paths": {
64 "type": "array",
65 "items": { "type": "string" },
66 "description": "Multiple files to read sequentially, in order."
67 }
68 },
69 "oneOf": [
70 { "required": ["path"] },
71 { "required": ["paths"] }
72 ]
73 }),
74 )
75 }
76
77 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
78 let paths = match extract_paths(&args) {
79 Ok(p) => p,
80 Err(e) => return ToolOutcome::error(e, 0.0),
81 };
82 if paths.is_empty() {
83 return ToolOutcome::error("read_file requires at least one path", 0.0);
84 }
85
86 let start = std::time::Instant::now();
87 let workdir = ctx.workdir.clone();
88 let mut combined = String::new();
89 let mut any_truncated = false;
90
91 for (idx, raw_path) in paths.iter().enumerate() {
92 tokio::select! {
95 biased;
96 _ = ctx.token.cancelled() => {
97 return ToolOutcome::cancelled();
98 },
99 read = read_one(&workdir, raw_path) => {
100 match read {
101 Ok((content, was_truncated)) => {
102 any_truncated |= was_truncated;
103 if paths.len() > 1 {
104 let _ = ctx.progress.send(ProgressEvent::Status(
105 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
106 )).await;
107 combined.push_str(&format!(
108 "=== {} ===\n{}\n\n",
109 raw_path, content
110 ));
111 } else {
112 combined = content;
113 }
114 },
115 Err(e) => {
116 return ToolOutcome::error(
117 format!("{}: {}", raw_path, e),
118 start.elapsed().as_secs_f64(),
119 );
120 },
121 }
122 },
123 }
124 }
125
126 if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
132 combined = crate::utils::truncate_content(&combined, MAX_READ_AGGREGATE_CHARS);
133 any_truncated = true;
134 }
135
136 let duration_secs = start.elapsed().as_secs_f64();
137 let line_count = combined.lines().count();
138 let byte_count = combined.len();
139 let truncated = any_truncated;
143 ToolOutcome::success(
144 combined,
145 format!(
146 "{} {} read",
147 line_count,
148 plural(line_count, "line", "lines")
149 ),
150 duration_secs,
151 )
152 .with_metadata(ToolRunMetadata {
153 detail: ToolMetadata::ReadFile {
154 paths,
155 line_count,
156 byte_count,
157 truncated,
158 },
159 line_count: Some(line_count),
160 byte_count: Some(byte_count),
161 ..ToolRunMetadata::default()
162 })
163 }
164}
165
166pub struct EditFileTool;
170
171#[async_trait]
172impl ToolExecutor for EditFileTool {
173 fn name(&self) -> &'static str {
174 "edit_file"
175 }
176
177 fn schema(&self) -> ToolDefinition {
178 defn(
179 "edit_file",
180 "Replace exactly one occurrence of `old_string` with `new_string` in the file at `path`. Fails if `old_string` doesn't appear or appears more than once — add surrounding context until the match is unique.",
181 serde_json::json!({
182 "type": "object",
183 "properties": {
184 "path": { "type": "string" },
185 "old_string": { "type": "string", "description": "Exact text to replace. Must appear exactly once." },
186 "new_string": { "type": "string", "description": "Replacement text." }
187 },
188 "required": ["path", "old_string", "new_string"]
189 }),
190 )
191 }
192
193 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
194 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
195 return err("edit_file requires 'path'", 0.0);
196 };
197 let Some(old_string) = args.get("old_string").and_then(|v| v.as_str()) else {
198 return err("edit_file requires 'old_string'", 0.0);
199 };
200 let Some(new_string) = args.get("new_string").and_then(|v| v.as_str()) else {
201 return err("edit_file requires 'new_string'", 0.0);
202 };
203
204 let start = std::time::Instant::now();
205 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
206 Ok(p) => p,
207 Err(e) => return err(&format!("edit_file: {}", e), 0.0),
208 };
209 let rel = match relative_within(&ctx.workdir, raw_path) {
210 Ok(r) => r,
211 Err(e) => return err(&format!("edit_file: {}", e), 0.0),
212 };
213 let pending_action = serde_json::json!({
214 "tool": "edit_file",
215 "args": {
216 "path": raw_path,
217 "old_string": old_string,
218 "new_string": new_string,
219 },
220 "workdir": ctx.workdir.display().to_string(),
221 "turn_id": ctx.turn.0,
222 "call_id": ctx.call_id.0,
223 "task_id": ctx.task_id.clone(),
224 });
225 if let Some(outcome) = mutation_policy_outcome(
226 &ctx,
227 "edit_file",
228 raw_path,
229 std::slice::from_ref(&abs),
230 pending_action,
231 )
232 .await
233 {
234 return outcome;
235 }
236 if ctx.config.safety.checkpoint_on_mutation
237 && let Err(e) = crate::runtime::create_checkpoint_for_task(
238 &ctx.workdir,
239 std::slice::from_ref(&abs),
240 Some(serde_json::json!({
241 "tool": "edit_file",
242 "path": raw_path,
243 })),
244 ctx.task_id.clone(),
245 )
246 {
247 return err(&format!("edit_file checkpoint failed: {}", e), 0.0);
248 }
249 let old_owned = old_string.to_string();
250 let new_owned = new_string.to_string();
251 let workdir = ctx.workdir.clone();
252 let display_path = raw_path.to_string();
253
254 tokio::select! {
255 biased;
256 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
257 result = tokio::task::spawn_blocking(move || edit_blocking(&workdir, &rel, &old_owned, &new_owned)) => {
258 match result {
259 Ok(Ok(edit)) => {
260 let duration_secs = start.elapsed().as_secs_f64();
261 after_file_mutation(&ctx, "edit_file", &display_path);
262 ToolOutcome::success(
263 format!("Edited {} ({} replacement{})",
264 display_path,
265 edit.replacements,
266 if edit.replacements == 1 { "" } else { "s" }),
267 diff_summary(edit.added, edit.removed, duration_secs),
268 duration_secs,
269 )
270 .with_metadata(ToolRunMetadata {
271 detail: ToolMetadata::EditFile {
272 path: display_path,
273 replacements: edit.replacements,
274 },
275 display_diff: Some(edit.display_diff),
276 diff_truncated: edit.truncated,
277 ..ToolRunMetadata::default()
278 })
279 },
280 Ok(Err(e)) => err(&format!("edit_file({}): {}", display_path, e),
281 start.elapsed().as_secs_f64()),
282 Err(e) => err(&format!("edit_file join error: {}", e),
283 start.elapsed().as_secs_f64()),
284 }
285 }
286 }
287 }
288}
289
290pub struct DeleteFileTool;
294
295#[async_trait]
296impl ToolExecutor for DeleteFileTool {
297 fn name(&self) -> &'static str {
298 "delete_file"
299 }
300
301 fn schema(&self) -> ToolDefinition {
302 defn(
303 "delete_file",
304 "Remove a file from disk. Fails on directories — use `execute_command rm -rf` for those.",
305 serde_json::json!({
306 "type": "object",
307 "properties": { "path": { "type": "string" } },
308 "required": ["path"]
309 }),
310 )
311 }
312
313 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
314 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
315 return err("delete_file requires 'path'", 0.0);
316 };
317 let start = std::time::Instant::now();
318 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
319 Ok(p) => p,
320 Err(e) => return err(&format!("delete_file: {}", e), 0.0),
321 };
322 let rel = match relative_within(&ctx.workdir, raw_path) {
323 Ok(r) => r,
324 Err(e) => return err(&format!("delete_file: {}", e), 0.0),
325 };
326 let pending_action = serde_json::json!({
327 "tool": "delete_file",
328 "args": { "path": raw_path },
329 "workdir": ctx.workdir.display().to_string(),
330 "turn_id": ctx.turn.0,
331 "call_id": ctx.call_id.0,
332 "task_id": ctx.task_id.clone(),
333 });
334 if let Some(outcome) = mutation_policy_outcome(
335 &ctx,
336 "delete_file",
337 raw_path,
338 std::slice::from_ref(&abs),
339 pending_action,
340 )
341 .await
342 {
343 return outcome;
344 }
345 if ctx.config.safety.checkpoint_on_mutation
346 && let Err(e) = crate::runtime::create_checkpoint_for_task(
347 &ctx.workdir,
348 std::slice::from_ref(&abs),
349 Some(serde_json::json!({
350 "tool": "delete_file",
351 "path": raw_path,
352 })),
353 ctx.task_id.clone(),
354 )
355 {
356 return err(&format!("delete_file checkpoint failed: {}", e), 0.0);
357 }
358 let display = raw_path.to_string();
359 let workdir = ctx.workdir.clone();
360
361 tokio::select! {
362 biased;
363 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
364 result = tokio::task::spawn_blocking(move || crate::runtime::remove_file_beneath(&workdir, &rel)) => {
365 match result {
366 Ok(Ok(())) => {
367 let duration_secs = start.elapsed().as_secs_f64();
368 after_file_mutation(&ctx, "delete_file", &display);
369 ToolOutcome::success(
370 format!("Deleted {}", display),
371 "file deleted",
372 duration_secs,
373 )
374 .with_metadata(ToolRunMetadata {
375 detail: ToolMetadata::DeleteFile { path: display },
376 ..ToolRunMetadata::default()
377 })
378 },
379 Ok(Err(e)) => err(&format!("delete_file({}): {}", display, e),
380 start.elapsed().as_secs_f64()),
381 Err(e) => err(&format!("delete_file join error: {}", e),
382 start.elapsed().as_secs_f64()),
383 }
384 }
385 }
386 }
387}
388
389pub struct CreateDirectoryTool;
391
392#[async_trait]
393impl ToolExecutor for CreateDirectoryTool {
394 fn name(&self) -> &'static str {
395 "create_directory"
396 }
397
398 fn schema(&self) -> ToolDefinition {
399 defn(
400 "create_directory",
401 "Create a directory (and any missing parents) at the given path.",
402 serde_json::json!({
403 "type": "object",
404 "properties": { "path": { "type": "string" } },
405 "required": ["path"]
406 }),
407 )
408 }
409
410 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
411 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
412 return err("create_directory requires 'path'", 0.0);
413 };
414 let start = std::time::Instant::now();
415 let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
416 Ok(p) => p,
417 Err(e) => return err(&format!("create_directory: {}", e), 0.0),
418 };
419 let rel = match relative_within(&ctx.workdir, raw_path) {
420 Ok(r) => r,
421 Err(e) => return err(&format!("create_directory: {}", e), 0.0),
422 };
423 let pending_action = serde_json::json!({
424 "tool": "create_directory",
425 "args": { "path": raw_path },
426 "workdir": ctx.workdir.display().to_string(),
427 "turn_id": ctx.turn.0,
428 "call_id": ctx.call_id.0,
429 "task_id": ctx.task_id.clone(),
430 });
431 if let Some(outcome) = mutation_policy_outcome(
432 &ctx,
433 "create_directory",
434 raw_path,
435 std::slice::from_ref(&abs),
436 pending_action,
437 )
438 .await
439 {
440 return outcome;
441 }
442 if ctx.config.safety.checkpoint_on_mutation
443 && let Err(e) = crate::runtime::create_checkpoint_for_task(
444 &ctx.workdir,
445 std::slice::from_ref(&abs),
446 Some(serde_json::json!({
447 "tool": "create_directory",
448 "path": raw_path,
449 })),
450 ctx.task_id.clone(),
451 )
452 {
453 return err(&format!("create_directory checkpoint failed: {}", e), 0.0);
454 }
455 let display = raw_path.to_string();
456 let workdir = ctx.workdir.clone();
457
458 tokio::select! {
459 biased;
460 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
461 result = tokio::task::spawn_blocking(move || crate::runtime::create_dir_all_beneath(&workdir, &rel)) => {
462 match result {
463 Ok(Ok(())) => {
464 let duration_secs = start.elapsed().as_secs_f64();
465 after_file_mutation(&ctx, "create_directory", &display);
466 ToolOutcome::success(
467 format!("Created directory {}", display),
468 "directory created",
469 duration_secs,
470 )
471 .with_metadata(ToolRunMetadata {
472 detail: ToolMetadata::CreateDirectory { path: display },
473 ..ToolRunMetadata::default()
474 })
475 },
476 Ok(Err(e)) => err(&format!("create_directory({}): {}", display, e),
477 start.elapsed().as_secs_f64()),
478 Err(e) => err(&format!("create_directory join error: {}", e),
479 start.elapsed().as_secs_f64()),
480 }
481 }
482 }
483 }
484}
485
486pub struct WriteFileTool;
488
489#[async_trait]
490impl ToolExecutor for WriteFileTool {
491 fn name(&self) -> &'static str {
492 "write_file"
493 }
494
495 fn schema(&self) -> ToolDefinition {
496 defn(
497 "write_file",
498 "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Prefer `edit_file` for small targeted changes.",
499 serde_json::json!({
500 "type": "object",
501 "properties": {
502 "path": { "type": "string" },
503 "content": { "type": "string" }
504 },
505 "required": ["path", "content"]
506 }),
507 )
508 }
509
510 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
511 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
512 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
513 };
514 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
515 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
516 };
517
518 let start = std::time::Instant::now();
519 let abs_path = match resolve_path_safe(&ctx.workdir, path) {
520 Ok(p) => p,
521 Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
522 };
523 let rel = match relative_within(&ctx.workdir, path) {
525 Ok(r) => r,
526 Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
527 };
528 let pending_action = serde_json::json!({
529 "tool": "write_file",
530 "args": { "path": path, "content": content },
531 "workdir": ctx.workdir.display().to_string(),
532 "turn_id": ctx.turn.0,
533 "call_id": ctx.call_id.0,
534 "task_id": ctx.task_id.clone(),
535 });
536 if let Some(outcome) = mutation_policy_outcome(
537 &ctx,
538 "write_file",
539 path,
540 std::slice::from_ref(&abs_path),
541 pending_action,
542 )
543 .await
544 {
545 return outcome;
546 }
547 if ctx.config.safety.checkpoint_on_mutation
548 && let Err(e) = crate::runtime::create_checkpoint_for_task(
549 &ctx.workdir,
550 std::slice::from_ref(&abs_path),
551 Some(serde_json::json!({
552 "tool": "write_file",
553 "path": path,
554 })),
555 ctx.task_id.clone(),
556 )
557 {
558 return ToolOutcome::error(format!("write_file checkpoint failed: {}", e), 0.0);
559 }
560 let display_path = path.to_string();
561 let line_count = content.lines().count();
562 let byte_count = content.len();
563 let content = content.to_string();
564 let workdir = ctx.workdir.clone();
565
566 tokio::select! {
567 biased;
568 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
569 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&workdir, &abs_path, &rel, &content)) => {
573 match result {
574 Ok(Ok(write)) => {
575 let duration_secs = start.elapsed().as_secs_f64();
576 after_file_mutation(&ctx, "write_file", &display_path);
577 ToolOutcome::success(
578 format!("Wrote {} ({} lines)", display_path, write.line_count),
579 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
580 duration_secs,
581 )
582 .with_metadata(ToolRunMetadata {
583 detail: ToolMetadata::WriteFile {
584 path: display_path,
585 line_count,
586 byte_count,
587 created: Some(write.created),
588 },
589 line_count: Some(line_count),
590 byte_count: Some(byte_count),
591 display_diff: Some(write.diff.display_diff),
592 diff_truncated: write.diff.truncated,
593 ..ToolRunMetadata::default()
594 })
595 },
596 Ok(Err(e)) => ToolOutcome::error(
597 format!("write_file({}): {}", display_path, e),
598 start.elapsed().as_secs_f64(),
599 ),
600 Err(e) => ToolOutcome::error(
601 format!("write_file join error: {}", e),
602 start.elapsed().as_secs_f64(),
603 ),
604 }
605 }
606 }
607 }
608}
609
610fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
613 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
615 return Ok(vec![p.to_string()]);
616 }
617 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
618 if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
619 return Err(format!(
620 "read_file: too many paths ({}); cap is {} per call — split the request",
621 arr.len(),
622 crate::constants::MAX_BATCH_TOOL_ITEMS
623 ));
624 }
625 let mut out = Vec::with_capacity(arr.len());
626 for v in arr {
627 let Some(s) = v.as_str() else {
628 return Err("read_file 'paths' must be an array of strings".to_string());
629 };
630 out.push(s.to_string());
631 }
632 return Ok(out);
633 }
634 Err("read_file requires 'path' or 'paths'".to_string())
635}
636
637async fn read_one(workdir: &Path, raw: &str) -> std::io::Result<(String, bool)> {
642 resolve_path_safe(workdir, raw)
645 .map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
646 let rel = relative_within(workdir, raw)
650 .map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
651 let workdir = workdir.to_path_buf();
652 let result = tokio::task::spawn_blocking(move || {
653 let file = crate::runtime::open_beneath(&workdir, &rel, crate::runtime::OpenIntent::Read)?;
654 let (data, truncated) = crate::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
658 let mut s = String::from_utf8_lossy(&data).into_owned();
659 if truncated {
660 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
662 s.truncate(cut);
663 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
664 }
665 Ok::<_, std::io::Error>((s, truncated))
666 })
667 .await
668 .map_err(|e| std::io::Error::other(e.to_string()))??;
669 Ok(result)
670}
671
672fn write_one_blocking(workdir: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
680 if let Some(parent) = rel.parent()
681 && !parent.as_os_str().is_empty()
682 {
683 crate::runtime::create_dir_all_beneath(workdir, parent)?;
684 }
685 crate::runtime::write_atomic_beneath(workdir, rel, content.as_bytes())?;
686 Ok(content.lines().count())
687}
688
689struct WriteResult {
690 line_count: usize,
691 created: bool,
692 diff: DisplayDiff,
693}
694
695fn write_with_diff_blocking(
702 workdir: &Path,
703 abs_path: &Path,
704 rel: &Path,
705 content: &str,
706) -> std::io::Result<WriteResult> {
707 let (old_content, created, elide_diff) =
708 match crate::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
709 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
710 Ok((_, true)) => (String::new(), false, true),
712 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
714 Err(_) => (String::new(), false, true),
716 };
717 let diff = if elide_diff {
718 DisplayDiff {
719 display_diff: format!(
720 "[diff preview skipped: existing file exceeds the {}-byte cap]",
721 MAX_FILE_READ_BYTES
722 ),
723 added: 0,
724 removed: 0,
725 truncated: true,
726 }
727 } else {
728 generate_display_diff(&old_content, content)
729 };
730 let line_count = write_one_blocking(workdir, rel, content)?;
731 Ok(WriteResult {
732 line_count,
733 created,
734 diff,
735 })
736}
737
738async fn mutation_policy_outcome(
739 ctx: &ExecContext,
740 tool: &str,
741 path: &str,
742 checkpoint_paths: &[PathBuf],
743 pending_action: serde_json::Value,
744) -> Option<ToolOutcome> {
745 let mut request = crate::runtime::ActionRequest::new(
746 tool,
747 crate::runtime::ToolCategory::Edit,
748 format!("{} {}", tool, path),
749 );
750 request.path = Some(path.to_string());
751 match super::policy_gate::gate(ctx, request, checkpoint_paths, pending_action, true).await {
754 super::policy_gate::Gate::Block(outcome) => Some(outcome),
755 super::policy_gate::Gate::Proceed { .. } => {
756 let _ = crate::runtime::run_plugin_hooks(
757 "before_file_mutation",
758 &serde_json::json!({
759 "task_id": ctx.task_id.clone(),
760 "turn_id": ctx.turn.0,
761 "call_id": ctx.call_id.0,
762 "tool": tool,
763 "path": path,
764 }),
765 );
766 None
767 },
768 }
769}
770
771fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
772 let _ = crate::runtime::run_plugin_hooks(
773 "after_file_mutation",
774 &serde_json::json!({
775 "task_id": ctx.task_id.clone(),
776 "turn_id": ctx.turn.0,
777 "call_id": ctx.call_id.0,
778 "tool": tool,
779 "path": path,
780 }),
781 );
782}
783
784struct EditResult {
785 replacements: usize,
786 display_diff: String,
787 added: usize,
788 removed: usize,
789 truncated: bool,
790}
791
792fn edit_blocking(
798 workdir: &Path,
799 rel: &Path,
800 old_string: &str,
801 new_string: &str,
802) -> std::io::Result<EditResult> {
803 let current = {
804 let mut file =
805 crate::runtime::open_beneath(workdir, rel, crate::runtime::OpenIntent::Read)?;
806 let mut s = String::new();
807 std::io::Read::read_to_string(&mut file, &mut s)?;
808 s
809 };
810 let count = current.matches(old_string).count();
811 if count == 0 {
812 return Err(std::io::Error::other(
813 "old_string not found (is the snippet correct? use read_file to verify)",
814 ));
815 }
816 if count > 1 {
817 return Err(std::io::Error::other(format!(
818 "old_string appears {} times — add more context so the match is unique",
819 count
820 )));
821 }
822 let updated = current.replacen(old_string, new_string, 1);
823 let diff = generate_display_diff(¤t, &updated);
824 crate::runtime::write_atomic_beneath(workdir, rel, updated.as_bytes())?;
825 Ok(EditResult {
826 replacements: 1,
827 display_diff: diff.display_diff,
828 added: diff.added,
829 removed: diff.removed,
830 truncated: diff.truncated,
831 })
832}
833
834fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
835 ToolOutcome::error(msg, duration_secs)
836}
837
838fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
839 if count == 1 { singular } else { plural }
840}
841
842#[derive(Debug, Clone)]
843struct DisplayDiff {
844 display_diff: String,
845 added: usize,
846 removed: usize,
847 truncated: bool,
848}
849
850const DIFF_CONTEXT_LINES: usize = 3;
851const MAX_DISPLAY_DIFF_LINES: usize = 220;
852
853fn generate_display_diff(old: &str, new: &str) -> DisplayDiff {
854 let old_lines: Vec<&str> = old.lines().collect();
855 let new_lines: Vec<&str> = new.lines().collect();
856 let mut prefix = 0usize;
857 let min_len = old_lines.len().min(new_lines.len());
858 while prefix < min_len && old_lines[prefix] == new_lines[prefix] {
859 prefix += 1;
860 }
861
862 let mut suffix = 0usize;
863 while suffix < min_len.saturating_sub(prefix)
864 && old_lines[old_lines.len() - 1 - suffix] == new_lines[new_lines.len() - 1 - suffix]
865 {
866 suffix += 1;
867 }
868
869 let old_changed_end = old_lines.len().saturating_sub(suffix);
870 let new_changed_end = new_lines.len().saturating_sub(suffix);
871 let old_changed = &old_lines[prefix..old_changed_end];
872 let new_changed = &new_lines[prefix..new_changed_end];
873 let added = new_changed.len();
874 let removed = old_changed.len();
875
876 let context_start = prefix.saturating_sub(DIFF_CONTEXT_LINES);
877 let context_end_old = (old_changed_end + DIFF_CONTEXT_LINES).min(old_lines.len());
878 let mut lines = Vec::new();
882
883 let mut truncated = false;
884 let push_line = |line: String, lines: &mut Vec<String>, truncated: &mut bool| {
885 if lines.len() < MAX_DISPLAY_DIFF_LINES {
886 lines.push(line);
887 } else {
888 *truncated = true;
889 }
890 };
891
892 for (idx, line) in old_lines[context_start..prefix].iter().enumerate() {
893 push_line(
894 format!("{:>4} {}", context_start + idx + 1, line),
895 &mut lines,
896 &mut truncated,
897 );
898 }
899 for (idx, line) in old_changed.iter().enumerate() {
900 push_line(
901 format!("{:>4}{}{}", prefix + idx + 1, DIFF_REMOVED_MARKER, line),
902 &mut lines,
903 &mut truncated,
904 );
905 }
906 for (idx, line) in new_changed.iter().enumerate() {
907 push_line(
908 format!("{:>4}{}{}", prefix + idx + 1, DIFF_ADDED_MARKER, line),
909 &mut lines,
910 &mut truncated,
911 );
912 }
913 for (idx, line) in old_lines[old_changed_end..context_end_old]
914 .iter()
915 .enumerate()
916 {
917 push_line(
918 format!("{:>4} {}", old_changed_end + idx + 1, line),
919 &mut lines,
920 &mut truncated,
921 );
922 }
923 if truncated {
924 lines.push(format!(
925 "... diff truncated after {} display lines",
926 MAX_DISPLAY_DIFF_LINES
927 ));
928 }
929
930 DisplayDiff {
931 display_diff: lines.join("\n"),
932 added,
933 removed,
934 truncated,
935 }
936}
937
938fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
939 format!(
940 "Success, +{} -{}, took {}",
941 added,
942 removed,
943 format_duration_for_diff(duration_secs)
944 )
945}
946
947fn format_duration_for_diff(seconds: f64) -> String {
948 if seconds < 1.0 {
949 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
950 } else if seconds < 10.0 {
951 format!("{:.1}s", seconds)
952 } else {
953 format!("{}s", seconds.round() as u64)
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use crate::domain::{ToolCallId, TurnId};
961 use crate::providers::ctx::test_exec_context;
962 use std::fs;
963
964 #[test]
965 fn resolve_path_safe_contains_to_workdir() {
966 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
967 let _ = fs::remove_dir_all(&root);
968 fs::create_dir_all(root.join("sub")).unwrap();
969
970 assert!(resolve_path_safe(&root, "sub").is_ok());
972 assert!(resolve_path_safe(&root, "sub/new.txt").is_ok());
973 let resolved = resolve_path_safe(&root, "sub/new.txt").unwrap();
974 let canon_root = fs::canonicalize(&root).unwrap();
975 assert!(resolved.starts_with(&canon_root));
976
977 assert!(resolve_path_safe(&root, "../escape.txt").is_err());
979 assert!(resolve_path_safe(&root, "../../etc/passwd").is_err());
980 let outside = std::env::temp_dir().join("definitely_outside.txt");
981 assert!(resolve_path_safe(&root, &outside.display().to_string()).is_err());
982
983 let _ = fs::remove_dir_all(&root);
984 }
985
986 fn temp_root(name: &str) -> PathBuf {
987 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
988 let _ = fs::remove_dir_all(&p);
989 fs::create_dir_all(&p).expect("create tmpdir");
990 p
991 }
992
993 #[tokio::test]
994 async fn read_file_returns_content() {
995 let dir = temp_root("read_ok");
996 fs::write(dir.join("a.txt"), "hello").expect("write");
997 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
998
999 let tool = ReadFileTool;
1000 let outcome = tool
1001 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1002 .await;
1003 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1004 assert_eq!(outcome.output(), "hello");
1005 let _ = fs::remove_dir_all(&dir);
1006 }
1007
1008 #[tokio::test]
1009 async fn read_file_missing_path_errors() {
1010 let dir = temp_root("read_missing_path");
1011 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1012 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1013 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1014 let _ = fs::remove_dir_all(&dir);
1015 }
1016
1017 #[tokio::test]
1018 async fn read_file_nonexistent_errors() {
1019 let dir = temp_root("read_nonex");
1020 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1021 let outcome = ReadFileTool
1022 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1023 .await;
1024 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1025 let _ = fs::remove_dir_all(&dir);
1026 }
1027
1028 #[tokio::test]
1029 async fn read_file_with_multiple_paths_joins_contents() {
1030 let dir = temp_root("read_multi");
1031 fs::write(dir.join("a.txt"), "alpha").expect("write");
1032 fs::write(dir.join("b.txt"), "beta").expect("write");
1033 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1034 let outcome = ReadFileTool
1035 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1036 .await;
1037 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1038 let output = outcome.output();
1039 assert!(output.contains("=== a.txt ==="));
1040 assert!(output.contains("alpha"));
1041 assert!(output.contains("=== b.txt ==="));
1042 assert!(output.contains("beta"));
1043 let _ = fs::remove_dir_all(&dir);
1044 }
1045
1046 #[tokio::test]
1047 async fn read_file_multi_aggregate_is_capped() {
1048 let dir = temp_root("read_aggregate_cap");
1051 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1052 fs::write(dir.join("a.txt"), &chunk).expect("write a");
1053 fs::write(dir.join("b.txt"), &chunk).expect("write b");
1054 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1055 let outcome = ReadFileTool
1056 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1057 .await;
1058 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1059 let output = outcome.output();
1060 assert!(
1061 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1062 "combined must be capped, got {} bytes",
1063 output.len()
1064 );
1065 assert!(
1066 output.contains("...[truncated]"),
1067 "expected aggregate truncation marker"
1068 );
1069 match &outcome.metadata.detail {
1070 ToolMetadata::ReadFile { truncated, .. } => {
1071 assert!(*truncated, "aggregate truncation must set truncated")
1072 },
1073 other => panic!("expected ReadFile metadata, got {:?}", other),
1074 }
1075 let _ = fs::remove_dir_all(&dir);
1076 }
1077
1078 #[tokio::test]
1079 async fn write_file_elides_diff_for_oversized_existing_file() {
1080 let dir = temp_root("write_oversized_diff");
1083 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1084 fs::write(dir.join("big.txt"), &big).expect("write fixture");
1085 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1086 let outcome = WriteFileTool
1087 .execute(
1088 serde_json::json!({"path": "big.txt", "content": "small\n"}),
1089 ctx,
1090 )
1091 .await;
1092 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1093 let diff = outcome
1094 .metadata
1095 .display_diff
1096 .as_deref()
1097 .expect("display diff");
1098 assert!(
1099 diff.contains("diff preview skipped"),
1100 "expected elision marker, got: {diff}"
1101 );
1102 assert!(
1103 outcome.metadata.diff_truncated,
1104 "oversized diff must set diff_truncated"
1105 );
1106 match &outcome.metadata.detail {
1107 ToolMetadata::WriteFile { created, .. } => {
1108 assert_eq!(*created, Some(false), "existing file is not 'created'")
1109 },
1110 other => panic!("expected WriteFile metadata, got {:?}", other),
1111 }
1112 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1114 assert_eq!(written, "small\n");
1115 let _ = fs::remove_dir_all(&dir);
1116 }
1117
1118 #[tokio::test]
1119 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1120 let dir = temp_root("read_marker_content");
1124 fs::write(
1125 dir.join("a.txt"),
1126 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1127 )
1128 .expect("write");
1129 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1130
1131 let outcome = ReadFileTool
1132 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1133 .await;
1134 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1135 match &outcome.metadata.detail {
1136 ToolMetadata::ReadFile { truncated, .. } => assert!(
1137 !truncated,
1138 "a file whose content contains the marker must not be flagged truncated"
1139 ),
1140 other => panic!("expected ReadFile metadata, got {:?}", other),
1141 }
1142 let _ = fs::remove_dir_all(&dir);
1143 }
1144
1145 #[tokio::test]
1146 async fn read_file_respects_cancellation() {
1147 let dir = temp_root("read_cancel");
1148 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1153 ctx.token.cancel();
1154 let outcome = ReadFileTool
1155 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1156 .await;
1157 assert!(outcome.was_cancelled());
1158 let _ = fs::remove_dir_all(&dir);
1159 }
1160
1161 #[tokio::test]
1162 async fn write_file_creates_and_counts_lines() {
1163 let dir = temp_root("write_ok");
1164 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1165 let outcome = WriteFileTool
1166 .execute(
1167 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1168 ctx,
1169 )
1170 .await;
1171 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1172 assert!(outcome.output().contains("3 lines"));
1173 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1174 assert!(written.contains("line1"));
1175 let _ = fs::remove_dir_all(&dir);
1176 }
1177
1178 #[tokio::test]
1179 async fn write_file_new_file_records_added_display_diff() {
1180 let dir = temp_root("write_new_diff");
1181 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1182 let outcome = WriteFileTool
1183 .execute(
1184 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1185 ctx,
1186 )
1187 .await;
1188 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1189 let diff = outcome
1190 .metadata
1191 .display_diff
1192 .as_deref()
1193 .expect("display diff");
1194 assert!(diff.contains("+ alpha"));
1195 assert!(diff.contains("+ beta"));
1196 assert!(
1198 !diff.contains("@@"),
1199 "diff should not carry hunk headers: {diff}"
1200 );
1201 assert!(!diff.contains("/dev/null"));
1202 let _ = fs::remove_dir_all(&dir);
1203 }
1204
1205 #[tokio::test]
1206 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1207 let dir = temp_root("write_existing_diff");
1208 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1209 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1210 let outcome = WriteFileTool
1211 .execute(
1212 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1213 ctx,
1214 )
1215 .await;
1216 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1217 let diff = outcome
1218 .metadata
1219 .display_diff
1220 .as_deref()
1221 .expect("display diff");
1222 assert!(diff.contains("- old"));
1223 assert!(diff.contains("+ new"));
1224 let _ = fs::remove_dir_all(&dir);
1225 }
1226
1227 #[tokio::test]
1228 async fn edit_file_records_display_diff() {
1229 let dir = temp_root("edit_diff");
1230 fs::write(dir.join("main.py"), "alpha\nold\nomega\n").expect("write fixture");
1231 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1232 let outcome = EditFileTool
1233 .execute(
1234 serde_json::json!({
1235 "path": "main.py",
1236 "old_string": "old",
1237 "new_string": "new",
1238 }),
1239 ctx,
1240 )
1241 .await;
1242 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1243 let diff = outcome
1244 .metadata
1245 .display_diff
1246 .as_deref()
1247 .expect("display diff");
1248 assert!(diff.contains("- old"));
1249 assert!(diff.contains("+ new"));
1250 assert!(
1251 !diff.contains("@@"),
1252 "diff should not carry hunk headers: {diff}"
1253 );
1254 let _ = fs::remove_dir_all(&dir);
1255 }
1256
1257 #[tokio::test]
1258 async fn write_file_creates_parent_dirs() {
1259 let dir = temp_root("write_parents");
1260 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1261 let outcome = WriteFileTool
1262 .execute(
1263 serde_json::json!({
1264 "path": "sub/nested/out.txt",
1265 "content": "deep",
1266 }),
1267 ctx,
1268 )
1269 .await;
1270 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1271 assert!(dir.join("sub/nested/out.txt").exists());
1272 let _ = fs::remove_dir_all(&dir);
1273 }
1274
1275 #[tokio::test]
1276 async fn write_file_missing_content_errors() {
1277 let dir = temp_root("write_missing");
1278 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1279 let outcome = WriteFileTool
1280 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1281 .await;
1282 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1283 let _ = fs::remove_dir_all(&dir);
1284 }
1285
1286 #[tokio::test]
1292 async fn read_file_rejects_absolute_path_outside_workdir() {
1293 let dir = temp_root("read_abs_escape");
1294 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1295 let outcome = ReadFileTool
1297 .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1298 .await;
1299 let error = outcome.error_message().expect("expected error");
1300 assert!(
1301 error.contains("outside the project"),
1302 "expected security reject, got: {}",
1303 error
1304 );
1305 let _ = fs::remove_dir_all(&dir);
1306 }
1307
1308 #[tokio::test]
1310 async fn read_file_accepts_absolute_path_inside_workdir() {
1311 let dir = temp_root("read_abs_inside");
1312 let file = dir.join("hello.txt");
1313 fs::write(&file, "ok").expect("write fixture");
1314 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1315 let outcome = ReadFileTool
1316 .execute(
1317 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1318 ctx,
1319 )
1320 .await;
1321 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1322 let _ = fs::remove_dir_all(&dir);
1323 }
1324
1325 #[tokio::test]
1329 async fn write_file_rejects_relative_parent_escape() {
1330 let dir = temp_root("write_dotdot_escape");
1331 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1332 let outcome = WriteFileTool
1333 .execute(
1334 serde_json::json!({
1335 "path": "../escape.txt",
1336 "content": "should not write",
1337 }),
1338 ctx,
1339 )
1340 .await;
1341 let error = outcome.error_message().expect("expected error");
1342 assert!(
1343 error.contains("outside the project"),
1344 "expected security reject, got: {}",
1345 error
1346 );
1347 let _ = fs::remove_dir_all(&dir);
1348 }
1349
1350 #[tokio::test]
1354 async fn create_directory_rejects_absolute_path_outside_workdir() {
1355 let dir = temp_root("mkdir_abs_escape");
1356 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1357 let outcome = CreateDirectoryTool
1358 .execute(
1359 serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1360 ctx,
1361 )
1362 .await;
1363 let error = outcome.error_message().expect("expected error");
1364 assert!(
1365 error.contains("outside the project"),
1366 "expected security reject, got: {}",
1367 error
1368 );
1369 let _ = fs::remove_dir_all(&dir);
1370 }
1371}