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