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