1use mermaid_domain::ProgressEvent;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19
20use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21use mermaid_model::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25use super::path_safety::{
26 AllowedRoots, ResolvedInRoot, relative_within, resolve_in_roots, resolve_path_within,
27};
28
29fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
33 ToolDefinition {
34 name: name.to_string(),
35 description: description.to_string(),
36 input_schema,
37 }
38}
39
40const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
46
47const READ_DEDUP_CAP: usize = 128;
51
52struct ReadDedupEntry {
55 scope: String,
56 path: String,
57 turn: u64,
58 hash: [u8; 32],
59 line_count: usize,
60}
61
62static READ_DEDUP: std::sync::OnceLock<
72 std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
73> = std::sync::OnceLock::new();
74
75fn read_dedup_scope(ctx: &ExecContext) -> String {
80 format!(
81 "{}|{}|{}",
82 ctx.session_id.as_deref().unwrap_or(""),
83 ctx.task_id.as_deref().unwrap_or(""),
84 ctx.workdir.display(),
85 )
86}
87
88fn duplicate_read_note(ctx: &ExecContext, path: &str, content: &str) -> Option<String> {
93 use sha2::{Digest, Sha256};
94 let hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
95 let line_count = content.lines().count();
96 let scope = read_dedup_scope(ctx);
97 let mut store = READ_DEDUP
98 .get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()))
99 .lock()
100 .unwrap_or_else(std::sync::PoisonError::into_inner);
101 if let Some(entry) = store
102 .iter_mut()
103 .find(|e| e.scope == scope && e.path == path)
104 {
105 let identical_this_turn = entry.turn == ctx.turn.0 && entry.hash == hash;
106 entry.turn = ctx.turn.0;
107 entry.hash = hash;
108 entry.line_count = line_count;
109 return identical_this_turn.then(|| {
110 format!(
111 "{path}: unchanged since your read earlier this turn — the full \
112 content ({line_count} lines) is already in this turn's tool \
113 results; reuse it. A read after the file changes, or in a later \
114 turn, returns the full content again."
115 )
116 });
117 }
118 store.push_back(ReadDedupEntry {
119 scope,
120 path: path.to_string(),
121 turn: ctx.turn.0,
122 hash,
123 line_count,
124 });
125 if store.len() > READ_DEDUP_CAP {
126 store.pop_front();
127 }
128 None
129}
130
131pub struct ReadFileTool;
134
135#[async_trait]
136impl ToolExecutor for ReadFileTool {
137 fn name(&self) -> &'static str {
138 "read_file"
139 }
140
141 fn schema(&self) -> ToolDefinition {
142 defn(
143 "read_file",
144 "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory, the session scratchpad, or the memory directories, or the call is rejected.",
145 serde_json::json!({
146 "type": "object",
147 "properties": {
148 "path": { "type": "string", "description": "File to read (single)." },
149 "paths": {
150 "type": "array",
151 "items": { "type": "string" },
152 "description": "Multiple files to read sequentially, in order."
153 }
154 },
155 "oneOf": [
156 { "required": ["path"] },
157 { "required": ["paths"] }
158 ]
159 }),
160 )
161 }
162
163 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
164 let paths = match extract_paths(&args) {
165 Ok(p) => p,
166 Err(e) => return ToolOutcome::error(e, 0.0),
167 };
168 if paths.is_empty() {
169 return ToolOutcome::error("read_file requires at least one path", 0.0);
170 }
171
172 let start = std::time::Instant::now();
173 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
174 let mut combined = String::new();
175 let mut any_truncated = false;
176
177 for (idx, raw_path) in paths.iter().enumerate() {
178 tokio::select! {
181 biased;
182 _ = ctx.token.cancelled() => {
183 return ToolOutcome::cancelled();
184 },
185 read = read_one(&roots, raw_path) => {
186 match read {
187 Ok((content, was_truncated)) => {
188 let (content, was_truncated) =
194 duplicate_read_note(&ctx, raw_path, &content)
195 .map_or((content, was_truncated), |note| (note, false));
196 any_truncated |= was_truncated;
197 if paths.len() > 1 {
198 let _ = ctx.progress.send(ProgressEvent::Status(
199 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
200 )).await;
201 combined.push_str(&format!(
202 "=== {raw_path} ===\n{content}\n\n"
203 ));
204 } else {
205 combined = content;
206 }
207 },
208 Err(e) => {
209 return ToolOutcome::error(
210 format!("{raw_path}: {e}"),
211 start.elapsed().as_secs_f64(),
212 );
213 },
214 }
215 },
216 }
217 }
218
219 if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
225 combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
226 any_truncated = true;
227 }
228
229 let duration_secs = start.elapsed().as_secs_f64();
230 let line_count = combined.lines().count();
231 let byte_count = combined.len();
232 let truncated = any_truncated;
236 ToolOutcome::success(
237 combined,
238 format!(
239 "{} {} read",
240 line_count,
241 plural(line_count, "line", "lines")
242 ),
243 duration_secs,
244 )
245 .with_metadata(ToolRunMetadata {
246 detail: ToolMetadata::ReadFile {
247 paths,
248 line_count,
249 byte_count,
250 truncated,
251 },
252 line_count: Some(line_count),
253 byte_count: Some(byte_count),
254 ..ToolRunMetadata::default()
255 })
256 }
257}
258
259pub struct DeleteFileTool;
263
264#[async_trait]
265impl ToolExecutor for DeleteFileTool {
266 fn name(&self) -> &'static str {
267 "delete_file"
268 }
269
270 fn schema(&self) -> ToolDefinition {
271 defn(
272 "delete_file",
273 "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.",
274 serde_json::json!({
275 "type": "object",
276 "properties": { "path": { "type": "string" } },
277 "required": ["path"]
278 }),
279 )
280 }
281
282 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
283 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
284 return err("delete_file requires 'path'", 0.0);
285 };
286 let start = std::time::Instant::now();
287 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
288 let ResolvedInRoot {
289 abs,
290 rel,
291 root,
292 in_scratchpad,
293 } = match resolve_in_roots(&roots, raw_path) {
294 Ok(r) => r,
295 Err(e) => return err(&format!("delete_file: {e}"), 0.0),
296 };
297 let pending_action = serde_json::json!({
298 "tool": "delete_file",
299 "args": { "path": raw_path },
300 "workdir": ctx.workdir.display().to_string(),
301 "turn_id": ctx.turn.0,
302 "call_id": ctx.call_id.0,
303 "task_id": ctx.task_id.clone(),
304 });
305 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
306 &ctx,
307 "delete_file",
308 raw_path,
309 std::slice::from_ref(&abs),
310 pending_action,
311 in_scratchpad,
312 )
313 .await
314 {
315 return *outcome;
316 }
317 let _write_guard = tokio::select! {
322 biased;
323 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
324 g = super::path_lock::lock_path(&abs) => g,
325 };
326 if ctx.config.safety.checkpoint_on_mutation
329 && !in_scratchpad
330 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
331 &ctx.workdir,
332 std::slice::from_ref(&abs),
333 Some(serde_json::json!({
334 "tool": "delete_file",
335 "path": raw_path,
336 })),
337 ctx.checkpoint_origin(),
338 )
339 {
340 return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
341 }
342 let display = raw_path.to_string();
343
344 tokio::select! {
345 biased;
346 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
347 result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &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, inside the project directory or the session scratchpad.",
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 roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
399 let ResolvedInRoot {
400 abs,
401 rel,
402 root,
403 in_scratchpad,
404 } = match resolve_in_roots(&roots, raw_path) {
405 Ok(r) => r,
406 Err(e) => return err(&format!("create_directory: {e}"), 0.0),
407 };
408 let pending_action = serde_json::json!({
409 "tool": "create_directory",
410 "args": { "path": raw_path },
411 "workdir": ctx.workdir.display().to_string(),
412 "turn_id": ctx.turn.0,
413 "call_id": ctx.call_id.0,
414 "task_id": ctx.task_id.clone(),
415 });
416 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
417 &ctx,
418 "create_directory",
419 raw_path,
420 std::slice::from_ref(&abs),
421 pending_action,
422 in_scratchpad,
423 )
424 .await
425 {
426 return *outcome;
427 }
428 let _write_guard = tokio::select! {
431 biased;
432 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
433 g = super::path_lock::lock_path(&abs) => g,
434 };
435 if ctx.config.safety.checkpoint_on_mutation
437 && !in_scratchpad
438 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
439 &ctx.workdir,
440 std::slice::from_ref(&abs),
441 Some(serde_json::json!({
442 "tool": "create_directory",
443 "path": raw_path,
444 })),
445 ctx.checkpoint_origin(),
446 )
447 {
448 return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
449 }
450 let display = raw_path.to_string();
451
452 tokio::select! {
453 biased;
454 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
455 result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
456 match result {
457 Ok(Ok(())) => {
458 let duration_secs = start.elapsed().as_secs_f64();
459 after_file_mutation(&ctx, "create_directory", &display);
460 ToolOutcome::success(
461 format!("Created directory {display}"),
462 "directory created",
463 duration_secs,
464 )
465 .with_metadata(ToolRunMetadata {
466 detail: ToolMetadata::CreateDirectory { path: display },
467 ..ToolRunMetadata::default()
468 })
469 },
470 Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
471 start.elapsed().as_secs_f64()),
472 Err(e) => err(&format!("create_directory join error: {e}"),
473 start.elapsed().as_secs_f64()),
474 }
475 }
476 }
477 }
478}
479
480pub struct WriteFileTool;
482
483#[expect(
484 clippy::too_many_lines,
485 reason = "predates the lint; see .github/baselines/expect_budget.txt"
486)]
487#[async_trait]
488impl ToolExecutor for WriteFileTool {
489 fn name(&self) -> &'static str {
490 "write_file"
491 }
492
493 fn schema(&self) -> ToolDefinition {
494 defn(
495 "write_file",
496 "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths must resolve inside the project directory or the session scratchpad. Prefer `apply_patch` for small targeted changes.",
497 serde_json::json!({
498 "type": "object",
499 "properties": {
500 "path": { "type": "string" },
501 "content": { "type": "string" }
502 },
503 "required": ["path", "content"]
504 }),
505 )
506 }
507
508 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
509 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
510 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
511 };
512 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
513 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
514 };
515
516 let start = std::time::Instant::now();
517 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
518 let ResolvedInRoot {
521 abs: abs_path,
522 rel,
523 root,
524 in_scratchpad,
525 } = match resolve_in_roots(&roots, path) {
526 Ok(r) => r,
527 Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
528 };
529 let pending_action = serde_json::json!({
530 "tool": "write_file",
531 "args": { "path": path, "content": content },
532 "workdir": ctx.workdir.display().to_string(),
533 "turn_id": ctx.turn.0,
534 "call_id": ctx.call_id.0,
535 "task_id": ctx.task_id.clone(),
536 });
537 let plan_write = match mutation_policy_outcome(
538 &ctx,
539 "write_file",
540 path,
541 std::slice::from_ref(&abs_path),
542 pending_action,
543 in_scratchpad,
544 )
545 .await
546 {
547 MutationGate::Blocked(outcome) => return *outcome,
548 MutationGate::Proceed { plan_write } => plan_write,
549 };
550 let _write_guard = tokio::select! {
555 biased;
556 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
557 g = super::path_lock::lock_path(&abs_path) => g,
558 };
559 if ctx.config.safety.checkpoint_on_mutation
561 && !in_scratchpad
562 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
563 &ctx.workdir,
564 std::slice::from_ref(&abs_path),
565 Some(serde_json::json!({
566 "tool": "write_file",
567 "path": path,
568 })),
569 ctx.checkpoint_origin(),
570 )
571 {
572 return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
573 }
574 let display_path = path.to_string();
575 let line_count = content.lines().count();
576 let byte_count = content.len();
577 let content = content.to_string();
578
579 tokio::select! {
580 biased;
581 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
582 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
586 match result {
587 Ok(Ok(write)) => {
588 let duration_secs = start.elapsed().as_secs_f64();
589 after_file_mutation(&ctx, "write_file", &display_path);
590 ToolOutcome::success(
591 format!("Wrote {} ({} lines)", display_path, write.line_count),
592 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
593 duration_secs,
594 )
595 .with_metadata(ToolRunMetadata {
596 detail: ToolMetadata::WriteFile {
597 path: display_path,
598 line_count,
599 byte_count,
600 created: Some(write.created),
601 },
602 line_count: Some(line_count),
603 byte_count: Some(byte_count),
604 display_diff: Some(write.diff.display_diff),
605 diff_truncated: write.diff.truncated,
606 lines_added: write.diff.added,
607 lines_removed: write.diff.removed,
608 plan_file_written: plan_write,
609 ..ToolRunMetadata::default()
610 })
611 },
612 Ok(Err(e)) => ToolOutcome::error(
613 format!("write_file({display_path}): {e}"),
614 start.elapsed().as_secs_f64(),
615 ),
616 Err(e) => ToolOutcome::error(
617 format!("write_file join error: {e}"),
618 start.elapsed().as_secs_f64(),
619 ),
620 }
621 }
622 }
623 }
624}
625
626fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
629 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
631 reject_web_url(p)?;
632 return Ok(vec![p.to_string()]);
633 }
634 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
635 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
636 return Err(format!(
637 "read_file: too many paths ({}); cap is {} per call — split the request",
638 arr.len(),
639 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
640 ));
641 }
642 let mut out = Vec::with_capacity(arr.len());
643 for v in arr {
644 let Some(s) = v.as_str() else {
645 return Err("read_file 'paths' must be an array of strings".to_string());
646 };
647 reject_web_url(s)?;
648 out.push(s.to_string());
649 }
650 return Ok(out);
651 }
652 Err("read_file requires 'path' or 'paths'".to_string())
653}
654
655fn reject_web_url(path: &str) -> Result<(), String> {
661 let head: String = path
662 .trim_start()
663 .chars()
664 .take(8)
665 .collect::<String>()
666 .to_ascii_lowercase();
667 if head.starts_with("http://") || head.starts_with("https://") {
668 return Err(format!(
669 "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
670 ));
671 }
672 Ok(())
673}
674
675fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
683 if !Path::new(raw).is_absolute() {
684 return None;
685 }
686 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
687 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
688 && let Ok(rel) = relative_within(&root, raw)
689 {
690 return Some((root, rel));
691 }
692 }
693 None
694}
695
696async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
701 let ResolvedInRoot { rel, root, .. } = match resolve_in_roots(roots, raw) {
707 Ok(resolved) => resolved,
708 Err(msg) => {
709 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
710 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
711 ResolvedInRoot {
712 abs: root.join(&rel),
713 rel,
714 root,
715 in_scratchpad: false,
716 }
717 },
718 };
719 let result = tokio::task::spawn_blocking(move || {
720 let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
721 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
725 let mut s = String::from_utf8_lossy(&data).into_owned();
726 if truncated {
727 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
729 s.truncate(cut);
730 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
731 }
732 Ok::<_, std::io::Error>((s, truncated))
733 })
734 .await
735 .map_err(|e| std::io::Error::other(e.to_string()))??;
736 Ok(result)
737}
738
739fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
748 if let Some(parent) = rel.parent()
749 && !parent.as_os_str().is_empty()
750 {
751 mermaid_runtime::create_dir_all_beneath(root, parent)?;
752 }
753 mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
754 Ok(content.lines().count())
755}
756
757struct WriteResult {
758 line_count: usize,
759 created: bool,
760 diff: mermaid_model::diff::DisplayDiff,
761}
762
763fn write_with_diff_blocking(
770 root: &Path,
771 abs_path: &Path,
772 rel: &Path,
773 content: &str,
774) -> std::io::Result<WriteResult> {
775 let (old_content, created, elide_diff) =
776 match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
777 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
778 Ok((_, true)) => (String::new(), false, true),
780 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
782 Err(_) => (String::new(), false, true),
784 };
785 let diff = if elide_diff {
786 mermaid_model::diff::DisplayDiff {
787 display_diff: format!(
788 "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
789 ),
790 added: 0,
791 removed: 0,
792 truncated: true,
793 }
794 } else {
795 mermaid_model::diff::generate_display_diff(&old_content, content)
796 };
797 let line_count = write_one_blocking(root, rel, content)?;
798 Ok(WriteResult {
799 line_count,
800 created,
801 diff,
802 })
803}
804
805pub(super) enum MutationGate {
818 Blocked(Box<ToolOutcome>),
820 Proceed {
821 plan_write: bool,
822 },
823}
824
825pub(super) async fn mutation_policy_outcome(
826 ctx: &ExecContext,
827 tool: &str,
828 path: &str,
829 checkpoint_paths: &[PathBuf],
830 pending_action: serde_json::Value,
831 scratch_contained: bool,
832) -> MutationGate {
833 let mut request = mermaid_runtime::ActionRequest::new(
834 tool,
835 mermaid_runtime::ToolCategory::Edit,
836 format!("{tool} {path}"),
837 );
838 request.path = Some(path.to_string());
839 match super::policy_gate::gate(
842 ctx,
843 request,
844 checkpoint_paths,
845 pending_action,
846 true,
847 scratch_contained,
848 )
849 .await
850 {
851 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
852 super::policy_gate::Gate::Proceed { plan_write, .. } => {
853 let _ = mermaid_runtime::run_plugin_hooks(
854 "before_file_mutation",
855 &serde_json::json!({
856 "task_id": ctx.task_id.clone(),
857 "turn_id": ctx.turn.0,
858 "call_id": ctx.call_id.0,
859 "tool": tool,
860 "path": path,
861 }),
862 );
863 MutationGate::Proceed { plan_write }
864 },
865 }
866}
867
868pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
869 let _ = mermaid_runtime::run_plugin_hooks(
870 "after_file_mutation",
871 &serde_json::json!({
872 "task_id": ctx.task_id.clone(),
873 "turn_id": ctx.turn.0,
874 "call_id": ctx.call_id.0,
875 "tool": tool,
876 "path": path,
877 }),
878 );
879}
880
881fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
882 ToolOutcome::error(msg, duration_secs)
883}
884
885fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
886 if count == 1 { singular } else { plural }
887}
888
889pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
890 format!(
891 "+{} -{}, took {}",
892 added,
893 removed,
894 format_duration_for_diff(duration_secs)
895 )
896}
897
898fn format_duration_for_diff(seconds: f64) -> String {
899 if seconds < 1.0 {
900 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
901 } else if seconds < 10.0 {
902 format!("{seconds:.1}s")
903 } else {
904 format!("{}s", seconds.round() as u64)
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use crate::providers::ctx::test_exec_context;
912 use mermaid_domain::{ToolCallId, TurnId};
913 use std::fs;
914
915 #[tokio::test]
921 async fn read_file_resolves_memory_roots_read_only() {
922 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
923 let _ = fs::remove_dir_all(&base);
924 let repo = base.join("repo");
925 let workdir = repo.join("src");
926 fs::create_dir_all(&workdir).unwrap();
927 fs::create_dir_all(repo.join(".git")).unwrap();
928 let mem_dir = repo.join(".mermaid").join("memory");
929 fs::create_dir_all(&mem_dir).unwrap();
930 let fact = mem_dir.join("fact.md");
931 fs::write(&fact, "the fact body").unwrap();
932
933 let roots = AllowedRoots::new(&workdir, None);
934 let (content, truncated) = read_one(&roots, fact.to_str().unwrap()).await.unwrap();
935 assert!(!truncated);
936 assert_eq!(content, "the fact body");
937
938 let stray = base.join("stray.txt");
941 fs::write(&stray, "nope").unwrap();
942 assert!(read_one(&roots, stray.to_str().unwrap()).await.is_err());
943
944 let _ = fs::remove_dir_all(&base);
945 }
946
947 #[test]
948 fn resolve_in_roots_contains_to_workdir() {
949 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
950 let _ = fs::remove_dir_all(&root);
951 fs::create_dir_all(root.join("sub")).unwrap();
952 let roots = AllowedRoots::new(&root, None);
953
954 assert!(resolve_in_roots(&roots, "sub").is_ok());
956 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
957 let canon_root = fs::canonicalize(&root).unwrap();
958 assert!(resolved.abs.starts_with(&canon_root));
959
960 assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
962 assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
963 let outside = std::env::temp_dir().join("definitely_outside.txt");
964 assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
965
966 let _ = fs::remove_dir_all(&root);
967 }
968
969 fn temp_root(name: &str) -> PathBuf {
970 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
971 let _ = fs::remove_dir_all(&p);
972 fs::create_dir_all(&p).expect("create tmpdir");
973 p
974 }
975
976 #[tokio::test]
977 async fn read_file_returns_content() {
978 let dir = temp_root("read_ok");
979 fs::write(dir.join("a.txt"), "hello").expect("write");
980 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
981
982 let tool = ReadFileTool;
983 let outcome = tool
984 .execute(serde_json::json!({"path": "a.txt"}), ctx)
985 .await;
986 assert!(outcome.is_success(), "expected success: {outcome:?}");
987 assert_eq!(outcome.output(), "hello");
988 let _ = fs::remove_dir_all(&dir);
989 }
990
991 #[tokio::test]
992 async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
993 let dir = temp_root("read_url");
998 fs::write(dir.join("a.txt"), "hello").expect("write");
999 for args in [
1000 serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1001 serde_json::json!({"path": "HTTP://example.com/x"}),
1002 serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1003 ] {
1004 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1005 let outcome = ReadFileTool.execute(args, ctx).await;
1006 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1007 let msg = outcome.error_message().unwrap_or_default();
1008 assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1009 }
1010 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1011 let outcome = ReadFileTool
1012 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1013 .await;
1014 assert!(outcome.is_success(), "plain local reads must still work");
1015 let _ = fs::remove_dir_all(&dir);
1016 }
1017
1018 #[tokio::test]
1019 async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1020 let dir = temp_root("read_dedup");
1021 fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1022
1023 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1025 let outcome = ReadFileTool
1026 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1027 .await;
1028 assert_eq!(outcome.output(), "line one\nline two");
1029
1030 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1033 let outcome = ReadFileTool
1034 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1035 .await;
1036 assert!(outcome.is_success());
1037 assert!(
1038 outcome
1039 .output()
1040 .contains("unchanged since your read earlier this turn"),
1041 "{}",
1042 outcome.output()
1043 );
1044 assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1045 assert!(
1046 !outcome.output().contains("line two"),
1047 "the body must not repeat: {}",
1048 outcome.output()
1049 );
1050
1051 fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1057 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1058 let outcome = ReadFileTool
1059 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1060 .await;
1061 assert_eq!(
1062 outcome.output(),
1063 "line one\nline two\nline three",
1064 "a changed file must read in full"
1065 );
1066
1067 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1069 let outcome = ReadFileTool
1070 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1071 .await;
1072 assert!(
1073 outcome.output().contains("unchanged since"),
1074 "{}",
1075 outcome.output()
1076 );
1077
1078 let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1082 let outcome = ReadFileTool
1083 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1084 .await;
1085 assert_eq!(outcome.output(), "line one\nline two\nline three");
1086
1087 let _ = fs::remove_dir_all(&dir);
1088 }
1089
1090 #[tokio::test]
1091 async fn read_file_missing_path_errors() {
1092 let dir = temp_root("read_missing_path");
1093 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1094 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1095 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1096 let _ = fs::remove_dir_all(&dir);
1097 }
1098
1099 #[tokio::test]
1100 async fn read_file_nonexistent_errors() {
1101 let dir = temp_root("read_nonex");
1102 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1103 let outcome = ReadFileTool
1104 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1105 .await;
1106 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1107 let _ = fs::remove_dir_all(&dir);
1108 }
1109
1110 #[tokio::test]
1111 async fn read_file_with_multiple_paths_joins_contents() {
1112 let dir = temp_root("read_multi");
1113 fs::write(dir.join("a.txt"), "alpha").expect("write");
1114 fs::write(dir.join("b.txt"), "beta").expect("write");
1115 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1116 let outcome = ReadFileTool
1117 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1118 .await;
1119 assert!(outcome.is_success(), "expected success: {outcome:?}");
1120 let output = outcome.output();
1121 assert!(output.contains("=== a.txt ==="));
1122 assert!(output.contains("alpha"));
1123 assert!(output.contains("=== b.txt ==="));
1124 assert!(output.contains("beta"));
1125 let _ = fs::remove_dir_all(&dir);
1126 }
1127
1128 #[tokio::test]
1129 async fn read_file_multi_aggregate_is_capped() {
1130 let dir = temp_root("read_aggregate_cap");
1133 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1134 fs::write(dir.join("a.txt"), &chunk).expect("write a");
1135 fs::write(dir.join("b.txt"), &chunk).expect("write b");
1136 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1137 let outcome = ReadFileTool
1138 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1139 .await;
1140 assert!(outcome.is_success(), "expected success: {outcome:?}");
1141 let output = outcome.output();
1142 assert!(
1143 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1144 "combined must be capped, got {} bytes",
1145 output.len()
1146 );
1147 assert!(
1148 output.contains("elided"),
1149 "expected aggregate head+tail elision marker"
1150 );
1151 match &outcome.metadata.detail {
1152 ToolMetadata::ReadFile { truncated, .. } => {
1153 assert!(*truncated, "aggregate truncation must set truncated")
1154 },
1155 other => panic!("expected ReadFile metadata, got {other:?}"),
1156 }
1157 let _ = fs::remove_dir_all(&dir);
1158 }
1159
1160 #[tokio::test]
1161 async fn write_file_elides_diff_for_oversized_existing_file() {
1162 let dir = temp_root("write_oversized_diff");
1165 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1166 fs::write(dir.join("big.txt"), &big).expect("write fixture");
1167 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1168 let outcome = WriteFileTool
1169 .execute(
1170 serde_json::json!({"path": "big.txt", "content": "small\n"}),
1171 ctx,
1172 )
1173 .await;
1174 assert!(outcome.is_success(), "expected success: {outcome:?}");
1175 let diff = outcome
1176 .metadata
1177 .display_diff
1178 .as_deref()
1179 .expect("display diff");
1180 assert!(
1181 diff.contains("diff preview skipped"),
1182 "expected elision marker, got: {diff}"
1183 );
1184 assert!(
1185 outcome.metadata.diff_truncated,
1186 "oversized diff must set diff_truncated"
1187 );
1188 match &outcome.metadata.detail {
1189 ToolMetadata::WriteFile { created, .. } => {
1190 assert_eq!(*created, Some(false), "existing file is not 'created'")
1191 },
1192 other => panic!("expected WriteFile metadata, got {other:?}"),
1193 }
1194 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1196 assert_eq!(written, "small\n");
1197 let _ = fs::remove_dir_all(&dir);
1198 }
1199
1200 #[tokio::test]
1201 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1202 let dir = temp_root("read_marker_content");
1206 fs::write(
1207 dir.join("a.txt"),
1208 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1209 )
1210 .expect("write");
1211 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1212
1213 let outcome = ReadFileTool
1214 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1215 .await;
1216 assert!(outcome.is_success(), "expected success: {outcome:?}");
1217 match &outcome.metadata.detail {
1218 ToolMetadata::ReadFile { truncated, .. } => assert!(
1219 !truncated,
1220 "a file whose content contains the marker must not be flagged truncated"
1221 ),
1222 other => panic!("expected ReadFile metadata, got {other:?}"),
1223 }
1224 let _ = fs::remove_dir_all(&dir);
1225 }
1226
1227 #[tokio::test]
1228 async fn read_file_respects_cancellation() {
1229 let dir = temp_root("read_cancel");
1230 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1235 ctx.token.cancel();
1236 let outcome = ReadFileTool
1237 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1238 .await;
1239 assert!(outcome.was_cancelled());
1240 let _ = fs::remove_dir_all(&dir);
1241 }
1242
1243 #[tokio::test]
1244 async fn write_file_creates_and_counts_lines() {
1245 let dir = temp_root("write_ok");
1246 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1247 let outcome = WriteFileTool
1248 .execute(
1249 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1250 ctx,
1251 )
1252 .await;
1253 assert!(outcome.is_success(), "expected success: {outcome:?}");
1254 assert!(outcome.output().contains("3 lines"));
1255 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1256 assert!(written.contains("line1"));
1257 let _ = fs::remove_dir_all(&dir);
1258 }
1259
1260 #[tokio::test]
1261 async fn concurrent_write_file_same_path_serializes_cleanly() {
1262 let dir = temp_root("write_race");
1266 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1267 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1268 let a = "AAAA\nAAAA\n";
1269 let b = "BBBB\nBBBB\n";
1270 let (o1, o2) = tokio::join!(
1271 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1272 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1273 );
1274 assert!(o1.is_success(), "first write failed: {o1:?}");
1275 assert!(o2.is_success(), "second write failed: {o2:?}");
1276 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1277 assert!(
1278 final_content == a || final_content == b,
1279 "file must be exactly one clean write, got {final_content:?}"
1280 );
1281 let _ = fs::remove_dir_all(&dir);
1282 }
1283
1284 #[tokio::test]
1285 async fn write_file_new_file_records_added_display_diff() {
1286 let dir = temp_root("write_new_diff");
1287 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1288 let outcome = WriteFileTool
1289 .execute(
1290 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1291 ctx,
1292 )
1293 .await;
1294 assert!(outcome.is_success(), "expected success: {outcome:?}");
1295 let diff = outcome
1296 .metadata
1297 .display_diff
1298 .as_deref()
1299 .expect("display diff");
1300 assert!(diff.contains("+ alpha"));
1301 assert!(diff.contains("+ beta"));
1302 assert!(
1304 !diff.contains("@@"),
1305 "diff should not carry hunk headers: {diff}"
1306 );
1307 assert!(!diff.contains("/dev/null"));
1308 let _ = fs::remove_dir_all(&dir);
1309 }
1310
1311 #[tokio::test]
1312 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1313 let dir = temp_root("write_existing_diff");
1314 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1315 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1316 let outcome = WriteFileTool
1317 .execute(
1318 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1319 ctx,
1320 )
1321 .await;
1322 assert!(outcome.is_success(), "expected success: {outcome:?}");
1323 let diff = outcome
1324 .metadata
1325 .display_diff
1326 .as_deref()
1327 .expect("display diff");
1328 assert!(diff.contains("- old"));
1329 assert!(diff.contains("+ new"));
1330 let _ = fs::remove_dir_all(&dir);
1331 }
1332
1333 #[tokio::test]
1334 async fn write_file_creates_parent_dirs() {
1335 let dir = temp_root("write_parents");
1336 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1337 let outcome = WriteFileTool
1338 .execute(
1339 serde_json::json!({
1340 "path": "sub/nested/out.txt",
1341 "content": "deep",
1342 }),
1343 ctx,
1344 )
1345 .await;
1346 assert!(outcome.is_success(), "expected success: {outcome:?}");
1347 assert!(dir.join("sub/nested/out.txt").exists());
1348 let _ = fs::remove_dir_all(&dir);
1349 }
1350
1351 #[tokio::test]
1352 async fn write_file_missing_content_errors() {
1353 let dir = temp_root("write_missing");
1354 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1355 let outcome = WriteFileTool
1356 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1357 .await;
1358 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1359 let _ = fs::remove_dir_all(&dir);
1360 }
1361
1362 #[tokio::test]
1368 async fn read_file_rejects_absolute_path_outside_workdir() {
1369 let dir = temp_root("read_abs_escape");
1370 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1371 let outcome = ReadFileTool
1373 .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1374 .await;
1375 let error = outcome.error_message().expect("expected error");
1376 assert!(
1377 error.contains("outside the project"),
1378 "expected security reject, got: {error}"
1379 );
1380 let _ = fs::remove_dir_all(&dir);
1381 }
1382
1383 #[tokio::test]
1385 async fn read_file_accepts_absolute_path_inside_workdir() {
1386 let dir = temp_root("read_abs_inside");
1387 let file = dir.join("hello.txt");
1388 fs::write(&file, "ok").expect("write fixture");
1389 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1390 let outcome = ReadFileTool
1391 .execute(
1392 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1393 ctx,
1394 )
1395 .await;
1396 assert!(outcome.is_success(), "expected success: {outcome:?}");
1397 let _ = fs::remove_dir_all(&dir);
1398 }
1399
1400 #[tokio::test]
1404 async fn write_file_rejects_relative_parent_escape() {
1405 let dir = temp_root("write_dotdot_escape");
1406 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1407 let outcome = WriteFileTool
1408 .execute(
1409 serde_json::json!({
1410 "path": "../escape.txt",
1411 "content": "should not write",
1412 }),
1413 ctx,
1414 )
1415 .await;
1416 let error = outcome.error_message().expect("expected error");
1417 assert!(
1418 error.contains("outside the project"),
1419 "expected security reject, got: {error}"
1420 );
1421 let _ = fs::remove_dir_all(&dir);
1422 }
1423
1424 #[tokio::test]
1428 async fn create_directory_rejects_absolute_path_outside_workdir() {
1429 let dir = temp_root("mkdir_abs_escape");
1430 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1431 let outcome = CreateDirectoryTool
1432 .execute(
1433 serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1434 ctx,
1435 )
1436 .await;
1437 let error = outcome.error_message().expect("expected error");
1438 assert!(
1439 error.contains("outside the project"),
1440 "expected security reject, got: {error}"
1441 );
1442 let _ = fs::remove_dir_all(&dir);
1443 }
1444
1445 fn scratch_ctx(
1451 mode: mermaid_runtime::SafetyMode,
1452 workdir: PathBuf,
1453 scratchpad: Option<PathBuf>,
1454 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1455 let mut config = mermaid_domain::Config::default();
1456 config.safety.mode = mode;
1457 let (tx, rx) = tokio::sync::mpsc::channel(8);
1458 let mut ctx = ExecContext::new(
1459 tokio_util::sync::CancellationToken::new(),
1460 tx,
1461 ToolCallId(1),
1462 TurnId(1),
1463 workdir,
1464 std::sync::Arc::new(config),
1465 String::new(),
1466 None,
1467 None,
1468 None,
1469 mode,
1470 None,
1471 None,
1472 None,
1473 None,
1474 None,
1475 );
1476 ctx.scratchpad = scratchpad;
1477 (ctx, rx)
1478 }
1479
1480 fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1482 let base = std::env::temp_dir().join(format!(
1483 "mermaid_fs_scratch_{}_{}",
1484 name,
1485 std::process::id()
1486 ));
1487 let _ = fs::remove_dir_all(&base);
1488 let project = base.join("project");
1489 let scratch = base.join("scratch");
1490 fs::create_dir_all(&project).unwrap();
1491 fs::create_dir_all(&scratch).unwrap();
1492 (project, scratch)
1493 }
1494
1495 fn any_checkpoint_mentions(marker: &str) -> bool {
1499 let Ok(data) = mermaid_runtime::data_dir() else {
1500 return false;
1501 };
1502 let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1503 return false;
1504 };
1505 entries.flatten().any(|entry| {
1506 fs::read_to_string(entry.path().join("manifest.json"))
1507 .is_ok_and(|manifest| manifest.contains(marker))
1508 })
1509 }
1510
1511 #[tokio::test]
1514 async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1515 let (project, scratch) = scratch_fixture("ungated");
1516 let marker = scratch.display().to_string();
1517
1518 let file = scratch.join("notes.txt");
1520 let (ctx, _rx) = scratch_ctx(
1521 mermaid_runtime::SafetyMode::Ask,
1522 project.clone(),
1523 Some(scratch.clone()),
1524 );
1525 let outcome = WriteFileTool
1526 .execute(
1527 serde_json::json!({
1528 "path": file.to_str().unwrap(),
1529 "content": "scratch note\n",
1530 }),
1531 ctx,
1532 )
1533 .await;
1534 assert!(outcome.is_success(), "scratch write: {outcome:?}");
1535 assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1536
1537 let subdir = scratch.join("work/area");
1539 let (ctx, _rx) = scratch_ctx(
1540 mermaid_runtime::SafetyMode::Ask,
1541 project.clone(),
1542 Some(scratch.clone()),
1543 );
1544 let outcome = CreateDirectoryTool
1545 .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1546 .await;
1547 assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1548 assert!(subdir.is_dir());
1549
1550 let (ctx, _rx) = scratch_ctx(
1552 mermaid_runtime::SafetyMode::Ask,
1553 project.clone(),
1554 Some(scratch.clone()),
1555 );
1556 let outcome = DeleteFileTool
1557 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1558 .await;
1559 assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1560 assert!(!file.exists());
1561
1562 assert!(
1564 !any_checkpoint_mentions(&marker),
1565 "scratch mutation must not create a checkpoint"
1566 );
1567 let _ = fs::remove_dir_all(project.parent().unwrap());
1568 }
1569
1570 #[tokio::test]
1573 async fn scratch_mutation_blocked_in_read_only() {
1574 let (project, scratch) = scratch_fixture("readonly");
1575 let file = scratch.join("blocked.txt");
1576 let (ctx, _rx) = scratch_ctx(
1577 mermaid_runtime::SafetyMode::ReadOnly,
1578 project.clone(),
1579 Some(scratch.clone()),
1580 );
1581 let outcome = WriteFileTool
1582 .execute(
1583 serde_json::json!({
1584 "path": file.to_str().unwrap(),
1585 "content": "nope",
1586 }),
1587 ctx,
1588 )
1589 .await;
1590 let error = outcome.error_message().expect("expected block");
1591 assert!(
1592 error.contains("blocked by policy"),
1593 "expected policy block, got: {error}"
1594 );
1595 assert!(!file.exists());
1596 let _ = fs::remove_dir_all(project.parent().unwrap());
1597 }
1598
1599 #[tokio::test]
1601 async fn write_outside_both_roots_is_rejected() {
1602 let (project, scratch) = scratch_fixture("outside");
1603 let outside = project.parent().unwrap().join("elsewhere/out.txt");
1604 let (ctx, _rx) = scratch_ctx(
1605 mermaid_runtime::SafetyMode::Ask,
1606 project.clone(),
1607 Some(scratch.clone()),
1608 );
1609 let outcome = WriteFileTool
1610 .execute(
1611 serde_json::json!({
1612 "path": outside.to_str().unwrap(),
1613 "content": "should not write",
1614 }),
1615 ctx,
1616 )
1617 .await;
1618 let error = outcome.error_message().expect("expected error");
1619 assert!(
1620 error.contains("outside the project"),
1621 "expected containment reject, got: {error}"
1622 );
1623 assert!(!outside.exists());
1624 let _ = fs::remove_dir_all(project.parent().unwrap());
1625 }
1626
1627 #[tokio::test]
1629 async fn read_file_reads_from_scratchpad() {
1630 let (project, scratch) = scratch_fixture("read");
1631 let file = scratch.join("stash.txt");
1632 fs::write(&file, "stashed").unwrap();
1633 let (ctx, _rx) = scratch_ctx(
1634 mermaid_runtime::SafetyMode::Ask,
1635 project.clone(),
1636 Some(scratch.clone()),
1637 );
1638 let outcome = ReadFileTool
1639 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1640 .await;
1641 assert!(outcome.is_success(), "scratch read: {outcome:?}");
1642 assert_eq!(outcome.output(), "stashed");
1643 let _ = fs::remove_dir_all(project.parent().unwrap());
1644 }
1645}