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