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