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