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