1use std::path::{Path, PathBuf};
16
17use async_trait::async_trait;
18
19use crate::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
20use crate::domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21
22use super::super::ctx::{ExecContext, ProgressEvent};
23use super::ToolExecutor;
24use super::path_safety::{
25 AllowedRoots, ResolvedInRoot, relative_within, resolve_in_roots, resolve_path_within,
26};
27
28fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
32 ToolDefinition {
33 name: name.to_string(),
34 description: description.to_string(),
35 input_schema,
36 }
37}
38
39const MAX_READ_AGGREGATE_CHARS: usize = crate::constants::MAX_RESPONSE_CHARS;
45
46pub struct ReadFileTool;
49
50#[async_trait]
51impl ToolExecutor for ReadFileTool {
52 fn name(&self) -> &'static str {
53 "read_file"
54 }
55
56 fn schema(&self) -> ToolDefinition {
57 defn(
58 "read_file",
59 "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.",
60 serde_json::json!({
61 "type": "object",
62 "properties": {
63 "path": { "type": "string", "description": "File to read (single)." },
64 "paths": {
65 "type": "array",
66 "items": { "type": "string" },
67 "description": "Multiple files to read sequentially, in order."
68 }
69 },
70 "oneOf": [
71 { "required": ["path"] },
72 { "required": ["paths"] }
73 ]
74 }),
75 )
76 }
77
78 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
79 let paths = match extract_paths(&args) {
80 Ok(p) => p,
81 Err(e) => return ToolOutcome::error(e, 0.0),
82 };
83 if paths.is_empty() {
84 return ToolOutcome::error("read_file requires at least one path", 0.0);
85 }
86
87 let start = std::time::Instant::now();
88 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
89 let mut combined = String::new();
90 let mut any_truncated = false;
91
92 for (idx, raw_path) in paths.iter().enumerate() {
93 tokio::select! {
96 biased;
97 _ = ctx.token.cancelled() => {
98 return ToolOutcome::cancelled();
99 },
100 read = read_one(&roots, raw_path) => {
101 match read {
102 Ok((content, was_truncated)) => {
103 any_truncated |= was_truncated;
104 if paths.len() > 1 {
105 let _ = ctx.progress.send(ProgressEvent::Status(
106 format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
107 )).await;
108 combined.push_str(&format!(
109 "=== {} ===\n{}\n\n",
110 raw_path, content
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 = crate::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) = crate::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 || crate::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) = crate::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 || crate::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#[async_trait]
392impl ToolExecutor for WriteFileTool {
393 fn name(&self) -> &'static str {
394 "write_file"
395 }
396
397 fn schema(&self) -> ToolDefinition {
398 defn(
399 "write_file",
400 "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.",
401 serde_json::json!({
402 "type": "object",
403 "properties": {
404 "path": { "type": "string" },
405 "content": { "type": "string" }
406 },
407 "required": ["path", "content"]
408 }),
409 )
410 }
411
412 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
413 let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
414 return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
415 };
416 let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
417 return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
418 };
419
420 let start = std::time::Instant::now();
421 let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
422 let ResolvedInRoot {
425 abs: abs_path,
426 rel,
427 root,
428 in_scratchpad,
429 } = match resolve_in_roots(&roots, path) {
430 Ok(r) => r,
431 Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
432 };
433 let pending_action = serde_json::json!({
434 "tool": "write_file",
435 "args": { "path": path, "content": content },
436 "workdir": ctx.workdir.display().to_string(),
437 "turn_id": ctx.turn.0,
438 "call_id": ctx.call_id.0,
439 "task_id": ctx.task_id.clone(),
440 });
441 let plan_write = match mutation_policy_outcome(
442 &ctx,
443 "write_file",
444 path,
445 std::slice::from_ref(&abs_path),
446 pending_action,
447 in_scratchpad,
448 )
449 .await
450 {
451 MutationGate::Blocked(outcome) => return *outcome,
452 MutationGate::Proceed { plan_write } => plan_write,
453 };
454 let _write_guard = tokio::select! {
459 biased;
460 _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
461 g = super::path_lock::lock_path(&abs_path) => g,
462 };
463 if ctx.config.safety.checkpoint_on_mutation
465 && !in_scratchpad
466 && let Err(e) = crate::runtime::create_checkpoint_for_task(
467 &ctx.workdir,
468 std::slice::from_ref(&abs_path),
469 Some(serde_json::json!({
470 "tool": "write_file",
471 "path": path,
472 })),
473 ctx.checkpoint_origin(),
474 )
475 {
476 return ToolOutcome::error(format!("write_file checkpoint failed: {}", e), 0.0);
477 }
478 let display_path = path.to_string();
479 let line_count = content.lines().count();
480 let byte_count = content.len();
481 let content = content.to_string();
482
483 tokio::select! {
484 biased;
485 _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
486 result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
490 match result {
491 Ok(Ok(write)) => {
492 let duration_secs = start.elapsed().as_secs_f64();
493 after_file_mutation(&ctx, "write_file", &display_path);
494 ToolOutcome::success(
495 format!("Wrote {} ({} lines)", display_path, write.line_count),
496 format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
497 duration_secs,
498 )
499 .with_metadata(ToolRunMetadata {
500 detail: ToolMetadata::WriteFile {
501 path: display_path,
502 line_count,
503 byte_count,
504 created: Some(write.created),
505 },
506 line_count: Some(line_count),
507 byte_count: Some(byte_count),
508 display_diff: Some(write.diff.display_diff),
509 diff_truncated: write.diff.truncated,
510 lines_added: write.diff.added,
511 lines_removed: write.diff.removed,
512 plan_file_written: plan_write,
513 ..ToolRunMetadata::default()
514 })
515 },
516 Ok(Err(e)) => ToolOutcome::error(
517 format!("write_file({}): {}", display_path, e),
518 start.elapsed().as_secs_f64(),
519 ),
520 Err(e) => ToolOutcome::error(
521 format!("write_file join error: {}", e),
522 start.elapsed().as_secs_f64(),
523 ),
524 }
525 }
526 }
527 }
528}
529
530fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
533 if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
535 return Ok(vec![p.to_string()]);
536 }
537 if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
538 if arr.len() > crate::constants::MAX_BATCH_TOOL_ITEMS {
539 return Err(format!(
540 "read_file: too many paths ({}); cap is {} per call — split the request",
541 arr.len(),
542 crate::constants::MAX_BATCH_TOOL_ITEMS
543 ));
544 }
545 let mut out = Vec::with_capacity(arr.len());
546 for v in arr {
547 let Some(s) = v.as_str() else {
548 return Err("read_file 'paths' must be an array of strings".to_string());
549 };
550 out.push(s.to_string());
551 }
552 return Ok(out);
553 }
554 Err("read_file requires 'path' or 'paths'".to_string())
555}
556
557fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
565 if !Path::new(raw).is_absolute() {
566 return None;
567 }
568 for (root, _scope) in crate::app::memory::memory_roots(workdir) {
569 if let Ok((_abs, true)) = resolve_path_within(&root, raw)
570 && let Ok(rel) = relative_within(&root, raw)
571 {
572 return Some((root, rel));
573 }
574 }
575 None
576}
577
578async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
583 let ResolvedInRoot { rel, root, .. } = match resolve_in_roots(roots, raw) {
589 Ok(resolved) => resolved,
590 Err(msg) => {
591 let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
592 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
593 ResolvedInRoot {
594 abs: root.join(&rel),
595 rel,
596 root,
597 in_scratchpad: false,
598 }
599 },
600 };
601 let result = tokio::task::spawn_blocking(move || {
602 let file = crate::runtime::open_beneath(&root, &rel, crate::runtime::OpenIntent::Read)?;
603 let (data, truncated) = crate::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
607 let mut s = String::from_utf8_lossy(&data).into_owned();
608 if truncated {
609 let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
611 s.truncate(cut);
612 s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
613 }
614 Ok::<_, std::io::Error>((s, truncated))
615 })
616 .await
617 .map_err(|e| std::io::Error::other(e.to_string()))??;
618 Ok(result)
619}
620
621fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
630 if let Some(parent) = rel.parent()
631 && !parent.as_os_str().is_empty()
632 {
633 crate::runtime::create_dir_all_beneath(root, parent)?;
634 }
635 crate::runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
636 Ok(content.lines().count())
637}
638
639struct WriteResult {
640 line_count: usize,
641 created: bool,
642 diff: crate::render::diff::DisplayDiff,
643}
644
645fn write_with_diff_blocking(
652 root: &Path,
653 abs_path: &Path,
654 rel: &Path,
655 content: &str,
656) -> std::io::Result<WriteResult> {
657 let (old_content, created, elide_diff) =
658 match crate::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
659 Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
660 Ok((_, true)) => (String::new(), false, true),
662 Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
664 Err(_) => (String::new(), false, true),
666 };
667 let diff = if elide_diff {
668 crate::render::diff::DisplayDiff {
669 display_diff: format!(
670 "[diff preview skipped: existing file exceeds the {}-byte cap]",
671 MAX_FILE_READ_BYTES
672 ),
673 added: 0,
674 removed: 0,
675 truncated: true,
676 }
677 } else {
678 crate::render::diff::generate_display_diff(&old_content, content)
679 };
680 let line_count = write_one_blocking(root, rel, content)?;
681 Ok(WriteResult {
682 line_count,
683 created,
684 diff,
685 })
686}
687
688pub(super) enum MutationGate {
701 Blocked(Box<ToolOutcome>),
703 Proceed {
704 plan_write: bool,
705 },
706}
707
708pub(super) async fn mutation_policy_outcome(
709 ctx: &ExecContext,
710 tool: &str,
711 path: &str,
712 checkpoint_paths: &[PathBuf],
713 pending_action: serde_json::Value,
714 scratch_contained: bool,
715) -> MutationGate {
716 let mut request = crate::runtime::ActionRequest::new(
717 tool,
718 crate::runtime::ToolCategory::Edit,
719 format!("{} {}", tool, path),
720 );
721 request.path = Some(path.to_string());
722 match super::policy_gate::gate(
725 ctx,
726 request,
727 checkpoint_paths,
728 pending_action,
729 true,
730 scratch_contained,
731 )
732 .await
733 {
734 super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
735 super::policy_gate::Gate::Proceed { plan_write, .. } => {
736 let _ = crate::runtime::run_plugin_hooks(
737 "before_file_mutation",
738 &serde_json::json!({
739 "task_id": ctx.task_id.clone(),
740 "turn_id": ctx.turn.0,
741 "call_id": ctx.call_id.0,
742 "tool": tool,
743 "path": path,
744 }),
745 );
746 MutationGate::Proceed { plan_write }
747 },
748 }
749}
750
751pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
752 let _ = crate::runtime::run_plugin_hooks(
753 "after_file_mutation",
754 &serde_json::json!({
755 "task_id": ctx.task_id.clone(),
756 "turn_id": ctx.turn.0,
757 "call_id": ctx.call_id.0,
758 "tool": tool,
759 "path": path,
760 }),
761 );
762}
763
764fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
765 ToolOutcome::error(msg, duration_secs)
766}
767
768fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
769 if count == 1 { singular } else { plural }
770}
771
772pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
773 format!(
774 "+{} -{}, took {}",
775 added,
776 removed,
777 format_duration_for_diff(duration_secs)
778 )
779}
780
781fn format_duration_for_diff(seconds: f64) -> String {
782 if seconds < 1.0 {
783 format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
784 } else if seconds < 10.0 {
785 format!("{:.1}s", seconds)
786 } else {
787 format!("{}s", seconds.round() as u64)
788 }
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use crate::domain::{ToolCallId, TurnId};
795 use crate::providers::ctx::test_exec_context;
796 use std::fs;
797
798 #[tokio::test]
804 async fn read_file_resolves_memory_roots_read_only() {
805 let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
806 let _ = fs::remove_dir_all(&base);
807 let repo = base.join("repo");
808 let workdir = repo.join("src");
809 fs::create_dir_all(&workdir).unwrap();
810 fs::create_dir_all(repo.join(".git")).unwrap();
811 let mem_dir = repo.join(".mermaid").join("memory");
812 fs::create_dir_all(&mem_dir).unwrap();
813 let fact = mem_dir.join("fact.md");
814 fs::write(&fact, "the fact body").unwrap();
815
816 let roots = AllowedRoots::new(&workdir, None);
817 let (content, truncated) = read_one(&roots, fact.to_str().unwrap()).await.unwrap();
818 assert!(!truncated);
819 assert_eq!(content, "the fact body");
820
821 let stray = base.join("stray.txt");
824 fs::write(&stray, "nope").unwrap();
825 assert!(read_one(&roots, stray.to_str().unwrap()).await.is_err());
826
827 let _ = fs::remove_dir_all(&base);
828 }
829
830 #[test]
831 fn resolve_in_roots_contains_to_workdir() {
832 let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
833 let _ = fs::remove_dir_all(&root);
834 fs::create_dir_all(root.join("sub")).unwrap();
835 let roots = AllowedRoots::new(&root, None);
836
837 assert!(resolve_in_roots(&roots, "sub").is_ok());
839 let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
840 let canon_root = fs::canonicalize(&root).unwrap();
841 assert!(resolved.abs.starts_with(&canon_root));
842
843 assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
845 assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
846 let outside = std::env::temp_dir().join("definitely_outside.txt");
847 assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
848
849 let _ = fs::remove_dir_all(&root);
850 }
851
852 fn temp_root(name: &str) -> PathBuf {
853 let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
854 let _ = fs::remove_dir_all(&p);
855 fs::create_dir_all(&p).expect("create tmpdir");
856 p
857 }
858
859 #[tokio::test]
860 async fn read_file_returns_content() {
861 let dir = temp_root("read_ok");
862 fs::write(dir.join("a.txt"), "hello").expect("write");
863 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
864
865 let tool = ReadFileTool;
866 let outcome = tool
867 .execute(serde_json::json!({"path": "a.txt"}), ctx)
868 .await;
869 assert!(outcome.is_success(), "expected success: {:?}", outcome);
870 assert_eq!(outcome.output(), "hello");
871 let _ = fs::remove_dir_all(&dir);
872 }
873
874 #[tokio::test]
875 async fn read_file_missing_path_errors() {
876 let dir = temp_root("read_missing_path");
877 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
878 let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
879 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
880 let _ = fs::remove_dir_all(&dir);
881 }
882
883 #[tokio::test]
884 async fn read_file_nonexistent_errors() {
885 let dir = temp_root("read_nonex");
886 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
887 let outcome = ReadFileTool
888 .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
889 .await;
890 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
891 let _ = fs::remove_dir_all(&dir);
892 }
893
894 #[tokio::test]
895 async fn read_file_with_multiple_paths_joins_contents() {
896 let dir = temp_root("read_multi");
897 fs::write(dir.join("a.txt"), "alpha").expect("write");
898 fs::write(dir.join("b.txt"), "beta").expect("write");
899 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
900 let outcome = ReadFileTool
901 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
902 .await;
903 assert!(outcome.is_success(), "expected success: {:?}", outcome);
904 let output = outcome.output();
905 assert!(output.contains("=== a.txt ==="));
906 assert!(output.contains("alpha"));
907 assert!(output.contains("=== b.txt ==="));
908 assert!(output.contains("beta"));
909 let _ = fs::remove_dir_all(&dir);
910 }
911
912 #[tokio::test]
913 async fn read_file_multi_aggregate_is_capped() {
914 let dir = temp_root("read_aggregate_cap");
917 let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
918 fs::write(dir.join("a.txt"), &chunk).expect("write a");
919 fs::write(dir.join("b.txt"), &chunk).expect("write b");
920 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
921 let outcome = ReadFileTool
922 .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
923 .await;
924 assert!(outcome.is_success(), "expected success: {:?}", outcome);
925 let output = outcome.output();
926 assert!(
927 output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
928 "combined must be capped, got {} bytes",
929 output.len()
930 );
931 assert!(
932 output.contains("elided"),
933 "expected aggregate head+tail elision marker"
934 );
935 match &outcome.metadata.detail {
936 ToolMetadata::ReadFile { truncated, .. } => {
937 assert!(*truncated, "aggregate truncation must set truncated")
938 },
939 other => panic!("expected ReadFile metadata, got {:?}", other),
940 }
941 let _ = fs::remove_dir_all(&dir);
942 }
943
944 #[tokio::test]
945 async fn write_file_elides_diff_for_oversized_existing_file() {
946 let dir = temp_root("write_oversized_diff");
949 let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
950 fs::write(dir.join("big.txt"), &big).expect("write fixture");
951 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
952 let outcome = WriteFileTool
953 .execute(
954 serde_json::json!({"path": "big.txt", "content": "small\n"}),
955 ctx,
956 )
957 .await;
958 assert!(outcome.is_success(), "expected success: {:?}", outcome);
959 let diff = outcome
960 .metadata
961 .display_diff
962 .as_deref()
963 .expect("display diff");
964 assert!(
965 diff.contains("diff preview skipped"),
966 "expected elision marker, got: {diff}"
967 );
968 assert!(
969 outcome.metadata.diff_truncated,
970 "oversized diff must set diff_truncated"
971 );
972 match &outcome.metadata.detail {
973 ToolMetadata::WriteFile { created, .. } => {
974 assert_eq!(*created, Some(false), "existing file is not 'created'")
975 },
976 other => panic!("expected WriteFile metadata, got {:?}", other),
977 }
978 let written = fs::read_to_string(dir.join("big.txt")).expect("read");
980 assert_eq!(written, "small\n");
981 let _ = fs::remove_dir_all(&dir);
982 }
983
984 #[tokio::test]
985 async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
986 let dir = temp_root("read_marker_content");
990 fs::write(
991 dir.join("a.txt"),
992 "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
993 )
994 .expect("write");
995 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
996
997 let outcome = ReadFileTool
998 .execute(serde_json::json!({"path": "a.txt"}), ctx)
999 .await;
1000 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1001 match &outcome.metadata.detail {
1002 ToolMetadata::ReadFile { truncated, .. } => assert!(
1003 !truncated,
1004 "a file whose content contains the marker must not be flagged truncated"
1005 ),
1006 other => panic!("expected ReadFile metadata, got {:?}", other),
1007 }
1008 let _ = fs::remove_dir_all(&dir);
1009 }
1010
1011 #[tokio::test]
1012 async fn read_file_respects_cancellation() {
1013 let dir = temp_root("read_cancel");
1014 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1019 ctx.token.cancel();
1020 let outcome = ReadFileTool
1021 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1022 .await;
1023 assert!(outcome.was_cancelled());
1024 let _ = fs::remove_dir_all(&dir);
1025 }
1026
1027 #[tokio::test]
1028 async fn write_file_creates_and_counts_lines() {
1029 let dir = temp_root("write_ok");
1030 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1031 let outcome = WriteFileTool
1032 .execute(
1033 serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1034 ctx,
1035 )
1036 .await;
1037 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1038 assert!(outcome.output().contains("3 lines"));
1039 let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1040 assert!(written.contains("line1"));
1041 let _ = fs::remove_dir_all(&dir);
1042 }
1043
1044 #[tokio::test]
1045 async fn concurrent_write_file_same_path_serializes_cleanly() {
1046 let dir = temp_root("write_race");
1050 let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1051 let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1052 let a = "AAAA\nAAAA\n";
1053 let b = "BBBB\nBBBB\n";
1054 let (o1, o2) = tokio::join!(
1055 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1056 WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1057 );
1058 assert!(o1.is_success(), "first write failed: {o1:?}");
1059 assert!(o2.is_success(), "second write failed: {o2:?}");
1060 let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1061 assert!(
1062 final_content == a || final_content == b,
1063 "file must be exactly one clean write, got {final_content:?}"
1064 );
1065 let _ = fs::remove_dir_all(&dir);
1066 }
1067
1068 #[tokio::test]
1069 async fn write_file_new_file_records_added_display_diff() {
1070 let dir = temp_root("write_new_diff");
1071 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1072 let outcome = WriteFileTool
1073 .execute(
1074 serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1075 ctx,
1076 )
1077 .await;
1078 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1079 let diff = outcome
1080 .metadata
1081 .display_diff
1082 .as_deref()
1083 .expect("display diff");
1084 assert!(diff.contains("+ alpha"));
1085 assert!(diff.contains("+ beta"));
1086 assert!(
1088 !diff.contains("@@"),
1089 "diff should not carry hunk headers: {diff}"
1090 );
1091 assert!(!diff.contains("/dev/null"));
1092 let _ = fs::remove_dir_all(&dir);
1093 }
1094
1095 #[tokio::test]
1096 async fn write_file_existing_file_records_added_and_removed_display_diff() {
1097 let dir = temp_root("write_existing_diff");
1098 fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1099 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1100 let outcome = WriteFileTool
1101 .execute(
1102 serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1103 ctx,
1104 )
1105 .await;
1106 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1107 let diff = outcome
1108 .metadata
1109 .display_diff
1110 .as_deref()
1111 .expect("display diff");
1112 assert!(diff.contains("- old"));
1113 assert!(diff.contains("+ new"));
1114 let _ = fs::remove_dir_all(&dir);
1115 }
1116
1117 #[tokio::test]
1118 async fn write_file_creates_parent_dirs() {
1119 let dir = temp_root("write_parents");
1120 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1121 let outcome = WriteFileTool
1122 .execute(
1123 serde_json::json!({
1124 "path": "sub/nested/out.txt",
1125 "content": "deep",
1126 }),
1127 ctx,
1128 )
1129 .await;
1130 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1131 assert!(dir.join("sub/nested/out.txt").exists());
1132 let _ = fs::remove_dir_all(&dir);
1133 }
1134
1135 #[tokio::test]
1136 async fn write_file_missing_content_errors() {
1137 let dir = temp_root("write_missing");
1138 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1139 let outcome = WriteFileTool
1140 .execute(serde_json::json!({"path": "x.txt"}), ctx)
1141 .await;
1142 assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1143 let _ = fs::remove_dir_all(&dir);
1144 }
1145
1146 #[tokio::test]
1152 async fn read_file_rejects_absolute_path_outside_workdir() {
1153 let dir = temp_root("read_abs_escape");
1154 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1155 let outcome = ReadFileTool
1157 .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1158 .await;
1159 let error = outcome.error_message().expect("expected error");
1160 assert!(
1161 error.contains("outside the project"),
1162 "expected security reject, got: {}",
1163 error
1164 );
1165 let _ = fs::remove_dir_all(&dir);
1166 }
1167
1168 #[tokio::test]
1170 async fn read_file_accepts_absolute_path_inside_workdir() {
1171 let dir = temp_root("read_abs_inside");
1172 let file = dir.join("hello.txt");
1173 fs::write(&file, "ok").expect("write fixture");
1174 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1175 let outcome = ReadFileTool
1176 .execute(
1177 serde_json::json!({"path": file.to_string_lossy().to_string()}),
1178 ctx,
1179 )
1180 .await;
1181 assert!(outcome.is_success(), "expected success: {:?}", outcome);
1182 let _ = fs::remove_dir_all(&dir);
1183 }
1184
1185 #[tokio::test]
1189 async fn write_file_rejects_relative_parent_escape() {
1190 let dir = temp_root("write_dotdot_escape");
1191 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1192 let outcome = WriteFileTool
1193 .execute(
1194 serde_json::json!({
1195 "path": "../escape.txt",
1196 "content": "should not write",
1197 }),
1198 ctx,
1199 )
1200 .await;
1201 let error = outcome.error_message().expect("expected error");
1202 assert!(
1203 error.contains("outside the project"),
1204 "expected security reject, got: {}",
1205 error
1206 );
1207 let _ = fs::remove_dir_all(&dir);
1208 }
1209
1210 #[tokio::test]
1214 async fn create_directory_rejects_absolute_path_outside_workdir() {
1215 let dir = temp_root("mkdir_abs_escape");
1216 let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1217 let outcome = CreateDirectoryTool
1218 .execute(
1219 serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1220 ctx,
1221 )
1222 .await;
1223 let error = outcome.error_message().expect("expected error");
1224 assert!(
1225 error.contains("outside the project"),
1226 "expected security reject, got: {}",
1227 error
1228 );
1229 let _ = fs::remove_dir_all(&dir);
1230 }
1231
1232 fn scratch_ctx(
1238 mode: crate::runtime::SafetyMode,
1239 workdir: PathBuf,
1240 scratchpad: Option<PathBuf>,
1241 ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1242 let mut config = crate::app::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) = crate::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 crate::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 crate::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 crate::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 crate::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 crate::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 crate::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}