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, PathContainment, ResolvedInRoot, relative_within, resolve_in_roots,
27 resolve_path_within,
28};
29
30fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
34 ToolDefinition {
35 name: name.to_string(),
36 description: description.to_string(),
37 input_schema,
38 }
39}
40
41const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
47
48const READ_DEDUP_CAP: usize = 128;
52
53struct ReadDedupEntry {
56 scope: String,
57 path: String,
58 turn: u64,
59 hash: [u8; 32],
60 line_count: usize,
61}
62
63static READ_DEDUP: std::sync::OnceLock<
73 std::sync::Mutex<std::collections::VecDeque<ReadDedupEntry>>,
74> = std::sync::OnceLock::new();
75
76fn read_dedup_scope(ctx: &ExecContext) -> String {
81 format!(
82 "{}|{}|{}",
83 ctx.session_id.as_deref().unwrap_or(""),
84 ctx.task_id.as_deref().unwrap_or(""),
85 ctx.workdir.display(),
86 )
87}
88
89fn duplicate_read_note(ctx: &ExecContext, path: &str, content: &str) -> Option<String> {
94 use sha2::{Digest, Sha256};
95 let hash: [u8; 32] = Sha256::digest(content.as_bytes()).into();
96 let line_count = content.lines().count();
97 let scope = read_dedup_scope(ctx);
98 let mut store = READ_DEDUP
99 .get_or_init(|| std::sync::Mutex::new(std::collections::VecDeque::new()))
100 .lock()
101 .unwrap_or_else(std::sync::PoisonError::into_inner);
102 if let Some(entry) = store
103 .iter_mut()
104 .find(|e| e.scope == scope && e.path == path)
105 {
106 let identical_this_turn = entry.turn == ctx.turn.0 && entry.hash == hash;
107 entry.turn = ctx.turn.0;
108 entry.hash = hash;
109 entry.line_count = line_count;
110 return identical_this_turn.then(|| {
111 format!(
112 "{path}: unchanged since your read earlier this turn — the full \
113 content ({line_count} lines) is already in this turn's tool \
114 results; reuse it. A read after the file changes, or in a later \
115 turn, returns the full content again."
116 )
117 });
118 }
119 store.push_back(ReadDedupEntry {
120 scope,
121 path: path.to_string(),
122 turn: ctx.turn.0,
123 hash,
124 line_count,
125 });
126 if store.len() > READ_DEDUP_CAP {
127 store.pop_front();
128 }
129 None
130}
131
132pub struct ReadFileTool;
135
136#[async_trait]
137impl ToolExecutor for ReadFileTool {
138 fn name(&self) -> &'static str {
139 "read_file"
140 }
141
142 fn schema(&self) -> ToolDefinition {
143 defn(
144 "read_file",
145 "Read the contents of one or more files from disk. Relative paths resolve relative to the project directory; absolute paths may resolve anywhere on disk.",
146 serde_json::json!({
147 "type": "object",
148 "properties": {
149 "path": { "type": "string", "description": "File to read (single)." },
150 "paths": {
151 "type": "array",
152 "items": { "type": "string" },
153 "description": "Multiple files to read sequentially, in order."
154 }
155 },
156 "oneOf": [
157 { "required": ["path"] },
158 { "required": ["paths"] }
159 ]
160 }),
161 )
162 }
163
164 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
165 let paths = match extract_paths(&args) {
166 Ok(p) => p,
167 Err(e) => return ToolOutcome::error(e, None),
168 };
169 if paths.is_empty() {
170 return ToolOutcome::error("read_file requires at least one path", None);
171 }
172 let view = match View::from_args(&args) {
173 Ok(view) => view,
174 Err(e) => return ToolOutcome::error(e, None),
175 };
176
177 let start = std::time::Instant::now();
178 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
179 let mut combined = String::new();
180 let mut any_truncated = false;
181
182 for (idx, raw_path) in paths.iter().enumerate() {
183 let target = match resolve_read_target(&roots, raw_path) {
184 Ok(target) => target,
185 Err(e) => {
186 return ToolOutcome::error(
187 format!("{raw_path}: {e}"),
188 Some(start.elapsed().as_secs_f64()),
189 );
190 },
191 };
192 if let Some(abs) = &target.external
196 && let Some(blocked) = external_read_gate(&ctx, raw_path, abs).await
197 {
198 return blocked;
199 }
200 tokio::select! {
203 biased;
204 _ = ctx.token.cancelled() => {
205 return ToolOutcome::cancelled();
206 },
207 read = read_target(target.root, target.rel) => {
208 let read = read.and_then(|(content, was_truncated, is_dir)| {
209 if is_dir {
210 return Ok((content, was_truncated));
211 }
212 view.apply(&content)
213 .map(|content| (content, was_truncated))
214 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))
215 });
216 match read {
217 Ok((content, was_truncated)) => {
218 let (content, was_truncated) =
224 duplicate_read_note(&ctx, raw_path, &content)
225 .map_or((content, was_truncated), |note| (note, false));
226 any_truncated |= was_truncated;
227 if paths.len() > 1 {
228 let _ = ctx.progress.send(ProgressEvent::Status(
229 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
230 )).await;
231 combined.push_str(&format!(
232 "=== {raw_path} ===\n{content}\n\n"
233 ));
234 } else {
235 combined = content;
236 }
237 },
238 Err(e) => {
239 return ToolOutcome::error(
240 format!("{raw_path}: {e}"),
241 Some(start.elapsed().as_secs_f64()),
242 );
243 },
244 }
245 },
246 }
247 }
248
249 if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
255 combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
256 any_truncated = true;
257 }
258
259 let duration_secs = start.elapsed().as_secs_f64();
260 let line_count = combined.lines().count();
261 let byte_count = combined.len();
262 let truncated = any_truncated;
266 ToolOutcome::success(
267 combined,
268 format!(
269 "{} {} read",
270 line_count,
271 plural(line_count, "line", "lines")
272 ),
273 duration_secs,
274 )
275 .with_metadata(ToolRunMetadata {
276 detail: ToolMetadata::ReadFile {
277 paths,
278 line_count,
279 byte_count,
280 truncated,
281 },
282 line_count: Some(line_count),
283 byte_count: Some(byte_count),
284 ..ToolRunMetadata::default()
285 })
286 }
287}
288
289pub struct DeleteFileTool;
293
294#[async_trait]
295impl ToolExecutor for DeleteFileTool {
296 fn name(&self) -> &'static str {
297 "delete_file"
298 }
299
300 fn schema(&self) -> ToolDefinition {
301 defn(
302 "delete_file",
303 "Remove a file from disk. Paths may be relative to the project directory or absolute paths on disk. Fails on directories — use `execute_command rm -rf` for those.",
304 serde_json::json!({
305 "type": "object",
306 "properties": { "path": { "type": "string" } },
307 "required": ["path"]
308 }),
309 )
310 }
311
312 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
313 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
314 return err("delete_file requires 'path'", 0.0);
315 };
316 let start = std::time::Instant::now();
317 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
318 let ResolvedInRoot {
319 abs,
320 rel,
321 root,
322 containment,
323 } = match resolve_in_roots(&roots, raw_path) {
324 Ok(r) => r,
325 Err(e) => return err(&format!("delete_file: {e}"), 0.0),
326 };
327 let pending_action = serde_json::json!({
328 "tool": "delete_file",
329 "args": { "path": raw_path },
330 "workdir": ctx.workdir.display().to_string(),
331 "turn_id": ctx.turn.0,
332 "call_id": ctx.call_id.0,
333 "task_id": ctx.task_id.clone(),
334 });
335 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
336 &ctx,
337 "delete_file",
338 raw_path,
339 std::slice::from_ref(&abs),
340 pending_action,
341 containment,
342 )
343 .await
344 {
345 return *outcome;
346 }
347 let _write_guard = tokio::select! {
352 biased;
353 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
354 g = super::path_lock::lock_path(&abs) => g,
355 };
356 if ctx.config.safety.checkpoint_on_mutation
359 && containment != PathContainment::Scratchpad
360 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
361 &ctx.workdir,
362 std::slice::from_ref(&abs),
363 Some(serde_json::json!({
364 "tool": "delete_file",
365 "path": raw_path,
366 })),
367 ctx.checkpoint_origin(),
368 )
369 {
370 return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
371 }
372 let display = raw_path.to_string();
373
374 tokio::select! {
375 biased;
376 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
377 result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
378 match result {
379 Ok(Ok(())) => {
380 let duration_secs = start.elapsed().as_secs_f64();
381 after_file_mutation(&ctx, "delete_file", &display);
382 ToolOutcome::success(
383 format!("Deleted {display}"),
384 "file deleted",
385 duration_secs,
386 )
387 .with_metadata(ToolRunMetadata {
388 detail: ToolMetadata::DeleteFile { path: display },
389 ..ToolRunMetadata::default()
390 })
391 },
392 Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
393 start.elapsed().as_secs_f64()),
394 Err(e) => err(&format!("delete_file join error: {e}"),
395 start.elapsed().as_secs_f64()),
396 }
397 }
398 }
399 }
400}
401
402pub struct CreateDirectoryTool;
404
405#[async_trait]
406impl ToolExecutor for CreateDirectoryTool {
407 fn name(&self) -> &'static str {
408 "create_directory"
409 }
410
411 fn schema(&self) -> ToolDefinition {
412 defn(
413 "create_directory",
414 "Create a directory (and any missing parents) at the given path. Paths may be relative to the project directory or absolute paths on disk.",
415 serde_json::json!({
416 "type": "object",
417 "properties": { "path": { "type": "string" } },
418 "required": ["path"]
419 }),
420 )
421 }
422
423 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
424 let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
425 return err("create_directory requires 'path'", 0.0);
426 };
427 let start = std::time::Instant::now();
428 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
429 let ResolvedInRoot {
430 abs,
431 rel,
432 root,
433 containment,
434 } = match resolve_in_roots(&roots, raw_path) {
435 Ok(r) => r,
436 Err(e) => return err(&format!("create_directory: {e}"), 0.0),
437 };
438 let pending_action = serde_json::json!({
439 "tool": "create_directory",
440 "args": { "path": raw_path },
441 "workdir": ctx.workdir.display().to_string(),
442 "turn_id": ctx.turn.0,
443 "call_id": ctx.call_id.0,
444 "task_id": ctx.task_id.clone(),
445 });
446 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
447 &ctx,
448 "create_directory",
449 raw_path,
450 std::slice::from_ref(&abs),
451 pending_action,
452 containment,
453 )
454 .await
455 {
456 return *outcome;
457 }
458 let _write_guard = tokio::select! {
461 biased;
462 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
463 g = super::path_lock::lock_path(&abs) => g,
464 };
465 if ctx.config.safety.checkpoint_on_mutation
467 && containment != PathContainment::Scratchpad
468 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
469 &ctx.workdir,
470 std::slice::from_ref(&abs),
471 Some(serde_json::json!({
472 "tool": "create_directory",
473 "path": raw_path,
474 })),
475 ctx.checkpoint_origin(),
476 )
477 {
478 return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
479 }
480 let display = raw_path.to_string();
481
482 tokio::select! {
483 biased;
484 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
485 result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
486 match result {
487 Ok(Ok(())) => {
488 let duration_secs = start.elapsed().as_secs_f64();
489 after_file_mutation(&ctx, "create_directory", &display);
490 ToolOutcome::success(
491 format!("Created directory {display}"),
492 "directory created",
493 duration_secs,
494 )
495 .with_metadata(ToolRunMetadata {
496 detail: ToolMetadata::CreateDirectory { path: display },
497 ..ToolRunMetadata::default()
498 })
499 },
500 Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
501 start.elapsed().as_secs_f64()),
502 Err(e) => err(&format!("create_directory join error: {e}"),
503 start.elapsed().as_secs_f64()),
504 }
505 }
506 }
507 }
508}
509
510pub struct WriteFileTool;
512
513#[async_trait]
514impl ToolExecutor for WriteFileTool {
515 fn name(&self) -> &'static str {
516 "write_file"
517 }
518
519 fn schema(&self) -> ToolDefinition {
520 defn(
521 "write_file",
522 "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths may be relative to the project directory or absolute. Prefer `apply_patch` for small targeted changes.",
523 serde_json::json!({
524 "type": "object",
525 "properties": {
526 "path": { "type": "string" },
527 "content": { "type": "string" }
528 },
529 "required": ["path", "content"]
530 }),
531 )
532 }
533
534 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
535 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
536 return ToolOutcome::error("write_file requires 'path' (string)", None);
537 };
538 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
539 return ToolOutcome::error("write_file requires 'content' (string)", None);
540 };
541
542 let start = std::time::Instant::now();
543 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
544 let ResolvedInRoot {
547 abs: abs_path,
548 rel,
549 root,
550 containment,
551 } = match resolve_in_roots(&roots, path) {
552 Ok(r) => r,
553 Err(e) => return ToolOutcome::error(format!("write_file: {e}"), None),
554 };
555 let pending_action = serde_json::json!({
556 "tool": "write_file",
557 "args": { "path": path, "content": content },
558 "workdir": ctx.workdir.display().to_string(),
559 "turn_id": ctx.turn.0,
560 "call_id": ctx.call_id.0,
561 "task_id": ctx.task_id.clone(),
562 });
563 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
564 &ctx,
565 "write_file",
566 path,
567 std::slice::from_ref(&abs_path),
568 pending_action,
569 containment,
570 )
571 .await
572 {
573 return *outcome;
574 }
575 let _write_guard = tokio::select! {
580 biased;
581 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
582 g = super::path_lock::lock_path(&abs_path) => g,
583 };
584 if ctx.config.safety.checkpoint_on_mutation
586 && containment != PathContainment::Scratchpad
587 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
588 &ctx.workdir,
589 std::slice::from_ref(&abs_path),
590 Some(serde_json::json!({
591 "tool": "write_file",
592 "path": path,
593 })),
594 ctx.checkpoint_origin(),
595 )
596 {
597 return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), None);
598 }
599 let display_path = path.to_string();
600 let line_count = content.lines().count();
601 let byte_count = content.len();
602 let content = content.to_string();
603
604 tokio::select! {
605 biased;
606 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
607 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
611 match result {
612 Ok(Ok(write)) => {
613 let duration_secs = start.elapsed().as_secs_f64();
614 after_file_mutation(&ctx, "write_file", &display_path);
615 ToolOutcome::success(
616 format!("Wrote {} ({} lines)", display_path, write.line_count),
617 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
618 duration_secs,
619 )
620 .with_metadata(ToolRunMetadata {
621 detail: ToolMetadata::WriteFile {
622 path: display_path,
623 line_count,
624 byte_count,
625 created: Some(write.created),
626 },
627 line_count: Some(line_count),
628 byte_count: Some(byte_count),
629 display_diff: Some(write.diff.display_diff),
630 diff_truncated: write.diff.truncated,
631 lines_added: write.diff.added,
632 lines_removed: write.diff.removed,
633 ..ToolRunMetadata::default()
634 })
635 },
636 Ok(Err(e)) => ToolOutcome::error(
637 format!("write_file({display_path}): {e}"),
638 Some(start.elapsed().as_secs_f64()),
639 ),
640 Err(e) => ToolOutcome::error(
641 format!("write_file join error: {e}"),
642 Some(start.elapsed().as_secs_f64()),
643 ),
644 }
645 }
646 }
647 }
648}
649
650pub struct EditFileTool;
652
653#[async_trait]
654impl ToolExecutor for EditFileTool {
655 fn name(&self) -> &'static str {
656 "edit_file"
657 }
658
659 fn schema(&self) -> ToolDefinition {
660 defn(
661 "edit_file",
662 "Perform a precise search-and-replace edit on an existing file. Replaces `target_content` with `replacement_content`. Fails if `target_content` is not found or matches multiple locations (unless `allow_multiple` is true). Matching tolerates minor whitespace and quotation drift. Paths may be relative to the project directory or absolute.",
663 serde_json::json!({
664 "type": "object",
665 "properties": {
666 "path": {
667 "type": "string",
668 "description": "Path to the file to edit."
669 },
670 "target_content": {
671 "type": "string",
672 "description": "The exact or near-exact block of text to replace. Must match uniquely in the file unless allow_multiple is true."
673 },
674 "replacement_content": {
675 "type": "string",
676 "description": "The new replacement text."
677 },
678 "allow_multiple": {
679 "type": "boolean",
680 "description": "If true, replace all occurrences of target_content instead of requiring uniqueness. Defaults to false."
681 }
682 },
683 "required": ["path", "target_content", "replacement_content"]
684 }),
685 )
686 }
687
688 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
689 let start = std::time::Instant::now();
690 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
691 return ToolOutcome::error("edit_file requires 'path' (string)", None);
692 };
693 let edit = match Edit::from_args(&args) {
694 Ok(edit) => edit,
695 Err(e) => return ToolOutcome::error(e, None),
696 };
697
698 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
699 let ResolvedInRoot {
700 abs: abs_path,
701 rel,
702 root,
703 containment,
704 } = match resolve_in_roots(&roots, path) {
705 Ok(r) => r,
706 Err(e) => return ToolOutcome::error(format!("edit_file: {e}"), None),
707 };
708
709 let mut pending_args = edit.args();
710 pending_args["path"] = serde_json::json!(path);
711 let pending_action = serde_json::json!({
712 "tool": "edit_file",
713 "args": pending_args,
714 "workdir": ctx.workdir.display().to_string(),
715 "turn_id": ctx.turn.0,
716 "call_id": ctx.call_id.0,
717 "task_id": ctx.task_id.clone(),
718 });
719 if let MutationGate::Blocked(outcome) = mutation_policy_outcome(
720 &ctx,
721 "edit_file",
722 path,
723 std::slice::from_ref(&abs_path),
724 pending_action,
725 containment,
726 )
727 .await
728 {
729 return *outcome;
730 }
731
732 let _write_guard = tokio::select! {
733 biased;
734 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
735 g = super::path_lock::lock_path(&abs_path) => g,
736 };
737
738 if ctx.config.safety.checkpoint_on_mutation
739 && containment != PathContainment::Scratchpad
740 && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
741 &ctx.workdir,
742 std::slice::from_ref(&abs_path),
743 Some(serde_json::json!({
744 "tool": "edit_file",
745 "path": path,
746 })),
747 ctx.checkpoint_origin(),
748 )
749 {
750 return ToolOutcome::error(format!("edit_file checkpoint failed: {e}"), None);
751 }
752
753 let display_path = path.to_string();
754 tokio::select! {
755 biased;
756 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
757 result = tokio::task::spawn_blocking(move || edit_file_blocking(&root, &rel, &edit)) => {
758 match result {
759 Ok(Ok(edit)) => {
760 let duration_secs = start.elapsed().as_secs_f64();
761 after_file_mutation(&ctx, "edit_file", &display_path);
762 edit_success_outcome(&display_path, edit, duration_secs)
763 },
764 Ok(Err(e)) => ToolOutcome::error(
765 format!("edit_file({display_path}): {e}"),
766 Some(start.elapsed().as_secs_f64()),
767 ),
768 Err(e) => ToolOutcome::error(
769 format!("edit_file join error: {e}"),
770 Some(start.elapsed().as_secs_f64()),
771 ),
772 }
773 }
774 }
775 }
776}
777
778fn edit_success_outcome(display_path: &str, edit: EditResult, duration_secs: f64) -> ToolOutcome {
779 let fuzzy_note = if edit.fuzzy {
780 "\nnote: matched with fuzzy (whitespace/Unicode) context; verify the result."
781 } else {
782 ""
783 };
784 ToolOutcome::success(
785 format!("Edited {display_path}{fuzzy_note}"),
786 diff_summary(edit.diff.added, edit.diff.removed, duration_secs),
787 duration_secs,
788 )
789 .with_metadata(ToolRunMetadata {
790 detail: ToolMetadata::ApplyPatch {
791 added: Vec::new(),
792 modified: vec![display_path.to_string()],
793 deleted: Vec::new(),
794 renamed: Vec::new(),
795 fuzzy: edit.fuzzy,
796 },
797 display_diff: Some(edit.diff.display_diff),
798 diff_truncated: edit.diff.truncated,
799 lines_added: edit.diff.added,
800 lines_removed: edit.diff.removed,
801 ..ToolRunMetadata::default()
802 })
803}
804
805fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
808 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
810 reject_web_url(p)?;
811 return Ok(vec![p.to_string()]);
812 }
813 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
814 if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
815 return Err(format!(
816 "read_file: too many paths ({}); cap is {} per call — split the request",
817 arr.len(),
818 mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
819 ));
820 }
821 let mut out = Vec::with_capacity(arr.len());
822 for v in arr {
823 let Some(s) = v.as_str() else {
824 return Err("read_file 'paths' must be an array of strings".to_string());
825 };
826 reject_web_url(s)?;
827 out.push(s.to_string());
828 }
829 return Ok(out);
830 }
831 Err("read_file requires 'path' or 'paths'".to_string())
832}
833
834fn reject_web_url(path: &str) -> Result<(), String> {
840 let head: String = path
841 .trim_start()
842 .chars()
843 .take(8)
844 .collect::<String>()
845 .to_ascii_lowercase();
846 if head.starts_with("http://") || head.starts_with("https://") {
847 return Err(format!(
848 "read_file reads local files; '{path}' is a web URL — use web_fetch for URLs"
849 ));
850 }
851 Ok(())
852}
853
854fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
862 if !Path::new(raw).is_absolute() {
863 return None;
864 }
865 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
866 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
867 && let Ok(rel) = relative_within(&root, raw)
868 {
869 return Some((root, rel));
870 }
871 }
872 None
873}
874
875struct ReadTarget {
878 root: PathBuf,
879 rel: PathBuf,
880 external: Option<PathBuf>,
883}
884
885fn resolve_read_target(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<ReadTarget> {
894 match resolve_in_roots(roots, raw) {
895 Ok(ResolvedInRoot {
896 abs,
897 rel,
898 root,
899 containment,
900 }) => match containment {
901 PathContainment::Project | PathContainment::Scratchpad => Ok(ReadTarget {
902 root,
903 rel,
904 external: None,
905 }),
906 PathContainment::External => Ok(resolve_in_memory_roots(roots.workdir, raw).map_or(
907 ReadTarget {
908 root,
909 rel,
910 external: Some(abs),
911 },
912 |(root, rel)| ReadTarget {
913 root,
914 rel,
915 external: None,
916 },
917 )),
918 },
919 Err(msg) => {
920 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
921 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
922 Ok(ReadTarget {
923 root,
924 rel,
925 external: None,
926 })
927 },
928 }
929}
930
931async fn external_read_gate(ctx: &ExecContext, raw: &str, abs: &Path) -> Option<ToolOutcome> {
941 let mut request = mermaid_runtime::ActionRequest::new(
942 "read_file",
943 mermaid_runtime::ToolCategory::ExternalDirectory,
944 format!("read_file {raw}"),
945 );
946 request.path = Some(abs.display().to_string());
947 let pending_action = serde_json::json!({
948 "tool": "read_file",
949 "args": { "path": raw },
950 "workdir": ctx.workdir.display().to_string(),
951 "turn_id": ctx.turn.0,
952 "call_id": ctx.call_id.0,
953 "task_id": ctx.task_id.clone(),
954 });
955 match super::policy_gate::gate(ctx, request, &[], pending_action, false, false).await {
956 super::policy_gate::Gate::Block(outcome) => Some(outcome),
957 super::policy_gate::Gate::Proceed { .. } => None,
958 }
959}
960
961#[derive(Debug, Default)]
967struct View {
968 line_numbers: bool,
969 range: Option<(usize, Option<usize>)>,
971}
972
973impl View {
974 fn from_args(args: &serde_json::Value) -> Result<Self, String> {
975 let line_numbers = args
976 .get("line_numbers")
977 .and_then(serde_json::Value::as_bool)
978 .unwrap_or(false);
979 let range = match args.get("view_range") {
980 None | Some(serde_json::Value::Null) => None,
981 Some(range) => {
982 let bad = || {
983 format!(
984 "view_range must be [start, end] with 1 <= start and end >= start or \
985 end = -1, got {range}"
986 )
987 };
988 let [start, end] = range.as_array().map(Vec::as_slice).ok_or_else(bad)? else {
989 return Err(bad());
990 };
991 let start = start
992 .as_u64()
993 .filter(|s| *s >= 1)
994 .and_then(|s| usize::try_from(s).ok())
995 .ok_or_else(bad)?;
996 let end = match end.as_i64() {
997 Some(-1) => None,
998 Some(e) => Some(
999 usize::try_from(e)
1000 .ok()
1001 .filter(|e| *e >= start)
1002 .ok_or_else(bad)?,
1003 ),
1004 None => return Err(bad()),
1005 };
1006 Some((start, end))
1007 },
1008 };
1009 Ok(Self {
1010 line_numbers: line_numbers || range.is_some(),
1011 range,
1012 })
1013 }
1014
1015 fn apply(&self, content: &str) -> Result<String, String> {
1016 if !self.line_numbers {
1017 return Ok(content.to_string());
1018 }
1019 let total = content.lines().count();
1020 let (start, end) = self.range.unwrap_or((1, None));
1021 if start > total.max(1) {
1022 return Err(format!(
1023 "view_range starts at line {start}, but the file has {total} lines"
1024 ));
1025 }
1026 let end = end.unwrap_or(total).min(total);
1027 let mut out = String::new();
1028 for (idx, line) in content.lines().enumerate().take(end).skip(start - 1) {
1029 out.push_str(&format!("{:>6}\t{line}\n", idx + 1));
1030 }
1031 Ok(out)
1032 }
1033}
1034
1035const DIRECTORY_LISTING_CAP: usize = 1_000;
1037
1038async fn read_target(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool, bool)> {
1042 let abs = root.join(&rel);
1043 if tokio::fs::metadata(&abs).await.is_ok_and(|m| m.is_dir()) {
1044 let listing = tokio::task::spawn_blocking(move || list_directory(&abs))
1045 .await
1046 .map_err(|e| std::io::Error::other(e.to_string()))?;
1047 return Ok((listing.0, listing.1, true));
1048 }
1049 let (content, truncated) = read_one(root, rel).await?;
1050 Ok((content, truncated, false))
1051}
1052
1053fn list_directory(dir: &Path) -> (String, bool) {
1056 let mut entries: Vec<String> = ignore::WalkBuilder::new(dir)
1057 .max_depth(Some(2))
1058 .hidden(true)
1059 .build()
1060 .filter_map(Result::ok)
1061 .filter(|entry| entry.depth() > 0)
1062 .filter_map(|entry| {
1063 let rel = entry.path().strip_prefix(dir).ok()?.display().to_string();
1064 let is_dir = entry.file_type().is_some_and(|t| t.is_dir());
1065 Some(if is_dir { format!("{rel}/") } else { rel })
1066 })
1067 .collect();
1068 entries.sort();
1069 let truncated = entries.len() > DIRECTORY_LISTING_CAP;
1070 entries.truncate(DIRECTORY_LISTING_CAP);
1071 let mut out = entries.join("\n");
1072 if truncated {
1073 out.push_str(&format!(
1074 "\n[TRUNCATED: listing stops at {DIRECTORY_LISTING_CAP} entries]"
1075 ));
1076 }
1077 (out, truncated)
1078}
1079
1080async fn read_one(root: PathBuf, rel: PathBuf) -> std::io::Result<(String, bool)> {
1090 let result = tokio::task::spawn_blocking(move || {
1091 let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
1092 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
1096 let mut s = String::from_utf8_lossy(&data).into_owned();
1097 if truncated {
1098 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
1100 s.truncate(cut);
1101 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
1102 }
1103 Ok::<_, std::io::Error>((s, truncated))
1104 })
1105 .await
1106 .map_err(|e| std::io::Error::other(e.to_string()))??;
1107 Ok(result)
1108}
1109
1110fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
1119 if let Some(parent) = rel.parent()
1120 && !parent.as_os_str().is_empty()
1121 {
1122 mermaid_runtime::create_dir_all_beneath(root, parent)?;
1123 }
1124 mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
1125 Ok(content.lines().count())
1126}
1127
1128struct WriteResult {
1129 line_count: usize,
1130 created: bool,
1131 diff: mermaid_model::diff::DisplayDiff,
1132}
1133
1134fn write_with_diff_blocking(
1141 root: &Path,
1142 abs_path: &Path,
1143 rel: &Path,
1144 content: &str,
1145) -> std::io::Result<WriteResult> {
1146 let (old_content, created, elide_diff) =
1147 match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
1148 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
1149 Ok((_, true)) => (String::new(), false, true),
1151 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
1153 Err(_) => (String::new(), false, true),
1155 };
1156 let diff = if elide_diff {
1157 mermaid_model::diff::DisplayDiff {
1158 display_diff: format!(
1159 "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
1160 ),
1161 added: 0,
1162 removed: 0,
1163 truncated: true,
1164 }
1165 } else {
1166 mermaid_model::diff::generate_display_diff(&old_content, content)
1167 };
1168 let line_count = write_one_blocking(root, rel, content)?;
1169 Ok(WriteResult {
1170 line_count,
1171 created,
1172 diff,
1173 })
1174}
1175
1176struct EditResult {
1177 diff: mermaid_model::diff::DisplayDiff,
1178 fuzzy: bool,
1179}
1180
1181enum Edit {
1183 Replace {
1184 target: String,
1185 replacement: String,
1186 allow_multiple: bool,
1187 },
1188 Insert {
1189 after_line: usize,
1190 text: String,
1191 },
1192}
1193
1194impl Edit {
1195 fn from_args(args: &serde_json::Value) -> Result<Self, String> {
1200 let text = |key: &str| args.get(key).and_then(serde_json::Value::as_str);
1201 let replacement = text("replacement_content")
1202 .ok_or("edit_file requires 'replacement_content' (string)")?
1203 .to_string();
1204 if let Some(line) = args.get("insert_line") {
1205 let after_line = line
1206 .as_u64()
1207 .and_then(|l| usize::try_from(l).ok())
1208 .ok_or("edit_file 'insert_line' must be a non-negative line number")?;
1209 return Ok(Self::Insert {
1210 after_line,
1211 text: replacement,
1212 });
1213 }
1214 Ok(Self::Replace {
1215 target: text("target_content")
1216 .ok_or("edit_file requires 'target_content' (string)")?
1217 .to_string(),
1218 replacement,
1219 allow_multiple: args
1220 .get("allow_multiple")
1221 .and_then(serde_json::Value::as_bool)
1222 .unwrap_or(false),
1223 })
1224 }
1225
1226 fn args(&self) -> serde_json::Value {
1228 match self {
1229 Self::Replace {
1230 target,
1231 replacement,
1232 allow_multiple,
1233 } => serde_json::json!({
1234 "target_content": target,
1235 "replacement_content": replacement,
1236 "allow_multiple": allow_multiple,
1237 }),
1238 Self::Insert { after_line, text } => serde_json::json!({
1239 "insert_line": after_line,
1240 "replacement_content": text,
1241 }),
1242 }
1243 }
1244}
1245
1246fn edit_file_blocking(root: &Path, rel: &Path, edit: &Edit) -> Result<EditResult, String> {
1247 let file = mermaid_runtime::open_beneath(root, rel, mermaid_runtime::OpenIntent::Read)
1248 .map_err(|e| format!("cannot open file: {e}"))?;
1249 let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)
1250 .map_err(|e| format!("cannot read file: {e}"))?;
1251 if truncated {
1252 return Err(format!(
1253 "file exceeds maximum size limit ({MAX_FILE_READ_BYTES} bytes)"
1254 ));
1255 }
1256 let original = String::from_utf8_lossy(&data).into_owned();
1257 let applied = match edit {
1258 Edit::Replace {
1259 target,
1260 replacement,
1261 allow_multiple,
1262 } => mermaid_runtime::replace_content(&original, target, replacement, *allow_multiple),
1263 Edit::Insert { after_line, text } => {
1264 mermaid_runtime::insert_content(&original, *after_line, text)
1265 },
1266 }
1267 .map_err(|e| e.to_string())?;
1268
1269 write_one_blocking(root, rel, &applied.new_contents)
1270 .map_err(|e| format!("cannot write file: {e}"))?;
1271
1272 let diff = mermaid_model::diff::generate_display_diff(&original, &applied.new_contents);
1273 Ok(EditResult {
1274 diff,
1275 fuzzy: applied.fuzzy,
1276 })
1277}
1278
1279pub(super) enum MutationGate {
1281 Blocked(Box<ToolOutcome>),
1283 Proceed,
1284}
1285
1286pub(super) async fn mutation_policy_outcome(
1301 ctx: &ExecContext,
1302 tool: &str,
1303 path: &str,
1304 checkpoint_paths: &[PathBuf],
1305 pending_action: serde_json::Value,
1306 containment: PathContainment,
1307) -> MutationGate {
1308 let category = match containment {
1309 PathContainment::Project | PathContainment::Scratchpad => {
1310 mermaid_runtime::ToolCategory::Edit
1311 },
1312 PathContainment::External => mermaid_runtime::ToolCategory::ExternalDirectory,
1313 };
1314 let mut request = mermaid_runtime::ActionRequest::new(tool, category, format!("{tool} {path}"));
1315 request.path = Some(path.to_string());
1316 match super::policy_gate::gate(
1319 ctx,
1320 request,
1321 checkpoint_paths,
1322 pending_action,
1323 true,
1324 containment == PathContainment::Scratchpad,
1325 )
1326 .await
1327 {
1328 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
1329 super::policy_gate::Gate::Proceed { .. } => {
1330 let _ = mermaid_runtime::run_plugin_hooks(
1331 "before_file_mutation",
1332 &serde_json::json!({
1333 "task_id": ctx.task_id.clone(),
1334 "turn_id": ctx.turn.0,
1335 "call_id": ctx.call_id.0,
1336 "tool": tool,
1337 "path": path,
1338 }),
1339 );
1340 MutationGate::Proceed
1341 },
1342 }
1343}
1344
1345pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
1346 let _ = mermaid_runtime::run_plugin_hooks(
1347 "after_file_mutation",
1348 &serde_json::json!({
1349 "task_id": ctx.task_id.clone(),
1350 "turn_id": ctx.turn.0,
1351 "call_id": ctx.call_id.0,
1352 "tool": tool,
1353 "path": path,
1354 }),
1355 );
1356}
1357
1358fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
1359 ToolOutcome::error(msg, Some(duration_secs))
1360}
1361
1362fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
1363 if count == 1 { singular } else { plural }
1364}
1365
1366pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
1367 format!(
1368 "+{} -{}, took {}",
1369 added,
1370 removed,
1371 format_duration_for_diff(duration_secs)
1372 )
1373}
1374
1375fn format_duration_for_diff(seconds: f64) -> String {
1376 if seconds < 1.0 {
1377 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
1378 } else if seconds < 10.0 {
1379 format!("{seconds:.1}s")
1380 } else {
1381 format!("{}s", seconds.round() as u64)
1382 }
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387 use super::*;
1388 use crate::providers::ctx::test_exec_context;
1389 use mermaid_domain::{ToolCallId, TurnId};
1390 use std::fs;
1391
1392 #[tokio::test]
1398 async fn read_file_resolves_memory_roots_read_only() {
1399 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
1400 let _ = fs::remove_dir_all(&base);
1401 let repo = base.join("repo");
1402 let workdir = repo.join("src");
1403 fs::create_dir_all(&workdir).unwrap();
1404 fs::create_dir_all(repo.join(".git")).unwrap();
1405 let mem_dir = repo.join(".mermaid").join("memory");
1406 fs::create_dir_all(&mem_dir).unwrap();
1407 let fact = mem_dir.join("fact.md");
1408 fs::write(&fact, "the fact body").unwrap();
1409
1410 let roots = AllowedRoots::new(&workdir, None);
1411 let target = resolve_read_target(&roots, fact.to_str().unwrap()).unwrap();
1414 assert!(target.external.is_none(), "memory reads are not external");
1415 let (content, truncated) = read_one(target.root, target.rel).await.unwrap();
1416 assert!(!truncated);
1417 assert_eq!(content, "the fact body");
1418
1419 let stray = base.join("stray.txt");
1422 fs::write(&stray, "nope").unwrap();
1423 let target = resolve_read_target(&roots, stray.to_str().unwrap()).unwrap();
1424 assert_eq!(
1425 target.external.as_deref().map(|p| p.ends_with("stray.txt")),
1426 Some(true),
1427 "a path outside every root is external"
1428 );
1429
1430 let _ = fs::remove_dir_all(&base);
1431 }
1432
1433 #[test]
1434 fn resolve_in_roots_contains_to_workdir() {
1435 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
1436 let _ = fs::remove_dir_all(&root);
1437 fs::create_dir_all(root.join("sub")).unwrap();
1438 let roots = AllowedRoots::new(&root, None);
1439
1440 assert!(resolve_in_roots(&roots, "sub").is_ok());
1442 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
1443 let canon_root = fs::canonicalize(&root).unwrap();
1444 assert!(resolved.abs.starts_with(&canon_root));
1445
1446 assert!(resolve_in_roots(&roots, "../escape.txt").is_ok());
1448 let outside = std::env::temp_dir().join("definitely_outside.txt");
1449 let r = resolve_in_roots(&roots, &outside.display().to_string()).unwrap();
1450 assert!(r.abs.ends_with(Path::new("definitely_outside.txt")));
1451
1452 let _ = fs::remove_dir_all(&root);
1453 }
1454
1455 fn temp_root(name: &str) -> PathBuf {
1456 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
1457 let _ = fs::remove_dir_all(&p);
1458 fs::create_dir_all(&p).expect("create tmpdir");
1459 p
1460 }
1461
1462 #[tokio::test]
1463 async fn read_file_returns_content() {
1464 let dir = temp_root("read_ok");
1465 fs::write(dir.join("a.txt"), "hello").expect("write");
1466 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1467
1468 let tool = ReadFileTool;
1469 let outcome = tool
1470 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1471 .await;
1472 assert!(outcome.is_success(), "expected success: {outcome:?}");
1473 assert_eq!(outcome.output(), "hello");
1474 let _ = fs::remove_dir_all(&dir);
1475 }
1476
1477 #[tokio::test]
1480 async fn read_file_numbers_lines_and_cuts_ranges_for_the_text_editor() {
1481 let dir = temp_root("read_view");
1482 fs::write(dir.join("a.txt"), "one\ntwo\nthree\n").expect("write");
1483 let read = |args: serde_json::Value, call: u64| {
1484 let (ctx, _rx) = test_exec_context(TurnId(call), ToolCallId(call), dir.clone());
1485 async move { ReadFileTool.execute(args, ctx).await }
1486 };
1487 let all = read(
1488 serde_json::json!({"path": "a.txt", "line_numbers": true}),
1489 1,
1490 )
1491 .await;
1492 assert_eq!(all.output(), " 1\tone\n 2\ttwo\n 3\tthree\n");
1493 let ranged = read(
1494 serde_json::json!({"path": "a.txt", "view_range": [2, -1]}),
1495 2,
1496 )
1497 .await;
1498 assert_eq!(ranged.output(), " 2\ttwo\n 3\tthree\n");
1499 let one = read(
1500 serde_json::json!({"path": "a.txt", "view_range": [2, 2]}),
1501 3,
1502 )
1503 .await;
1504 assert_eq!(one.output(), " 2\ttwo\n");
1505 for bad in [
1506 serde_json::json!([0, 2]),
1507 serde_json::json!([3, 1]),
1508 serde_json::json!([9, -1]),
1509 serde_json::json!("1-2"),
1510 ] {
1511 let outcome = read(serde_json::json!({"path": "a.txt", "view_range": bad}), 4).await;
1512 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error, "{bad}");
1513 }
1514 let plain = read(serde_json::json!({"path": "a.txt"}), 5).await;
1515 assert_eq!(plain.output(), "one\ntwo\nthree\n", "unchanged by default");
1516 let _ = fs::remove_dir_all(&dir);
1517 }
1518
1519 #[tokio::test]
1520 async fn read_file_lists_a_directory_two_levels_deep() {
1521 let dir = temp_root("read_dir");
1522 fs::create_dir_all(dir.join("src/deep/deeper")).expect("mkdir");
1523 fs::write(dir.join("src/lib.rs"), "").expect("write");
1524 fs::write(dir.join("src/deep/deeper/x.rs"), "").expect("write");
1525 fs::write(dir.join(".hidden"), "").expect("write");
1526 fs::write(dir.join("README.md"), "").expect("write");
1527 for (turn, args) in [
1528 serde_json::json!({"path": "."}),
1529 serde_json::json!({"path": ".", "line_numbers": true}),
1530 ]
1531 .into_iter()
1532 .enumerate()
1533 {
1534 let turn = TurnId(turn as u64 + 1);
1535 let (ctx, _rx) = test_exec_context(turn, ToolCallId(1), dir.clone());
1536 let outcome = ReadFileTool.execute(args, ctx).await;
1537 assert!(outcome.is_success(), "{outcome:?}");
1538 assert_eq!(
1539 outcome.output(),
1540 format!(
1541 "README.md\nsrc/\nsrc{0}deep/\nsrc{0}lib.rs",
1542 std::path::MAIN_SEPARATOR
1543 )
1544 );
1545 }
1546 let _ = fs::remove_dir_all(&dir);
1547 }
1548
1549 #[tokio::test]
1552 async fn edit_file_inserts_after_a_line() {
1553 let dir = temp_root("edit_insert");
1554 fs::write(dir.join("a.txt"), "one\ntwo\n").expect("write");
1555 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1556 let outcome = EditFileTool
1557 .execute(
1558 serde_json::json!({"path": "a.txt", "insert_line": 1, "replacement_content": "mid"}),
1559 ctx,
1560 )
1561 .await;
1562 assert!(outcome.is_success(), "{outcome:?}");
1563 assert_eq!(
1564 fs::read_to_string(dir.join("a.txt")).unwrap(),
1565 "one\nmid\ntwo\n"
1566 );
1567
1568 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1569 let past_end = EditFileTool
1570 .execute(
1571 serde_json::json!({"path": "a.txt", "insert_line": 9, "replacement_content": "x"}),
1572 ctx,
1573 )
1574 .await;
1575 assert_eq!(past_end.status, mermaid_domain::ToolStatus::Error);
1576 assert_eq!(
1577 fs::read_to_string(dir.join("a.txt")).unwrap(),
1578 "one\nmid\ntwo\n"
1579 );
1580 let _ = fs::remove_dir_all(&dir);
1581 }
1582
1583 #[tokio::test]
1584 async fn read_file_rejects_web_urls_with_a_web_fetch_hint() {
1585 let dir = temp_root("read_url");
1590 fs::write(dir.join("a.txt"), "hello").expect("write");
1591 for args in [
1592 serde_json::json!({"path": "https://learn.microsoft.com/clipboard"}),
1593 serde_json::json!({"path": "HTTP://example.com/x"}),
1594 serde_json::json!({"paths": ["a.txt", "https://example.com/x"]}),
1595 ] {
1596 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1597 let outcome = ReadFileTool.execute(args, ctx).await;
1598 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1599 let msg = outcome.error_message().unwrap_or_default();
1600 assert!(msg.contains("web_fetch"), "must name the right tool: {msg}");
1601 }
1602 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1603 let outcome = ReadFileTool
1604 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1605 .await;
1606 assert!(outcome.is_success(), "plain local reads must still work");
1607 let _ = fs::remove_dir_all(&dir);
1608 }
1609
1610 #[tokio::test]
1611 async fn duplicate_same_turn_read_collapses_to_a_reuse_note() {
1612 let dir = temp_root("read_dedup");
1613 fs::write(dir.join("a.txt"), "line one\nline two").expect("write");
1614
1615 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(1), dir.clone());
1617 let outcome = ReadFileTool
1618 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1619 .await;
1620 assert_eq!(outcome.output(), "line one\nline two");
1621
1622 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(2), dir.clone());
1625 let outcome = ReadFileTool
1626 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1627 .await;
1628 assert!(outcome.is_success());
1629 assert!(
1630 outcome
1631 .output()
1632 .contains("unchanged since your read earlier this turn"),
1633 "{}",
1634 outcome.output()
1635 );
1636 assert!(outcome.output().contains("2 lines"), "{}", outcome.output());
1637 assert!(
1638 !outcome.output().contains("line two"),
1639 "the body must not repeat: {}",
1640 outcome.output()
1641 );
1642
1643 fs::write(dir.join("a.txt"), "line one\nline two\nline three").expect("write");
1649 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(3), dir.clone());
1650 let outcome = ReadFileTool
1651 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1652 .await;
1653 assert_eq!(
1654 outcome.output(),
1655 "line one\nline two\nline three",
1656 "a changed file must read in full"
1657 );
1658
1659 let (ctx, _rx) = test_exec_context(TurnId(9), ToolCallId(4), dir.clone());
1661 let outcome = ReadFileTool
1662 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1663 .await;
1664 assert!(
1665 outcome.output().contains("unchanged since"),
1666 "{}",
1667 outcome.output()
1668 );
1669
1670 let (ctx, _rx) = test_exec_context(TurnId(10), ToolCallId(5), dir.clone());
1674 let outcome = ReadFileTool
1675 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1676 .await;
1677 assert_eq!(outcome.output(), "line one\nline two\nline three");
1678
1679 let _ = fs::remove_dir_all(&dir);
1680 }
1681
1682 #[tokio::test]
1683 async fn read_file_missing_path_errors() {
1684 let dir = temp_root("read_missing_path");
1685 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1686 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1687 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1688 let _ = fs::remove_dir_all(&dir);
1689 }
1690
1691 #[tokio::test]
1692 async fn read_file_nonexistent_errors() {
1693 let dir = temp_root("read_nonex");
1694 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1695 let outcome = ReadFileTool
1696 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1697 .await;
1698 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1699 let _ = fs::remove_dir_all(&dir);
1700 }
1701
1702 #[tokio::test]
1703 async fn read_file_with_multiple_paths_joins_contents() {
1704 let dir = temp_root("read_multi");
1705 fs::write(dir.join("a.txt"), "alpha").expect("write");
1706 fs::write(dir.join("b.txt"), "beta").expect("write");
1707 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1708 let outcome = ReadFileTool
1709 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1710 .await;
1711 assert!(outcome.is_success(), "expected success: {outcome:?}");
1712 let output = outcome.output();
1713 assert!(output.contains("=== a.txt ==="));
1714 assert!(output.contains("alpha"));
1715 assert!(output.contains("=== b.txt ==="));
1716 assert!(output.contains("beta"));
1717 let _ = fs::remove_dir_all(&dir);
1718 }
1719
1720 #[tokio::test]
1721 async fn read_file_multi_aggregate_is_capped() {
1722 let dir = temp_root("read_aggregate_cap");
1725 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
1726 fs::write(dir.join("a.txt"), &chunk).expect("write a");
1727 fs::write(dir.join("b.txt"), &chunk).expect("write b");
1728 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1729 let outcome = ReadFileTool
1730 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1731 .await;
1732 assert!(outcome.is_success(), "expected success: {outcome:?}");
1733 let output = outcome.output();
1734 assert!(
1735 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
1736 "combined must be capped, got {} bytes",
1737 output.len()
1738 );
1739 assert!(
1740 output.contains("elided"),
1741 "expected aggregate head+tail elision marker"
1742 );
1743 match &outcome.metadata.detail {
1744 ToolMetadata::ReadFile { truncated, .. } => {
1745 assert!(*truncated, "aggregate truncation must set truncated")
1746 },
1747 other => panic!("expected ReadFile metadata, got {other:?}"),
1748 }
1749 let _ = fs::remove_dir_all(&dir);
1750 }
1751
1752 #[tokio::test]
1753 async fn write_file_elides_diff_for_oversized_existing_file() {
1754 let dir = temp_root("write_oversized_diff");
1757 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
1758 fs::write(dir.join("big.txt"), &big).expect("write fixture");
1759 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1760 let outcome = WriteFileTool
1761 .execute(
1762 serde_json::json!({"path": "big.txt", "content": "small\n"}),
1763 ctx,
1764 )
1765 .await;
1766 assert!(outcome.is_success(), "expected success: {outcome:?}");
1767 let diff = outcome
1768 .metadata
1769 .display_diff
1770 .as_deref()
1771 .expect("display diff");
1772 assert!(
1773 diff.contains("diff preview skipped"),
1774 "expected elision marker, got: {diff}"
1775 );
1776 assert!(
1777 outcome.metadata.diff_truncated,
1778 "oversized diff must set diff_truncated"
1779 );
1780 match &outcome.metadata.detail {
1781 ToolMetadata::WriteFile { created, .. } => {
1782 assert_eq!(*created, Some(false), "existing file is not 'created'")
1783 },
1784 other => panic!("expected WriteFile metadata, got {other:?}"),
1785 }
1786 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
1788 assert_eq!(written, "small\n");
1789 let _ = fs::remove_dir_all(&dir);
1790 }
1791
1792 #[tokio::test]
1793 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
1794 let dir = temp_root("read_marker_content");
1798 fs::write(
1799 dir.join("a.txt"),
1800 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
1801 )
1802 .expect("write");
1803 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1804
1805 let outcome = ReadFileTool
1806 .execute(serde_json::json!({"path": "a.txt"}), ctx)
1807 .await;
1808 assert!(outcome.is_success(), "expected success: {outcome:?}");
1809 match &outcome.metadata.detail {
1810 ToolMetadata::ReadFile { truncated, .. } => assert!(
1811 !truncated,
1812 "a file whose content contains the marker must not be flagged truncated"
1813 ),
1814 other => panic!("expected ReadFile metadata, got {other:?}"),
1815 }
1816 let _ = fs::remove_dir_all(&dir);
1817 }
1818
1819 #[tokio::test]
1820 async fn read_file_respects_cancellation() {
1821 let dir = temp_root("read_cancel");
1822 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1827 ctx.token.cancel();
1828 let outcome = ReadFileTool
1829 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1830 .await;
1831 assert!(outcome.was_cancelled());
1832 let _ = fs::remove_dir_all(&dir);
1833 }
1834
1835 #[tokio::test]
1836 async fn write_file_creates_and_counts_lines() {
1837 let dir = temp_root("write_ok");
1838 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1839 let outcome = WriteFileTool
1840 .execute(
1841 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1842 ctx,
1843 )
1844 .await;
1845 assert!(outcome.is_success(), "expected success: {outcome:?}");
1846 assert!(outcome.output().contains("3 lines"));
1847 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1848 assert!(written.contains("line1"));
1849 let _ = fs::remove_dir_all(&dir);
1850 }
1851
1852 #[tokio::test]
1853 async fn concurrent_write_file_same_path_serializes_cleanly() {
1854 let dir = temp_root("write_race");
1858 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1859 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1860 let a = "AAAA\nAAAA\n";
1861 let b = "BBBB\nBBBB\n";
1862 let (o1, o2) = tokio::join!(
1863 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1864 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1865 );
1866 assert!(o1.is_success(), "first write failed: {o1:?}");
1867 assert!(o2.is_success(), "second write failed: {o2:?}");
1868 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1869 assert!(
1870 final_content == a || final_content == b,
1871 "file must be exactly one clean write, got {final_content:?}"
1872 );
1873 let _ = fs::remove_dir_all(&dir);
1874 }
1875
1876 #[tokio::test]
1877 async fn write_file_new_file_records_added_display_diff() {
1878 let dir = temp_root("write_new_diff");
1879 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1880 let outcome = WriteFileTool
1881 .execute(
1882 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1883 ctx,
1884 )
1885 .await;
1886 assert!(outcome.is_success(), "expected success: {outcome:?}");
1887 let diff = outcome
1888 .metadata
1889 .display_diff
1890 .as_deref()
1891 .expect("display diff");
1892 assert!(diff.contains("+ alpha"));
1893 assert!(diff.contains("+ beta"));
1894 assert!(
1896 !diff.contains("@@"),
1897 "diff should not carry hunk headers: {diff}"
1898 );
1899 assert!(!diff.contains("/dev/null"));
1900 let _ = fs::remove_dir_all(&dir);
1901 }
1902
1903 #[tokio::test]
1904 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1905 let dir = temp_root("write_existing_diff");
1906 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1907 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1908 let outcome = WriteFileTool
1909 .execute(
1910 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1911 ctx,
1912 )
1913 .await;
1914 assert!(outcome.is_success(), "expected success: {outcome:?}");
1915 let diff = outcome
1916 .metadata
1917 .display_diff
1918 .as_deref()
1919 .expect("display diff");
1920 assert!(diff.contains("- old"));
1921 assert!(diff.contains("+ new"));
1922 let _ = fs::remove_dir_all(&dir);
1923 }
1924
1925 #[tokio::test]
1926 async fn write_file_creates_parent_dirs() {
1927 let dir = temp_root("write_parents");
1928 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1929 let outcome = WriteFileTool
1930 .execute(
1931 serde_json::json!({
1932 "path": "sub/nested/out.txt",
1933 "content": "deep",
1934 }),
1935 ctx,
1936 )
1937 .await;
1938 assert!(outcome.is_success(), "expected success: {outcome:?}");
1939 assert!(dir.join("sub/nested/out.txt").exists());
1940 let _ = fs::remove_dir_all(&dir);
1941 }
1942
1943 #[tokio::test]
1944 async fn write_file_missing_content_errors() {
1945 let dir = temp_root("write_missing");
1946 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1947 let outcome = WriteFileTool
1948 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1949 .await;
1950 assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1951 let _ = fs::remove_dir_all(&dir);
1952 }
1953
1954 #[tokio::test]
1961 async fn read_file_allows_absolute_path_outside_workdir() {
1962 let dir = temp_root("read_abs_escape");
1963 let external_dir = temp_root("read_abs_external");
1964 let external_file = external_dir.join("ext.txt");
1965 fs::write(&external_file, "external content").unwrap();
1966 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1967 let outcome = ReadFileTool
1968 .execute(
1969 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
1970 ctx,
1971 )
1972 .await;
1973 assert!(outcome.is_success(), "expected success: {outcome:?}");
1974 assert_eq!(outcome.output(), "external content");
1975 let _ = fs::remove_dir_all(&dir);
1976 let _ = fs::remove_dir_all(&external_dir);
1977 }
1978
1979 #[tokio::test]
1981 async fn read_file_accepts_absolute_path_inside_workdir() {
1982 let dir = temp_root("read_abs_inside");
1983 let file = dir.join("hello.txt");
1984 fs::write(&file, "ok").expect("write fixture");
1985 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1986 let outcome = ReadFileTool
1987 .execute(
1988 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1989 ctx,
1990 )
1991 .await;
1992 assert!(outcome.is_success(), "expected success: {outcome:?}");
1993 let _ = fs::remove_dir_all(&dir);
1994 }
1995
1996 #[tokio::test]
1998 async fn write_file_allows_relative_parent_and_external_path() {
1999 let base = temp_root("write_dotdot_escape");
2000 let dir = base.join("project");
2001 fs::create_dir_all(&dir).unwrap();
2002 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
2003 let outcome = WriteFileTool
2004 .execute(
2005 serde_json::json!({
2006 "path": "../escape.txt",
2007 "content": "written outside",
2008 }),
2009 ctx,
2010 )
2011 .await;
2012 assert!(
2013 outcome.is_success(),
2014 "expected success for write to ../escape.txt"
2015 );
2016 let written = base.join("escape.txt");
2017 assert!(written.exists());
2018 assert_eq!(fs::read_to_string(&written).unwrap(), "written outside");
2019 let _ = fs::remove_dir_all(&base);
2020 }
2021
2022 #[tokio::test]
2027 async fn create_directory_allows_absolute_path_outside_workdir() {
2028 let dir = temp_root("mkdir_abs_proj");
2029 let target = std::env::temp_dir().join(format!("mermaid_fs_target_{}", std::process::id()));
2030 let _ = fs::remove_dir_all(&target);
2031 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
2032 let outcome = CreateDirectoryTool
2033 .execute(
2034 serde_json::json!({"path": target.to_string_lossy().to_string()}),
2035 ctx,
2036 )
2037 .await;
2038 assert!(outcome.is_success(), "expected success: {outcome:?}");
2039 assert!(target.exists());
2040 let _ = fs::remove_dir_all(&target);
2041 let _ = fs::remove_dir_all(&dir);
2042 }
2043
2044 fn scratch_ctx(
2050 mode: mermaid_runtime::SafetyMode,
2051 workdir: PathBuf,
2052 scratchpad: Option<PathBuf>,
2053 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2054 let mut config = mermaid_domain::Config::default();
2055 config.safety.mode = mode;
2056 let (mut ctx, rx) = crate::providers::ctx::test_exec_context_with_config(
2057 TurnId(1),
2058 ToolCallId(1),
2059 workdir,
2060 config,
2061 );
2062 ctx.scratchpad = scratchpad;
2063 (ctx, rx)
2064 }
2065
2066 fn scratch_fixture(name: &str) -> (PathBuf, PathBuf) {
2068 let base = std::env::temp_dir().join(format!(
2069 "mermaid_fs_scratch_{}_{}",
2070 name,
2071 std::process::id()
2072 ));
2073 let _ = fs::remove_dir_all(&base);
2074 let project = base.join("project");
2075 let scratch = base.join("scratch");
2076 fs::create_dir_all(&project).unwrap();
2077 fs::create_dir_all(&scratch).unwrap();
2078 (project, scratch)
2079 }
2080
2081 fn any_checkpoint_mentions(marker: &str) -> bool {
2085 let Ok(data) = mermaid_runtime::data_dir() else {
2086 return false;
2087 };
2088 let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
2089 return false;
2090 };
2091 entries.flatten().any(|entry| {
2092 fs::read_to_string(entry.path().join("manifest.json"))
2093 .is_ok_and(|manifest| manifest.contains(marker))
2094 })
2095 }
2096
2097 #[tokio::test]
2100 async fn scratch_mutations_are_ungated_and_never_checkpointed() {
2101 let (project, scratch) = scratch_fixture("ungated");
2102 let marker = scratch.display().to_string();
2103
2104 let file = scratch.join("notes.txt");
2106 let (ctx, _rx) = scratch_ctx(
2107 mermaid_runtime::SafetyMode::Ask,
2108 project.clone(),
2109 Some(scratch.clone()),
2110 );
2111 let outcome = WriteFileTool
2112 .execute(
2113 serde_json::json!({
2114 "path": file.to_str().unwrap(),
2115 "content": "scratch note\n",
2116 }),
2117 ctx,
2118 )
2119 .await;
2120 assert!(outcome.is_success(), "scratch write: {outcome:?}");
2121 assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
2122
2123 let subdir = scratch.join("work/area");
2125 let (ctx, _rx) = scratch_ctx(
2126 mermaid_runtime::SafetyMode::Ask,
2127 project.clone(),
2128 Some(scratch.clone()),
2129 );
2130 let outcome = CreateDirectoryTool
2131 .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
2132 .await;
2133 assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
2134 assert!(subdir.is_dir());
2135
2136 let (ctx, _rx) = scratch_ctx(
2138 mermaid_runtime::SafetyMode::Ask,
2139 project.clone(),
2140 Some(scratch.clone()),
2141 );
2142 let outcome = DeleteFileTool
2143 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
2144 .await;
2145 assert!(outcome.is_success(), "scratch delete: {outcome:?}");
2146 assert!(!file.exists());
2147
2148 assert!(
2150 !any_checkpoint_mentions(&marker),
2151 "scratch mutation must not create a checkpoint"
2152 );
2153 let _ = fs::remove_dir_all(project.parent().unwrap());
2154 }
2155
2156 #[tokio::test]
2159 async fn scratch_mutation_blocked_in_read_only() {
2160 let (project, scratch) = scratch_fixture("readonly");
2161 let file = scratch.join("blocked.txt");
2162 let (ctx, _rx) = scratch_ctx(
2163 mermaid_runtime::SafetyMode::ReadOnly,
2164 project.clone(),
2165 Some(scratch.clone()),
2166 );
2167 let outcome = WriteFileTool
2168 .execute(
2169 serde_json::json!({
2170 "path": file.to_str().unwrap(),
2171 "content": "nope",
2172 }),
2173 ctx,
2174 )
2175 .await;
2176 let error = outcome.error_message().expect("expected block");
2177 assert!(
2178 error.contains("blocked by policy"),
2179 "expected policy block, got: {error}"
2180 );
2181 assert!(!file.exists());
2182 let _ = fs::remove_dir_all(project.parent().unwrap());
2183 }
2184
2185 #[tokio::test]
2187 async fn write_outside_both_roots_requires_approval_in_ask_mode() {
2188 let (project, scratch) = scratch_fixture("outside");
2189 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2190 let (ctx, _rx) = scratch_ctx(
2191 mermaid_runtime::SafetyMode::Ask,
2192 project.clone(),
2193 Some(scratch.clone()),
2194 );
2195 let outcome = WriteFileTool
2196 .execute(
2197 serde_json::json!({
2198 "path": outside.to_str().unwrap(),
2199 "content": "should require approval",
2200 }),
2201 ctx,
2202 )
2203 .await;
2204 let error = outcome.error_message().expect("expected approval required");
2205 assert!(
2206 error.contains("Approval required"),
2207 "expected approval block, got: {error}"
2208 );
2209 assert!(!outside.exists());
2210 let _ = fs::remove_dir_all(project.parent().unwrap());
2211 }
2212
2213 #[tokio::test]
2215 async fn read_file_reads_from_scratchpad() {
2216 let (project, scratch) = scratch_fixture("read");
2217 let file = scratch.join("stash.txt");
2218 fs::write(&file, "stashed").unwrap();
2219 let (ctx, _rx) = scratch_ctx(
2220 mermaid_runtime::SafetyMode::Ask,
2221 project.clone(),
2222 Some(scratch.clone()),
2223 );
2224 let outcome = ReadFileTool
2225 .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
2226 .await;
2227 assert!(outcome.is_success(), "scratch read: {outcome:?}");
2228 assert_eq!(outcome.output(), "stashed");
2229 let _ = fs::remove_dir_all(project.parent().unwrap());
2230 }
2231
2232 #[tokio::test]
2233 async fn edit_file_replaces_target_content_successfully() {
2234 let (project, _scratch) = scratch_fixture("edit_success");
2235 let file = project.join("src").join("main.rs");
2236 fs::create_dir_all(file.parent().unwrap()).unwrap();
2237 fs::write(&file, "fn main() {\n println!(\"old\");\n}\n").unwrap();
2238
2239 let (ctx, _rx) = scratch_ctx(
2240 mermaid_runtime::SafetyMode::FullAccess,
2241 project.clone(),
2242 None,
2243 );
2244 let outcome = EditFileTool
2245 .execute(
2246 serde_json::json!({
2247 "path": "src/main.rs",
2248 "target_content": " println!(\"old\");",
2249 "replacement_content": " println!(\"new\");",
2250 }),
2251 ctx,
2252 )
2253 .await;
2254
2255 assert!(outcome.is_success(), "edit outcome: {outcome:?}");
2256 let new_content = fs::read_to_string(&file).unwrap();
2257 assert_eq!(new_content, "fn main() {\n println!(\"new\");\n}\n");
2258 let diff = outcome
2259 .metadata
2260 .display_diff
2261 .as_deref()
2262 .expect("display diff");
2263 assert!(diff.contains("- println!(\"old\");"));
2264 assert!(diff.contains("+ println!(\"new\");"));
2265 let _ = fs::remove_dir_all(project.parent().unwrap());
2266 }
2267
2268 #[tokio::test]
2269 async fn edit_file_target_not_found_returns_error() {
2270 let (project, _scratch) = scratch_fixture("edit_not_found");
2271 let file = project.join("hello.txt");
2272 fs::write(&file, "line 1\nline 2\n").unwrap();
2273
2274 let (ctx, _rx) = scratch_ctx(
2275 mermaid_runtime::SafetyMode::FullAccess,
2276 project.clone(),
2277 None,
2278 );
2279 let outcome = EditFileTool
2280 .execute(
2281 serde_json::json!({
2282 "path": "hello.txt",
2283 "target_content": "nonexistent text",
2284 "replacement_content": "replacement",
2285 }),
2286 ctx,
2287 )
2288 .await;
2289
2290 assert!(!outcome.is_success());
2291 let err = outcome.error_message().unwrap();
2292 assert!(err.contains("could not find target_content"), "{err}");
2293 let _ = fs::remove_dir_all(project.parent().unwrap());
2294 }
2295
2296 #[tokio::test]
2297 async fn edit_file_ambiguous_target_errors_unless_allow_multiple() {
2298 let (project, _scratch) = scratch_fixture("edit_ambig");
2299 let file = project.join("dup.txt");
2300 fs::write(&file, "foo\nbar\nfoo\n").unwrap();
2301
2302 let (ctx, _rx) = scratch_ctx(
2303 mermaid_runtime::SafetyMode::FullAccess,
2304 project.clone(),
2305 None,
2306 );
2307 let outcome_ambig = EditFileTool
2308 .execute(
2309 serde_json::json!({
2310 "path": "dup.txt",
2311 "target_content": "foo",
2312 "replacement_content": "baz",
2313 }),
2314 ctx,
2315 )
2316 .await;
2317 assert!(!outcome_ambig.is_success());
2318 let err = outcome_ambig.error_message().unwrap();
2319 assert!(err.contains("found 2 times"), "{err}");
2320
2321 let (ctx2, _rx2) = scratch_ctx(
2322 mermaid_runtime::SafetyMode::FullAccess,
2323 project.clone(),
2324 None,
2325 );
2326 let outcome_allow = EditFileTool
2327 .execute(
2328 serde_json::json!({
2329 "path": "dup.txt",
2330 "target_content": "foo",
2331 "replacement_content": "baz",
2332 "allow_multiple": true,
2333 }),
2334 ctx2,
2335 )
2336 .await;
2337 assert!(outcome_allow.is_success());
2338 let new_content = fs::read_to_string(&file).unwrap();
2339 assert_eq!(new_content, "baz\nbar\nbaz\n");
2340 let _ = fs::remove_dir_all(project.parent().unwrap());
2341 }
2342
2343 #[tokio::test]
2344 async fn edit_file_fuzzy_whitespace_matching_succeeds() {
2345 let (project, _scratch) = scratch_fixture("edit_fuzzy");
2346 let file = project.join("fuzzy.txt");
2347 fs::write(&file, "start\n loose_whitespace(); \nend\n").unwrap();
2348
2349 let (ctx, _rx) = scratch_ctx(
2350 mermaid_runtime::SafetyMode::FullAccess,
2351 project.clone(),
2352 None,
2353 );
2354 let outcome = EditFileTool
2355 .execute(
2356 serde_json::json!({
2357 "path": "fuzzy.txt",
2358 "target_content": "loose_whitespace();",
2359 "replacement_content": " tight_whitespace();",
2360 }),
2361 ctx,
2362 )
2363 .await;
2364 assert!(outcome.is_success());
2365 assert!(outcome.output().contains("fuzzy"));
2366 let new_content = fs::read_to_string(&file).unwrap();
2367 assert_eq!(new_content, "start\n tight_whitespace();\nend\n");
2368 let _ = fs::remove_dir_all(project.parent().unwrap());
2369 }
2370
2371 fn external_read_fixture(name: &str) -> (PathBuf, PathBuf) {
2375 let workdir = temp_root(&format!("{name}_project"));
2376 let external_dir = temp_root(&format!("{name}_external"));
2377 let external_file = external_dir.join("ext.txt");
2378 fs::write(&external_file, "external content").unwrap();
2379 (workdir, external_file)
2380 }
2381
2382 fn ctx_in_mode(
2383 mode: mermaid_runtime::SafetyMode,
2384 workdir: PathBuf,
2385 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
2386 let mut config = mermaid_domain::Config::default();
2387 config.safety.mode = mode;
2388 crate::providers::ctx::test_exec_context_with_config(
2389 TurnId(1),
2390 ToolCallId(1),
2391 workdir,
2392 config,
2393 )
2394 }
2395
2396 async fn read_external(ctx: ExecContext, external_file: &Path) -> ToolOutcome {
2397 ReadFileTool
2398 .execute(
2399 serde_json::json!({"path": external_file.to_string_lossy().to_string()}),
2400 ctx,
2401 )
2402 .await
2403 }
2404
2405 #[tokio::test]
2409 async fn read_outside_the_project_is_denied_under_the_read_only_floor() {
2410 let (workdir, external_file) = external_read_fixture("read_ext_readonly");
2411 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2412 let outcome = read_external(ctx, &external_file).await;
2413 assert_eq!(
2414 outcome.status,
2415 mermaid_domain::ToolStatus::Error,
2416 "read_only must deny an external read: {outcome:?}"
2417 );
2418 assert!(
2419 !outcome.output().contains("external content"),
2420 "read_only leaked the file body: {}",
2421 outcome.output()
2422 );
2423 let _ = fs::remove_dir_all(&workdir);
2424 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2425 }
2426
2427 #[tokio::test]
2430 async fn read_inside_the_project_stays_ungated_in_read_only_mode() {
2431 let workdir = temp_root("read_inside_readonly");
2432 fs::write(workdir.join("notes.md"), "project content").unwrap();
2433 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2434 let outcome = ReadFileTool
2435 .execute(serde_json::json!({"path": "notes.md"}), ctx)
2436 .await;
2437 assert_eq!(
2438 outcome.status,
2439 mermaid_domain::ToolStatus::Success,
2440 "{outcome:?}"
2441 );
2442 assert_eq!(outcome.output(), "project content");
2443 let _ = fs::remove_dir_all(&workdir);
2444 }
2445
2446 #[tokio::test]
2450 async fn read_outside_the_project_prompts_in_ask_mode_scoped_to_its_directory() {
2451 let (workdir, external_file) = external_read_fixture("read_ext_ask");
2452 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2453 let broker = crate::providers::ApprovalBroker::new(tx);
2454 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2455 ctx.approval = Some(broker.clone());
2456 let file_for_task = external_file.clone();
2457 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2458
2459 let (call_id, tool, scope) = match rx.recv().await.expect("approval requested") {
2460 mermaid_domain::Msg::ApprovalRequested {
2461 call_id,
2462 tool,
2463 allowlist_scope,
2464 ..
2465 } => (call_id, tool, allowlist_scope),
2466 other => panic!("expected ApprovalRequested, got {other:?}"),
2467 };
2468 assert_eq!(tool, "read_file");
2469 let expected_dir = external_file.canonicalize().unwrap();
2470 let expected_dir = expected_dir.parent().unwrap();
2471 assert_eq!(
2472 scope,
2473 format!("read_file:{}", expected_dir.display()),
2474 "external reads are allowlisted per directory"
2475 );
2476
2477 broker.resolve(call_id, crate::providers::ApprovalDecision::Approve);
2478 let outcome = handle.await.unwrap();
2479 assert_eq!(
2480 outcome.status,
2481 mermaid_domain::ToolStatus::Success,
2482 "{outcome:?}"
2483 );
2484 assert_eq!(outcome.output(), "external content");
2485 let _ = fs::remove_dir_all(&workdir);
2486 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2487 }
2488
2489 #[tokio::test]
2491 async fn read_outside_the_project_denied_by_the_user_is_an_error() {
2492 let (workdir, external_file) = external_read_fixture("read_ext_deny");
2493 let (tx, mut rx) = tokio::sync::mpsc::channel::<mermaid_domain::Msg>(8);
2494 let broker = crate::providers::ApprovalBroker::new(tx);
2495 let (mut ctx, _prx) = ctx_in_mode(mermaid_runtime::SafetyMode::Ask, workdir.clone());
2496 ctx.approval = Some(broker.clone());
2497 let file_for_task = external_file.clone();
2498 let handle = tokio::spawn(async move { read_external(ctx, &file_for_task).await });
2499 let call_id = match rx.recv().await.expect("approval requested") {
2500 mermaid_domain::Msg::ApprovalRequested { call_id, .. } => call_id,
2501 other => panic!("expected ApprovalRequested, got {other:?}"),
2502 };
2503 broker.resolve(call_id, crate::providers::ApprovalDecision::Deny);
2504 let outcome = handle.await.unwrap();
2505 assert_eq!(
2506 outcome.status,
2507 mermaid_domain::ToolStatus::Error,
2508 "{outcome:?}"
2509 );
2510 assert!(!outcome.output().contains("external content"));
2511 let _ = fs::remove_dir_all(&workdir);
2512 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2513 }
2514
2515 #[cfg(unix)]
2519 #[tokio::test]
2520 async fn a_symlink_inside_the_project_that_points_outside_is_gated() {
2521 let (workdir, external_file) = external_read_fixture("read_ext_symlink");
2522 std::os::unix::fs::symlink(&external_file, workdir.join("innocent.txt")).unwrap();
2523 let (ctx, _rx) = ctx_in_mode(mermaid_runtime::SafetyMode::ReadOnly, workdir.clone());
2524 let outcome = ReadFileTool
2525 .execute(serde_json::json!({"path": "innocent.txt"}), ctx)
2526 .await;
2527 assert_eq!(
2528 outcome.status,
2529 mermaid_domain::ToolStatus::Error,
2530 "{outcome:?}"
2531 );
2532 assert!(!outcome.output().contains("external content"));
2533 let _ = fs::remove_dir_all(&workdir);
2534 let _ = fs::remove_dir_all(external_file.parent().unwrap());
2535 }
2536
2537 #[tokio::test]
2542 async fn write_outside_the_project_is_not_silently_allowed_in_auto_mode() {
2543 let (project, scratch) = scratch_fixture("outside_auto");
2544 let outside = project.parent().unwrap().join("elsewhere").join("out.txt");
2545 let (ctx, _rx) = scratch_ctx(
2546 mermaid_runtime::SafetyMode::Auto,
2547 project.clone(),
2548 Some(scratch.clone()),
2549 );
2550 let outcome = WriteFileTool
2551 .execute(
2552 serde_json::json!({
2553 "path": outside.to_str().unwrap(),
2554 "content": "must not land unasked",
2555 }),
2556 ctx,
2557 )
2558 .await;
2559 assert_eq!(
2560 outcome.status,
2561 mermaid_domain::ToolStatus::Error,
2562 "auto mode must escalate an external write: {outcome:?}"
2563 );
2564 assert!(!outside.exists(), "the external file was written anyway");
2565 let _ = fs::remove_dir_all(project.parent().unwrap());
2566 }
2567
2568 #[tokio::test]
2571 async fn write_inside_the_project_stays_allowed_in_auto_mode() {
2572 let (project, scratch) = scratch_fixture("inside_auto");
2573 let (ctx, _rx) = scratch_ctx(
2574 mermaid_runtime::SafetyMode::Auto,
2575 project.clone(),
2576 Some(scratch.clone()),
2577 );
2578 let outcome = WriteFileTool
2579 .execute(
2580 serde_json::json!({"path": "inside.txt", "content": "fine"}),
2581 ctx,
2582 )
2583 .await;
2584 assert_eq!(
2585 outcome.status,
2586 mermaid_domain::ToolStatus::Success,
2587 "{outcome:?}"
2588 );
2589 assert_eq!(
2590 fs::read_to_string(project.join("inside.txt")).unwrap(),
2591 "fine"
2592 );
2593 let _ = fs::remove_dir_all(project.parent().unwrap());
2594 }
2595}