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 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};
21use crate::render::diff::{DIFF_ADDED_MARKER, DIFF_REMOVED_MARKER};
22
23use super::super::ctx::{ExecContext, ProgressEvent};
24use super::ToolExecutor;
25
26/// Small helper for building a `ToolDefinition` with a typical
27/// JSON-schema-shaped input_schema. Keeps the per-tool definitions
28/// readable.
29fn defn(name: &str, description: &str, input_schema: serde_json::Value) -> ToolDefinition {
30    ToolDefinition {
31        name: name.to_string(),
32        description: description.to_string(),
33        input_schema,
34    }
35}
36
37/// `read_file` — read one or more files and return their contents
38/// joined with section markers.
39pub struct ReadFileTool;
40
41#[async_trait]
42impl ToolExecutor for ReadFileTool {
43    fn name(&self) -> &'static str {
44        "read_file"
45    }
46
47    fn schema(&self) -> ToolDefinition {
48        defn(
49            "read_file",
50            "Read the contents of one or more files from disk. Prefer relative paths; absolute paths must resolve inside the project directory or the call is rejected.",
51            serde_json::json!({
52                "type": "object",
53                "properties": {
54                    "path": { "type": "string", "description": "File to read (single)." },
55                    "paths": {
56                        "type": "array",
57                        "items": { "type": "string" },
58                        "description": "Multiple files to read in parallel."
59                    }
60                },
61                "oneOf": [
62                    { "required": ["path"] },
63                    { "required": ["paths"] }
64                ]
65            }),
66        )
67    }
68
69    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
70        let paths = match extract_paths(&args) {
71            Ok(p) => p,
72            Err(e) => return ToolOutcome::error(e, 0.0),
73        };
74        if paths.is_empty() {
75            return ToolOutcome::error("read_file requires at least one path", 0.0);
76        }
77
78        let start = std::time::Instant::now();
79        let workdir = ctx.workdir.clone();
80        let mut combined = String::new();
81
82        for (idx, raw_path) in paths.iter().enumerate() {
83            // Race the file read against the turn's cancel token. If
84            // the user Ctrl+C's mid-read, we bail immediately.
85            tokio::select! {
86                biased;
87                _ = ctx.token.cancelled() => {
88                    return ToolOutcome::cancelled();
89                },
90                read = read_one(&workdir, raw_path) => {
91                    match read {
92                        Ok(content) => {
93                            if paths.len() > 1 {
94                                let _ = ctx.progress.send(ProgressEvent::Status(
95                                    format!("read {}/{}: {}", idx + 1, paths.len(), raw_path),
96                                )).await;
97                                combined.push_str(&format!(
98                                    "=== {} ===\n{}\n\n",
99                                    raw_path, content
100                                ));
101                            } else {
102                                combined = content;
103                            }
104                        },
105                        Err(e) => {
106                            return ToolOutcome::error(
107                                format!("{}: {}", raw_path, e),
108                                start.elapsed().as_secs_f64(),
109                            );
110                        },
111                    }
112                },
113            }
114        }
115
116        let duration_secs = start.elapsed().as_secs_f64();
117        let line_count = combined.lines().count();
118        let byte_count = combined.len();
119        let truncated = combined.contains("[TRUNCATED: file exceeded read cap]");
120        ToolOutcome::success(
121            combined,
122            format!(
123                "{} {} read",
124                line_count,
125                plural(line_count, "line", "lines")
126            ),
127            duration_secs,
128        )
129        .with_metadata(ToolRunMetadata {
130            detail: ToolMetadata::ReadFile {
131                paths,
132                line_count,
133                byte_count,
134                truncated,
135            },
136            line_count: Some(line_count),
137            byte_count: Some(byte_count),
138            ..ToolRunMetadata::default()
139        })
140    }
141}
142
143/// `edit_file` — exact-match string replacement. Used for targeted
144/// edits rather than full file rewrites. Errors if the `old_string`
145/// doesn't appear exactly once.
146pub struct EditFileTool;
147
148#[async_trait]
149impl ToolExecutor for EditFileTool {
150    fn name(&self) -> &'static str {
151        "edit_file"
152    }
153
154    fn schema(&self) -> ToolDefinition {
155        defn(
156            "edit_file",
157            "Replace exactly one occurrence of `old_string` with `new_string` in the file at `path`. Fails if `old_string` doesn't appear or appears more than once — add surrounding context until the match is unique.",
158            serde_json::json!({
159                "type": "object",
160                "properties": {
161                    "path": { "type": "string" },
162                    "old_string": { "type": "string", "description": "Exact text to replace. Must appear exactly once." },
163                    "new_string": { "type": "string", "description": "Replacement text." }
164                },
165                "required": ["path", "old_string", "new_string"]
166            }),
167        )
168    }
169
170    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
171        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
172            return err("edit_file requires 'path'", 0.0);
173        };
174        let Some(old_string) = args.get("old_string").and_then(|v| v.as_str()) else {
175            return err("edit_file requires 'old_string'", 0.0);
176        };
177        let Some(new_string) = args.get("new_string").and_then(|v| v.as_str()) else {
178            return err("edit_file requires 'new_string'", 0.0);
179        };
180
181        let start = std::time::Instant::now();
182        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
183            Ok(p) => p,
184            Err(e) => return err(&format!("edit_file: {}", e), 0.0),
185        };
186        let pending_action = serde_json::json!({
187            "tool": "edit_file",
188            "args": {
189                "path": raw_path,
190                "old_string": old_string,
191                "new_string": new_string,
192            },
193            "workdir": ctx.workdir.display().to_string(),
194            "turn_id": ctx.turn.0,
195            "call_id": ctx.call_id.0,
196            "task_id": ctx.task_id.clone(),
197        });
198        if let Some(outcome) = mutation_policy_outcome(
199            &ctx,
200            "edit_file",
201            raw_path,
202            std::slice::from_ref(&abs),
203            pending_action,
204        ) {
205            return outcome;
206        }
207        if ctx.config.safety.checkpoint_on_mutation
208            && let Err(e) = crate::runtime::create_checkpoint_for_task(
209                &ctx.workdir,
210                std::slice::from_ref(&abs),
211                Some(serde_json::json!({
212                    "tool": "edit_file",
213                    "path": raw_path,
214                })),
215                ctx.task_id.clone(),
216            )
217        {
218            return err(&format!("edit_file checkpoint failed: {}", e), 0.0);
219        }
220        let old_owned = old_string.to_string();
221        let new_owned = new_string.to_string();
222        let abs_clone = abs.clone();
223        let display_path = raw_path.to_string();
224        let diff_path = display_path.clone();
225
226        tokio::select! {
227            biased;
228            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
229            result = tokio::task::spawn_blocking(move || edit_blocking(&abs_clone, &diff_path, &old_owned, &new_owned)) => {
230                match result {
231                    Ok(Ok(edit)) => {
232                        let duration_secs = start.elapsed().as_secs_f64();
233                        after_file_mutation(&ctx, "edit_file", &display_path);
234                        ToolOutcome::success(
235                            format!("Edited {} ({} replacement{})",
236                            display_path,
237                            edit.replacements,
238                            if edit.replacements == 1 { "" } else { "s" }),
239                            diff_summary(edit.added, edit.removed, duration_secs),
240                            duration_secs,
241                        )
242                        .with_metadata(ToolRunMetadata {
243                            detail: ToolMetadata::EditFile {
244                                path: display_path,
245                                replacements: edit.replacements,
246                            },
247                            display_diff: Some(edit.display_diff),
248                            diff_truncated: edit.truncated,
249                            ..ToolRunMetadata::default()
250                        })
251                    },
252                    Ok(Err(e)) => err(&format!("edit_file({}): {}", display_path, e),
253                                       start.elapsed().as_secs_f64()),
254                    Err(e) => err(&format!("edit_file join error: {}", e),
255                                   start.elapsed().as_secs_f64()),
256                }
257            }
258        }
259    }
260}
261
262/// `delete_file` — unlink a file. Errors on directories (use
263/// `execute_command rm -rf` for those — the model shouldn't be
264/// blowing away directories as a routine op).
265pub struct DeleteFileTool;
266
267#[async_trait]
268impl ToolExecutor for DeleteFileTool {
269    fn name(&self) -> &'static str {
270        "delete_file"
271    }
272
273    fn schema(&self) -> ToolDefinition {
274        defn(
275            "delete_file",
276            "Remove a file from disk. Fails on directories — use `execute_command rm -rf` for those.",
277            serde_json::json!({
278                "type": "object",
279                "properties": { "path": { "type": "string" } },
280                "required": ["path"]
281            }),
282        )
283    }
284
285    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
286        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
287            return err("delete_file requires 'path'", 0.0);
288        };
289        let start = std::time::Instant::now();
290        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
291            Ok(p) => p,
292            Err(e) => return err(&format!("delete_file: {}", e), 0.0),
293        };
294        let pending_action = serde_json::json!({
295            "tool": "delete_file",
296            "args": { "path": raw_path },
297            "workdir": ctx.workdir.display().to_string(),
298            "turn_id": ctx.turn.0,
299            "call_id": ctx.call_id.0,
300            "task_id": ctx.task_id.clone(),
301        });
302        if let Some(outcome) = mutation_policy_outcome(
303            &ctx,
304            "delete_file",
305            raw_path,
306            std::slice::from_ref(&abs),
307            pending_action,
308        ) {
309            return outcome;
310        }
311        if ctx.config.safety.checkpoint_on_mutation
312            && let Err(e) = crate::runtime::create_checkpoint_for_task(
313                &ctx.workdir,
314                std::slice::from_ref(&abs),
315                Some(serde_json::json!({
316                    "tool": "delete_file",
317                    "path": raw_path,
318                })),
319                ctx.task_id.clone(),
320            )
321        {
322            return err(&format!("delete_file checkpoint failed: {}", e), 0.0);
323        }
324        let display = raw_path.to_string();
325
326        tokio::select! {
327            biased;
328            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
329            result = tokio::task::spawn_blocking(move || std::fs::remove_file(&abs)) => {
330                match result {
331                    Ok(Ok(())) => {
332                        let duration_secs = start.elapsed().as_secs_f64();
333                        after_file_mutation(&ctx, "delete_file", &display);
334                        ToolOutcome::success(
335                            format!("Deleted {}", display),
336                            "file deleted",
337                            duration_secs,
338                        )
339                        .with_metadata(ToolRunMetadata {
340                            detail: ToolMetadata::DeleteFile { path: display },
341                            ..ToolRunMetadata::default()
342                        })
343                    },
344                    Ok(Err(e)) => err(&format!("delete_file({}): {}", display, e),
345                                       start.elapsed().as_secs_f64()),
346                    Err(e) => err(&format!("delete_file join error: {}", e),
347                                   start.elapsed().as_secs_f64()),
348                }
349            }
350        }
351    }
352}
353
354/// `create_directory` — `mkdir -p` semantics.
355pub struct CreateDirectoryTool;
356
357#[async_trait]
358impl ToolExecutor for CreateDirectoryTool {
359    fn name(&self) -> &'static str {
360        "create_directory"
361    }
362
363    fn schema(&self) -> ToolDefinition {
364        defn(
365            "create_directory",
366            "Create a directory (and any missing parents) at the given path.",
367            serde_json::json!({
368                "type": "object",
369                "properties": { "path": { "type": "string" } },
370                "required": ["path"]
371            }),
372        )
373    }
374
375    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
376        let Some(raw_path) = args.get("path").and_then(|v| v.as_str()) else {
377            return err("create_directory requires 'path'", 0.0);
378        };
379        let start = std::time::Instant::now();
380        let abs = match resolve_path_safe(&ctx.workdir, raw_path) {
381            Ok(p) => p,
382            Err(e) => return err(&format!("create_directory: {}", e), 0.0),
383        };
384        let pending_action = serde_json::json!({
385            "tool": "create_directory",
386            "args": { "path": raw_path },
387            "workdir": ctx.workdir.display().to_string(),
388            "turn_id": ctx.turn.0,
389            "call_id": ctx.call_id.0,
390            "task_id": ctx.task_id.clone(),
391        });
392        if let Some(outcome) = mutation_policy_outcome(
393            &ctx,
394            "create_directory",
395            raw_path,
396            std::slice::from_ref(&abs),
397            pending_action,
398        ) {
399            return outcome;
400        }
401        if ctx.config.safety.checkpoint_on_mutation
402            && let Err(e) = crate::runtime::create_checkpoint_for_task(
403                &ctx.workdir,
404                std::slice::from_ref(&abs),
405                Some(serde_json::json!({
406                    "tool": "create_directory",
407                    "path": raw_path,
408                })),
409                ctx.task_id.clone(),
410            )
411        {
412            return err(&format!("create_directory checkpoint failed: {}", e), 0.0);
413        }
414        let display = raw_path.to_string();
415
416        tokio::select! {
417            biased;
418            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
419            result = tokio::task::spawn_blocking(move || std::fs::create_dir_all(&abs)) => {
420                match result {
421                    Ok(Ok(())) => {
422                        let duration_secs = start.elapsed().as_secs_f64();
423                        after_file_mutation(&ctx, "create_directory", &display);
424                        ToolOutcome::success(
425                            format!("Created directory {}", display),
426                            "directory created",
427                            duration_secs,
428                        )
429                        .with_metadata(ToolRunMetadata {
430                            detail: ToolMetadata::CreateDirectory { path: display },
431                            ..ToolRunMetadata::default()
432                        })
433                    },
434                    Ok(Err(e)) => err(&format!("create_directory({}): {}", display, e),
435                                       start.elapsed().as_secs_f64()),
436                    Err(e) => err(&format!("create_directory join error: {}", e),
437                                   start.elapsed().as_secs_f64()),
438                }
439            }
440        }
441    }
442}
443
444/// `write_file` — write a single file, creating parent dirs as needed.
445pub struct WriteFileTool;
446
447#[async_trait]
448impl ToolExecutor for WriteFileTool {
449    fn name(&self) -> &'static str {
450        "write_file"
451    }
452
453    fn schema(&self) -> ToolDefinition {
454        defn(
455            "write_file",
456            "Write (overwrite) a file at `path` with `content`. Creates parent directories automatically. Prefer `edit_file` for small targeted changes.",
457            serde_json::json!({
458                "type": "object",
459                "properties": {
460                    "path": { "type": "string" },
461                    "content": { "type": "string" }
462                },
463                "required": ["path", "content"]
464            }),
465        )
466    }
467
468    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
469        let Some(path) = args.get("path").and_then(|v| v.as_str()) else {
470            return ToolOutcome::error("write_file requires 'path' (string)", 0.0);
471        };
472        let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
473            return ToolOutcome::error("write_file requires 'content' (string)", 0.0);
474        };
475
476        let start = std::time::Instant::now();
477        let abs_path = match resolve_path_safe(&ctx.workdir, path) {
478            Ok(p) => p,
479            Err(e) => return ToolOutcome::error(format!("write_file: {}", e), 0.0),
480        };
481        let pending_action = serde_json::json!({
482            "tool": "write_file",
483            "args": { "path": path, "content": content },
484            "workdir": ctx.workdir.display().to_string(),
485            "turn_id": ctx.turn.0,
486            "call_id": ctx.call_id.0,
487            "task_id": ctx.task_id.clone(),
488        });
489        if let Some(outcome) = mutation_policy_outcome(
490            &ctx,
491            "write_file",
492            path,
493            std::slice::from_ref(&abs_path),
494            pending_action,
495        ) {
496            return outcome;
497        }
498        if ctx.config.safety.checkpoint_on_mutation
499            && let Err(e) = crate::runtime::create_checkpoint_for_task(
500                &ctx.workdir,
501                std::slice::from_ref(&abs_path),
502                Some(serde_json::json!({
503                    "tool": "write_file",
504                    "path": path,
505                })),
506                ctx.task_id.clone(),
507            )
508        {
509            return ToolOutcome::error(format!("write_file checkpoint failed: {}", e), 0.0);
510        }
511        let display_path = path.to_string();
512        let line_count = content.lines().count();
513        let byte_count = content.len();
514        let old_content = std::fs::read_to_string(&abs_path).ok();
515        let created = Some(old_content.is_none());
516        let diff = generate_display_diff(
517            &display_path,
518            old_content.as_deref().unwrap_or(""),
519            content,
520            old_content.is_none(),
521        );
522        let content = content.to_string();
523
524        tokio::select! {
525            biased;
526            _ = ctx.token.cancelled() => ToolOutcome::cancelled(),
527            result = tokio::task::spawn_blocking(move || write_one_blocking(&abs_path, &content)) => {
528                match result {
529                    Ok(Ok(actual_line_count)) => {
530                        let duration_secs = start.elapsed().as_secs_f64();
531                        after_file_mutation(&ctx, "write_file", &display_path);
532                        ToolOutcome::success(
533                            format!("Wrote {} ({} lines)", display_path, actual_line_count),
534                            format!("{} {} written", actual_line_count, plural(actual_line_count, "line", "lines")),
535                            duration_secs,
536                        )
537                        .with_metadata(ToolRunMetadata {
538                            detail: ToolMetadata::WriteFile {
539                                path: display_path,
540                                line_count,
541                                byte_count,
542                                created,
543                            },
544                            line_count: Some(line_count),
545                            byte_count: Some(byte_count),
546                            display_diff: Some(diff.display_diff),
547                            diff_truncated: diff.truncated,
548                            ..ToolRunMetadata::default()
549                        })
550                    },
551                    Ok(Err(e)) => ToolOutcome::error(
552                        format!("write_file({}): {}", display_path, e),
553                        start.elapsed().as_secs_f64(),
554                    ),
555                    Err(e) => ToolOutcome::error(
556                        format!("write_file join error: {}", e),
557                        start.elapsed().as_secs_f64(),
558                    ),
559                }
560            }
561        }
562    }
563}
564
565// ─── helpers ────────────────────────────────────────────────────────
566
567fn extract_paths(args: &serde_json::Value) -> Result<Vec<String>, String> {
568    // Accept both shapes: `{path: "x"}` and `{paths: ["x", "y"]}`.
569    if let Some(p) = args.get("path").and_then(|v| v.as_str()) {
570        return Ok(vec![p.to_string()]);
571    }
572    if let Some(arr) = args.get("paths").and_then(|v| v.as_array()) {
573        let mut out = Vec::with_capacity(arr.len());
574        for v in arr {
575            let Some(s) = v.as_str() else {
576                return Err("read_file 'paths' must be an array of strings".to_string());
577            };
578            out.push(s.to_string());
579        }
580        return Ok(out);
581    }
582    Err("read_file requires 'path' or 'paths'".to_string())
583}
584
585/// Resolve a caller-supplied path against `workdir`, enforcing the
586/// "absolute paths outside the project are blocked" contract advertised
587/// in the tool schema.
588///
589/// Rules (F10):
590/// - Relative paths → joined onto `workdir` unchanged. `..` components
591///   are NOT rejected here — a relative `../foo` resolves against the
592///   workdir and then gets the same absolute-path containment check as
593///   an absolute input.
594/// - Absolute paths → canonicalized (resolves `..` + symlinks) and
595///   checked against the canonicalized `workdir`. Escape → `Err`.
596/// - Non-existent paths that won't canonicalize → lexical fallback:
597///   normalize `..` components manually, then compare prefixes. This
598///   matters for `write_file` / `create_directory` where the target
599///   doesn't exist yet.
600fn resolve_path_safe(workdir: &Path, raw: &str) -> Result<PathBuf, String> {
601    let p = PathBuf::from(raw);
602    let candidate = if p.is_absolute() { p } else { workdir.join(&p) };
603
604    // Canonical project root. If the workdir itself can't canonicalize we
605    // cannot make a sound containment decision — fail closed rather than
606    // falling back to a weaker lexical check.
607    let root = std::fs::canonicalize(workdir).map_err(|e| {
608        format!(
609            "cannot canonicalize project dir '{}': {}",
610            workdir.display(),
611            e
612        )
613    })?;
614
615    // Resolve the target THROUGH symlinks and return the resolved path (the
616    // callers operate on this, not the raw candidate). For an existing target
617    // `canonicalize` gives the real location; for a not-yet-existing target
618    // (write_file / create_directory) we canonicalize the nearest existing
619    // ancestor — which resolves any symlinked parent — and re-attach the
620    // remaining components. This closes both the symlink-follow/TOCTOU gap and
621    // the symlinked-parent-on-create gap.
622    let resolved = match std::fs::canonicalize(&candidate) {
623        Ok(real) => real,
624        Err(_) => resolve_via_existing_ancestor(&candidate)?,
625    };
626
627    if resolved.starts_with(&root) {
628        Ok(resolved)
629    } else {
630        Err(format!(
631            "path '{}' is outside the project directory '{}'",
632            raw,
633            workdir.display()
634        ))
635    }
636}
637
638/// Resolve a not-yet-existing target by canonicalizing its nearest existing
639/// ancestor (resolving any symlinked parent directory) and re-joining the
640/// remaining path components lexically. Rejects paths with no
641/// canonicalizable ancestor.
642fn resolve_via_existing_ancestor(candidate: &Path) -> Result<PathBuf, String> {
643    let normalized = lexical_normalize(candidate);
644    let mut ancestor = normalized.as_path();
645    let mut tail: Vec<std::ffi::OsString> = Vec::new();
646    loop {
647        if let Ok(real) = std::fs::canonicalize(ancestor) {
648            let mut out = real;
649            for comp in tail.iter().rev() {
650                out.push(comp);
651            }
652            return Ok(out);
653        }
654        let Some(file) = ancestor.file_name() else {
655            return Err(format!(
656                "cannot resolve path '{}': no existing ancestor directory",
657                candidate.display()
658            ));
659        };
660        tail.push(file.to_os_string());
661        match ancestor.parent() {
662            Some(parent) => ancestor = parent,
663            None => {
664                return Err(format!(
665                    "cannot resolve path '{}': no existing ancestor directory",
666                    candidate.display()
667                ));
668            },
669        }
670    }
671}
672
673/// Normalize a path lexically (no filesystem access), collapsing `.` and
674/// resolving `..` without symlink expansion. Used when a target doesn't
675/// exist yet (write_file / create_directory) so `canonicalize` would
676/// fail but we still want to reject `..`-escapes.
677fn lexical_normalize(p: &Path) -> PathBuf {
678    use std::path::Component;
679    let mut out = PathBuf::new();
680    for comp in p.components() {
681        match comp {
682            Component::ParentDir => {
683                // Drop the last segment if one exists; otherwise keep
684                // the `..` (can only happen on relative paths, which
685                // the caller has already joined against workdir).
686                if !out.pop() {
687                    out.push("..");
688                }
689            },
690            Component::CurDir => {},
691            other => out.push(other.as_os_str()),
692        }
693    }
694    out
695}
696
697async fn read_one(workdir: &Path, raw: &str) -> std::io::Result<String> {
698    let abs = resolve_path_safe(workdir, raw)
699        .map_err(|msg| std::io::Error::new(std::io::ErrorKind::PermissionDenied, msg))?;
700    let abs_clone = abs.clone();
701    let content = tokio::task::spawn_blocking(move || {
702        let data = std::fs::read(&abs_clone)?;
703        if data.len() > MAX_FILE_READ_BYTES {
704            // Char-boundary-safe truncation with a marker footer.
705            let mut s = String::from_utf8_lossy(&data).into_owned();
706            let cut = s.floor_char_boundary(MAX_FILE_READ_BYTES);
707            s.truncate(cut);
708            s.push_str("\n\n[TRUNCATED: file exceeded read cap]");
709            Ok::<_, std::io::Error>(s)
710        } else {
711            Ok(String::from_utf8_lossy(&data).into_owned())
712        }
713    })
714    .await
715    .map_err(|e| std::io::Error::other(e.to_string()))??;
716    let _ = abs;
717    Ok(content)
718}
719
720fn write_one_blocking(path: &Path, content: &str) -> std::io::Result<usize> {
721    if let Some(parent) = path.parent() {
722        std::fs::create_dir_all(parent)?;
723    }
724    std::fs::write(path, content)?;
725    Ok(content.lines().count())
726}
727
728fn mutation_policy_outcome(
729    ctx: &ExecContext,
730    tool: &str,
731    path: &str,
732    checkpoint_paths: &[PathBuf],
733    pending_action: serde_json::Value,
734) -> Option<ToolOutcome> {
735    let mut request = crate::runtime::ActionRequest::new(
736        tool,
737        crate::runtime::ToolCategory::Edit,
738        format!("{} {}", tool, path),
739    );
740    request.path = Some(path.to_string());
741    // File mutations are replayable: an Ask decision checkpoints, records an
742    // approval, and blocks (handled inside the gate).
743    match super::policy_gate::gate(ctx, request, checkpoint_paths, pending_action, true) {
744        super::policy_gate::Gate::Block(outcome) => Some(outcome),
745        super::policy_gate::Gate::Proceed { .. } => {
746            let _ = crate::runtime::run_plugin_hooks(
747                "before_file_mutation",
748                &serde_json::json!({
749                    "task_id": ctx.task_id.clone(),
750                    "turn_id": ctx.turn.0,
751                    "call_id": ctx.call_id.0,
752                    "tool": tool,
753                    "path": path,
754                }),
755            );
756            None
757        },
758    }
759}
760
761fn after_file_mutation(ctx: &ExecContext, tool: &str, path: &str) {
762    let _ = crate::runtime::run_plugin_hooks(
763        "after_file_mutation",
764        &serde_json::json!({
765            "task_id": ctx.task_id.clone(),
766            "turn_id": ctx.turn.0,
767            "call_id": ctx.call_id.0,
768            "tool": tool,
769            "path": path,
770        }),
771    );
772}
773
774struct EditResult {
775    replacements: usize,
776    display_diff: String,
777    added: usize,
778    removed: usize,
779    truncated: bool,
780}
781
782fn edit_blocking(
783    path: &Path,
784    display_path: &str,
785    old_string: &str,
786    new_string: &str,
787) -> std::io::Result<EditResult> {
788    let current = std::fs::read_to_string(path)?;
789    let count = current.matches(old_string).count();
790    if count == 0 {
791        return Err(std::io::Error::other(
792            "old_string not found (is the snippet correct? use read_file to verify)",
793        ));
794    }
795    if count > 1 {
796        return Err(std::io::Error::other(format!(
797            "old_string appears {} times — add more context so the match is unique",
798            count
799        )));
800    }
801    let updated = current.replacen(old_string, new_string, 1);
802    let diff = generate_display_diff(display_path, &current, &updated, false);
803    std::fs::write(path, updated)?;
804    Ok(EditResult {
805        replacements: 1,
806        display_diff: diff.display_diff,
807        added: diff.added,
808        removed: diff.removed,
809        truncated: diff.truncated,
810    })
811}
812
813fn err(msg: &str, duration_secs: f64) -> ToolOutcome {
814    ToolOutcome::error(msg, duration_secs)
815}
816
817fn plural(count: usize, singular: &'static str, plural: &'static str) -> &'static str {
818    if count == 1 { singular } else { plural }
819}
820
821#[derive(Debug, Clone)]
822struct DisplayDiff {
823    display_diff: String,
824    added: usize,
825    removed: usize,
826    truncated: bool,
827}
828
829const DIFF_CONTEXT_LINES: usize = 3;
830const MAX_DISPLAY_DIFF_LINES: usize = 220;
831
832fn generate_display_diff(path: &str, old: &str, new: &str, created: bool) -> DisplayDiff {
833    let old_lines: Vec<&str> = old.lines().collect();
834    let new_lines: Vec<&str> = new.lines().collect();
835    let mut prefix = 0usize;
836    let min_len = old_lines.len().min(new_lines.len());
837    while prefix < min_len && old_lines[prefix] == new_lines[prefix] {
838        prefix += 1;
839    }
840
841    let mut suffix = 0usize;
842    while suffix < min_len.saturating_sub(prefix)
843        && old_lines[old_lines.len() - 1 - suffix] == new_lines[new_lines.len() - 1 - suffix]
844    {
845        suffix += 1;
846    }
847
848    let old_changed_end = old_lines.len().saturating_sub(suffix);
849    let new_changed_end = new_lines.len().saturating_sub(suffix);
850    let old_changed = &old_lines[prefix..old_changed_end];
851    let new_changed = &new_lines[prefix..new_changed_end];
852    let added = new_changed.len();
853    let removed = old_changed.len();
854
855    let context_start = prefix.saturating_sub(DIFF_CONTEXT_LINES);
856    let context_end_old = (old_changed_end + DIFF_CONTEXT_LINES).min(old_lines.len());
857    let mut lines = Vec::new();
858    lines.push(format!("--- {}", if created { "/dev/null" } else { path }));
859    lines.push(format!("+++ {}", path));
860    lines.push(format!(
861        "@@ -{},{} +{},{} @@",
862        context_start + 1,
863        context_end_old.saturating_sub(context_start),
864        context_start + 1,
865        (new_changed_end + DIFF_CONTEXT_LINES)
866            .min(new_lines.len())
867            .saturating_sub(context_start)
868    ));
869
870    let mut truncated = false;
871    let push_line = |line: String, lines: &mut Vec<String>, truncated: &mut bool| {
872        if lines.len() < MAX_DISPLAY_DIFF_LINES {
873            lines.push(line);
874        } else {
875            *truncated = true;
876        }
877    };
878
879    for (idx, line) in old_lines[context_start..prefix].iter().enumerate() {
880        push_line(
881            format!("{:>4}   {}", context_start + idx + 1, line),
882            &mut lines,
883            &mut truncated,
884        );
885    }
886    for (idx, line) in old_changed.iter().enumerate() {
887        push_line(
888            format!("{:>4}{}{}", prefix + idx + 1, DIFF_REMOVED_MARKER, line),
889            &mut lines,
890            &mut truncated,
891        );
892    }
893    for (idx, line) in new_changed.iter().enumerate() {
894        push_line(
895            format!("{:>4}{}{}", prefix + idx + 1, DIFF_ADDED_MARKER, line),
896            &mut lines,
897            &mut truncated,
898        );
899    }
900    for (idx, line) in old_lines[old_changed_end..context_end_old]
901        .iter()
902        .enumerate()
903    {
904        push_line(
905            format!("{:>4}   {}", old_changed_end + idx + 1, line),
906            &mut lines,
907            &mut truncated,
908        );
909    }
910    if truncated {
911        lines.push(format!(
912            "... diff truncated after {} display lines",
913            MAX_DISPLAY_DIFF_LINES
914        ));
915    }
916
917    DisplayDiff {
918        display_diff: lines.join("\n"),
919        added,
920        removed,
921        truncated,
922    }
923}
924
925fn diff_summary(added: usize, removed: usize, duration_secs: f64) -> String {
926    format!(
927        "Success, +{} -{}, took {}",
928        added,
929        removed,
930        format_duration_for_diff(duration_secs)
931    )
932}
933
934fn format_duration_for_diff(seconds: f64) -> String {
935    if seconds < 1.0 {
936        format!("{}ms", (seconds * 1000.0).round().max(1.0) as u64)
937    } else if seconds < 10.0 {
938        format!("{:.1}s", seconds)
939    } else {
940        format!("{}s", seconds.round() as u64)
941    }
942}
943
944#[cfg(test)]
945mod tests {
946    use super::*;
947    use crate::domain::{ToolCallId, TurnId};
948    use crate::providers::ctx::test_exec_context;
949    use std::fs;
950
951    #[test]
952    fn resolve_path_safe_contains_to_workdir() {
953        let root = std::env::temp_dir().join(format!("mermaid_rps_{}", std::process::id()));
954        let _ = fs::remove_dir_all(&root);
955        fs::create_dir_all(root.join("sub")).unwrap();
956
957        // In-root existing + not-yet-existing targets resolve inside root.
958        assert!(resolve_path_safe(&root, "sub").is_ok());
959        assert!(resolve_path_safe(&root, "sub/new.txt").is_ok());
960        let resolved = resolve_path_safe(&root, "sub/new.txt").unwrap();
961        let canon_root = fs::canonicalize(&root).unwrap();
962        assert!(resolved.starts_with(&canon_root));
963
964        // `..` escape and absolute outside are rejected.
965        assert!(resolve_path_safe(&root, "../escape.txt").is_err());
966        assert!(resolve_path_safe(&root, "../../etc/passwd").is_err());
967        let outside = std::env::temp_dir().join("definitely_outside.txt");
968        assert!(resolve_path_safe(&root, &outside.display().to_string()).is_err());
969
970        let _ = fs::remove_dir_all(&root);
971    }
972
973    fn temp_root(name: &str) -> PathBuf {
974        let p = std::env::temp_dir().join(format!("mermaid_providers_fs_{}", name));
975        let _ = fs::remove_dir_all(&p);
976        fs::create_dir_all(&p).expect("create tmpdir");
977        p
978    }
979
980    #[tokio::test]
981    async fn read_file_returns_content() {
982        let dir = temp_root("read_ok");
983        fs::write(dir.join("a.txt"), "hello").expect("write");
984        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
985
986        let tool = ReadFileTool;
987        let outcome = tool
988            .execute(serde_json::json!({"path": "a.txt"}), ctx)
989            .await;
990        assert!(outcome.is_success(), "expected success: {:?}", outcome);
991        assert_eq!(outcome.output(), "hello");
992        let _ = fs::remove_dir_all(&dir);
993    }
994
995    #[tokio::test]
996    async fn read_file_missing_path_errors() {
997        let dir = temp_root("read_missing_path");
998        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
999        let outcome = ReadFileTool.execute(serde_json::json!({}), ctx).await;
1000        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1001        let _ = fs::remove_dir_all(&dir);
1002    }
1003
1004    #[tokio::test]
1005    async fn read_file_nonexistent_errors() {
1006        let dir = temp_root("read_nonex");
1007        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1008        let outcome = ReadFileTool
1009            .execute(serde_json::json!({"path": "does_not_exist.txt"}), ctx)
1010            .await;
1011        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1012        let _ = fs::remove_dir_all(&dir);
1013    }
1014
1015    #[tokio::test]
1016    async fn read_file_with_multiple_paths_joins_contents() {
1017        let dir = temp_root("read_multi");
1018        fs::write(dir.join("a.txt"), "alpha").expect("write");
1019        fs::write(dir.join("b.txt"), "beta").expect("write");
1020        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1021        let outcome = ReadFileTool
1022            .execute(serde_json::json!({"paths": ["a.txt", "b.txt"]}), ctx)
1023            .await;
1024        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1025        let output = outcome.output();
1026        assert!(output.contains("=== a.txt ==="));
1027        assert!(output.contains("alpha"));
1028        assert!(output.contains("=== b.txt ==="));
1029        assert!(output.contains("beta"));
1030        let _ = fs::remove_dir_all(&dir);
1031    }
1032
1033    #[tokio::test]
1034    async fn read_file_respects_cancellation() {
1035        let dir = temp_root("read_cancel");
1036        // Write a huge file so the read is slow enough to race cancel.
1037        // Actually spawn_blocking on read is fast on tmpfs — this test
1038        // just verifies the select! arm compiles + the token trips
1039        // the cancel path when pre-cancelled.
1040        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1041        ctx.token.cancel();
1042        let outcome = ReadFileTool
1043            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1044            .await;
1045        assert!(outcome.was_cancelled());
1046        let _ = fs::remove_dir_all(&dir);
1047    }
1048
1049    #[tokio::test]
1050    async fn write_file_creates_and_counts_lines() {
1051        let dir = temp_root("write_ok");
1052        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1053        let outcome = WriteFileTool
1054            .execute(
1055                serde_json::json!({"path": "out.txt", "content": "line1\nline2\nline3\n"}),
1056                ctx,
1057            )
1058            .await;
1059        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1060        assert!(outcome.output().contains("3 lines"));
1061        let written = fs::read_to_string(dir.join("out.txt")).expect("read");
1062        assert!(written.contains("line1"));
1063        let _ = fs::remove_dir_all(&dir);
1064    }
1065
1066    #[tokio::test]
1067    async fn write_file_new_file_records_added_display_diff() {
1068        let dir = temp_root("write_new_diff");
1069        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1070        let outcome = WriteFileTool
1071            .execute(
1072                serde_json::json!({"path": "out.txt", "content": "alpha\nbeta\n"}),
1073                ctx,
1074            )
1075            .await;
1076        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1077        let diff = outcome
1078            .metadata
1079            .display_diff
1080            .as_deref()
1081            .expect("display diff");
1082        assert!(diff.contains("--- /dev/null"));
1083        assert!(diff.contains("+++ out.txt"));
1084        assert!(diff.contains("+ alpha"));
1085        assert!(diff.contains("+ beta"));
1086        let _ = fs::remove_dir_all(&dir);
1087    }
1088
1089    #[tokio::test]
1090    async fn write_file_existing_file_records_added_and_removed_display_diff() {
1091        let dir = temp_root("write_existing_diff");
1092        fs::write(dir.join("out.txt"), "alpha\nold\nomega\n").expect("write fixture");
1093        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1094        let outcome = WriteFileTool
1095            .execute(
1096                serde_json::json!({"path": "out.txt", "content": "alpha\nnew\nomega\n"}),
1097                ctx,
1098            )
1099            .await;
1100        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1101        let diff = outcome
1102            .metadata
1103            .display_diff
1104            .as_deref()
1105            .expect("display diff");
1106        assert!(diff.contains("- old"));
1107        assert!(diff.contains("+ new"));
1108        let _ = fs::remove_dir_all(&dir);
1109    }
1110
1111    #[tokio::test]
1112    async fn edit_file_records_display_diff() {
1113        let dir = temp_root("edit_diff");
1114        fs::write(dir.join("main.py"), "alpha\nold\nomega\n").expect("write fixture");
1115        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1116        let outcome = EditFileTool
1117            .execute(
1118                serde_json::json!({
1119                    "path": "main.py",
1120                    "old_string": "old",
1121                    "new_string": "new",
1122                }),
1123                ctx,
1124            )
1125            .await;
1126        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1127        let diff = outcome
1128            .metadata
1129            .display_diff
1130            .as_deref()
1131            .expect("display diff");
1132        assert!(diff.contains("--- main.py"));
1133        assert!(diff.contains("- old"));
1134        assert!(diff.contains("+ new"));
1135        let _ = fs::remove_dir_all(&dir);
1136    }
1137
1138    #[tokio::test]
1139    async fn write_file_creates_parent_dirs() {
1140        let dir = temp_root("write_parents");
1141        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1142        let outcome = WriteFileTool
1143            .execute(
1144                serde_json::json!({
1145                    "path": "sub/nested/out.txt",
1146                    "content": "deep",
1147                }),
1148                ctx,
1149            )
1150            .await;
1151        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1152        assert!(dir.join("sub/nested/out.txt").exists());
1153        let _ = fs::remove_dir_all(&dir);
1154    }
1155
1156    #[tokio::test]
1157    async fn write_file_missing_content_errors() {
1158        let dir = temp_root("write_missing");
1159        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1160        let outcome = WriteFileTool
1161            .execute(serde_json::json!({"path": "x.txt"}), ctx)
1162            .await;
1163        assert_eq!(outcome.status, crate::domain::ToolStatus::Error);
1164        let _ = fs::remove_dir_all(&dir);
1165    }
1166
1167    // ─── F10: absolute-path block ───────────────────────────────────
1168
1169    /// Reading `/etc/passwd` (or any absolute path outside workdir)
1170    /// must fail with a clear "outside the project" error. The tool
1171    /// schema advertises this contract; before F10 it was a lie.
1172    #[tokio::test]
1173    async fn read_file_rejects_absolute_path_outside_workdir() {
1174        let dir = temp_root("read_abs_escape");
1175        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1176        // Pick a path that's definitely outside a fresh /tmp/* workdir.
1177        let outcome = ReadFileTool
1178            .execute(serde_json::json!({"path": "/etc/passwd"}), ctx)
1179            .await;
1180        let error = outcome.error_message().expect("expected error");
1181        assert!(
1182            error.contains("outside the project"),
1183            "expected security reject, got: {}",
1184            error
1185        );
1186        let _ = fs::remove_dir_all(&dir);
1187    }
1188
1189    /// Absolute path that lives INSIDE the workdir is allowed.
1190    #[tokio::test]
1191    async fn read_file_accepts_absolute_path_inside_workdir() {
1192        let dir = temp_root("read_abs_inside");
1193        let file = dir.join("hello.txt");
1194        fs::write(&file, "ok").expect("write fixture");
1195        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1196        let outcome = ReadFileTool
1197            .execute(
1198                serde_json::json!({"path": file.to_string_lossy().to_string()}),
1199                ctx,
1200            )
1201            .await;
1202        assert!(outcome.is_success(), "expected success: {:?}", outcome);
1203        let _ = fs::remove_dir_all(&dir);
1204    }
1205
1206    /// Relative `..`-escape must also be blocked — they resolve against
1207    /// the workdir and land outside it, so the lexical normalization
1208    /// in `resolve_path_safe` catches them.
1209    #[tokio::test]
1210    async fn write_file_rejects_relative_parent_escape() {
1211        let dir = temp_root("write_dotdot_escape");
1212        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1213        let outcome = WriteFileTool
1214            .execute(
1215                serde_json::json!({
1216                    "path": "../escape.txt",
1217                    "content": "should not write",
1218                }),
1219                ctx,
1220            )
1221            .await;
1222        let error = outcome.error_message().expect("expected error");
1223        assert!(
1224            error.contains("outside the project"),
1225            "expected security reject, got: {}",
1226            error
1227        );
1228        let _ = fs::remove_dir_all(&dir);
1229    }
1230
1231    /// `create_directory` needs the lexical-normalization fallback
1232    /// because the target doesn't exist yet (can't canonicalize).
1233    /// Verify the escape check still fires for non-existent targets.
1234    #[tokio::test]
1235    async fn create_directory_rejects_absolute_path_outside_workdir() {
1236        let dir = temp_root("mkdir_abs_escape");
1237        let (ctx, _rx) = test_exec_context(TurnId(1), ToolCallId(1), dir.clone());
1238        let outcome = CreateDirectoryTool
1239            .execute(
1240                serde_json::json!({"path": "/tmp/mermaid_fs_escape_target"}),
1241                ctx,
1242            )
1243            .await;
1244        let error = outcome.error_message().expect("expected error");
1245        assert!(
1246            error.contains("outside the project"),
1247            "expected security reject, got: {}",
1248            error
1249        );
1250        let _ = fs::remove_dir_all(&dir);
1251    }
1252}