Skip to main content

mermaid_cli/providers/tool/
filesystem.rs

1//! Filesystem tools ported to `ToolExecutor`.
2//!
3//! This is the proof-of-pattern tool impl for C3: `ReadFileTool` and
4//! `WriteFileTool`. They hook the `ExecContext::token` so Ctrl+C
5//! cancels mid-read (relevant for large files on slow storage), and
6//! they emit `ProgressEvent::Status` breadcrumbs for multi-file
7//! operations the old code couldn't surface without an observer
8//! callback.
9//!
10//! The implementations don't try to out-clever the existing tool
11//! behavior in `src/agents/filesystem.rs`. Same semantics, same error
12//! shapes — just wrapped in the new trait so future tools only have
13//! to learn this surface.
14
15use mermaid_domain::ProgressEvent;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19
20use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
21use mermaid_model::constants::MAX_RESPONSE_CHARS as MAX_FILE_READ_BYTES;
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25use super::path_safety::{
26    AllowedRoots, ResolvedInRoot, relative_within, resolve_in_roots, resolve_path_within,
27};
28
29/// Small helper for building a `ToolDefinition` with a typical
30/// JSON-schema-shaped `input_schema`. Keeps the per-tool definitions
31/// readable.
32fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
33    ToolDefinition {
34        name: name.to_string(),
35        description: description.to_string(),
36        input_schema,
37    }
38}
39
40/// Aggregate cap for a multi-file `read_file` result (#F45). Each file is
41/// individually bounded at `MAX_RESPONSE_CHARS` by `read_one`, but a batch of up
42/// to `MAX_BATCH_TOOL_ITEMS` files could otherwise sum to ~12.8 MB in a single
43/// tool result — far past any sane model-context budget. This bounds the
44/// combined total; single-file reads are already bounded and unaffected.
45const MAX_READ_AGGREGATE_CHARS: usize = mermaid_model::constants::MAX_RESPONSE_CHARS;
46
47/// `read_file` — read one or more files and return their contents
48/// joined with section markers.
49pub struct ReadFileTool;
50
51#[async_trait]
52impl ToolExecutor for ReadFileTool {
53    fn name(&self) -> &'static str {
54        "read_file"
55    }
56
57    fn schema(&self) -> ToolDefinition {
58        defn(
59            "read_file",
60            "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory, the session scratchpad, or the memory directories, or the call is rejected.",
61            serde_json::json!({
62                "type": "object",
63                "properties": {
64                    "path": { "type": "string", "description": "File to read (single)." },
65                    "paths": {
66                        "type": "array",
67                        "items": { "type": "string" },
68                        "description": "Multiple files to read sequentially, in order."
69                    }
70                },
71                "oneOf": [
72                    { "required": ["path"] },
73                    { "required": ["paths"] }
74                ]
75            }),
76        )
77    }
78
79    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
80        let paths = match extract_paths(&args) {
81            Ok(p) => p,
82            Err(e) => return ToolOutcome::error(e, 0.0),
83        };
84        if paths.is_empty() {
85            return ToolOutcome::error("read_file requires at least one path", 0.0);
86        }
87
88        let start = std::time::Instant::now();
89        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
90        let mut combined = String::new();
91        let mut any_truncated = false;
92
93        for (idx, raw_path) in paths.iter().enumerate() {
94            // Race the file read against the turn's cancel token. If
95            // the user Ctrl+C's mid-read, we bail immediately.
96            tokio::select! {
97                biased;
98                _ = ctx.token.cancelled() => {
99                    return ToolOutcome::cancelled();
100                },
101                read = read_one(&roots, raw_path) => {
102                    match read {
103                        Ok((content, was_truncated)) => {
104                            any_truncated |= was_truncated;
105                            if paths.len() > 1 {
106                                let _ = ctx.progress.send(ProgressEvent::Status(
107                                    format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
108                                )).await;
109                                combined.push_str(&format!(
110                                    "=== {raw_path} ===\n{content}\n\n"
111                                ));
112                            } else {
113                                combined = content;
114                            }
115                        },
116                        Err(e) => {
117                            return ToolOutcome::error(
118                                format!("{raw_path}: {e}"),
119                                start.elapsed().as_secs_f64(),
120                            );
121                        },
122                    }
123                },
124            }
125        }
126
127        // F45: bound the COMBINED multi-file result. Each file is already capped
128        // at MAX_RESPONSE_CHARS by read_one, but a batch of files can still sum to
129        // ~12.8 MB in one tool result — past any sane context budget. Only the
130        // multi-file accumulation needs this (single-file output is already
131        // bounded); truncate_middle keeps the head AND tail with an elision marker.
132        if paths.len() > 1 && combined.len() > MAX_READ_AGGREGATE_CHARS {
133            combined = mermaid_model::utils::truncate_middle(&combined, MAX_READ_AGGREGATE_CHARS);
134            any_truncated = true;
135        }
136
137        let duration_secs = start.elapsed().as_secs_f64();
138        let line_count = combined.lines().count();
139        let byte_count = combined.len();
140        // The REAL truncation flag from the bounded read — not a sniff for the
141        // marker string, which a file containing that literal text would
142        // falsely trip (#78).
143        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
167/// `delete_file` — unlink a file. Errors on directories (use
168/// `execute_command rm -rf` for those — the model shouldn't be
169/// blowing away directories as a routine op).
170pub 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        // Serialize writers to this canonical path: sibling tool calls in the same
226        // turn run concurrently, so without this the checkpoint + delete could race
227        // another writer to the same file. Distinct paths still overlap. Raced
228        // against cancellation so a contended lock stays Ctrl+C-responsive.
229        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        // Scratchpad files are session-private and ephemeral — never
235        // checkpointed into the project's restore history.
236        if ctx.config.safety.checkpoint_on_mutation
237            && !in_scratchpad
238            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
239                &ctx.workdir,
240                std::slice::from_ref(&abs),
241                Some(serde_json::json!({
242                    "tool": "delete_file",
243                    "path": raw_path,
244                })),
245                ctx.checkpoint_origin(),
246            )
247        {
248            return err(&format!("delete_file checkpoint failed: {e}"), 0.0);
249        }
250        let display = raw_path.to_string();
251
252        tokio::select! {
253            biased;
254            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
255            result = tokio::task::spawn_blocking(move || mermaid_runtime::remove_file_beneath(&root, &rel)) => {
256                match result {
257                    Ok(Ok(())) => {
258                        let duration_secs = start.elapsed().as_secs_f64();
259                        after_file_mutation(&ctx, "delete_file", &display);
260                        ToolOutcome::success(
261                            format!("Deleted {display}"),
262                            "file deleted",
263                            duration_secs,
264                        )
265                        .with_metadata(ToolRunMetadata {
266                            detail: ToolMetadata::DeleteFile { path: display },
267                            ..ToolRunMetadata::default()
268                        })
269                    },
270                    Ok(Err(e)) => err(&format!("delete_file({display}): {e}"),
271                                       start.elapsed().as_secs_f64()),
272                    Err(e) => err(&format!("delete_file join error: {e}"),
273                                   start.elapsed().as_secs_f64()),
274                }
275            }
276        }
277    }
278}
279
280/// `create_directory` — `mkdir -p` semantics.
281pub 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        // Serialize writers to this canonical path (see delete_file). mkdir -p is
337        // idempotent, but a uniform gate keeps ordering consistent and cheap.
338        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        // Scratchpad dirs are session-private and ephemeral — never checkpointed.
344        if ctx.config.safety.checkpoint_on_mutation
345            && !in_scratchpad
346            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
347                &ctx.workdir,
348                std::slice::from_ref(&abs),
349                Some(serde_json::json!({
350                    "tool": "create_directory",
351                    "path": raw_path,
352                })),
353                ctx.checkpoint_origin(),
354            )
355        {
356            return err(&format!("create_directory checkpoint failed: {e}"), 0.0);
357        }
358        let display = raw_path.to_string();
359
360        tokio::select! {
361            biased;
362            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
363            result = tokio::task::spawn_blocking(move || mermaid_runtime::create_dir_all_beneath(&root, &rel)) => {
364                match result {
365                    Ok(Ok(())) => {
366                        let duration_secs = start.elapsed().as_secs_f64();
367                        after_file_mutation(&ctx, "create_directory", &display);
368                        ToolOutcome::success(
369                            format!("Created directory {display}"),
370                            "directory created",
371                            duration_secs,
372                        )
373                        .with_metadata(ToolRunMetadata {
374                            detail: ToolMetadata::CreateDirectory { path: display },
375                            ..ToolRunMetadata::default()
376                        })
377                    },
378                    Ok(Err(e)) => err(&format!("create_directory({display}): {e}"),
379                                       start.elapsed().as_secs_f64()),
380                    Err(e) => err(&format!("create_directory join error: {e}"),
381                                   start.elapsed().as_secs_f64()),
382                }
383            }
384        }
385    }
386}
387
388/// `write_file` — write a single file, creating parent dirs as needed.
389pub struct WriteFileTool;
390
391#[expect(
392    clippy::too_many_lines,
393    reason = "predates the lint; see .github/baselines/expect_budget.txt"
394)]
395#[async_trait]
396impl ToolExecutor for WriteFileTool {
397    fn name(&self) -> &'static str {
398        "write_file"
399    }
400
401    fn schema(&self) -> ToolDefinition {
402        defn(
403            "write_file",
404            "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Paths must resolve inside the project directory or the session scratchpad. Prefer `apply_patch` for small targeted changes.",
405            serde_json::json!({
406                "type": "object",
407                "properties": {
408                    "path": { "type": "string" },
409                    "content": { "type": "string" }
410                },
411                "required": ["path", "content"]
412            }),
413        )
414    }
415
416    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
417        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
418            return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
419        };
420        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
421            return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
422        };
423
424        let start = std::time::Instant::now();
425        let roots = AllowedRoots::new(&ctx.workdir, ctx.scratchpad.as_deref());
426        // `rel` is the root-relative name for the confined fd write (the actual
427        // byte path).
428        let ResolvedInRoot {
429            abs: abs_path,
430            rel,
431            root,
432            in_scratchpad,
433        } = match resolve_in_roots(&roots, path) {
434            Ok(r) => r,
435            Err(e) => return ToolOutcome::error(format!("write_file: {e}"), 0.0),
436        };
437        let pending_action = serde_json::json!({
438            "tool": "write_file",
439            "args": { "path": path, "content": content },
440            "workdir": ctx.workdir.display().to_string(),
441            "turn_id": ctx.turn.0,
442            "call_id": ctx.call_id.0,
443            "task_id": ctx.task_id.clone(),
444        });
445        let plan_write = match mutation_policy_outcome(
446            &ctx,
447            "write_file",
448            path,
449            std::slice::from_ref(&abs_path),
450            pending_action,
451            in_scratchpad,
452        )
453        .await
454        {
455            MutationGate::Blocked(outcome) => return *outcome,
456            MutationGate::Proceed { plan_write } => plan_write,
457        };
458        // Serialize writers to this canonical path: two write_file/edit calls to
459        // the same file in one turn run concurrently, so without this the last
460        // atomic rename silently wins (lost update). Distinct paths still overlap.
461        // The owned guard is Send and held across the spawn_blocking below.
462        let _write_guard = tokio::select! {
463            biased;
464            _ = ctx.token.cancelled() => return ToolOutcome::cancelled(),
465            g = super::path_lock::lock_path(&abs_path) => g,
466        };
467        // Scratchpad files are session-private and ephemeral — never checkpointed.
468        if ctx.config.safety.checkpoint_on_mutation
469            && !in_scratchpad
470            && let Err(e) = mermaid_runtime::create_checkpoint_for_task(
471                &ctx.workdir,
472                std::slice::from_ref(&abs_path),
473                Some(serde_json::json!({
474                    "tool": "write_file",
475                    "path": path,
476                })),
477                ctx.checkpoint_origin(),
478            )
479        {
480            return ToolOutcome::error(format!("write_file checkpoint failed: {e}"), 0.0);
481        }
482        let display_path = path.to_string();
483        let line_count = content.lines().count();
484        let byte_count = content.len();
485        let content = content.to_string();
486
487        tokio::select! {
488            biased;
489            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
490            // The prior-content read (for the display diff) now happens INSIDE
491            // this blocking job and BOUNDED (#F44/RC-L) — never a synchronous
492            // unbounded `read_to_string` on the async worker thread.
493            result = tokio::task::spawn_blocking(move || write_with_diff_blocking(&root, &abs_path, &rel, &content)) => {
494                match result {
495                    Ok(Ok(write)) => {
496                        let duration_secs = start.elapsed().as_secs_f64();
497                        after_file_mutation(&ctx, "write_file", &display_path);
498                        ToolOutcome::success(
499                            format!("Wrote {} ({} lines)", display_path, write.line_count),
500                            format!("{} {} written", write.line_count, plural(write.line_count, "line", "lines")),
501                            duration_secs,
502                        )
503                        .with_metadata(ToolRunMetadata {
504                            detail: ToolMetadata::WriteFile {
505                                path: display_path,
506                                line_count,
507                                byte_count,
508                                created: Some(write.created),
509                            },
510                            line_count: Some(line_count),
511                            byte_count: Some(byte_count),
512                            display_diff: Some(write.diff.display_diff),
513                            diff_truncated: write.diff.truncated,
514                            lines_added: write.diff.added,
515                            lines_removed: write.diff.removed,
516                            plan_file_written: plan_write,
517                            ..ToolRunMetadata::default()
518                        })
519                    },
520                    Ok(Err(e)) => ToolOutcome::error(
521                        format!("write_file({display_path}): {e}"),
522                        start.elapsed().as_secs_f64(),
523                    ),
524                    Err(e) => ToolOutcome::error(
525                        format!("write_file join error: {e}"),
526                        start.elapsed().as_secs_f64(),
527                    ),
528                }
529            }
530        }
531    }
532}
533
534// ─── helpers ────────────────────────────────────────────────────────
535
536fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
537    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
538    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
539        return Ok(vec![p.to_string()]);
540    }
541    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
542        if arr.len() > mermaid_model::constants::MAX_BATCH_TOOL_ITEMS {
543            return Err(format!(
544                "read_file: too many paths ({}); cap is {} per call — split the request",
545                arr.len(),
546                mermaid_model::constants::MAX_BATCH_TOOL_ITEMS
547            ));
548        }
549        let mut out = Vec::with_capacity(arr.len());
550        for v in arr {
551            let Some(s) = v.as_str() else {
552                return Err("read_file 'paths' must be an array of strings".to_string());
553            };
554            out.push(s.to_string());
555        }
556        return Ok(out);
557    }
558    Err("read_file requires 'path' or 'paths'".to_string())
559}
560
561/// Read-only carve-out for memory facts. Global and project-private memory
562/// live under the OS data dir — outside both allowed roots — and the memory
563/// index tells the model to `read_file` the fact's path, so reads resolve
564/// against the memory roots too. Absolute paths only, with the same
565/// canonical (symlink-resolving) containment as the scratchpad arm. The
566/// write tools never consult this: memory mutation goes through the `memory`
567/// tool, where the policy gate can see it.
568fn resolve_in_memory_roots(workdir: &Path, raw: &str) -> Option<(PathBuf, PathBuf)> {
569    if !Path::new(raw).is_absolute() {
570        return None;
571    }
572    for (root, _scope) in crate::app::memory::memory_roots(workdir) {
573        if let Ok((_abs, true)) = resolve_path_within(&root, raw)
574            && let Ok(rel) = relative_within(&root, raw)
575        {
576            return Some((root, rel));
577        }
578    }
579    None
580}
581
582/// Read one file (bounded). Returns the (possibly marker-footed) text and the
583/// REAL truncation flag from the bounded read, so the caller propagates that
584/// rather than sniffing the output for the marker string — which a file whose
585/// own content contains that literal text would otherwise falsely trip (#78).
586async fn read_one(roots: &AllowedRoots<'_>, raw: &str) -> std::io::Result<(String, bool)> {
587    // Canonical containment gate — rejects escapes (incl. existing symlinks
588    // that resolve outside the allowed roots) before we touch the file, and
589    // names the root-relative path for the confined fd read, so the bytes come
590    // from the inode the kernel resolved under RESOLVE_BENEATH rather than
591    // whatever a concurrently-swapped symlink now points at (#77).
592    let ResolvedInRoot { rel, root, .. } = match resolve_in_roots(roots, raw) {
593        Ok(resolved) => resolved,
594        Err(msg) => {
595            let (root, rel) = resolve_in_memory_roots(roots.workdir, raw)
596                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
597            ResolvedInRoot {
598                abs: root.join(&rel),
599                rel,
600                root,
601                in_scratchpad: false,
602            }
603        },
604    };
605    let result = tokio::task::spawn_blocking(move || {
606        let file = mermaid_runtime::open_beneath(&root, &rel, mermaid_runtime::OpenIntent::Read)?;
607        // Bounded read: never pull more than the cap (+1 probe byte) into RAM,
608        // so a model pointing `read_file` at a multi-gigabyte file can't OOM the
609        // process — a full read would have slurped the whole thing first (#15).
610        let (data, truncated) = mermaid_model::utils::read_capped(file, MAX_FILE_READ_BYTES)?;
611        let mut s = String::from_utf8_lossy(&data).into_owned();
612        if truncated {
613            // Char-boundary-safe truncation with a marker footer.
614            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
615            s.truncate(cut);
616            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
617        }
618        Ok::<_, std::io::Error>((s, truncated))
619    })
620    .await
621    .map_err(|e| std::io::Error::other(e.to_string()))??;
622    Ok(result)
623}
624
625/// Write `content` to `rel` beneath `root` (the project workdir or the session
626/// scratchpad) through the symlink-confined *atomic* writer, creating parent
627/// dirs the same confined way. The bytes are written to a temp and
628/// `renameat`-swapped over the target, all beneath the directory fd the kernel
629/// resolved under `RESOLVE_BENEATH`: a parent dir swapped for an escaping
630/// symlink can't redirect the write (#77), and a crash/kill/disk-full
631/// mid-write leaves the previous file intact rather than a truncated or
632/// half-written one.
633fn write_one_blocking(root: &Path, rel: &Path, content: &str) -> std::io::Result<usize> {
634    if let Some(parent) = rel.parent()
635        && !parent.as_os_str().is_empty()
636    {
637        mermaid_runtime::create_dir_all_beneath(root, parent)?;
638    }
639    mermaid_runtime::write_atomic_beneath(root, rel, content.as_bytes())?;
640    Ok(content.lines().count())
641}
642
643struct WriteResult {
644    line_count: usize,
645    created: bool,
646    diff: mermaid_model::diff::DisplayDiff,
647}
648
649/// Write `content` and build the display diff against the prior file in ONE
650/// blocking job (#F44/RC-L). The prior content is read BOUNDED via
651/// [`mermaid_model::utils::read_file_capped`] — overwriting a multi-gigabyte file must
652/// not slurp it into RAM on the async worker just to render a diff. A prior file
653/// larger than the read cap (or otherwise unreadable) is elided from the diff
654/// rather than read whole.
655fn write_with_diff_blocking(
656    root: &Path,
657    abs_path: &Path,
658    rel: &Path,
659    content: &str,
660) -> std::io::Result<WriteResult> {
661    let (old_content, created, elide_diff) =
662        match mermaid_model::utils::read_file_capped(abs_path, MAX_FILE_READ_BYTES) {
663            Ok((data, false)) => (String::from_utf8_lossy(&data).into_owned(), false, false),
664            // Existing file is past the read cap — don't pull it all into RAM.
665            Ok((_, true)) => (String::new(), false, true),
666            // Missing file → a fresh create; the diff shows the whole content added.
667            Err(e) if e.kind() == std::io::ErrorKind::NotFound => (String::new(), true, false),
668            // Any other read error: don't fail the write over a diff preview.
669            Err(_) => (String::new(), false, true),
670        };
671    let diff = if elide_diff {
672        mermaid_model::diff::DisplayDiff {
673            display_diff: format!(
674                "[diff preview skipped: existing file exceeds the {MAX_FILE_READ_BYTES}-byte cap]"
675            ),
676            added: 0,
677            removed: 0,
678            truncated: true,
679        }
680    } else {
681        mermaid_model::diff::generate_display_diff(&old_content, content)
682    };
683    let line_count = write_one_blocking(root, rel, content)?;
684    Ok(WriteResult {
685        line_count,
686        created,
687        diff,
688    })
689}
690
691/// `scratch_contained` is true when the mutation touches ONLY the session
692/// scratchpad (`ResolvedInRoot::in_scratchpad`). The gate downgrades an
693/// `Ask`/`Classify` on such a mutation to proceed — scratch files are
694/// session-private and ephemeral — while read-only mode and `Deny` overrides
695/// still block it.
696/// Outcome of gating a file mutation. `Proceed` carries `plan_write`: whether
697/// the allowance came from plan mode's plan-file carve-out, which the caller
698/// stamps onto `ToolRunMetadata::plan_file_written`.
699///
700/// The tool NAME is not a usable stand-in for this. "Under the plan floor the
701/// only Edit that can succeed is the plan file" stops being true as soon as
702/// `[plan] memory = allow` lets a `write_file` to a memory path succeed.
703pub(super) enum MutationGate {
704    /// Blocked — return this outcome verbatim. Boxed to keep the enum small.
705    Blocked(Box<ToolOutcome>),
706    Proceed {
707        plan_write: bool,
708    },
709}
710
711pub(super) async fn mutation_policy_outcome(
712    ctx: &ExecContext,
713    tool: &str,
714    path: &str,
715    checkpoint_paths: &[PathBuf],
716    pending_action: serde_json::Value,
717    scratch_contained: bool,
718) -> MutationGate {
719    let mut request = mermaid_runtime::ActionRequest::new(
720        tool,
721        mermaid_runtime::ToolCategory::Edit,
722        format!("{tool} {path}"),
723    );
724    request.path = Some(path.to_string());
725    // File mutations are replayable: an Ask decision checkpoints, records an
726    // approval, and blocks (handled inside the gate).
727    match super::policy_gate::gate(
728        ctx,
729        request,
730        checkpoint_paths,
731        pending_action,
732        true,
733        scratch_contained,
734    )
735    .await
736    {
737        super::policy_gate::Gate::Block(outcome) => MutationGate::Blocked(Box::new(outcome)),
738        super::policy_gate::Gate::Proceed { plan_write, .. } => {
739            let _ = mermaid_runtime::run_plugin_hooks(
740                "before_file_mutation",
741                &serde_json::json!({
742                    "task_id": ctx.task_id.clone(),
743                    "turn_id": ctx.turn.0,
744                    "call_id": ctx.call_id.0,
745                    "tool": tool,
746                    "path": path,
747                }),
748            );
749            MutationGate::Proceed { plan_write }
750        },
751    }
752}
753
754pub(super) fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
755    let _ = mermaid_runtime::run_plugin_hooks(
756        "after_file_mutation",
757        &serde_json::json!({
758            "task_id": ctx.task_id.clone(),
759            "turn_id": ctx.turn.0,
760            "call_id": ctx.call_id.0,
761            "tool": tool,
762            "path": path,
763        }),
764    );
765}
766
767fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
768    ToolOutcome::error(msg, duration_secs)
769}
770
771fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
772    if count == 1 { singular } else { plural }
773}
774
775pub(super) fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
776    format!(
777        "+{} -{}, took {}",
778        added,
779        removed,
780        format_duration_for_diff(duration_secs)
781    )
782}
783
784fn format_duration_for_diff(seconds: f64) -> String {
785    if seconds < 1.0 {
786        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
787    } else if seconds < 10.0 {
788        format!("{seconds:.1}s")
789    } else {
790        format!("{}s", seconds.round() as u64)
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797    use crate::providers::ctx::test_exec_context;
798    use mermaid_domain::{ToolCallId, TurnId};
799    use std::fs;
800
801    /// Memory facts live outside the project/scratchpad roots and the index
802    /// tells the model to `read_file` them — reads must resolve against the
803    /// memory roots (here the `ProjectShared` root, reached by putting the
804    /// workdir in a subdir of the git root), while unrelated outside paths
805    /// stay rejected.
806    #[tokio::test]
807    async fn read_file_resolves_memory_roots_read_only() {
808        let base = std::env::temp_dir().join(format!("mermaid_memread_{}", std::process::id()));
809        let _ = fs::remove_dir_all(&base);
810        let repo = base.join("repo");
811        let workdir = repo.join("src");
812        fs::create_dir_all(&workdir).unwrap();
813        fs::create_dir_all(repo.join(".git")).unwrap();
814        let mem_dir = repo.join(".mermaid").join("memory");
815        fs::create_dir_all(&mem_dir).unwrap();
816        let fact = mem_dir.join("fact.md");
817        fs::write(&fact, "the fact body").unwrap();
818
819        let roots = AllowedRoots::new(&workdir, None);
820        let (content, truncated) = read_one(&roots, fact.to_str().unwrap()).await.unwrap();
821        assert!(!truncated);
822        assert_eq!(content, "the fact body");
823
824        // Still confined: an outside path that is NOT under a memory root
825        // rejects exactly as before.
826        let stray = base.join("stray.txt");
827        fs::write(&stray, "nope").unwrap();
828        assert!(read_one(&roots, stray.to_str().unwrap()).await.is_err());
829
830        let _ = fs::remove_dir_all(&base);
831    }
832
833    #[test]
834    fn resolve_in_roots_contains_to_workdir() {
835        let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
836        let _ = fs::remove_dir_all(&root);
837        fs::create_dir_all(root.join("sub")).unwrap();
838        let roots = AllowedRoots::new(&root, None);
839
840        // In-root existing + not-yet-existing targets resolve inside root.
841        assert!(resolve_in_roots(&roots, "sub").is_ok());
842        let resolved = resolve_in_roots(&roots, "sub/new.txt").unwrap();
843        let canon_root = fs::canonicalize(&root).unwrap();
844        assert!(resolved.abs.starts_with(&canon_root));
845
846        // `..` escape and absolute outside are rejected.
847        assert!(resolve_in_roots(&roots, "../escape.txt").is_err());
848        assert!(resolve_in_roots(&roots, "../../etc/passwd").is_err());
849        let outside = std::env::temp_dir().join("definitely_outside.txt");
850        assert!(resolve_in_roots(&roots, &outside.display().to_string()).is_err());
851
852        let _ = fs::remove_dir_all(&root);
853    }
854
855    fn temp_root(name: &str) -> PathBuf {
856        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{name}"));
857        let _ = fs::remove_dir_all(&p);
858        fs::create_dir_all(&p).expect("create tmpdir");
859        p
860    }
861
862    #[tokio::test]
863    async fn read_file_returns_content() {
864        let dir = temp_root("read_ok");
865        fs::write(dir.join("a.txt"), "hello").expect("write");
866        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
867
868        let tool = ReadFileTool;
869        let outcome = tool
870            .execute(serde_json::json!({"path": "a.txt"}), ctx)
871            .await;
872        assert!(outcome.is_success(), "expected success: {outcome:?}");
873        assert_eq!(outcome.output(), "hello");
874        let _ = fs::remove_dir_all(&dir);
875    }
876
877    #[tokio::test]
878    async fn read_file_missing_path_errors() {
879        let dir = temp_root("read_missing_path");
880        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
881        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
882        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
883        let _ = fs::remove_dir_all(&dir);
884    }
885
886    #[tokio::test]
887    async fn read_file_nonexistent_errors() {
888        let dir = temp_root("read_nonex");
889        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
890        let outcome = ReadFileTool
891            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
892            .await;
893        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
894        let _ = fs::remove_dir_all(&dir);
895    }
896
897    #[tokio::test]
898    async fn read_file_with_multiple_paths_joins_contents() {
899        let dir = temp_root("read_multi");
900        fs::write(dir.join("a.txt"), "alpha").expect("write");
901        fs::write(dir.join("b.txt"), "beta").expect("write");
902        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
903        let outcome = ReadFileTool
904            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
905            .await;
906        assert!(outcome.is_success(), "expected success: {outcome:?}");
907        let output = outcome.output();
908        assert!(output.contains("=== a.txt ==="));
909        assert!(output.contains("alpha"));
910        assert!(output.contains("=== b.txt ==="));
911        assert!(output.contains("beta"));
912        let _ = fs::remove_dir_all(&dir);
913    }
914
915    #[tokio::test]
916    async fn read_file_multi_aggregate_is_capped() {
917        // F45: many files in one call can't blow past the aggregate cap. Each
918        // file is under the per-file cap, but their sum exceeds the aggregate.
919        let dir = temp_root("read_aggregate_cap");
920        let chunk = "a".repeat(MAX_READ_AGGREGATE_CHARS * 2 / 3);
921        fs::write(dir.join("a.txt"), &chunk).expect("write a");
922        fs::write(dir.join("b.txt"), &chunk).expect("write b");
923        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
924        let outcome = ReadFileTool
925            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
926            .await;
927        assert!(outcome.is_success(), "expected success: {outcome:?}");
928        let output = outcome.output();
929        assert!(
930            output.len() <= MAX_READ_AGGREGATE_CHARS + 64,
931            "combined must be capped, got {} bytes",
932            output.len()
933        );
934        assert!(
935            output.contains("elided"),
936            "expected aggregate head+tail elision marker"
937        );
938        match &outcome.metadata.detail {
939            ToolMetadata::ReadFile { truncated, .. } => {
940                assert!(*truncated, "aggregate truncation must set truncated")
941            },
942            other => panic!("expected ReadFile metadata, got {other:?}"),
943        }
944        let _ = fs::remove_dir_all(&dir);
945    }
946
947    #[tokio::test]
948    async fn write_file_elides_diff_for_oversized_existing_file() {
949        // F44: overwriting a file larger than the read cap must NOT slurp it into
950        // RAM for a diff — the diff is elided with a marker instead.
951        let dir = temp_root("write_oversized_diff");
952        let big = "a".repeat(MAX_FILE_READ_BYTES + 1);
953        fs::write(dir.join("big.txt"), &big).expect("write fixture");
954        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
955        let outcome = WriteFileTool
956            .execute(
957                serde_json::json!({"path": "big.txt", "content": "small\n"}),
958                ctx,
959            )
960            .await;
961        assert!(outcome.is_success(), "expected success: {outcome:?}");
962        let diff = outcome
963            .metadata
964            .display_diff
965            .as_deref()
966            .expect("display diff");
967        assert!(
968            diff.contains("diff preview skipped"),
969            "expected elision marker, got: {diff}"
970        );
971        assert!(
972            outcome.metadata.diff_truncated,
973            "oversized diff must set diff_truncated"
974        );
975        match &outcome.metadata.detail {
976            ToolMetadata::WriteFile { created, .. } => {
977                assert_eq!(*created, Some(false), "existing file is not 'created'")
978            },
979            other => panic!("expected WriteFile metadata, got {other:?}"),
980        }
981        // The file was actually overwritten despite the elided diff.
982        let written = fs::read_to_string(dir.join("big.txt")).expect("read");
983        assert_eq!(written, "small\n");
984        let _ = fs::remove_dir_all(&dir);
985    }
986
987    #[tokio::test]
988    async fn read_file_with_marker_in_content_is_not_flagged_truncated() {
989        // #78: a small file whose own content contains the truncation-marker
990        // string must NOT be reported as truncated — the flag comes from the
991        // bounded read now, not a substring sniff of the output.
992        let dir = temp_root("read_marker_content");
993        fs::write(
994            dir.join("a.txt"),
995            "before\n\n[TRUNCATED: file exceeded read cap]\nafter",
996        )
997        .expect("write");
998        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
999
1000        let outcome = ReadFileTool
1001            .execute(serde_json::json!({"path": "a.txt"}), ctx)
1002            .await;
1003        assert!(outcome.is_success(), "expected success: {outcome:?}");
1004        match &outcome.metadata.detail {
1005            ToolMetadata::ReadFile { truncated, .. } => assert!(
1006                !truncated,
1007                "a file whose content contains the marker must not be flagged truncated"
1008            ),
1009            other => panic!("expected ReadFile metadata, got {other:?}"),
1010        }
1011        let _ = fs::remove_dir_all(&dir);
1012    }
1013
1014    #[tokio::test]
1015    async fn read_file_respects_cancellation() {
1016        let dir = temp_root("read_cancel");
1017        // Write a huge file so the read is slow enough to race cancel.
1018        // Actually spawn_blocking on read is fast on tmpfs — this test
1019        // just verifies the select! arm compiles + the token trips
1020        // the cancel path when pre-cancelled.
1021        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1022        ctx.token.cancel();
1023        let outcome = ReadFileTool
1024            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1025            .await;
1026        assert!(outcome.was_cancelled());
1027        let _ = fs::remove_dir_all(&dir);
1028    }
1029
1030    #[tokio::test]
1031    async fn write_file_creates_and_counts_lines() {
1032        let dir = temp_root("write_ok");
1033        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1034        let outcome = WriteFileTool
1035            .execute(
1036                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1037                ctx,
1038            )
1039            .await;
1040        assert!(outcome.is_success(), "expected success: {outcome:?}");
1041        assert!(outcome.output().contains("3 lines"));
1042        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1043        assert!(written.contains("line1"));
1044        let _ = fs::remove_dir_all(&dir);
1045    }
1046
1047    #[tokio::test]
1048    async fn concurrent_write_file_same_path_serializes_cleanly() {
1049        // The per-path write gate must let two writes to the same file in one turn
1050        // both succeed and leave the file as exactly one clean write (never a
1051        // corrupt interleave), and must not deadlock.
1052        let dir = temp_root("write_race");
1053        let (ctx1, _r1) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1054        let (ctx2, _r2) = test_exec_context(TurnId(1), ToolCallId(2), dir.clone());
1055        let a = "AAAA\nAAAA\n";
1056        let b = "BBBB\nBBBB\n";
1057        let (o1, o2) = tokio::join!(
1058            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": a}), ctx1),
1059            WriteFileTool.execute(serde_json::json!({"path": "race.txt", "content": b}), ctx2),
1060        );
1061        assert!(o1.is_success(), "first write failed: {o1:?}");
1062        assert!(o2.is_success(), "second write failed: {o2:?}");
1063        let final_content = fs::read_to_string(dir.join("race.txt")).expect("read");
1064        assert!(
1065            final_content == a || final_content == b,
1066            "file must be exactly one clean write, got {final_content:?}"
1067        );
1068        let _ = fs::remove_dir_all(&dir);
1069    }
1070
1071    #[tokio::test]
1072    async fn write_file_new_file_records_added_display_diff() {
1073        let dir = temp_root("write_new_diff");
1074        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1075        let outcome = WriteFileTool
1076            .execute(
1077                serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1078                ctx,
1079            )
1080            .await;
1081        assert!(outcome.is_success(), "expected success: {outcome:?}");
1082        let diff = outcome
1083            .metadata
1084            .display_diff
1085            .as_deref()
1086            .expect("display diff");
1087        assert!(diff.contains("+ alpha"));
1088        assert!(diff.contains("+ beta"));
1089        // No unified-diff header clutter (`---`/`+++`/`@@`).
1090        assert!(
1091            !diff.contains("@@"),
1092            "diff should not carry hunk headers: {diff}"
1093        );
1094        assert!(!diff.contains("/dev/null"));
1095        let _ = fs::remove_dir_all(&dir);
1096    }
1097
1098    #[tokio::test]
1099    async fn write_file_existing_file_records_added_and_removed_display_diff() {
1100        let dir = temp_root("write_existing_diff");
1101        fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1102        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1103        let outcome = WriteFileTool
1104            .execute(
1105                serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1106                ctx,
1107            )
1108            .await;
1109        assert!(outcome.is_success(), "expected success: {outcome:?}");
1110        let diff = outcome
1111            .metadata
1112            .display_diff
1113            .as_deref()
1114            .expect("display diff");
1115        assert!(diff.contains("- old"));
1116        assert!(diff.contains("+ new"));
1117        let _ = fs::remove_dir_all(&dir);
1118    }
1119
1120    #[tokio::test]
1121    async fn write_file_creates_parent_dirs() {
1122        let dir = temp_root("write_parents");
1123        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1124        let outcome = WriteFileTool
1125            .execute(
1126                serde_json::json!({
1127                    "path": "sub/nested/out.txt",
1128                    "content": "deep",
1129                }),
1130                ctx,
1131            )
1132            .await;
1133        assert!(outcome.is_success(), "expected success: {outcome:?}");
1134        assert!(dir.join("sub/nested/out.txt").exists());
1135        let _ = fs::remove_dir_all(&dir);
1136    }
1137
1138    #[tokio::test]
1139    async fn write_file_missing_content_errors() {
1140        let dir = temp_root("write_missing");
1141        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1142        let outcome = WriteFileTool
1143            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1144            .await;
1145        assert_eq!(outcome.status, mermaid_domain::ToolStatus::Error);
1146        let _ = fs::remove_dir_all(&dir);
1147    }
1148
1149    // ─── F10: absolute-path block ───────────────────────────────────
1150
1151    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1152    /// must fail with a clear "outside the project" error. The tool
1153    /// schema advertises this contract; before F10 it was a lie.
1154    #[tokio::test]
1155    async fn read_file_rejects_absolute_path_outside_workdir() {
1156        let dir = temp_root("read_abs_escape");
1157        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1158        // Pick a path that's definitely outside a fresh /tmp/* workdir.
1159        let outcome = ReadFileTool
1160            .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1161            .await;
1162        let error = outcome.error_message().expect("expected error");
1163        assert!(
1164            error.contains("outside the project"),
1165            "expected security reject, got: {error}"
1166        );
1167        let _ = fs::remove_dir_all(&dir);
1168    }
1169
1170    /// Absolute path that lives INSIDE the workdir is allowed.
1171    #[tokio::test]
1172    async fn read_file_accepts_absolute_path_inside_workdir() {
1173        let dir = temp_root("read_abs_inside");
1174        let file = dir.join("hello.txt");
1175        fs::write(&file, "ok").expect("write fixture");
1176        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1177        let outcome = ReadFileTool
1178            .execute(
1179                serde_json::json!({"path": file.to_string_lossy().to_string()}),
1180                ctx,
1181            )
1182            .await;
1183        assert!(outcome.is_success(), "expected success: {outcome:?}");
1184        let _ = fs::remove_dir_all(&dir);
1185    }
1186
1187    /// Relative `..`-escape must also be blocked — they resolve against
1188    /// the workdir and land outside it, so the lexical normalization
1189    /// in `resolve_in_roots` catches them.
1190    #[tokio::test]
1191    async fn write_file_rejects_relative_parent_escape() {
1192        let dir = temp_root("write_dotdot_escape");
1193        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1194        let outcome = WriteFileTool
1195            .execute(
1196                serde_json::json!({
1197                    "path": "../escape.txt",
1198                    "content": "should not write",
1199                }),
1200                ctx,
1201            )
1202            .await;
1203        let error = outcome.error_message().expect("expected error");
1204        assert!(
1205            error.contains("outside the project"),
1206            "expected security reject, got: {error}"
1207        );
1208        let _ = fs::remove_dir_all(&dir);
1209    }
1210
1211    /// `create_directory` needs the lexical-normalization fallback
1212    /// because the target doesn't exist yet (can't canonicalize).
1213    /// Verify the escape check still fires for non-existent targets.
1214    #[tokio::test]
1215    async fn create_directory_rejects_absolute_path_outside_workdir() {
1216        let dir = temp_root("mkdir_abs_escape");
1217        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1218        let outcome = CreateDirectoryTool
1219            .execute(
1220                serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1221                ctx,
1222            )
1223            .await;
1224        let error = outcome.error_message().expect("expected error");
1225        assert!(
1226            error.contains("outside the project"),
1227            "expected security reject, got: {error}"
1228        );
1229        let _ = fs::remove_dir_all(&dir);
1230    }
1231
1232    // ─── session-scratchpad dual root ────────────────────────────────
1233
1234    /// Build an `ExecContext` with an explicit safety mode, NO approval
1235    /// broker, and (optionally) a materialized scratchpad. Unlike
1236    /// `test_exec_context` (pinned to `FullAccess`) this exercises the gate.
1237    fn scratch_ctx(
1238        mode: mermaid_runtime::SafetyMode,
1239        workdir: PathBuf,
1240        scratchpad: Option<PathBuf>,
1241    ) -> (ExecContext, tokio::sync::mpsc::Receiver<ProgressEvent>) {
1242        let mut config = mermaid_domain::Config::default();
1243        config.safety.mode = mode;
1244        let (tx, rx) = tokio::sync::mpsc::channel(8);
1245        let mut ctx = ExecContext::new(
1246            tokio_util::sync::CancellationToken::new(),
1247            tx,
1248            ToolCallId(1),
1249            TurnId(1),
1250            workdir,
1251            std::sync::Arc::new(config),
1252            String::new(),
1253            None,
1254            None,
1255            None,
1256            mode,
1257            None,
1258            None,
1259            None,
1260            None,
1261            None,
1262        );
1263        ctx.scratchpad = scratchpad;
1264        (ctx, rx)
1265    }
1266
1267    /// Project + scratch fixture pair with a unique, greppable name.
1268    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    /// True when any checkpoint manifest on disk references `marker`. The
1283    /// fixture paths are unique per test+pid, so a hit can only come from
1284    /// the mutation under test.
1285    fn any_checkpoint_mentions(marker: &str) -> bool {
1286        let Ok(data) = mermaid_runtime::data_dir() else {
1287            return false;
1288        };
1289        let Ok(entries) = fs::read_dir(data.join("checkpoints")) else {
1290            return false;
1291        };
1292        entries.flatten().any(|entry| {
1293            fs::read_to_string(entry.path().join("manifest.json"))
1294                .is_ok_and(|manifest| manifest.contains(marker))
1295        })
1296    }
1297
1298    /// Scratchpad mutations proceed in Ask mode with NO approval broker
1299    /// bound (the gate is bypassed entirely) and never take a checkpoint.
1300    #[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        // write_file into the scratchpad via absolute path.
1306        let file = scratch.join("notes.txt");
1307        let (ctx, _rx) = scratch_ctx(
1308            mermaid_runtime::SafetyMode::Ask,
1309            project.clone(),
1310            Some(scratch.clone()),
1311        );
1312        let outcome = WriteFileTool
1313            .execute(
1314                serde_json::json!({
1315                    "path": file.to_str().unwrap(),
1316                    "content": "scratch note\n",
1317                }),
1318                ctx,
1319            )
1320            .await;
1321        assert!(outcome.is_success(), "scratch write: {outcome:?}");
1322        assert_eq!(fs::read_to_string(&file).unwrap(), "scratch note\n");
1323
1324        // create_directory inside the scratchpad.
1325        let subdir = scratch.join("work/area");
1326        let (ctx, _rx) = scratch_ctx(
1327            mermaid_runtime::SafetyMode::Ask,
1328            project.clone(),
1329            Some(scratch.clone()),
1330        );
1331        let outcome = CreateDirectoryTool
1332            .execute(serde_json::json!({"path": subdir.to_str().unwrap()}), ctx)
1333            .await;
1334        assert!(outcome.is_success(), "scratch mkdir: {outcome:?}");
1335        assert!(subdir.is_dir());
1336
1337        // delete_file inside the scratchpad.
1338        let (ctx, _rx) = scratch_ctx(
1339            mermaid_runtime::SafetyMode::Ask,
1340            project.clone(),
1341            Some(scratch.clone()),
1342        );
1343        let outcome = DeleteFileTool
1344            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1345            .await;
1346        assert!(outcome.is_success(), "scratch delete: {outcome:?}");
1347        assert!(!file.exists());
1348
1349        // None of the mutations checkpointed the ephemeral scratch paths.
1350        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    /// `ReadOnly` still blocks scratchpad mutations — the bypass only skips
1358    /// the approval flow, never the mode's mutation ban.
1359    #[tokio::test]
1360    async fn scratch_mutation_blocked_in_read_only() {
1361        let (project, scratch) = scratch_fixture("readonly");
1362        let file = scratch.join("blocked.txt");
1363        let (ctx, _rx) = scratch_ctx(
1364            mermaid_runtime::SafetyMode::ReadOnly,
1365            project.clone(),
1366            Some(scratch.clone()),
1367        );
1368        let outcome = WriteFileTool
1369            .execute(
1370                serde_json::json!({
1371                    "path": file.to_str().unwrap(),
1372                    "content": "nope",
1373                }),
1374                ctx,
1375            )
1376            .await;
1377        let error = outcome.error_message().expect("expected block");
1378        assert!(
1379            error.contains("blocked by policy"),
1380            "expected policy block, got: {error}"
1381        );
1382        assert!(!file.exists());
1383        let _ = fs::remove_dir_all(project.parent().unwrap());
1384    }
1385
1386    /// A path outside BOTH roots is rejected at resolution — before the gate.
1387    #[tokio::test]
1388    async fn write_outside_both_roots_is_rejected() {
1389        let (project, scratch) = scratch_fixture("outside");
1390        let outside = project.parent().unwrap().join("elsewhere/out.txt");
1391        let (ctx, _rx) = scratch_ctx(
1392            mermaid_runtime::SafetyMode::Ask,
1393            project.clone(),
1394            Some(scratch.clone()),
1395        );
1396        let outcome = WriteFileTool
1397            .execute(
1398                serde_json::json!({
1399                    "path": outside.to_str().unwrap(),
1400                    "content": "should not write",
1401                }),
1402                ctx,
1403            )
1404            .await;
1405        let error = outcome.error_message().expect("expected error");
1406        assert!(
1407            error.contains("outside the project"),
1408            "expected containment reject, got: {error}"
1409        );
1410        assert!(!outside.exists());
1411        let _ = fs::remove_dir_all(project.parent().unwrap());
1412    }
1413
1414    /// `read_file` follows a materialized scratchpad too.
1415    #[tokio::test]
1416    async fn read_file_reads_from_scratchpad() {
1417        let (project, scratch) = scratch_fixture("read");
1418        let file = scratch.join("stash.txt");
1419        fs::write(&file, "stashed").unwrap();
1420        let (ctx, _rx) = scratch_ctx(
1421            mermaid_runtime::SafetyMode::Ask,
1422            project.clone(),
1423            Some(scratch.clone()),
1424        );
1425        let outcome = ReadFileTool
1426            .execute(serde_json::json!({"path": file.to_str().unwrap()}), ctx)
1427            .await;
1428        assert!(outcome.is_success(), "scratch read: {outcome:?}");
1429        assert_eq!(outcome.output(), "stashed");
1430        let _ = fs::remove_dir_all(project.parent().unwrap());
1431    }
1432}