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