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