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
47pub struct ReadFileTool;
50
51#[async_trait]
52impl ToolExecutor for ReadFileTool {
53 fn name(&self) -> &'static str {
54 "read_file"
55 }
56
57 fn schema(&self) -> ToolDefinition {
58 defn(
59 "read_file",
60 "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.",
61 serde_json::json!({
62 "type": "object",
63 "properties": {
64 "path": { "type": "string", "description": "File to read (single)." },
65 "paths": {
66 "type": "array",
67 "items": { "type": "string" },
68 "description": "Multiple files to read sequentially, in order."
69 }
70 },
71 "oneOf": [
72 { "required": ["path"] },
73 { "required": ["paths"] }
74 ]
75 }),
76 )
77 }
78
79 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
80 let paths = match extract_paths(&args) {
81 Ok(p) => p,
82 Err(e) => return ToolOutcome::error(e, 0.0),
83 };
84 if paths.is_empty() {
85 return ToolOutcome::error("read_file requires at least one path", 0.0);
86 }
87
88 let start = std::time::Instant::now();
89 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
90 let mut combined = String::new();
91 let mut any_truncated = false;
92
93 for (idx, raw_path) in paths.iter().enumerate() {
94 tokio::select! {
97 biased;
98 _ = ctx.token.cancelled() => {
99 return ToolOutcome::cancelled();
100 },
101 read = read_one(&roots, raw_path) => {
102 match read {
103 Ok((content, was_truncated)) => {
104 any_truncated |= was_truncated;
105 if paths.len() > 1 {
106 let _ = ctx.progress.send(ProgressEvent::Status(
107 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
108 )).await;
109 combined.push_str(&format!(
110 "=== {raw_path} ===\n{content}\n\n"
111 ));
112 } else {
113 combined = content;
114 }
115 },
116 Err(e) => {
117 return ToolOutcome::error(
118 format!("{raw_path}: {e}"),
119 start.elapsed().as_secs_f64(),
120 );
121 },
122 }
123 },
124 }
125 }
126
127 if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
133 combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
134 any_truncated = true;
135 }
136
137 let duration_secs = start.elapsed().as_secs_f64();
138 let line_count = combined.lines().count();
139 let byte_count = combined.len();
140 let truncated = any_truncated;
144 ToolOutcome::success(
145 combined,
146 format!(
147 "{} {} read",
148 line_count,
149 plural(line_count, "line", "lines")
150 ),
151 duration_secs,
152 )
153 .with_metadata(ToolRunMetadata {
154 detail: ToolMetadata::ReadFile {
155 paths,
156 line_count,
157 byte_count,
158 truncated,
159 },
160 line_count: Some(line_count),
161 byte_count: Some(byte_count),
162 ..ToolRunMetadata::default()
163 })
164 }
165}
166
167pub struct DeleteFileTool;
171
172#[async_trait]
173impl ToolExecutor for DeleteFileTool {
174 fn name(&self) -> &'static str {
175 "delete_file"
176 }
177
178 fn schema(&self) -> ToolDefinition {
179 defn(
180 "delete_file",
181 "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.",
182 serde_json::json!({
183 "type": "object",
184 "properties": { "path": { "type": "string" } },
185 "required": ["path"]
186 }),
187 )
188 }
189
190 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
191 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
192 return err("delete_file requires 'path'", 0.0);
193 };
194 let start = std::time::Instant::now();
195 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
196 let ResolvedInRoot {
197 abs,
198 rel,
199 root,
200 in_scratchpad,
201 } = match resolve_in_roots(&roots, raw_path) {
202 Ok(r) => r,
203 Err(e) => return err(&format!("delete_file: {e}"), 0.0),
204 };
205 let pending_action = serde_json::json!({
206 "tool": "delete_file",
207 "args": { "path": raw_path },
208 "workdir": ctx.workdir.display().to_string(),
209 "turn_id": ctx.turn.0,
210 "call_id": ctx.call_id.0,
211 "task_id": ctx.task_id.clone(),
212 });
213 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
214 &ctx,
215 "delete_file",
216 raw_path,
217 std::slice::from_ref(&abs),
218 pending_action,
219 in_scratchpad,
220 )
221 .await
222 {
223 return *outcome;
224 }
225 let _write_guard = tokio::select! {
230 biased;
231 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
232 g = super::path_lock::lock_path(&abs) => g,
233 };
234 if ctx.config.safety.checkpoint_on_mutation
237 && !in_scratchpad
238 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
239 &ctx.workdir,
240 std::slice::from_ref(&abs),
241 Some(serde_json::json!({
242 "tool": "delete_file",
243 "path": raw_path,
244 })),
245 ctx.checkpoint_origin(),
246 )
247 {
248 return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
249 }
250 let display = raw_path.to_string();
251
252 tokio::select! {
253 biased;
254 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
255 result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
256 match result {
257 Ok(Ok(())) => {
258 let duration_secs = start.elapsed().as_secs_f64();
259 after_file_mutation(&ctx, "delete_file", &display);
260 ToolOutcome::success(
261 format!("Deleted {display}"),
262 "file deleted",
263 duration_secs,
264 )
265 .with_metadata(ToolRunMetadata {
266 detail: ToolMetadata::DeleteFile { path: display },
267 ..ToolRunMetadata::default()
268 })
269 },
270 Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
271 start.elapsed().as_secs_f64()),
272 Err(e) => err(&format!("delete_file join error: {e}"),
273 start.elapsed().as_secs_f64()),
274 }
275 }
276 }
277 }
278}
279
280pub struct CreateDirectoryTool;
282
283#[async_trait]
284impl ToolExecutor for CreateDirectoryTool {
285 fn name(&self) -> &'static str {
286 "create_directory"
287 }
288
289 fn schema(&self) -> ToolDefinition {
290 defn(
291 "create_directory",
292 "Create a directory (and any missing parents) at the given path, inside the project directory or the session scratchpad.",
293 serde_json::json!({
294 "type": "object",
295 "properties": { "path": { "type": "string" } },
296 "required": ["path"]
297 }),
298 )
299 }
300
301 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
302 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
303 return err("create_directory requires 'path'", 0.0);
304 };
305 let start = std::time::Instant::now();
306 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
307 let ResolvedInRoot {
308 abs,
309 rel,
310 root,
311 in_scratchpad,
312 } = match resolve_in_roots(&roots, raw_path) {
313 Ok(r) => r,
314 Err(e) => return err(&format!("create_directory: {e}"), 0.0),
315 };
316 let pending_action = serde_json::json!({
317 "tool": "create_directory",
318 "args": { "path": raw_path },
319 "workdir": ctx.workdir.display().to_string(),
320 "turn_id": ctx.turn.0,
321 "call_id": ctx.call_id.0,
322 "task_id": ctx.task_id.clone(),
323 });
324 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
325 &ctx,
326 "create_directory",
327 raw_path,
328 std::slice::from_ref(&abs),
329 pending_action,
330 in_scratchpad,
331 )
332 .await
333 {
334 return *outcome;
335 }
336 let _write_guard = tokio::select! {
339 biased;
340 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
341 g = super::path_lock::lock_path(&abs) => g,
342 };
343 if ctx.config.safety.checkpoint_on_mutation
345 && !in_scratchpad
346 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
347 &ctx.workdir,
348 std::slice::from_ref(&abs),
349 Some(serde_json::json!({
350 "tool": "create_directory",
351 "path": raw_path,
352 })),
353 ctx.checkpoint_origin(),
354 )
355 {
356 return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
357 }
358 let display = raw_path.to_string();
359
360 tokio::select! {
361 biased;
362 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
363 result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
364 match result {
365 Ok(Ok(())) => {
366 let duration_secs = start.elapsed().as_secs_f64();
367 after_file_mutation(&ctx, "create_directory", &display);
368 ToolOutcome::success(
369 format!("Created directory {display}"),
370 "directory created",
371 duration_secs,
372 )
373 .with_metadata(ToolRunMetadata {
374 detail: ToolMetadata::CreateDirectory { path: display },
375 ..ToolRunMetadata::default()
376 })
377 },
378 Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
379 start.elapsed().as_secs_f64()),
380 Err(e) => err(&format!("create_directory join error: {e}"),
381 start.elapsed().as_secs_f64()),
382 }
383 }
384 }
385 }
386}
387
388pub struct WriteFileTool;
390
391#[expect(
392 clippy::too_many_lines,
393 reason = "predates the lint; see .github/baselines/expect_budget.txt"
394)]
395#[async_trait]
396impl ToolExecutor for WriteFileTool {
397 fn name(&self) -> &'static str {
398 "write_file"
399 }
400
401 fn schema(&self) -> ToolDefinition {
402 defn(
403 "write_file",
404 "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.",
405 serde_json::json!({
406 "type": "object",
407 "properties": {
408 "path": { "type": "string" },
409 "content": { "type": "string" }
410 },
411 "required": ["path", "content"]
412 }),
413 )
414 }
415
416 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
417 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
418 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
419 };
420 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
421 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
422 };
423
424 let start = std::time::Instant::now();
425 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
426 let ResolvedInRoot {
429 abs: abs_path,
430 rel,
431 root,
432 in_scratchpad,
433 } = match resolve_in_roots(&roots, path) {
434 Ok(r) => r,
435 Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
436 };
437 let pending_action = serde_json::json!({
438 "tool": "write_file",
439 "args": { "path": path, "content": content },
440 "workdir": ctx.workdir.display().to_string(),
441 "turn_id": ctx.turn.0,
442 "call_id": ctx.call_id.0,
443 "task_id": ctx.task_id.clone(),
444 });
445 let plan_write = match mutation_policy_outcome(
446 &ctx,
447 "write_file",
448 path,
449 std::slice::from_ref(&abs_path),
450 pending_action,
451 in_scratchpad,
452 )
453 .await
454 {
455 MutationGate::Blocked(outcome) => return *outcome,
456 MutationGate::Proceed { plan_write } => plan_write,
457 };
458 let _write_guard = tokio::select! {
463 biased;
464 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
465 g = super::path_lock::lock_path(&abs_path) => g,
466 };
467 if ctx.config.safety.checkpoint_on_mutation
469 && !in_scratchpad
470 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
471 &ctx.workdir,
472 std::slice::from_ref(&abs_path),
473 Some(serde_json::json!({
474 "tool": "write_file",
475 "path": path,
476 })),
477 ctx.checkpoint_origin(),
478 )
479 {
480 return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
481 }
482 let display_path = path.to_string();
483 let line_count = content.lines().count();
484 let byte_count = content.len();
485 let content = content.to_string();
486
487 tokio::select! {
488 biased;
489 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
490 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
494 match result {
495 Ok(Ok(write)) => {
496 let duration_secs = start.elapsed().as_secs_f64();
497 after_file_mutation(&ctx, "write_file", &display_path);
498 ToolOutcome::success(
499 format!("Wrote {} ({} lines)", display_path, write.line_count),
500 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
501 duration_secs,
502 )
503 .with_metadata(ToolRunMetadata {
504 detail: ToolMetadata::WriteFile {
505 path: display_path,
506 line_count,
507 byte_count,
508 created: Some(write.created),
509 },
510 line_count: Some(line_count),
511 byte_count: Some(byte_count),
512 display_diff: Some(write.diff.display_diff),
513 diff_truncated: write.diff.truncated,
514 lines_added: write.diff.added,
515 lines_removed: write.diff.removed,
516 plan_file_written: plan_write,
517 ..ToolRunMetadata::default()
518 })
519 },
520 Ok(Err(e)) => ToolOutcome::error(
521 format!("write_file({display_path}): {e}"),
522 start.elapsed().as_secs_f64(),
523 ),
524 Err(e) => ToolOutcome::error(
525 format!("write_file join error: {e}"),
526 start.elapsed().as_secs_f64(),
527 ),
528 }
529 }
530 }
531 }
532}
533
534fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
537 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
539 return Ok(vec![p.to_string()]);
540 }
541 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
542 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
543 return Err(format!(
544 "read_file: too many paths ({}); cap is {} per call — split the request",
545 arr.len(),
546 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
547 ));
548 }
549 let mut out = Vec::with_capacity(arr.len());
550 for v in arr {
551 let Some(s) = v.as_str() else {
552 return Err("read_file 'paths' must be an array of strings".to_string());
553 };
554 out.push(s.to_string());
555 }
556 return Ok(out);
557 }
558 Err("read_file requires 'path' or 'paths'".to_string())
559}
560
561fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
569 if !Path::new(raw).is_absolute() {
570 return None;
571 }
572 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
573 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
574 && let Ok(rel) = relative_within(&root, raw)
575 {
576 return Some((root, rel));
577 }
578 }
579 None
580}
581
582async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
587 let ResolvedInRoot { rel, root, .. } = match resolve_in_roots(roots, raw) {
593 Ok(resolved) => resolved,
594 Err(msg) => {
595 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
596 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
597 ResolvedInRoot {
598 abs: root.join(&rel),
599 rel,
600 root,
601 in_scratchpad: false,
602 }
603 },
604 };
605 let result = tokio::task::spawn_blocking(move || {
606 let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
607 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
611 let mut s = String::from_utf8_lossy(&data).into_owned();
612 if truncated {
613 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
615 s.truncate(cut);
616 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
617 }
618 Ok::<_, std::io::Error>((s, truncated))
619 })
620 .await
621 .map_err(|e| std::io::Error::other(e.to_string()))??;
622 Ok(result)
623}
624
625fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
634 if let Some(parent) = rel.parent()
635 && !parent.as_os_str().is_empty()
636 {
637 mermaid_runtime::create_dir_all_beneath(root, parent)?;
638 }
639 mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
640 Ok(content.lines().count())
641}
642
643struct WriteResult {
644 line_count: usize,
645 created: bool,
646 diff: mermaid_model::diff::DisplayDiff,
647}
648
649fn write_with_diff_blocking(
656 root: &Path,
657 abs_path: &Path,
658 rel: &Path,
659 content: &str,
660) -> std::io::Result<WriteResult> {
661 let (old_content, created, elide_diff) =
662 match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
663 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
664 Ok((_, true)) => (String::new(), false, true),
666 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
668 Err(_) => (String::new(), false, true),
670 };
671 let diff = if elide_diff {
672 mermaid_model::diff::DisplayDiff {
673 display_diff: format!(
674 "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
675 ),
676 added: 0,
677 removed: 0,
678 truncated: true,
679 }
680 } else {
681 mermaid_model::diff::generate_display_diff(&old_content, content)
682 };
683 let line_count = write_one_blocking(root, rel, content)?;
684 Ok(WriteResult {
685 line_count,
686 created,
687 diff,
688 })
689}
690
691pub(super) enum MutationGate {
704 Blocked(Box<ToolOutcome>),
706 Proceed {
707 plan_write: bool,
708 },
709}
710
711pub(super) async fn mutation_policy_outcome(
712 ctx: &ExecContext,
713 tool: &str,
714 path: &str,
715 checkpoint_paths: &[PathBuf],
716 pending_action: serde_json::Value,
717 scratch_contained: bool,
718) -> MutationGate {
719 let mut request = mermaid_runtime::ActionRequest::new(
720 tool,
721 mermaid_runtime::ToolCategory::Edit,
722 format!("{tool} {path}"),
723 );
724 request.path = Some(path.to_string());
725 match super::policy_gate::gate(
728 ctx,
729 request,
730 checkpoint_paths,
731 pending_action,
732 true,
733 scratch_contained,
734 )
735 .await
736 {
737 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
738 super::policy_gate::Gate::Proceed { plan_write, .. } => {
739 let _ = mermaid_runtime::run_plugin_hooks(
740 "before_file_mutation",
741 &serde_json::json!({
742 "task_id": ctx.task_id.clone(),
743 "turn_id": ctx.turn.0,
744 "call_id": ctx.call_id.0,
745 "tool": tool,
746 "path": path,
747 }),
748 );
749 MutationGate::Proceed { plan_write }
750 },
751 }
752}
753
754pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
755 let _ = mermaid_runtime::run_plugin_hooks(
756 "after_file_mutation",
757 &serde_json::json!({
758 "task_id": ctx.task_id.clone(),
759 "turn_id": ctx.turn.0,
760 "call_id": ctx.call_id.0,
761 "tool": tool,
762 "path": path,
763 }),
764 );
765}
766
767fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
768 ToolOutcome::error(msg, duration_secs)
769}
770
771fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
772 if count == 1 { singular } else { plural }
773}
774
775pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
776 format!(
777 "+{} -{}, took {}",
778 added,
779 removed,
780 format_duration_for_diff(duration_secs)
781 )
782}
783
784fn format_duration_for_diff(seconds: f64) -> String {
785 if seconds < 1.0 {
786 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
787 } else if seconds < 10.0 {
788 format!("{seconds:.1}s")
789 } else {
790 format!("{}s", seconds.round() as u64)
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use crate::providers::ctx::test_exec_context;
798 use mermaid_domain::{ToolCallId, TurnId};
799 use std::fs;
800
801 #[tokio::test]
807 async fn read_file_resolves_memory_roots_read_only() {
808 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
809 let _ = fs::remove_dir_all(&base);
810 let repo = base.join("repo");
811 let workdir = repo.join("src");
812 fs::create_dir_all(&workdir).unwrap();
813 fs::create_dir_all(repo.join(".git")).unwrap();
814 let mem_dir = repo.join(".mermaid").join("memory");
815 fs::create_dir_all(&mem_dir).unwrap();
816 let fact = mem_dir.join("fact.md");
817 fs::write(&fact, "the fact body").unwrap();
818
819 let roots = AllowedRoots::new(&workdir, None);
820 let (content, truncated) = read_one(&roots, fact.to_str().unwrap()).await.unwrap();
821 assert!(!truncated);
822 assert_eq!(content, "the fact body");
823
824 let stray = base.join("stray.txt");
827 fs::write(&stray, "nope").unwrap();
828 assert!(read_one(&roots, stray.to_str().unwrap()).await.is_err());
829
830 let _ = fs::remove_dir_all(&base);
831 }
832
833 #[test]
834 fn resolve_in_roots_contains_to_workdir() {
835 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
836 let _ = fs::remove_dir_all(&root);
837 fs::create_dir_all(root.join("sub")).unwrap();
838 let roots = AllowedRoots::new(&root, None);
839
840 assert!(resolve_in_roots(&roots, "sub").is_ok());
842 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
843 let canon_root = fs::canonicalize(&root).unwrap();
844 assert!(resolved.abs.starts_with(&canon_root));
845
846 assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
848 assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
849 let outside = std::env::temp_dir().join("definitely_outside.txt");
850 assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
851
852 let _ = fs::remove_dir_all(&root);
853 }
854
855 fn temp_root(name: &str) -> PathBuf {
856 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
857 let _ = fs::remove_dir_all(&p);
858 fs::create_dir_all(&p).expect("create tmpdir");
859 p
860 }
861
862 #[tokio::test]
863 async fn read_file_returns_content() {
864 let dir = temp_root("read_ok");
865 fs::write(dir.join("a.txt"), "hello").expect("write");
866 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
867
868 let tool = ReadFileTool;
869 let outcome = tool
870 .execute(serde_json::json!({"path": "a.txt"}), ctx)
871 .await;
872 assert!(outcome.is_success(), "expected success: {outcome:?}");
873 assert_eq!(outcome.output(), "hello");
874 let _ = fs::remove_dir_all(&dir);
875 }
876
877 #[tokio::test]
878 async fn read_file_missing_path_errors() {
879 let dir = temp_root("read_missing_path");
880 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
881 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
882 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
883 let _ = fs::remove_dir_all(&dir);
884 }
885
886 #[tokio::test]
887 async fn read_file_nonexistent_errors() {
888 let dir = temp_root("read_nonex");
889 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
890 let outcome = ReadFileTool
891 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
892 .await;
893 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
894 let _ = fs::remove_dir_all(&dir);
895 }
896
897 #[tokio::test]
898 async fn read_file_with_multiple_paths_joins_contents() {
899 let dir = temp_root("read_multi");
900 fs::write(dir.join("a.txt"), "alpha").expect("write");
901 fs::write(dir.join("b.txt"), "beta").expect("write");
902 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
903 let outcome = ReadFileTool
904 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
905 .await;
906 assert!(outcome.is_success(), "expected success: {outcome:?}");
907 let output = outcome.output();
908 assert!(output.contains("=== a.txt ==="));
909 assert!(output.contains("alpha"));
910 assert!(output.contains("=== b.txt ==="));
911 assert!(output.contains("beta"));
912 let _ = fs::remove_dir_all(&dir);
913 }
914
915 #[tokio::test]
916 async fn read_file_multi_aggregate_is_capped() {
917 let dir = temp_root("read_aggregate_cap");
920 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
921 fs::write(dir.join("a.txt"), &chunk).expect("write a");
922 fs::write(dir.join("b.txt"), &chunk).expect("write b");
923 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
924 let outcome = ReadFileTool
925 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
926 .await;
927 assert!(outcome.is_success(), "expected success: {outcome:?}");
928 let output = outcome.output();
929 assert!(
930 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
931 "combined must be capped, got {} bytes",
932 output.len()
933 );
934 assert!(
935 output.contains("elided"),
936 "expected aggregate head+tail elision marker"
937 );
938 match &outcome.metadata.detail {
939 ToolMetadata::ReadFile { truncated, .. } => {
940 assert!(*truncated, "aggregate truncation must set truncated")
941 },
942 other => panic!("expected ReadFile metadata, got {other:?}"),
943 }
944 let _ = fs::remove_dir_all(&dir);
945 }
946
947 #[tokio::test]
948 async fn write_file_elides_diff_for_oversized_existing_file() {
949 let dir = temp_root("write_oversized_diff");
952 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
953 fs::write(dir.join("big.txt"), &big).expect("write fixture");
954 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
955 let outcome = WriteFileTool
956 .execute(
957 serde_json::json!({"path": "big.txt", "content": "small\n"}),
958 ctx,
959 )
960 .await;
961 assert!(outcome.is_success(), "expected success: {outcome:?}");
962 let diff = outcome
963 .metadata
964 .display_diff
965 .as_deref()
966 .expect("display diff");
967 assert!(
968 diff.contains("diff preview skipped"),
969 "expected elision marker, got: {diff}"
970 );
971 assert!(
972 outcome.metadata.diff_truncated,
973 "oversized diff must set diff_truncated"
974 );
975 match &outcome.metadata.detail {
976 ToolMetadata::WriteFile { created, .. } => {
977 assert_eq!(*created, Some(false), "existing file is not 'created'")
978 },
979 other => panic!("expected WriteFile metadata, got {other:?}"),
980 }
981 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
983 assert_eq!(written, "small\n");
984 let _ = fs::remove_dir_all(&dir);
985 }
986
987 #[tokio::test]
988 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
989 let dir = temp_root("read_marker_content");
993 fs::write(
994 dir.join("a.txt"),
995 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
996 )
997 .expect("write");
998 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
999
1000 let outcome = ReadFileTool
1001 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1002 .await;
1003 assert!(outcome.is_success(), "expected success: {outcome:?}");
1004 match &outcome.metadata.detail {
1005 ToolMetadata::ReadFile { truncated, .. } => assert!(
1006 !truncated,
1007 "a file whose content contains the marker must not be flagged truncated"
1008 ),
1009 other => panic!("expected ReadFile metadata, got {other:?}"),
1010 }
1011 let _ = fs::remove_dir_all(&dir);
1012 }
1013
1014 #[tokio::test]
1015 async fn read_file_respects_cancellation() {
1016 let dir = temp_root("read_cancel");
1017 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1022 ctx.token.cancel();
1023 let outcome = ReadFileTool
1024 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1025 .await;
1026 assert!(outcome.was_cancelled());
1027 let _ = fs::remove_dir_all(&dir);
1028 }
1029
1030 #[tokio::test]
1031 async fn write_file_creates_and_counts_lines() {
1032 let dir = temp_root("write_ok");
1033 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1034 let outcome = WriteFileTool
1035 .execute(
1036 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1037 ctx,
1038 )
1039 .await;
1040 assert!(outcome.is_success(), "expected success: {outcome:?}");
1041 assert!(outcome.output().contains("3 lines"));
1042 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1043 assert!(written.contains("line1"));
1044 let _ = fs::remove_dir_all(&dir);
1045 }
1046
1047 #[tokio::test]
1048 async fn concurrent_write_file_same_path_serializes_cleanly() {
1049 let dir = temp_root("write_race");
1053 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1054 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1055 let a = "AAAA\nAAAA\n";
1056 let b = "BBBB\nBBBB\n";
1057 let (o1, o2) = tokio::join!(
1058 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1059 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1060 );
1061 assert!(o1.is_success(), "first write failed: {o1:?}");
1062 assert!(o2.is_success(), "second write failed: {o2:?}");
1063 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1064 assert!(
1065 final_content == a || final_content == b,
1066 "file must be exactly one clean write, got {final_content:?}"
1067 );
1068 let _ = fs::remove_dir_all(&dir);
1069 }
1070
1071 #[tokio::test]
1072 async fn write_file_new_file_records_added_display_diff() {
1073 let dir = temp_root("write_new_diff");
1074 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1075 let outcome = WriteFileTool
1076 .execute(
1077 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1078 ctx,
1079 )
1080 .await;
1081 assert!(outcome.is_success(), "expected success: {outcome:?}");
1082 let diff = outcome
1083 .metadata
1084 .display_diff
1085 .as_deref()
1086 .expect("display diff");
1087 assert!(diff.contains("+ alpha"));
1088 assert!(diff.contains("+ beta"));
1089 assert!(
1091 !diff.contains("@@"),
1092 "diff should not carry hunk headers: {diff}"
1093 );
1094 assert!(!diff.contains("/dev/null"));
1095 let _ = fs::remove_dir_all(&dir);
1096 }
1097
1098 #[tokio::test]
1099 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1100 let dir = temp_root("write_existing_diff");
1101 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1102 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1103 let outcome = WriteFileTool
1104 .execute(
1105 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1106 ctx,
1107 )
1108 .await;
1109 assert!(outcome.is_success(), "expected success: {outcome:?}");
1110 let diff = outcome
1111 .metadata
1112 .display_diff
1113 .as_deref()
1114 .expect("display diff");
1115 assert!(diff.contains("- old"));
1116 assert!(diff.contains("+ new"));
1117 let _ = fs::remove_dir_all(&dir);
1118 }
1119
1120 #[tokio::test]
1121 async fn write_file_creates_parent_dirs() {
1122 let dir = temp_root("write_parents");
1123 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1124 let outcome = WriteFileTool
1125 .execute(
1126 serde_json::json!({
1127 "path": "sub/nested/out.txt",
1128 "content": "deep",
1129 }),
1130 ctx,
1131 )
1132 .await;
1133 assert!(outcome.is_success(), "expected success: {outcome:?}");
1134 assert!(dir.join("sub/nested/out.txt").exists());
1135 let _ = fs::remove_dir_all(&dir);
1136 }
1137
1138 #[tokio::test]
1139 async fn write_file_missing_content_errors() {
1140 let dir = temp_root("write_missing");
1141 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1142 let outcome = WriteFileTool
1143 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1144 .await;
1145 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1146 let _ = fs::remove_dir_all(&dir);
1147 }
1148
1149 #[tokio::test]
1155 async fn read_file_rejects_absolute_path_outside_workdir() {
1156 let dir = temp_root("read_abs_escape");
1157 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1158 let outcome = ReadFileTool
1160 .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1161 .await;
1162 let error = outcome.error_message().expect("expected error");
1163 assert!(
1164 error.contains("outside the project"),
1165 "expected security reject, got: {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: {error}"
1207 );
1208 let _ = fs::remove_dir_all(&dir);
1209 }
1210
1211 #[tokio::test]
1215 async fn create_directory_rejects_absolute_path_outside_workdir() {
1216 let dir = temp_root("mkdir_abs_escape");
1217 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1218 let outcome = CreateDirectoryTool
1219 .execute(
1220 serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1221 ctx,
1222 )
1223 .await;
1224 let error = outcome.error_message().expect("expected error");
1225 assert!(
1226 error.contains("outside the project"),
1227 "expected security reject, got: {error}"
1228 );
1229 let _ = fs::remove_dir_all(&dir);
1230 }
1231
1232 fn scratch_ctx(
1238 mode: mermaid_runtime::SafetyMode,
1239 workdir: PathBuf,
1240 scratchpad: Option<PathBuf>,
1241 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1242 let mut config = mermaid_domain::Config::default();
1243 config.safety.mode = mode;
1244 let (tx, rx) = tokio::sync::mpsc::channel(8);
1245 let mut ctx = ExecContext::new(
1246 tokio_util::sync::CancellationToken::new(),
1247 tx,
1248 ToolCallId(1),
1249 TurnId(1),
1250 workdir,
1251 std::sync::Arc::new(config),
1252 String::new(),
1253 None,
1254 None,
1255 None,
1256 mode,
1257 None,
1258 None,
1259 None,
1260 None,
1261 None,
1262 );
1263 ctx.scratchpad = scratchpad;
1264 (ctx, rx)
1265 }
1266
1267 fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
1269 let base = std::env::temp_dir().join(format!(
1270 "mermaid_fs_scratch_{}_{}",
1271 name,
1272 std::process::id()
1273 ));
1274 let _ = fs::remove_dir_all(&base);
1275 let project = base.join("project");
1276 let scratch = base.join("scratch");
1277 fs::create_dir_all(&project).unwrap();
1278 fs::create_dir_all(&scratch).unwrap();
1279 (project, scratch)
1280 }
1281
1282 fn any_checkpoint_mentions(marker: &str) -> bool {
1286 let Ok(data) = mermaid_runtime::data_dir() else {
1287 return false;
1288 };
1289 let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1290 return false;
1291 };
1292 entries.flatten().any(|entry| {
1293 fs::read_to_string(entry.path().join("manifest.json"))
1294 .is_ok_and(|manifest| manifest.contains(marker))
1295 })
1296 }
1297
1298 #[tokio::test]
1301 async fn scratch_mutations_are_ungated_and_never_checkpointed() {
1302 let (project, scratch) = scratch_fixture("ungated");
1303 let marker = scratch.display().to_string();
1304
1305 let file = scratch.join("notes.txt");
1307 let (ctx, _rx) = scratch_ctx(
1308 mermaid_runtime::SafetyMode::Ask,
1309 project.clone(),
1310 Some(scratch.clone()),
1311 );
1312 let outcome = WriteFileTool
1313 .execute(
1314 serde_json::json!({
1315 "path": file.to_str().unwrap(),
1316 "content": "scratch note\n",
1317 }),
1318 ctx,
1319 )
1320 .await;
1321 assert!(outcome.is_success(), "scratch write: {outcome:?}");
1322 assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1323
1324 let subdir = scratch.join("work/area");
1326 let (ctx, _rx) = scratch_ctx(
1327 mermaid_runtime::SafetyMode::Ask,
1328 project.clone(),
1329 Some(scratch.clone()),
1330 );
1331 let outcome = CreateDirectoryTool
1332 .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1333 .await;
1334 assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1335 assert!(subdir.is_dir());
1336
1337 let (ctx, _rx) = scratch_ctx(
1339 mermaid_runtime::SafetyMode::Ask,
1340 project.clone(),
1341 Some(scratch.clone()),
1342 );
1343 let outcome = DeleteFileTool
1344 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1345 .await;
1346 assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1347 assert!(!file.exists());
1348
1349 assert!(
1351 !any_checkpoint_mentions(&marker),
1352 "scratch mutation must not create a checkpoint"
1353 );
1354 let _ = fs::remove_dir_all(project.parent().unwrap());
1355 }
1356
1357 #[tokio::test]
1360 async fn scratch_mutation_blocked_in_read_only() {
1361 let (project, scratch) = scratch_fixture("readonly");
1362 let file = scratch.join("blocked.txt");
1363 let (ctx, _rx) = scratch_ctx(
1364 mermaid_runtime::SafetyMode::ReadOnly,
1365 project.clone(),
1366 Some(scratch.clone()),
1367 );
1368 let outcome = WriteFileTool
1369 .execute(
1370 serde_json::json!({
1371 "path": file.to_str().unwrap(),
1372 "content": "nope",
1373 }),
1374 ctx,
1375 )
1376 .await;
1377 let error = outcome.error_message().expect("expected block");
1378 assert!(
1379 error.contains("blocked by policy"),
1380 "expected policy block, got: {error}"
1381 );
1382 assert!(!file.exists());
1383 let _ = fs::remove_dir_all(project.parent().unwrap());
1384 }
1385
1386 #[tokio::test]
1388 async fn write_outside_both_roots_is_rejected() {
1389 let (project, scratch) = scratch_fixture("outside");
1390 let outside = project.parent().unwrap().join("elsewhere/out.txt");
1391 let (ctx, _rx) = scratch_ctx(
1392 mermaid_runtime::SafetyMode::Ask,
1393 project.clone(),
1394 Some(scratch.clone()),
1395 );
1396 let outcome = WriteFileTool
1397 .execute(
1398 serde_json::json!({
1399 "path": outside.to_str().unwrap(),
1400 "content": "should not write",
1401 }),
1402 ctx,
1403 )
1404 .await;
1405 let error = outcome.error_message().expect("expected error");
1406 assert!(
1407 error.contains("outside the project"),
1408 "expected containment reject, got: {error}"
1409 );
1410 assert!(!outside.exists());
1411 let _ = fs::remove_dir_all(project.parent().unwrap());
1412 }
1413
1414 #[tokio::test]
1416 async fn read_file_reads_from_scratchpad() {
1417 let (project, scratch) = scratch_fixture("read");
1418 let file = scratch.join("stash.txt");
1419 fs::write(&file, "stashed").unwrap();
1420 let (ctx, _rx) = scratch_ctx(
1421 mermaid_runtime::SafetyMode::Ask,
1422 project.clone(),
1423 Some(scratch.clone()),
1424 );
1425 let outcome = ReadFileTool
1426 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1427 .await;
1428 assert!(outcome.is_success(), "scratch read: {outcome:?}");
1429 assert_eq!(outcome.output(), "stashed");
1430 let _ = fs::remove_dir_all(project.parent().unwrap());
1431 }
1432}