Skip to main content

ai_agents_tools/builtin/
fs_mutation.rs

1use async_trait::async_trait;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashSet;
6use std::fs;
7use std::io::{Read, Write};
8use std::path::{Component, Path, PathBuf};
9use uuid::Uuid;
10
11use ai_agents_core::{
12    PathAccessMode, PathBindingKind, PathPolicyBinding, ResultLimitBinding, ResultLimitKind, Tool,
13    ToolCallClassification, ToolExecutionContext, ToolOperationKind, ToolPolicyBindings,
14    ToolResult, ToolSafetyMetadata, ToolSideEffectLevel,
15};
16
17use crate::generate_schema;
18use crate::security::path::PathPolicyResolver;
19use crate::types::{FileVersionEvidence, FileVersionStore, file_version_evidence};
20
21const DEFAULT_MAX_OUTPUT_CHARS: usize = 20_000;
22const DEFAULT_MAX_REPLACEMENTS: usize = 20;
23const DEFAULT_MAX_CHANGED_FILES: usize = 10;
24const DEFAULT_MAX_CHANGED_LINES: usize = 500;
25
26/// Writes new files or policy-approved overwrites with atomic replacement.
27pub struct FileWriteTool {
28    versions: FileVersionStore,
29}
30
31impl FileWriteTool {
32    /// Create a file-write tool with isolated version storage.
33    pub fn new() -> Self {
34        Self::with_version_store(FileVersionStore::default())
35    }
36
37    /// Create a file-write tool backed by shared read-version storage.
38    pub fn with_version_store(versions: FileVersionStore) -> Self {
39        Self { versions }
40    }
41}
42
43impl Default for FileWriteTool {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49/// Performs exact text replacement with dry-run diff output.
50pub struct FileEditTool {
51    versions: FileVersionStore,
52}
53
54impl FileEditTool {
55    /// Create a file-edit tool with isolated version storage.
56    pub fn new() -> Self {
57        Self::with_version_store(FileVersionStore::default())
58    }
59
60    /// Create a file-edit tool backed by shared read-version storage.
61    pub fn with_version_store(versions: FileVersionStore) -> Self {
62        Self { versions }
63    }
64}
65
66impl Default for FileEditTool {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72/// Validates or applies bounded unified diffs after dry-run validation.
73pub struct PatchTool {
74    versions: FileVersionStore,
75}
76
77impl PatchTool {
78    /// Create a patch tool with isolated version storage.
79    pub fn new() -> Self {
80        Self::with_version_store(FileVersionStore::default())
81    }
82
83    /// Create a patch tool backed by shared read-version storage.
84    pub fn with_version_store(versions: FileVersionStore) -> Self {
85        Self { versions }
86    }
87}
88
89impl Default for PatchTool {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95#[derive(Debug, Deserialize, JsonSchema)]
96struct FileWriteInput {
97    /// File path to create or overwrite.
98    path: String,
99    /// New file content.
100    content: String,
101    /// Allow overwriting an existing file.
102    #[serde(default)]
103    overwrite: bool,
104    /// Create missing parent directories.
105    #[serde(default)]
106    create_parent_dirs: bool,
107    /// Validate and return a summary without writing.
108    #[serde(default)]
109    dry_run: bool,
110}
111
112#[derive(Debug, Deserialize, JsonSchema)]
113struct FileEditInput {
114    /// File path to edit.
115    path: String,
116    /// Exact text to replace.
117    old_text: String,
118    /// Replacement text.
119    new_text: String,
120    /// Replace every occurrence. Defaults to requiring a unique match.
121    #[serde(default)]
122    replace_all: bool,
123    /// Validate and return a diff without writing.
124    #[serde(default)]
125    dry_run: bool,
126    /// Request-level replacement cap lowered by policy.
127    #[serde(default)]
128    max_replacements: Option<usize>,
129}
130
131#[derive(Debug, Deserialize, JsonSchema)]
132struct PatchInput {
133    /// Unified diff text.
134    patch: String,
135    /// Base path for relative patch paths. Defaults to current directory.
136    #[serde(default)]
137    base_path: Option<String>,
138    /// Validate and return a summary without applying.
139    #[serde(default)]
140    dry_run: bool,
141    /// Permit creating new files when policy allows it.
142    #[serde(default)]
143    allow_new_files: Option<bool>,
144    /// Permit deleting files. Defaults to false.
145    #[serde(default)]
146    allow_delete: bool,
147}
148
149#[derive(Debug, Serialize)]
150struct MutationOutput {
151    path: Option<String>,
152    dry_run: bool,
153    mutation_performed: bool,
154    changed_files: usize,
155    changed_lines: usize,
156    replacements: usize,
157    bytes_written: usize,
158    created: bool,
159    overwritten: bool,
160    truncated: bool,
161    approval_required: bool,
162    diff_summary: String,
163    changed_paths: Vec<String>,
164    version: Option<FileVersionEvidence>,
165    near_matches: Vec<String>,
166}
167
168#[derive(Debug, Default)]
169struct MutationPolicySnapshot {
170    write_paths: Vec<String>,
171    allowed_paths: Vec<String>,
172    blocked_paths: Vec<String>,
173    overwrite_existing: bool,
174    create_parent_dirs: bool,
175    require_read_before_write: bool,
176    no_write_policy: String,
177    allow_without_confirmation: bool,
178}
179
180impl MutationPolicySnapshot {
181    fn from_context(value: &Value) -> Self {
182        let no_write_policy = value
183            .get("no_write_policy")
184            .and_then(Value::as_str)
185            .unwrap_or("dry_run_only")
186            .to_string();
187        let mut snapshot = Self {
188            write_paths: strings_at(value, "write_paths"),
189            allowed_paths: strings_at(value, "allowed_paths"),
190            blocked_paths: strings_at(value, "blocked_paths"),
191            overwrite_existing: bool_at(value, "overwrite_existing"),
192            create_parent_dirs: bool_at(value, "create_parent_dirs"),
193            require_read_before_write: bool_at(value, "require_read_before_write"),
194            no_write_policy,
195            allow_without_confirmation: bool_at(value, "allow_without_confirmation"),
196        };
197        if let Some(paths) = value.get("paths") {
198            snapshot.write_paths.extend(strings_at(paths, "allow"));
199            snapshot.blocked_paths.extend(strings_at(paths, "deny"));
200        }
201        snapshot
202    }
203
204    fn has_write_policy(&self) -> bool {
205        !self.write_paths.is_empty() || !self.allowed_paths.is_empty()
206    }
207
208    fn approval_required(&self) -> bool {
209        !self.allow_without_confirmation
210    }
211}
212
213#[async_trait]
214impl Tool for FileWriteTool {
215    fn id(&self) -> &str {
216        "file_write"
217    }
218
219    fn name(&self) -> &str {
220        "File Write"
221    }
222
223    fn description(&self) -> &str {
224        "Create or overwrite a file with policy-gated atomic writes and dry-run summaries."
225    }
226
227    fn input_schema(&self) -> Value {
228        generate_schema::<FileWriteInput>()
229    }
230
231    fn safety_metadata(&self) -> ToolSafetyMetadata {
232        mutation_metadata(ToolOperationKind::Write)
233    }
234
235    fn classify_call(&self, args: &Value) -> ToolCallClassification {
236        mutation_classification(&self.safety_metadata(), args)
237    }
238
239    fn policy_bindings(&self) -> ToolPolicyBindings {
240        ToolPolicyBindings {
241            path_fields: vec![PathPolicyBinding::write("path")],
242            result_limit_fields: vec![
243                ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
244                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
245            ],
246            ..Default::default()
247        }
248    }
249
250    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
251        let input: FileWriteInput = match serde_json::from_value(args) {
252            Ok(input) => input,
253            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
254        };
255        let path = PathBuf::from(&input.path);
256        if let Err(reason) = validate_safe_target(&path) {
257            return ToolResult::error(reason);
258        }
259        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
260        if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
261            return ToolResult::error(reason);
262        }
263        let exists = path.exists();
264        if exists && !input.overwrite {
265            return ToolResult::error("overwrite must be true to replace an existing file");
266        }
267        if exists && !policy.overwrite_existing && !input.dry_run {
268            return ToolResult::error("overwrite_existing policy is false for this target");
269        }
270        if let Some(parent) = path.parent()
271            && !parent.exists()
272        {
273            if !(input.create_parent_dirs && policy.create_parent_dirs) {
274                return ToolResult::error(
275                    "parent directory does not exist or create_parent_dirs is not allowed",
276                );
277            }
278            if !input.dry_run
279                && let Err(error) = fs::create_dir_all(parent)
280            {
281                return ToolResult::error(format!("Create parent directory error: {}", error));
282            }
283        }
284        if exists
285            && !input.dry_run
286            && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
287        {
288            return ToolResult::error(reason);
289        }
290        let changed_lines = input
291            .content
292            .lines()
293            .count()
294            .max(usize::from(!input.content.is_empty()));
295        if exceeds(ctx.limits.max_changed_files, 1)
296            || exceeds(ctx.limits.max_changed_lines, changed_lines)
297        {
298            return ToolResult::error(
299                "mutation exceeds configured changed-file or changed-line cap",
300            );
301        }
302        let action = match (input.dry_run, exists) {
303            (true, true) => "plan to overwrite",
304            (true, false) => "plan to create",
305            (false, true) => "overwrote",
306            (false, false) => "created",
307        };
308        let diff_summary = format!(
309            "{} {} with {} bytes",
310            action,
311            input.path,
312            input.content.len()
313        );
314        let version = if input.dry_run {
315            None
316        } else {
317            if let Err(error) = atomic_write(&path, input.content.as_bytes()) {
318                return ToolResult::error(format!("Write error: {}", error));
319            }
320            match file_version_evidence(&path, input.content.as_bytes()) {
321                Ok(version) => {
322                    self.versions.record(version.clone());
323                    Some(version)
324                }
325                Err(_) => None,
326            }
327        };
328        json_result(&MutationOutput {
329            path: Some(input.path.clone()),
330            dry_run: input.dry_run,
331            mutation_performed: !input.dry_run,
332            changed_files: 1,
333            changed_lines,
334            replacements: 0,
335            bytes_written: if input.dry_run {
336                0
337            } else {
338                input.content.len()
339            },
340            created: !input.dry_run && !exists,
341            overwritten: !input.dry_run && exists,
342            truncated: false,
343            approval_required: !input.dry_run && policy.approval_required(),
344            diff_summary,
345            changed_paths: vec![input.path],
346            version,
347            near_matches: Vec::new(),
348        })
349    }
350}
351
352#[async_trait]
353impl Tool for FileEditTool {
354    fn id(&self) -> &str {
355        "file_edit"
356    }
357
358    fn name(&self) -> &str {
359        "File Edit"
360    }
361
362    fn description(&self) -> &str {
363        "Replace exact text in a file with uniqueness checks, dry-run diff summaries, and policy-gated writes."
364    }
365
366    fn input_schema(&self) -> Value {
367        generate_schema::<FileEditInput>()
368    }
369
370    fn safety_metadata(&self) -> ToolSafetyMetadata {
371        mutation_metadata(ToolOperationKind::Edit)
372    }
373
374    fn classify_call(&self, args: &Value) -> ToolCallClassification {
375        mutation_classification(&self.safety_metadata(), args)
376    }
377
378    fn policy_bindings(&self) -> ToolPolicyBindings {
379        ToolPolicyBindings {
380            path_fields: vec![PathPolicyBinding::write("path")],
381            result_limit_fields: vec![
382                ResultLimitBinding::new("max_replacements", ResultLimitKind::MaxReplacements),
383                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
384            ],
385            ..Default::default()
386        }
387    }
388
389    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
390        let input: FileEditInput = match serde_json::from_value(args) {
391            Ok(input) => input,
392            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
393        };
394        if input.old_text == input.new_text {
395            return ToolResult::error("old_text and new_text must differ");
396        }
397        let path = PathBuf::from(&input.path);
398        if let Err(reason) = validate_safe_target(&path) {
399            return ToolResult::error(reason);
400        }
401        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
402        if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
403            return ToolResult::error(reason);
404        }
405        let original = match fs::read_to_string(&path) {
406            Ok(content) => content,
407            Err(error) => return ToolResult::error(format!("Read error: {}", error)),
408        };
409        let matches: Vec<_> = original.match_indices(&input.old_text).collect();
410        if matches.is_empty() {
411            let output = MutationOutput {
412                path: Some(input.path),
413                dry_run: input.dry_run,
414                mutation_performed: false,
415                changed_files: 0,
416                changed_lines: 0,
417                replacements: 0,
418                bytes_written: 0,
419                created: false,
420                overwritten: false,
421                truncated: false,
422                approval_required: false,
423                diff_summary: "old_text was not found".to_string(),
424                changed_paths: Vec::new(),
425                version: None,
426                near_matches: near_matches(&original, &input.old_text),
427            };
428            return match serde_json::to_string(&output) {
429                Ok(json) => ToolResult {
430                    success: false,
431                    output: json,
432                    metadata: None,
433                },
434                Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
435            };
436        }
437        if matches.len() > 1 && !input.replace_all {
438            return ToolResult::error(
439                "old_text appears multiple times; set replace_all to true to replace all occurrences",
440            );
441        }
442        let replacements = if input.replace_all { matches.len() } else { 1 };
443        let max_replacements = input
444            .max_replacements
445            .unwrap_or(DEFAULT_MAX_REPLACEMENTS)
446            .min(
447                ctx.limits
448                    .max_replacements
449                    .unwrap_or(DEFAULT_MAX_REPLACEMENTS),
450            );
451        if replacements > max_replacements {
452            return ToolResult::error("replacement count exceeds configured max_replacements");
453        }
454        if !input.dry_run
455            && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
456        {
457            return ToolResult::error(reason);
458        }
459        let edited = if input.replace_all {
460            original.replace(&input.old_text, &input.new_text)
461        } else {
462            original.replacen(&input.old_text, &input.new_text, 1)
463        };
464        let changed_lines = changed_line_count(&original, &edited);
465        if exceeds(ctx.limits.max_changed_lines, changed_lines) {
466            return ToolResult::error("edit exceeds configured max_changed_lines");
467        }
468        let diff_summary = preview_diff(&original, &edited, DEFAULT_MAX_OUTPUT_CHARS);
469        let version = if input.dry_run {
470            None
471        } else {
472            if let Err(error) = atomic_write(&path, edited.as_bytes()) {
473                return ToolResult::error(format!("Write error: {}", error));
474            }
475            match file_version_evidence(&path, edited.as_bytes()) {
476                Ok(version) => {
477                    self.versions.record(version.clone());
478                    Some(version)
479                }
480                Err(_) => None,
481            }
482        };
483        json_result(&MutationOutput {
484            path: Some(input.path.clone()),
485            dry_run: input.dry_run,
486            mutation_performed: !input.dry_run,
487            changed_files: 1,
488            changed_lines,
489            replacements,
490            bytes_written: if input.dry_run { 0 } else { edited.len() },
491            created: false,
492            overwritten: !input.dry_run,
493            truncated: diff_summary.chars().count() >= DEFAULT_MAX_OUTPUT_CHARS,
494            approval_required: !input.dry_run && policy.approval_required(),
495            diff_summary,
496            changed_paths: vec![input.path],
497            version,
498            near_matches: Vec::new(),
499        })
500    }
501}
502
503#[async_trait]
504impl Tool for PatchTool {
505    fn id(&self) -> &str {
506        "patch"
507    }
508
509    fn name(&self) -> &str {
510        "Patch"
511    }
512
513    fn description(&self) -> &str {
514        "Validate or apply bounded unified diffs with per-file write policy checks."
515    }
516
517    fn input_schema(&self) -> Value {
518        generate_schema::<PatchInput>()
519    }
520
521    fn safety_metadata(&self) -> ToolSafetyMetadata {
522        mutation_metadata(ToolOperationKind::Patch)
523    }
524
525    fn classify_call(&self, args: &Value) -> ToolCallClassification {
526        mutation_classification(&self.safety_metadata(), args)
527    }
528
529    fn policy_bindings(&self) -> ToolPolicyBindings {
530        ToolPolicyBindings {
531            path_fields: vec![
532                PathPolicyBinding::new(
533                    "base_path",
534                    PathAccessMode::Write,
535                    PathBindingKind::PatchBase,
536                )
537                .with_default_path("."),
538            ],
539            result_limit_fields: vec![
540                ResultLimitBinding::new("max_changed_files", ResultLimitKind::MaxChangedFiles),
541                ResultLimitBinding::new("max_changed_lines", ResultLimitKind::MaxChangedLines),
542            ],
543            ..Default::default()
544        }
545    }
546
547    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
548        let input: PatchInput = match serde_json::from_value(args) {
549            Ok(input) => input,
550            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
551        };
552        let base_path = PathBuf::from(input.base_path.unwrap_or_else(|| ".".to_string()));
553        if let Err(reason) = validate_safe_target(&base_path) {
554            return ToolResult::error(reason);
555        }
556        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
557        let files = match parse_unified_diff(&input.patch) {
558            Ok(files) => files,
559            Err(error) => return ToolResult::error(error),
560        };
561        if files.is_empty() {
562            return ToolResult::error("patch contains no changed files");
563        }
564        if exceeds(ctx.limits.max_changed_files, files.len())
565            || files.len() > DEFAULT_MAX_CHANGED_FILES
566        {
567            return ToolResult::error("patch exceeds configured max_changed_files");
568        }
569        let mut changed_lines = 0usize;
570        let mut changed_paths = Vec::new();
571        let mut target_paths = HashSet::new();
572        let mut outputs = Vec::new();
573        for file in &files {
574            changed_lines += file.changed_lines();
575            if changed_lines
576                > ctx
577                    .limits
578                    .max_changed_lines
579                    .unwrap_or(DEFAULT_MAX_CHANGED_LINES)
580            {
581                return ToolResult::error("patch exceeds configured max_changed_lines");
582            }
583            if file.is_delete() && !input.allow_delete {
584                return ToolResult::error("patch deletes a file but allow_delete is false");
585            }
586            let path = base_path.join(strip_patch_prefix(file.target_path()));
587            if let Err(reason) = validate_safe_target(&path) {
588                return ToolResult::error(reason);
589            }
590            if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
591                return ToolResult::error(reason);
592            }
593            if !target_paths.insert(normalize_path(&path)) {
594                return ToolResult::error("patch contains duplicate target paths");
595            }
596            let exists = path.exists();
597            if exists {
598                match fs::symlink_metadata(&path) {
599                    Ok(metadata) if metadata.file_type().is_symlink() => {
600                        return ToolResult::error("patch targets must not be symbolic links");
601                    }
602                    Ok(_) => {}
603                    Err(error) => {
604                        return ToolResult::error(format!("Patch metadata error: {}", error));
605                    }
606                }
607            }
608            if file.is_delete() && !exists {
609                return ToolResult::error("patch deletes a file that does not exist");
610            }
611            if file.is_new_file() {
612                if !input.allow_new_files.unwrap_or(false) {
613                    return ToolResult::error("patch creates a file but allow_new_files is false");
614                }
615                if exists {
616                    return ToolResult::error("new-file patch target already exists");
617                }
618            } else if !file.is_delete() && !exists {
619                return ToolResult::error("patch updates a file that does not exist");
620            }
621            if let Some(parent) = path.parent() {
622                if parent.exists() && !parent.is_dir() {
623                    return ToolResult::error("patch target parent is not a directory");
624                }
625                if !parent.exists() && !policy.create_parent_dirs {
626                    return ToolResult::error(
627                        "patch parent directory creation is not allowed by policy",
628                    );
629                }
630            }
631            if exists
632                && !input.dry_run
633                && let Err(reason) = enforce_read_before_write(&self.versions, &path, &policy)
634            {
635                return ToolResult::error(reason);
636            }
637            let (original, expected) = if exists {
638                let snapshot = match read_file_snapshot(&path) {
639                    Ok(snapshot) => snapshot,
640                    Err(error) => return ToolResult::error(format!("Read error: {}", error)),
641                };
642                let content = match String::from_utf8(snapshot.bytes.clone()) {
643                    Ok(content) => content,
644                    Err(error) => {
645                        return ToolResult::error(format!("Read error: {}", error));
646                    }
647                };
648                (content, ExpectedPathState::Present(snapshot))
649            } else {
650                (String::new(), ExpectedPathState::Absent)
651            };
652            let edited = match apply_file_patch(&original, file) {
653                Ok(edited) => edited,
654                Err(error) => return ToolResult::error(error),
655            };
656            changed_paths.push(path.to_string_lossy().to_string());
657            if file.is_delete() {
658                let ExpectedPathState::Present(expected) = expected else {
659                    return ToolResult::error("patch delete target disappeared during preflight");
660                };
661                outputs.push(PatchApply::Delete { path, expected });
662            } else {
663                outputs.push(PatchApply::Write {
664                    path,
665                    content: edited,
666                    expected,
667                });
668            }
669        }
670        if !input.dry_run {
671            if let Err(error) = apply_patch_transaction(&outputs) {
672                return ToolResult::error(error);
673            }
674            for output in &outputs {
675                if let Some((path, content)) = output.written_file()
676                    && let Ok(version) = file_version_evidence(path, content.as_bytes())
677                {
678                    self.versions.record(version);
679                }
680            }
681        }
682        let diff_summary = summarize_patch(&files, DEFAULT_MAX_OUTPUT_CHARS);
683        json_result(&MutationOutput {
684            path: Some(base_path.to_string_lossy().to_string()),
685            dry_run: input.dry_run,
686            mutation_performed: !input.dry_run && !files.is_empty(),
687            changed_files: files.len(),
688            changed_lines,
689            replacements: 0,
690            bytes_written: if input.dry_run {
691                0
692            } else {
693                outputs.iter().map(PatchApply::bytes_written).sum()
694            },
695            created: !input.dry_run && files.iter().any(PatchFile::is_new_file),
696            overwritten: !input.dry_run
697                && files
698                    .iter()
699                    .any(|file| !file.is_new_file() && !file.is_delete()),
700            truncated: diff_summary.chars().count() >= DEFAULT_MAX_OUTPUT_CHARS,
701            approval_required: !input.dry_run && policy.approval_required(),
702            diff_summary,
703            changed_paths,
704            version: None,
705            near_matches: Vec::new(),
706        })
707    }
708}
709
710#[derive(Debug, Clone)]
711struct PatchFile {
712    old_path: String,
713    new_path: String,
714    hunks: Vec<PatchHunk>,
715}
716
717impl PatchFile {
718    fn target_path(&self) -> &str {
719        if self.new_path == "/dev/null" {
720            &self.old_path
721        } else {
722            &self.new_path
723        }
724    }
725
726    fn is_delete(&self) -> bool {
727        self.new_path == "/dev/null"
728    }
729
730    fn is_new_file(&self) -> bool {
731        self.old_path == "/dev/null"
732    }
733
734    fn changed_lines(&self) -> usize {
735        self.hunks
736            .iter()
737            .flat_map(|hunk| hunk.lines.iter())
738            .filter(|line| matches!(line.kind, PatchLineKind::Add | PatchLineKind::Remove))
739            .count()
740    }
741}
742
743#[derive(Debug, Clone)]
744struct FileSnapshot {
745    bytes: Vec<u8>,
746    permissions: fs::Permissions,
747}
748
749#[derive(Debug, Clone)]
750enum ExpectedPathState {
751    Absent,
752    Present(FileSnapshot),
753}
754
755#[derive(Debug, Clone)]
756enum PatchApply {
757    Write {
758        path: PathBuf,
759        content: String,
760        expected: ExpectedPathState,
761    },
762    Delete {
763        path: PathBuf,
764        expected: FileSnapshot,
765    },
766}
767
768impl PatchApply {
769    fn bytes_written(&self) -> usize {
770        match self {
771            Self::Write { content, .. } => content.len(),
772            Self::Delete { .. } => 0,
773        }
774    }
775
776    fn written_file(&self) -> Option<(&Path, &str)> {
777        match self {
778            Self::Write { path, content, .. } => Some((path, content)),
779            Self::Delete { .. } => None,
780        }
781    }
782}
783
784#[derive(Debug, Clone)]
785enum PatchRollback {
786    RestoreWrite {
787        path: PathBuf,
788        original: FileSnapshot,
789        written: Vec<u8>,
790        written_permissions: fs::Permissions,
791    },
792    RestoreDelete {
793        path: PathBuf,
794        original: FileSnapshot,
795    },
796    RemoveCreated {
797        path: PathBuf,
798        written: Vec<u8>,
799    },
800}
801
802fn apply_patch_transaction(outputs: &[PatchApply]) -> Result<(), String> {
803    let mut rollbacks = Vec::new();
804    let mut created_directories = Vec::new();
805    for output in outputs {
806        match output {
807            PatchApply::Delete { path, expected } => {
808                if let Err(error) = verify_present_snapshot(path, expected, "pre-apply") {
809                    return fail_patch_transaction(error, &rollbacks, &created_directories);
810                }
811                if let Err(error) = fs::remove_file(path) {
812                    return fail_patch_transaction(
813                        format!("Patch delete error: {}", error),
814                        &rollbacks,
815                        &created_directories,
816                    );
817                }
818                rollbacks.push(PatchRollback::RestoreDelete {
819                    path: path.clone(),
820                    original: expected.clone(),
821                });
822            }
823            PatchApply::Write {
824                path,
825                content,
826                expected,
827            } => {
828                if let Some(parent) = path.parent()
829                    && !parent.exists()
830                {
831                    let missing = missing_directories(parent);
832                    if let Err(error) = fs::create_dir_all(parent) {
833                        let cleanup_error = cleanup_created_directories(&missing).err();
834                        let error = match cleanup_error {
835                            Some(cleanup_error) => format!(
836                                "Create parent directory error: {}; partial directory creation may remain: {}",
837                                error, cleanup_error
838                            ),
839                            None => format!("Create parent directory error: {}", error),
840                        };
841                        return fail_patch_transaction(error, &rollbacks, &created_directories);
842                    }
843                    created_directories.extend(missing);
844                }
845                if let Err(error) = verify_expected_state(path, expected, "pre-apply") {
846                    return fail_patch_transaction(error, &rollbacks, &created_directories);
847                }
848                if let Err(error) = atomic_write(path, content.as_bytes()) {
849                    return fail_patch_transaction(
850                        format!("Patch write error: {}", error),
851                        &rollbacks,
852                        &created_directories,
853                    );
854                }
855                let written = content.as_bytes().to_vec();
856                match expected {
857                    ExpectedPathState::Present(original) => {
858                        let rollback = PatchRollback::RestoreWrite {
859                            path: path.clone(),
860                            original: original.clone(),
861                            written,
862                            written_permissions: original.permissions.clone(),
863                        };
864                        if let Err(error) = fs::set_permissions(path, original.permissions.clone())
865                        {
866                            rollbacks.push(rollback);
867                            return fail_patch_transaction(
868                                format!("Patch permission restore error: {}", error),
869                                &rollbacks,
870                                &created_directories,
871                            );
872                        }
873                        rollbacks.push(rollback);
874                    }
875                    ExpectedPathState::Absent => {
876                        rollbacks.push(PatchRollback::RemoveCreated {
877                            path: path.clone(),
878                            written,
879                        });
880                    }
881                }
882            }
883        }
884    }
885    Ok(())
886}
887
888fn fail_patch_transaction(
889    error: String,
890    rollbacks: &[PatchRollback],
891    created_directories: &[PathBuf],
892) -> Result<(), String> {
893    match rollback_patch(rollbacks, created_directories) {
894        Ok(()) => Err(format!("{}; no patch changes were retained", error)),
895        Err(rollback_error) => Err(format!(
896            "{}; partial patch application may remain because rollback failed: {}",
897            error, rollback_error
898        )),
899    }
900}
901
902fn rollback_patch(
903    rollbacks: &[PatchRollback],
904    created_directories: &[PathBuf],
905) -> Result<(), String> {
906    let mut errors = Vec::new();
907    for rollback in rollbacks.iter().rev() {
908        let result = match rollback {
909            PatchRollback::RestoreWrite {
910                path,
911                original,
912                written,
913                written_permissions,
914            } => verify_present_content_and_permissions(
915                path,
916                written,
917                written_permissions,
918                "rollback",
919            )
920            .and_then(|_| restore_file_snapshot(path, original)),
921            PatchRollback::RestoreDelete { path, original } => {
922                verify_absent(path, "rollback").and_then(|_| restore_file_snapshot(path, original))
923            }
924            PatchRollback::RemoveCreated { path, written } => {
925                verify_present_content(path, written, "rollback").and_then(|_| {
926                    fs::remove_file(path).map_err(|error| {
927                        format!(
928                            "Patch rollback remove error for {}: {}",
929                            path.display(),
930                            error
931                        )
932                    })
933                })
934            }
935        };
936        if let Err(error) = result {
937            errors.push(error);
938        }
939    }
940    if let Err(error) = cleanup_created_directories(created_directories) {
941        errors.push(error);
942    }
943    if errors.is_empty() {
944        Ok(())
945    } else {
946        Err(errors.join("; "))
947    }
948}
949
950fn read_file_snapshot(path: &Path) -> std::io::Result<FileSnapshot> {
951    let mut file = fs::File::open(path)?;
952    let permissions = file.metadata()?.permissions();
953    let mut bytes = Vec::new();
954    file.read_to_end(&mut bytes)?;
955    Ok(FileSnapshot { bytes, permissions })
956}
957
958fn verify_expected_state(
959    path: &Path,
960    expected: &ExpectedPathState,
961    stage: &str,
962) -> Result<(), String> {
963    match expected {
964        ExpectedPathState::Absent => verify_absent(path, stage),
965        ExpectedPathState::Present(expected) => verify_present_snapshot(path, expected, stage),
966    }
967}
968
969fn verify_absent(path: &Path, stage: &str) -> Result<(), String> {
970    if path.exists() {
971        Err(format!(
972            "Patch {} conflict for {}: expected path to be absent",
973            stage,
974            path.display()
975        ))
976    } else {
977        Ok(())
978    }
979}
980
981fn verify_present_snapshot(
982    path: &Path,
983    expected: &FileSnapshot,
984    stage: &str,
985) -> Result<(), String> {
986    let current = read_file_snapshot(path).map_err(|error| {
987        format!(
988            "Patch {} conflict for {}: expected file could not be read: {}",
989            stage,
990            path.display(),
991            error
992        )
993    })?;
994    if current.bytes != expected.bytes
995        || !permissions_match(&current.permissions, &expected.permissions)
996    {
997        return Err(format!(
998            "Patch {} conflict for {}: file changed since the expected state",
999            stage,
1000            path.display()
1001        ));
1002    }
1003    Ok(())
1004}
1005
1006fn verify_present_content(path: &Path, expected: &[u8], stage: &str) -> Result<(), String> {
1007    let current = fs::read(path).map_err(|error| {
1008        format!(
1009            "Patch {} conflict for {}: expected file could not be read: {}",
1010            stage,
1011            path.display(),
1012            error
1013        )
1014    })?;
1015    if current != expected {
1016        return Err(format!(
1017            "Patch {} conflict for {}: file content changed since the transaction write",
1018            stage,
1019            path.display()
1020        ));
1021    }
1022    Ok(())
1023}
1024
1025fn verify_present_content_and_permissions(
1026    path: &Path,
1027    expected: &[u8],
1028    expected_permissions: &fs::Permissions,
1029    stage: &str,
1030) -> Result<(), String> {
1031    verify_present_content(path, expected, stage)?;
1032    let current = fs::metadata(path).map_err(|error| {
1033        format!(
1034            "Patch {} conflict for {}: expected file metadata could not be read: {}",
1035            stage,
1036            path.display(),
1037            error
1038        )
1039    })?;
1040    if !permissions_match(&current.permissions(), expected_permissions) {
1041        return Err(format!(
1042            "Patch {} conflict for {}: file permissions changed since the transaction write",
1043            stage,
1044            path.display()
1045        ));
1046    }
1047    Ok(())
1048}
1049
1050fn restore_file_snapshot(path: &Path, snapshot: &FileSnapshot) -> Result<(), String> {
1051    atomic_write(path, &snapshot.bytes).map_err(|error| {
1052        format!(
1053            "Patch rollback restore error for {}: {}",
1054            path.display(),
1055            error
1056        )
1057    })?;
1058    fs::set_permissions(path, snapshot.permissions.clone()).map_err(|error| {
1059        format!(
1060            "Patch rollback permission restore error for {}: {}",
1061            path.display(),
1062            error
1063        )
1064    })
1065}
1066
1067fn permissions_match(left: &fs::Permissions, right: &fs::Permissions) -> bool {
1068    #[cfg(unix)]
1069    {
1070        use std::os::unix::fs::PermissionsExt;
1071        left.mode() == right.mode()
1072    }
1073    #[cfg(not(unix))]
1074    {
1075        left.readonly() == right.readonly()
1076    }
1077}
1078
1079fn missing_directories(path: &Path) -> Vec<PathBuf> {
1080    let mut missing = Vec::new();
1081    let mut current = path;
1082    while !current.exists() {
1083        missing.push(current.to_path_buf());
1084        let Some(parent) = current.parent() else {
1085            break;
1086        };
1087        current = parent;
1088    }
1089    missing.reverse();
1090    missing
1091}
1092
1093fn cleanup_created_directories(paths: &[PathBuf]) -> Result<(), String> {
1094    let mut errors = Vec::new();
1095    for path in paths.iter().rev() {
1096        if let Err(error) = fs::remove_dir(path)
1097            && error.kind() != std::io::ErrorKind::NotFound
1098        {
1099            errors.push(format!(
1100                "Patch rollback directory cleanup conflict for {}: {}",
1101                path.display(),
1102                error
1103            ));
1104        }
1105    }
1106    if errors.is_empty() {
1107        Ok(())
1108    } else {
1109        Err(errors.join("; "))
1110    }
1111}
1112
1113#[derive(Debug, Clone)]
1114struct PatchHunk {
1115    old_start: usize,
1116    lines: Vec<PatchLine>,
1117}
1118
1119#[derive(Debug, Clone)]
1120struct PatchLine {
1121    kind: PatchLineKind,
1122    text: String,
1123}
1124
1125#[derive(Debug, Clone, Copy)]
1126enum PatchLineKind {
1127    Context,
1128    Add,
1129    Remove,
1130}
1131
1132fn mutation_metadata(operation: ToolOperationKind) -> ToolSafetyMetadata {
1133    ToolSafetyMetadata {
1134        read_only: false,
1135        concurrency_safe: false,
1136        operation,
1137        side_effect_level: ToolSideEffectLevel::LocalWrite,
1138        requires_network: false,
1139        destructive: false,
1140        open_world: false,
1141        host_dependent: false,
1142        requires_user_interaction: false,
1143        supports_cancellation: false,
1144        default_requires_approval: true,
1145        should_defer_schema: false,
1146        max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1147        max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
1148    }
1149}
1150
1151fn mutation_classification(metadata: &ToolSafetyMetadata, args: &Value) -> ToolCallClassification {
1152    let dry_run = args
1153        .get("dry_run")
1154        .and_then(Value::as_bool)
1155        .unwrap_or(false);
1156    let mut classification = ToolCallClassification::from_metadata(metadata);
1157    classification.safely_retryable = dry_run;
1158    if dry_run {
1159        classification.read_only = true;
1160        classification.concurrency_safe = true;
1161        classification.destructive = false;
1162        classification.side_effect_level = ToolSideEffectLevel::None;
1163        classification.requires_approval = false;
1164    }
1165    classification
1166}
1167
1168// Mutation tools repeat only the final local path guard; the shared executor remains authoritative for complete policy and approval evaluation.
1169fn ensure_write_allowed(
1170    path: &Path,
1171    dry_run: bool,
1172    policy: &MutationPolicySnapshot,
1173) -> Result<(), String> {
1174    let resolver = mutation_path_resolver()?;
1175    resolver
1176        .resolve_path(path)
1177        .map_err(|error| format!("Path policy resolution failed: {}", error))?;
1178    for blocked in &policy.blocked_paths {
1179        if resolver
1180            .matches_restriction(path, Path::new(blocked))
1181            .map_err(|error| format!("Path policy resolution failed: {}", error))?
1182        {
1183            return Err("path is blocked by policy".to_string());
1184        }
1185    }
1186    if !policy.has_write_policy() {
1187        if policy.no_write_policy == "deny" || !dry_run {
1188            return Err("actual mutation requires explicit write_paths policy".to_string());
1189        }
1190        return Ok(());
1191    }
1192    let mut matches_allowed = false;
1193    for allowed in policy.write_paths.iter().chain(policy.allowed_paths.iter()) {
1194        if resolver
1195            .is_allowed(path, Path::new(allowed))
1196            .map_err(|error| format!("Path policy resolution failed: {}", error))?
1197        {
1198            matches_allowed = true;
1199            break;
1200        }
1201    }
1202    if !matches_allowed {
1203        return Err("path is not under an allowed write root".to_string());
1204    }
1205    Ok(())
1206}
1207
1208fn ensure_not_write_root(path: &Path, policy: &MutationPolicySnapshot) -> Result<(), String> {
1209    let resolver = mutation_path_resolver()?;
1210    for root in policy.write_paths.iter().chain(policy.allowed_paths.iter()) {
1211        if resolver
1212            .is_same_location(path, Path::new(root))
1213            .map_err(|error| format!("Path policy resolution failed: {}", error))?
1214        {
1215            return Err("refusing to delete a configured write root".to_string());
1216        }
1217    }
1218    Ok(())
1219}
1220
1221fn enforce_read_before_write(
1222    versions: &FileVersionStore,
1223    path: &Path,
1224    policy: &MutationPolicySnapshot,
1225) -> Result<(), String> {
1226    if !policy.require_read_before_write {
1227        return Ok(());
1228    }
1229    let bytes =
1230        fs::read(path).map_err(|error| format!("Read-before-write check failed: {}", error))?;
1231    let current = file_version_evidence(path, &bytes)
1232        .map_err(|error| format!("Read-before-write version failed: {}", error))?;
1233    if versions.matches(&current) {
1234        Ok(())
1235    } else {
1236        Err(
1237            "file must be read with file_read before mutation and must not change before write"
1238                .to_string(),
1239        )
1240    }
1241}
1242
1243fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
1244    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1245    let temp = parent.join(format!(".{}.tmp", Uuid::new_v4()));
1246    let result = (|| {
1247        let mut file = fs::File::create(&temp)?;
1248        file.write_all(bytes)?;
1249        file.sync_all()?;
1250        fs::rename(&temp, path)
1251    })();
1252    if result.is_err() {
1253        let _ = fs::remove_file(&temp);
1254    }
1255    result
1256}
1257
1258fn validate_safe_target(path: &Path) -> Result<(), String> {
1259    for component in path.components() {
1260        if matches!(component, Component::ParentDir) {
1261            return Err("paths containing parent-directory traversal are not allowed".to_string());
1262        }
1263    }
1264    if path
1265        .components()
1266        .any(|component| component.as_os_str() == ".git")
1267    {
1268        return Err("raw .git paths are not allowed".to_string());
1269    }
1270    Ok(())
1271}
1272
1273fn changed_line_count(before: &str, after: &str) -> usize {
1274    let before_lines: Vec<_> = before.lines().collect();
1275    let after_lines: Vec<_> = after.lines().collect();
1276    before_lines
1277        .iter()
1278        .zip(after_lines.iter())
1279        .filter(|(left, right)| left != right)
1280        .count()
1281        + before_lines.len().abs_diff(after_lines.len())
1282}
1283
1284fn preview_diff(before: &str, after: &str, max_chars: usize) -> String {
1285    let before_lines: Vec<_> = before.lines().collect();
1286    let after_lines: Vec<_> = after.lines().collect();
1287    let mut output = String::new();
1288    for (index, (left, right)) in before_lines.iter().zip(after_lines.iter()).enumerate() {
1289        if left != right {
1290            output.push_str(&format!(
1291                "-{}:{}\n+{}:{}\n",
1292                index + 1,
1293                left,
1294                index + 1,
1295                right
1296            ));
1297        }
1298        if output.chars().count() >= max_chars {
1299            return output.chars().take(max_chars).collect();
1300        }
1301    }
1302    if before_lines.len() != after_lines.len() {
1303        output.push_str(&format!(
1304            "line count changed from {} to {}\n",
1305            before_lines.len(),
1306            after_lines.len()
1307        ));
1308    }
1309    output.chars().take(max_chars).collect()
1310}
1311
1312fn near_matches(content: &str, needle: &str) -> Vec<String> {
1313    let prefix: String = needle.chars().take(16).collect();
1314    if prefix.is_empty() {
1315        return Vec::new();
1316    }
1317    content
1318        .lines()
1319        .filter(|line| line.contains(&prefix))
1320        .take(3)
1321        .map(str::to_string)
1322        .collect()
1323}
1324
1325fn parse_unified_diff(input: &str) -> Result<Vec<PatchFile>, String> {
1326    let mut files = Vec::new();
1327    let mut current: Option<PatchFile> = None;
1328    let mut current_hunk: Option<PatchHunk> = None;
1329    for line in input.lines() {
1330        if let Some(rest) = line.strip_prefix("--- ") {
1331            if let Some(hunk) = current_hunk.take()
1332                && let Some(file) = current.as_mut()
1333            {
1334                file.hunks.push(hunk);
1335            }
1336            if let Some(file) = current.take() {
1337                files.push(file);
1338            }
1339            current = Some(PatchFile {
1340                old_path: clean_patch_path(rest),
1341                new_path: String::new(),
1342                hunks: Vec::new(),
1343            });
1344        } else if let Some(rest) = line.strip_prefix("+++ ") {
1345            let Some(file) = current.as_mut() else {
1346                return Err("patch has +++ before ---".to_string());
1347            };
1348            file.new_path = clean_patch_path(rest);
1349        } else if line.starts_with("@@") {
1350            if let Some(hunk) = current_hunk.take()
1351                && let Some(file) = current.as_mut()
1352            {
1353                file.hunks.push(hunk);
1354            }
1355            current_hunk = Some(PatchHunk {
1356                old_start: parse_hunk_old_start(line)?,
1357                lines: Vec::new(),
1358            });
1359        } else if let Some(hunk) = current_hunk.as_mut() {
1360            if let Some(text) = line.strip_prefix(' ') {
1361                hunk.lines.push(PatchLine {
1362                    kind: PatchLineKind::Context,
1363                    text: text.to_string(),
1364                });
1365            } else if let Some(text) = line.strip_prefix('+') {
1366                hunk.lines.push(PatchLine {
1367                    kind: PatchLineKind::Add,
1368                    text: text.to_string(),
1369                });
1370            } else if let Some(text) = line.strip_prefix('-') {
1371                hunk.lines.push(PatchLine {
1372                    kind: PatchLineKind::Remove,
1373                    text: text.to_string(),
1374                });
1375            }
1376        }
1377    }
1378    if let Some(hunk) = current_hunk
1379        && let Some(file) = current.as_mut()
1380    {
1381        file.hunks.push(hunk);
1382    }
1383    if let Some(file) = current {
1384        files.push(file);
1385    }
1386    for file in &files {
1387        if file.new_path.is_empty() || file.hunks.is_empty() {
1388            return Err("patch file is missing target path or hunks".to_string());
1389        }
1390    }
1391    Ok(files)
1392}
1393
1394fn apply_file_patch(original: &str, patch: &PatchFile) -> Result<String, String> {
1395    let newline = if original.contains("\r\n") {
1396        "\r\n"
1397    } else {
1398        "\n"
1399    };
1400    let original_lines: Vec<String> = if original.is_empty() {
1401        Vec::new()
1402    } else {
1403        original.lines().map(str::to_string).collect()
1404    };
1405    let mut output = Vec::new();
1406    let mut cursor = 0usize;
1407    for hunk in &patch.hunks {
1408        let start = hunk.old_start.saturating_sub(1);
1409        if start < cursor || start > original_lines.len() {
1410            return Err("patch hunk does not align with file contents".to_string());
1411        }
1412        output.extend_from_slice(&original_lines[cursor..start]);
1413        cursor = start;
1414        for line in &hunk.lines {
1415            match line.kind {
1416                PatchLineKind::Context => {
1417                    if original_lines.get(cursor).map(String::as_str) != Some(line.text.as_str()) {
1418                        return Err("patch context does not match file contents".to_string());
1419                    }
1420                    output.push(line.text.clone());
1421                    cursor += 1;
1422                }
1423                PatchLineKind::Remove => {
1424                    if original_lines.get(cursor).map(String::as_str) != Some(line.text.as_str()) {
1425                        return Err("patch removal does not match file contents".to_string());
1426                    }
1427                    cursor += 1;
1428                }
1429                PatchLineKind::Add => output.push(line.text.clone()),
1430            }
1431        }
1432    }
1433    output.extend_from_slice(&original_lines[cursor..]);
1434    let mut joined = output.join(newline);
1435    if original.ends_with('\n') || !joined.is_empty() {
1436        joined.push_str(newline);
1437    }
1438    Ok(joined)
1439}
1440
1441fn summarize_patch(files: &[PatchFile], max_chars: usize) -> String {
1442    let mut output = String::new();
1443    for file in files {
1444        output.push_str(&format!(
1445            "{} -> {} ({} changed lines)\n",
1446            file.old_path,
1447            file.new_path,
1448            file.changed_lines()
1449        ));
1450    }
1451    output.chars().take(max_chars).collect()
1452}
1453
1454fn parse_hunk_old_start(line: &str) -> Result<usize, String> {
1455    let start = line
1456        .split_whitespace()
1457        .find(|part| part.starts_with('-'))
1458        .ok_or_else(|| "invalid hunk header".to_string())?;
1459    let start = start
1460        .trim_start_matches('-')
1461        .split(',')
1462        .next()
1463        .unwrap_or("1");
1464    start
1465        .parse::<usize>()
1466        .map_err(|_| "invalid hunk start".to_string())
1467}
1468
1469fn clean_patch_path(path: &str) -> String {
1470    path.split_whitespace().next().unwrap_or(path).to_string()
1471}
1472
1473fn strip_patch_prefix(path: &str) -> &str {
1474    path.strip_prefix("a/")
1475        .or_else(|| path.strip_prefix("b/"))
1476        .unwrap_or(path)
1477}
1478
1479fn strings_at(value: &Value, field: &str) -> Vec<String> {
1480    value
1481        .get(field)
1482        .and_then(Value::as_array)
1483        .into_iter()
1484        .flatten()
1485        .filter_map(Value::as_str)
1486        .map(str::to_string)
1487        .collect()
1488}
1489
1490fn bool_at(value: &Value, field: &str) -> bool {
1491    value.get(field).and_then(Value::as_bool).unwrap_or(false)
1492}
1493
1494fn normalize_path(path: &Path) -> PathBuf {
1495    let base = if path.is_absolute() {
1496        path.to_path_buf()
1497    } else {
1498        std::env::current_dir()
1499            .unwrap_or_else(|_| PathBuf::from("."))
1500            .join(path)
1501    };
1502    base.components().collect()
1503}
1504
1505fn path_entry_exists(path: &Path) -> bool {
1506    fs::symlink_metadata(path).is_ok()
1507}
1508
1509fn mutation_path_resolver() -> Result<PathPolicyResolver, String> {
1510    PathPolicyResolver::new().map_err(|error| format!("Path policy resolution failed: {}", error))
1511}
1512
1513fn exceeds(limit: Option<usize>, value: usize) -> bool {
1514    limit.is_some_and(|limit| value > limit)
1515}
1516
1517fn json_result<T: Serialize>(output: &T) -> ToolResult {
1518    match serde_json::to_string(output) {
1519        Ok(json) => ToolResult::ok(json),
1520        Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
1521    }
1522}
1523
1524fn json_error_result<T: Serialize>(output: &T) -> ToolResult {
1525    match serde_json::to_string(output) {
1526        Ok(json) => ToolResult {
1527            success: false,
1528            output: json,
1529            metadata: None,
1530        },
1531        Err(error) => ToolResult::error(format!("Serialization error: {}", error)),
1532    }
1533}
1534
1535/// Copies a file or directory tree with policy-gated dry-run previews.
1536pub struct CopyPathTool;
1537
1538impl CopyPathTool {
1539    /// Create a copy tool.
1540    pub fn new() -> Self {
1541        Self
1542    }
1543}
1544
1545impl Default for CopyPathTool {
1546    fn default() -> Self {
1547        Self::new()
1548    }
1549}
1550
1551/// Moves a file or directory with policy-gated dry-run previews.
1552pub struct MovePathTool;
1553
1554impl MovePathTool {
1555    /// Create a move tool.
1556    pub fn new() -> Self {
1557        Self
1558    }
1559}
1560
1561impl Default for MovePathTool {
1562    fn default() -> Self {
1563        Self::new()
1564    }
1565}
1566
1567/// Deletes a file or directory with policy-gated dry-run previews.
1568pub struct DeletePathTool;
1569
1570impl DeletePathTool {
1571    /// Create a delete tool.
1572    pub fn new() -> Self {
1573        Self
1574    }
1575}
1576
1577impl Default for DeletePathTool {
1578    fn default() -> Self {
1579        Self::new()
1580    }
1581}
1582
1583#[derive(Debug, Deserialize, JsonSchema)]
1584struct CopyPathInput {
1585    /// Source path to copy from.
1586    source_path: String,
1587    /// Destination path to copy to.
1588    destination_path: String,
1589    /// Allow replacing an existing destination.
1590    #[serde(default)]
1591    overwrite: bool,
1592    /// Create missing parent directories for the destination.
1593    #[serde(default)]
1594    create_parent_dirs: bool,
1595    /// Validate and return a summary without copying.
1596    #[serde(default)]
1597    dry_run: bool,
1598}
1599
1600#[derive(Debug, Deserialize, JsonSchema)]
1601struct MovePathInput {
1602    /// Source path to move from.
1603    source_path: String,
1604    /// Destination path to move to.
1605    destination_path: String,
1606    /// Allow replacing an existing destination.
1607    #[serde(default)]
1608    overwrite: bool,
1609    /// Create missing parent directories for the destination.
1610    #[serde(default)]
1611    create_parent_dirs: bool,
1612    /// Validate and return a summary without moving.
1613    #[serde(default)]
1614    dry_run: bool,
1615}
1616
1617#[derive(Debug, Deserialize, JsonSchema)]
1618struct DeletePathInput {
1619    /// Path to delete.
1620    path: String,
1621    /// Remove a directory and its contents. Required for directories.
1622    #[serde(default)]
1623    recursive: bool,
1624    /// Validate and return a summary without deleting.
1625    #[serde(default)]
1626    dry_run: bool,
1627}
1628
1629#[derive(Debug, Serialize)]
1630struct PathMutationOutput {
1631    source_path: Option<String>,
1632    destination_path: Option<String>,
1633    path: Option<String>,
1634    dry_run: bool,
1635    mutation_performed: bool,
1636    copied: bool,
1637    moved: bool,
1638    deleted: bool,
1639    recursive: bool,
1640    overwritten: bool,
1641    bytes_affected: usize,
1642    items_affected: usize,
1643    approval_required: bool,
1644    diff_summary: String,
1645    #[serde(skip_serializing_if = "Option::is_none")]
1646    error: Option<String>,
1647    #[serde(skip_serializing_if = "Option::is_none")]
1648    cleanup_warning: Option<String>,
1649    #[serde(skip_serializing_if = "Option::is_none")]
1650    retained_backup_path: Option<String>,
1651}
1652
1653#[async_trait]
1654impl Tool for CopyPathTool {
1655    fn id(&self) -> &str {
1656        "copy_path"
1657    }
1658
1659    fn name(&self) -> &str {
1660        "Copy Path"
1661    }
1662
1663    fn description(&self) -> &str {
1664        "Copy a file or directory tree with source and destination policy checks and dry-run previews."
1665    }
1666
1667    fn input_schema(&self) -> Value {
1668        generate_schema::<CopyPathInput>()
1669    }
1670
1671    fn safety_metadata(&self) -> ToolSafetyMetadata {
1672        path_mutation_metadata(ToolOperationKind::Write)
1673    }
1674
1675    fn classify_call(&self, args: &Value) -> ToolCallClassification {
1676        path_mutation_classification(&self.safety_metadata(), args)
1677    }
1678
1679    fn policy_bindings(&self) -> ToolPolicyBindings {
1680        ToolPolicyBindings {
1681            path_fields: vec![
1682                PathPolicyBinding::read("source_path"),
1683                PathPolicyBinding::write("destination_path"),
1684            ],
1685            ..Default::default()
1686        }
1687    }
1688
1689    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
1690        let input: CopyPathInput = match serde_json::from_value(args) {
1691            Ok(input) => input,
1692            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
1693        };
1694        let source = PathBuf::from(&input.source_path);
1695        let destination = PathBuf::from(&input.destination_path);
1696        if let Err(reason) =
1697            validate_safe_target(&source).and_then(|_| validate_safe_target(&destination))
1698        {
1699            return ToolResult::error(reason);
1700        }
1701        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
1702        if let Err(reason) = ensure_read_allowed(&source, &policy) {
1703            return ToolResult::error(reason);
1704        }
1705        if let Err(reason) = ensure_write_allowed(&destination, input.dry_run, &policy) {
1706            return ToolResult::error(reason);
1707        }
1708        if !path_entry_exists(&source) {
1709            return ToolResult::error(format!("source path does not exist: {}", input.source_path));
1710        }
1711        if let Err(reason) = validate_copy_destination(&source, &destination) {
1712            return ToolResult::error(reason);
1713        }
1714        let (bytes_affected, items_affected, recursive) = match inspect_copy_source(&source) {
1715            Ok(details) => details,
1716            Err(error) => return ToolResult::error(format!("Copy source error: {}", error)),
1717        };
1718        let destination_exists = path_entry_exists(&destination);
1719        if destination_exists && !input.overwrite {
1720            return ToolResult::error("overwrite must be true to replace an existing destination");
1721        }
1722        if destination_exists && !policy.overwrite_existing && !input.dry_run {
1723            return ToolResult::error("overwrite_existing policy is false for this destination");
1724        }
1725        if let Some(parent) = destination.parent()
1726            && !parent.exists()
1727        {
1728            if !(input.create_parent_dirs && policy.create_parent_dirs) {
1729                return ToolResult::error(
1730                    "destination parent directory does not exist or create_parent_dirs is not allowed",
1731                );
1732            }
1733            if !input.dry_run
1734                && let Err(error) = fs::create_dir_all(parent)
1735            {
1736                return ToolResult::error(format!("Create parent directory error: {}", error));
1737            }
1738        }
1739        let mut cleanup_warning = None;
1740        let mut retained_backup_path = None;
1741        if !input.dry_run {
1742            if destination_exists {
1743                match replace_copy_path(&source, &destination) {
1744                    ReplacementOutcome::Committed {
1745                        cleanup_warning: warning,
1746                        retained_backup_path: backup,
1747                    } => {
1748                        cleanup_warning = warning;
1749                        retained_backup_path = backup;
1750                    }
1751                    ReplacementOutcome::Unchanged(error) => {
1752                        return ToolResult::error(format!("Copy error: {}", error));
1753                    }
1754                    ReplacementOutcome::RecoveryIncomplete {
1755                        error,
1756                        retained_backup_path,
1757                    } => {
1758                        return json_error_result(&PathMutationOutput {
1759                            source_path: Some(input.source_path.clone()),
1760                            destination_path: Some(input.destination_path.clone()),
1761                            path: None,
1762                            dry_run: false,
1763                            mutation_performed: true,
1764                            copied: false,
1765                            moved: false,
1766                            deleted: false,
1767                            recursive,
1768                            overwritten: false,
1769                            bytes_affected,
1770                            items_affected,
1771                            approval_required: policy.approval_required(),
1772                            diff_summary:
1773                                "copy failed after moving the previous destination to a backup"
1774                                    .to_string(),
1775                            error: Some(error),
1776                            cleanup_warning: None,
1777                            retained_backup_path: Some(retained_backup_path),
1778                        });
1779                    }
1780                }
1781            } else if let Err(error) = copy_path(&source, &destination) {
1782                return ToolResult::error(format!("Copy error: {}", error));
1783            }
1784        }
1785        json_result(&PathMutationOutput {
1786            source_path: Some(input.source_path.clone()),
1787            destination_path: Some(input.destination_path.clone()),
1788            path: None,
1789            dry_run: input.dry_run,
1790            mutation_performed: !input.dry_run,
1791            copied: !input.dry_run,
1792            moved: false,
1793            deleted: false,
1794            recursive,
1795            overwritten: !input.dry_run && destination_exists,
1796            bytes_affected,
1797            items_affected,
1798            approval_required: !input.dry_run && policy.approval_required(),
1799            diff_summary: format!(
1800                "{} {} -> {} ({} bytes)",
1801                if input.dry_run {
1802                    "plan to copy"
1803                } else {
1804                    "copied"
1805                },
1806                input.source_path,
1807                input.destination_path,
1808                bytes_affected
1809            ),
1810            error: None,
1811            cleanup_warning,
1812            retained_backup_path,
1813        })
1814    }
1815}
1816
1817#[async_trait]
1818impl Tool for MovePathTool {
1819    fn id(&self) -> &str {
1820        "move_path"
1821    }
1822
1823    fn name(&self) -> &str {
1824        "Move Path"
1825    }
1826
1827    fn description(&self) -> &str {
1828        "Move or rename a file or directory with source and destination policy checks and dry-run previews."
1829    }
1830
1831    fn input_schema(&self) -> Value {
1832        generate_schema::<MovePathInput>()
1833    }
1834
1835    fn safety_metadata(&self) -> ToolSafetyMetadata {
1836        path_mutation_metadata(ToolOperationKind::Write)
1837    }
1838
1839    fn classify_call(&self, args: &Value) -> ToolCallClassification {
1840        path_mutation_classification(&self.safety_metadata(), args)
1841    }
1842
1843    fn policy_bindings(&self) -> ToolPolicyBindings {
1844        ToolPolicyBindings {
1845            path_fields: vec![
1846                PathPolicyBinding::read_write("source_path"),
1847                PathPolicyBinding::write("destination_path"),
1848            ],
1849            ..Default::default()
1850        }
1851    }
1852
1853    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
1854        let input: MovePathInput = match serde_json::from_value(args) {
1855            Ok(input) => input,
1856            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
1857        };
1858        let source = PathBuf::from(&input.source_path);
1859        let destination = PathBuf::from(&input.destination_path);
1860        if let Err(reason) =
1861            validate_safe_target(&source).and_then(|_| validate_safe_target(&destination))
1862        {
1863            return ToolResult::error(reason);
1864        }
1865        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
1866        if let Err(reason) = ensure_write_allowed(&source, input.dry_run, &policy) {
1867            return ToolResult::error(reason);
1868        }
1869        if let Err(reason) = ensure_write_allowed(&destination, input.dry_run, &policy) {
1870            return ToolResult::error(reason);
1871        }
1872        if !path_entry_exists(&source) {
1873            return ToolResult::error(format!("source path does not exist: {}", input.source_path));
1874        }
1875        let resolved_source = match source.canonicalize() {
1876            Ok(path) => path,
1877            Err(error) => return ToolResult::error(format!("Source path error: {}", error)),
1878        };
1879        let resolved_destination = match mutation_path_resolver().and_then(|resolver| {
1880            resolver
1881                .resolve_path(&destination)
1882                .map_err(|error| format!("Path policy resolution failed: {}", error))
1883        }) {
1884            Ok(path) => path,
1885            Err(error) => return ToolResult::error(error),
1886        };
1887        if resolved_source == resolved_destination {
1888            return ToolResult::error("source and destination resolve to the same path");
1889        }
1890        let destination_exists = path_entry_exists(&destination);
1891        if destination_exists && !input.overwrite {
1892            return ToolResult::error("overwrite must be true to replace an existing destination");
1893        }
1894        if destination_exists && !policy.overwrite_existing && !input.dry_run {
1895            return ToolResult::error("overwrite_existing policy is false for this destination");
1896        }
1897        if let Some(parent) = destination.parent()
1898            && !parent.exists()
1899        {
1900            if !(input.create_parent_dirs && policy.create_parent_dirs) {
1901                return ToolResult::error(
1902                    "destination parent directory does not exist or create_parent_dirs is not allowed",
1903                );
1904            }
1905            if !input.dry_run
1906                && let Err(error) = fs::create_dir_all(parent)
1907            {
1908                return ToolResult::error(format!("Create parent directory error: {}", error));
1909            }
1910        }
1911        let recursive = source.is_dir();
1912        let bytes_affected = path_size(&source).unwrap_or(0);
1913        let items_affected = path_item_count(&source).unwrap_or(1);
1914        let mut cleanup_warning = None;
1915        let mut retained_backup_path = None;
1916        if !input.dry_run {
1917            if destination_exists {
1918                match replace_moved_path(&source, &destination) {
1919                    ReplacementOutcome::Committed {
1920                        cleanup_warning: warning,
1921                        retained_backup_path: backup,
1922                    } => {
1923                        cleanup_warning = warning;
1924                        retained_backup_path = backup;
1925                    }
1926                    ReplacementOutcome::Unchanged(error) => {
1927                        return ToolResult::error(format!("Move error: {}", error));
1928                    }
1929                    ReplacementOutcome::RecoveryIncomplete {
1930                        error,
1931                        retained_backup_path,
1932                    } => {
1933                        return json_error_result(&PathMutationOutput {
1934                            source_path: Some(input.source_path.clone()),
1935                            destination_path: Some(input.destination_path.clone()),
1936                            path: None,
1937                            dry_run: false,
1938                            mutation_performed: true,
1939                            copied: false,
1940                            moved: false,
1941                            deleted: false,
1942                            recursive,
1943                            overwritten: false,
1944                            bytes_affected,
1945                            items_affected,
1946                            approval_required: policy.approval_required(),
1947                            diff_summary:
1948                                "move failed after moving the previous destination to a backup"
1949                                    .to_string(),
1950                            error: Some(error),
1951                            cleanup_warning: None,
1952                            retained_backup_path: Some(retained_backup_path),
1953                        });
1954                    }
1955                }
1956            } else if let Err(error) = fs::rename(&source, &destination) {
1957                return ToolResult::error(format!("Move error: {}", error));
1958            }
1959        }
1960        json_result(&PathMutationOutput {
1961            source_path: Some(input.source_path.clone()),
1962            destination_path: Some(input.destination_path.clone()),
1963            path: None,
1964            dry_run: input.dry_run,
1965            mutation_performed: !input.dry_run,
1966            copied: false,
1967            moved: !input.dry_run,
1968            deleted: false,
1969            recursive,
1970            overwritten: !input.dry_run && destination_exists,
1971            bytes_affected,
1972            items_affected,
1973            approval_required: !input.dry_run && policy.approval_required(),
1974            diff_summary: format!(
1975                "{} {} -> {} ({} bytes)",
1976                if input.dry_run {
1977                    "plan to move"
1978                } else {
1979                    "moved"
1980                },
1981                input.source_path,
1982                input.destination_path,
1983                bytes_affected
1984            ),
1985            error: None,
1986            cleanup_warning,
1987            retained_backup_path,
1988        })
1989    }
1990}
1991
1992#[async_trait]
1993impl Tool for DeletePathTool {
1994    fn id(&self) -> &str {
1995        "delete_path"
1996    }
1997
1998    fn name(&self) -> &str {
1999        "Delete Path"
2000    }
2001
2002    fn description(&self) -> &str {
2003        "Delete a file or directory with explicit policy checks, recursive-delete gating, and dry-run previews."
2004    }
2005
2006    fn input_schema(&self) -> Value {
2007        generate_schema::<DeletePathInput>()
2008    }
2009
2010    fn safety_metadata(&self) -> ToolSafetyMetadata {
2011        path_mutation_metadata(ToolOperationKind::Delete)
2012    }
2013
2014    fn classify_call(&self, args: &Value) -> ToolCallClassification {
2015        path_mutation_classification(&self.safety_metadata(), args)
2016    }
2017
2018    fn policy_bindings(&self) -> ToolPolicyBindings {
2019        ToolPolicyBindings {
2020            path_fields: vec![PathPolicyBinding::write("path")],
2021            ..Default::default()
2022        }
2023    }
2024
2025    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
2026        let input: DeletePathInput = match serde_json::from_value(args) {
2027            Ok(input) => input,
2028            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
2029        };
2030        let path = PathBuf::from(&input.path);
2031        if let Err(reason) = validate_safe_target(&path) {
2032            return ToolResult::error(reason);
2033        }
2034        let policy = MutationPolicySnapshot::from_context(&ctx.policy_snapshot);
2035        if let Err(reason) = ensure_write_allowed(&path, input.dry_run, &policy) {
2036            return ToolResult::error(reason);
2037        }
2038        if let Err(reason) = ensure_not_write_root(&path, &policy) {
2039            return ToolResult::error(reason);
2040        }
2041        if !path_entry_exists(&path) {
2042            return ToolResult::error(format!("path does not exist: {}", input.path));
2043        }
2044        if path.is_dir() && !input.recursive {
2045            return ToolResult::error("recursive must be true to delete a directory");
2046        }
2047        let recursive = path.is_dir();
2048        let bytes_affected = path_size(&path).unwrap_or(0);
2049        let items_affected = path_item_count(&path).unwrap_or(1);
2050        if !input.dry_run {
2051            let result = if path.is_dir() {
2052                fs::remove_dir_all(&path)
2053            } else {
2054                fs::remove_file(&path)
2055            };
2056            if let Err(error) = result {
2057                return ToolResult::error(format!("Delete error: {}", error));
2058            }
2059        }
2060        json_result(&PathMutationOutput {
2061            source_path: None,
2062            destination_path: None,
2063            path: Some(input.path.clone()),
2064            dry_run: input.dry_run,
2065            mutation_performed: !input.dry_run,
2066            copied: false,
2067            moved: false,
2068            deleted: !input.dry_run,
2069            recursive,
2070            overwritten: false,
2071            bytes_affected,
2072            items_affected,
2073            approval_required: !input.dry_run && policy.approval_required(),
2074            diff_summary: format!(
2075                "{} {} ({} bytes, {} items)",
2076                if input.dry_run {
2077                    "plan to delete"
2078                } else {
2079                    "deleted"
2080                },
2081                input.path,
2082                bytes_affected,
2083                items_affected
2084            ),
2085            error: None,
2086            cleanup_warning: None,
2087            retained_backup_path: None,
2088        })
2089    }
2090}
2091
2092fn path_mutation_metadata(operation: ToolOperationKind) -> ToolSafetyMetadata {
2093    ToolSafetyMetadata {
2094        read_only: false,
2095        concurrency_safe: false,
2096        operation,
2097        side_effect_level: if matches!(operation, ToolOperationKind::Delete) {
2098            ToolSideEffectLevel::Destructive
2099        } else {
2100            ToolSideEffectLevel::LocalWrite
2101        },
2102        requires_network: false,
2103        destructive: matches!(operation, ToolOperationKind::Delete),
2104        open_world: false,
2105        host_dependent: false,
2106        requires_user_interaction: false,
2107        supports_cancellation: false,
2108        default_requires_approval: true,
2109        should_defer_schema: false,
2110        max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
2111        max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
2112    }
2113}
2114
2115fn path_mutation_classification(
2116    metadata: &ToolSafetyMetadata,
2117    args: &Value,
2118) -> ToolCallClassification {
2119    let dry_run = args
2120        .get("dry_run")
2121        .and_then(Value::as_bool)
2122        .unwrap_or(false);
2123    let mut classification = ToolCallClassification::from_metadata(metadata);
2124    classification.safely_retryable = dry_run;
2125    if dry_run {
2126        classification.read_only = true;
2127        classification.concurrency_safe = true;
2128        classification.destructive = false;
2129        classification.side_effect_level = ToolSideEffectLevel::None;
2130        classification.requires_approval = false;
2131    }
2132    classification
2133}
2134
2135fn ensure_read_allowed(path: &Path, policy: &MutationPolicySnapshot) -> Result<(), String> {
2136    let resolver = mutation_path_resolver()?;
2137    resolver
2138        .resolve_path(path)
2139        .map_err(|error| format!("Path policy resolution failed: {}", error))?;
2140    for blocked in &policy.blocked_paths {
2141        if resolver
2142            .matches_restriction(path, Path::new(blocked))
2143            .map_err(|error| format!("Path policy resolution failed: {}", error))?
2144        {
2145            return Err("source path is blocked by policy".to_string());
2146        }
2147    }
2148    Ok(())
2149}
2150
2151fn validate_copy_destination(source: &Path, destination: &Path) -> Result<(), String> {
2152    let source = source
2153        .canonicalize()
2154        .map_err(|error| format!("Source canonicalization failed: {}", error))?;
2155    let destination = mutation_path_resolver()?
2156        .resolve_path(destination)
2157        .map_err(|error| format!("Path policy resolution failed: {}", error))?;
2158    if destination == source || source.is_dir() && destination.starts_with(&source) {
2159        return Err("destination must not equal or be nested under the source".to_string());
2160    }
2161    Ok(())
2162}
2163
2164fn inspect_copy_source(path: &Path) -> std::io::Result<(usize, usize, bool)> {
2165    let metadata = fs::symlink_metadata(path)?;
2166    if metadata.file_type().is_symlink() {
2167        return Err(std::io::Error::new(
2168            std::io::ErrorKind::InvalidInput,
2169            format!("symbolic links are not supported: {}", path.display()),
2170        ));
2171    }
2172    if !metadata.is_dir() {
2173        return Ok((metadata.len() as usize, 1, false));
2174    }
2175
2176    let mut bytes = 0usize;
2177    let mut items = 1usize;
2178    for entry in fs::read_dir(path)? {
2179        let entry = entry?;
2180        let (entry_bytes, entry_items, _) = inspect_copy_source(&entry.path())?;
2181        bytes = bytes.saturating_add(entry_bytes);
2182        items = items.saturating_add(entry_items);
2183    }
2184    Ok((bytes, items, true))
2185}
2186
2187fn copy_path(source: &Path, destination: &Path) -> std::io::Result<()> {
2188    if source.is_dir() {
2189        copy_directory(source, destination)
2190    } else {
2191        if let Some(parent) = destination.parent()
2192            && !parent.exists()
2193        {
2194            fs::create_dir_all(parent)?;
2195        }
2196        fs::copy(source, destination)?;
2197        Ok(())
2198    }
2199}
2200
2201enum ReplacementOutcome {
2202    Committed {
2203        cleanup_warning: Option<String>,
2204        retained_backup_path: Option<String>,
2205    },
2206    Unchanged(String),
2207    RecoveryIncomplete {
2208        error: String,
2209        retained_backup_path: String,
2210    },
2211}
2212
2213fn replace_copy_path(source: &Path, destination: &Path) -> ReplacementOutcome {
2214    let staged = unique_sibling_path(destination, "copy");
2215    if let Err(error) = copy_path(source, &staged) {
2216        if path_entry_exists(&staged) {
2217            let _ = remove_path(&staged);
2218        }
2219        return ReplacementOutcome::Unchanged(error.to_string());
2220    }
2221    let outcome = replace_existing_path(&staged, destination);
2222    if matches!(
2223        outcome,
2224        ReplacementOutcome::Unchanged(_) | ReplacementOutcome::RecoveryIncomplete { .. }
2225    ) && path_entry_exists(&staged)
2226    {
2227        let _ = remove_path(&staged);
2228    }
2229    outcome
2230}
2231
2232fn replace_moved_path(source: &Path, destination: &Path) -> ReplacementOutcome {
2233    replace_existing_path(source, destination)
2234}
2235
2236fn replace_existing_path(prepared: &Path, destination: &Path) -> ReplacementOutcome {
2237    replace_existing_path_with(prepared, destination, remove_path)
2238}
2239
2240fn replace_existing_path_with<F>(
2241    prepared: &Path,
2242    destination: &Path,
2243    remove_backup: F,
2244) -> ReplacementOutcome
2245where
2246    F: FnOnce(&Path) -> std::io::Result<()>,
2247{
2248    let backup = unique_sibling_path(destination, "backup");
2249    if let Err(error) = fs::rename(destination, &backup) {
2250        return ReplacementOutcome::Unchanged(error.to_string());
2251    }
2252    if let Err(error) = fs::rename(prepared, destination) {
2253        return match fs::rename(&backup, destination) {
2254            Ok(()) => ReplacementOutcome::Unchanged(error.to_string()),
2255            Err(restore_error) => ReplacementOutcome::RecoveryIncomplete {
2256                error: format!(
2257                    "replacement failed: {}; destination restore failed: {}",
2258                    error, restore_error
2259                ),
2260                retained_backup_path: backup.to_string_lossy().into_owned(),
2261            },
2262        };
2263    }
2264
2265    //
2266    // The prepared-to-destination rename is the commit point. Cleanup must never hide a committed replacement.
2267    //
2268    match remove_backup(&backup) {
2269        Ok(()) => ReplacementOutcome::Committed {
2270            cleanup_warning: None,
2271            retained_backup_path: None,
2272        },
2273        Err(error) => ReplacementOutcome::Committed {
2274            cleanup_warning: Some(format!(
2275                "replacement committed but backup cleanup failed: {}",
2276                error
2277            )),
2278            retained_backup_path: Some(backup.to_string_lossy().into_owned()),
2279        },
2280    }
2281}
2282
2283fn unique_sibling_path(path: &Path, label: &str) -> PathBuf {
2284    let parent = path.parent().unwrap_or_else(|| Path::new("."));
2285    let name = path
2286        .file_name()
2287        .map(|name| name.to_string_lossy())
2288        .unwrap_or_else(|| "path".into());
2289    parent.join(format!(".{}.{}.{}", name, label, Uuid::new_v4()))
2290}
2291
2292fn remove_path(path: &Path) -> std::io::Result<()> {
2293    let metadata = fs::symlink_metadata(path)?;
2294    if metadata.is_dir() {
2295        fs::remove_dir_all(path)
2296    } else {
2297        fs::remove_file(path)
2298    }
2299}
2300
2301fn copy_directory(source: &Path, destination: &Path) -> std::io::Result<()> {
2302    fs::create_dir_all(destination)?;
2303    for entry in fs::read_dir(source)? {
2304        let entry = entry?;
2305        let from = entry.path();
2306        let metadata = fs::symlink_metadata(&from)?;
2307        if metadata.file_type().is_symlink() {
2308            return Err(std::io::Error::new(
2309                std::io::ErrorKind::InvalidInput,
2310                format!("symbolic links are not supported: {}", from.display()),
2311            ));
2312        }
2313        let to = destination.join(entry.file_name());
2314        if metadata.is_dir() {
2315            copy_directory(&from, &to)?;
2316        } else {
2317            fs::copy(&from, &to)?;
2318        }
2319    }
2320    Ok(())
2321}
2322
2323fn path_size(path: &Path) -> std::io::Result<usize> {
2324    if path.is_dir() {
2325        let mut total = 0usize;
2326        for entry in fs::read_dir(path)? {
2327            let entry = entry?;
2328            total += path_size(&entry.path())?;
2329        }
2330        Ok(total)
2331    } else {
2332        Ok(fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0))
2333    }
2334}
2335
2336fn path_item_count(path: &Path) -> std::io::Result<usize> {
2337    if path.is_dir() {
2338        let mut total = 1usize;
2339        for entry in fs::read_dir(path)? {
2340            let entry = entry?;
2341            total += path_item_count(&entry.path())?;
2342        }
2343        Ok(total)
2344    } else {
2345        Ok(1)
2346    }
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351    use super::*;
2352    use tempfile::tempdir;
2353
2354    fn mutation_context(tool_id: &str, root: &Path) -> ToolExecutionContext {
2355        let mut context = ToolExecutionContext::test(tool_id);
2356        context.policy_snapshot = serde_json::json!({
2357            "write_paths": [root.to_string_lossy()],
2358            "overwrite_existing": true,
2359            "create_parent_dirs": true,
2360            "allow_without_confirmation": true
2361        });
2362        context
2363    }
2364
2365    fn result_json(result: &ToolResult) -> Value {
2366        serde_json::from_str(&result.output).unwrap()
2367    }
2368
2369    #[tokio::test]
2370    async fn file_edit_dry_run_requires_unique_match() {
2371        let dir = tempdir().unwrap();
2372        let path = dir.path().join("test.txt");
2373        fs::write(&path, "hello\nhello\n").unwrap();
2374        let tool = FileEditTool::new();
2375        let result = tool
2376            .execute(
2377                serde_json::json!({
2378                    "path": path.to_string_lossy(),
2379                    "old_text": "hello",
2380                    "new_text": "hi",
2381                    "dry_run": true
2382                }),
2383                ToolExecutionContext::test("file_edit"),
2384            )
2385            .await;
2386        assert!(!result.success);
2387    }
2388
2389    #[tokio::test]
2390    async fn file_edit_missing_old_text_fails_with_near_matches() {
2391        let dir = tempdir().unwrap();
2392        let path = dir.path().join("test.txt");
2393        fs::write(&path, "Status: draft\n").unwrap();
2394        let tool = FileEditTool::new();
2395        let result = tool
2396            .execute(
2397                serde_json::json!({
2398                    "path": path.to_string_lossy(),
2399                    "old_text": "Status: ready",
2400                    "new_text": "Status: reviewed",
2401                    "dry_run": true
2402                }),
2403                ToolExecutionContext::test("file_edit"),
2404            )
2405            .await;
2406        assert!(!result.success);
2407        let output: Value = serde_json::from_str(&result.output).unwrap();
2408        assert_eq!(output["diff_summary"], "old_text was not found");
2409    }
2410
2411    #[tokio::test]
2412    async fn file_write_denies_actual_without_write_policy() {
2413        let dir = tempdir().unwrap();
2414        let path = dir.path().join("test.txt");
2415        let tool = FileWriteTool::new();
2416        let result = tool
2417            .execute(
2418                serde_json::json!({
2419                    "path": path.to_string_lossy(),
2420                    "content": "hello",
2421                    "dry_run": false
2422                }),
2423                ToolExecutionContext::test("file_write"),
2424            )
2425            .await;
2426        assert!(!result.success);
2427    }
2428
2429    #[tokio::test]
2430    async fn patch_delete_requires_allow_delete() {
2431        let dir = tempdir().unwrap();
2432        let path = dir.path().join("delete.txt");
2433        fs::write(&path, "old\n").unwrap();
2434        let tool = PatchTool::new();
2435        let result = tool
2436            .execute(
2437                serde_json::json!({
2438                    "base_path": dir.path().to_string_lossy(),
2439                    "dry_run": true,
2440                    "patch": "--- a/delete.txt\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n"
2441                }),
2442                ToolExecutionContext::test("patch"),
2443            )
2444            .await;
2445        assert!(!result.success);
2446        assert!(result.output.contains("allow_delete is false"));
2447    }
2448
2449    #[tokio::test]
2450    async fn patch_delete_dry_run_reports_planned_delete() {
2451        let dir = tempdir().unwrap();
2452        let path = dir.path().join("delete.txt");
2453        fs::write(&path, "old\n").unwrap();
2454        let result = PatchTool::new()
2455            .execute(
2456                serde_json::json!({
2457                    "base_path": dir.path().to_string_lossy(),
2458                    "allow_delete": true,
2459                    "dry_run": true,
2460                    "patch": "--- a/delete.txt\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-old\n"
2461                }),
2462                ToolExecutionContext::test("patch"),
2463            )
2464            .await;
2465        assert!(result.success, "{}", result.output);
2466        assert!(path.exists());
2467        let output = result_json(&result);
2468        assert_eq!(output["mutation_performed"], false);
2469        assert_eq!(output["created"], false);
2470        assert_eq!(output["overwritten"], false);
2471    }
2472
2473    #[tokio::test]
2474    async fn new_file_patch_rejects_existing_target() {
2475        let dir = tempdir().unwrap();
2476        let path = dir.path().join("existing.txt");
2477        fs::write(&path, "original\n").unwrap();
2478        let result = PatchTool::new()
2479            .execute(
2480                serde_json::json!({
2481                    "base_path": dir.path().to_string_lossy(),
2482                    "allow_new_files": true,
2483                    "patch": "--- /dev/null\n+++ b/existing.txt\n@@ -0,0 +1,1 @@\n+created\n"
2484                }),
2485                mutation_context("patch", dir.path()),
2486            )
2487            .await;
2488
2489        assert!(!result.success);
2490        assert!(result.output.contains("already exists"));
2491        assert_eq!(fs::read_to_string(path).unwrap(), "original\n");
2492    }
2493
2494    #[test]
2495    fn parses_simple_patch() {
2496        let patch = "--- a/test.txt\n+++ b/test.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n";
2497        let files = parse_unified_diff(patch).unwrap();
2498        assert_eq!(files.len(), 1);
2499        assert_eq!(files[0].changed_lines(), 2);
2500    }
2501
2502    #[tokio::test]
2503    async fn delete_path_dry_run_does_not_remove_file() {
2504        let dir = tempdir().unwrap();
2505        let path = dir.path().join("gone.txt");
2506        fs::write(&path, "bye\n").unwrap();
2507        let tool = DeletePathTool::new();
2508        let result = tool
2509            .execute(
2510                serde_json::json!({"path": path.to_string_lossy(), "dry_run": true}),
2511                ToolExecutionContext::test("delete_path"),
2512            )
2513            .await;
2514        assert!(result.success);
2515        assert!(path.exists(), "dry-run must not remove the file");
2516        let output: Value = serde_json::from_str(&result.output).unwrap();
2517        assert_eq!(output["mutation_performed"], false);
2518        assert_eq!(output["deleted"], false);
2519        assert_eq!(output["dry_run"], true);
2520    }
2521
2522    #[tokio::test]
2523    async fn delete_path_requires_recursive_for_directory() {
2524        let dir = tempdir().unwrap();
2525        let path = dir.path().join("nested");
2526        fs::create_dir_all(&path).unwrap();
2527        let tool = DeletePathTool::new();
2528        let result = tool
2529            .execute(
2530                serde_json::json!({"path": path.to_string_lossy(), "recursive": false, "dry_run": true}),
2531                ToolExecutionContext::test("delete_path"),
2532            )
2533            .await;
2534        assert!(!result.success);
2535        assert!(result.output.contains("recursive must be true"));
2536    }
2537
2538    #[tokio::test]
2539    async fn copy_path_dry_run_does_not_create_destination() {
2540        let dir = tempdir().unwrap();
2541        let source = dir.path().join("source.txt");
2542        let destination = dir.path().join("destination.txt");
2543        fs::write(&source, "hello").unwrap();
2544        let tool = CopyPathTool::new();
2545        let result = tool
2546            .execute(
2547                serde_json::json!({
2548                    "source_path": source.to_string_lossy(),
2549                    "destination_path": destination.to_string_lossy(),
2550                    "dry_run": true
2551                }),
2552                ToolExecutionContext::test("copy_path"),
2553            )
2554            .await;
2555        assert!(result.success);
2556        assert!(
2557            !destination.exists(),
2558            "dry-run must not create the destination"
2559        );
2560        let output: Value = serde_json::from_str(&result.output).unwrap();
2561        assert_eq!(output["mutation_performed"], false);
2562        assert_eq!(output["copied"], false);
2563        assert_eq!(output["dry_run"], true);
2564    }
2565
2566    #[tokio::test]
2567    async fn copy_path_rejects_destination_nested_under_source() {
2568        let dir = tempdir().unwrap();
2569        let source = dir.path().join("source");
2570        fs::create_dir_all(&source).unwrap();
2571        fs::write(source.join("file.txt"), "hello").unwrap();
2572        let destination = source.join("nested");
2573
2574        let result = CopyPathTool::new()
2575            .execute(
2576                serde_json::json!({
2577                    "source_path": source.to_string_lossy(),
2578                    "destination_path": destination.to_string_lossy(),
2579                    "dry_run": true
2580                }),
2581                mutation_context("copy_path", dir.path()),
2582            )
2583            .await;
2584
2585        assert!(!result.success);
2586        assert!(result.output.contains("nested under the source"));
2587        assert!(!destination.exists());
2588    }
2589
2590    #[cfg(unix)]
2591    #[tokio::test]
2592    async fn copy_path_rejects_symlinks_inside_source_tree() {
2593        use std::os::unix::fs::symlink;
2594
2595        let dir = tempdir().unwrap();
2596        let source = dir.path().join("source");
2597        let outside = dir.path().join("outside");
2598        let destination = dir.path().join("destination");
2599        fs::create_dir_all(&source).unwrap();
2600        fs::create_dir_all(&outside).unwrap();
2601        fs::write(outside.join("secret.txt"), "secret").unwrap();
2602        symlink(&outside, source.join("alias")).unwrap();
2603
2604        let result = CopyPathTool::new()
2605            .execute(
2606                serde_json::json!({
2607                    "source_path": source.to_string_lossy(),
2608                    "destination_path": destination.to_string_lossy(),
2609                    "dry_run": true
2610                }),
2611                mutation_context("copy_path", dir.path()),
2612            )
2613            .await;
2614
2615        assert!(!result.success);
2616        assert!(result.output.contains("symbolic links are not supported"));
2617        assert!(!destination.exists());
2618    }
2619
2620    #[test]
2621    fn mutation_schemas_default_to_execution() {
2622        for schema in [
2623            FileWriteTool::new().input_schema(),
2624            FileEditTool::new().input_schema(),
2625            PatchTool::new().input_schema(),
2626            CopyPathTool::new().input_schema(),
2627            MovePathTool::new().input_schema(),
2628            DeletePathTool::new().input_schema(),
2629        ] {
2630            assert_eq!(schema["properties"]["dry_run"]["default"], false);
2631        }
2632    }
2633
2634    #[test]
2635    fn omitted_dry_run_classifies_as_mutation() {
2636        for classification in [
2637            FileWriteTool::new().classify_call(&serde_json::json!({})),
2638            FileEditTool::new().classify_call(&serde_json::json!({})),
2639            PatchTool::new().classify_call(&serde_json::json!({})),
2640            CopyPathTool::new().classify_call(&serde_json::json!({})),
2641            MovePathTool::new().classify_call(&serde_json::json!({})),
2642            DeletePathTool::new().classify_call(&serde_json::json!({})),
2643        ] {
2644            assert!(!classification.read_only);
2645            assert!(!classification.concurrency_safe);
2646            assert!(!classification.safely_retryable);
2647            assert!(classification.requires_approval);
2648        }
2649    }
2650
2651    #[test]
2652    fn actual_delete_classification_remains_destructive_delete() {
2653        let classification =
2654            DeletePathTool::new().classify_call(&serde_json::json!({"dry_run": false}));
2655        assert!(matches!(
2656            classification.operation,
2657            ToolOperationKind::Delete
2658        ));
2659        assert!(matches!(
2660            classification.side_effect_level,
2661            ToolSideEffectLevel::Destructive
2662        ));
2663        assert!(classification.destructive);
2664        assert!(!classification.read_only);
2665    }
2666
2667    #[tokio::test]
2668    async fn file_write_omitted_dry_run_applies_and_records_version() {
2669        let dir = tempdir().unwrap();
2670        let parent = dir.path().join("missing");
2671        let path = parent.join("new.txt");
2672        let versions = FileVersionStore::default();
2673        let tool = FileWriteTool::with_version_store(versions.clone());
2674        let result = tool
2675            .execute(
2676                serde_json::json!({
2677                    "path": path.to_string_lossy(),
2678                    "content": "hello",
2679                    "create_parent_dirs": true
2680                }),
2681                mutation_context("file_write", dir.path()),
2682            )
2683            .await;
2684        assert!(result.success, "{}", result.output);
2685        assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
2686        assert!(versions.get(&path).is_some());
2687        let output = result_json(&result);
2688        assert_eq!(output["mutation_performed"], true);
2689        assert_eq!(output["created"], true);
2690        assert_eq!(output["overwritten"], false);
2691        assert_eq!(output["bytes_written"], 5);
2692    }
2693
2694    #[tokio::test]
2695    async fn file_edit_omitted_dry_run_applies_and_records_version() {
2696        let dir = tempdir().unwrap();
2697        let path = dir.path().join("edit.txt");
2698        fs::write(&path, "before\n").unwrap();
2699        let versions = FileVersionStore::default();
2700        let tool = FileEditTool::with_version_store(versions.clone());
2701        let result = tool
2702            .execute(
2703                serde_json::json!({
2704                    "path": path.to_string_lossy(),
2705                    "old_text": "before",
2706                    "new_text": "after"
2707                }),
2708                mutation_context("file_edit", dir.path()),
2709            )
2710            .await;
2711        assert!(result.success, "{}", result.output);
2712        assert_eq!(fs::read_to_string(&path).unwrap(), "after\n");
2713        assert!(versions.get(&path).is_some());
2714        let output = result_json(&result);
2715        assert_eq!(output["mutation_performed"], true);
2716        assert_eq!(output["overwritten"], true);
2717    }
2718
2719    #[tokio::test]
2720    async fn omitted_dry_run_copies_moves_and_deletes() {
2721        let dir = tempdir().unwrap();
2722        let source = dir.path().join("source.txt");
2723        let copy_parent = dir.path().join("copy-parent");
2724        let copy_destination = copy_parent.join("copy.txt");
2725        let move_parent = dir.path().join("move-parent");
2726        let move_destination = move_parent.join("move.txt");
2727        fs::write(&source, "content").unwrap();
2728
2729        let copy_result = CopyPathTool::new()
2730            .execute(
2731                serde_json::json!({
2732                    "source_path": source.to_string_lossy(),
2733                    "destination_path": copy_destination.to_string_lossy(),
2734                    "create_parent_dirs": true
2735                }),
2736                mutation_context("copy_path", dir.path()),
2737            )
2738            .await;
2739        assert!(copy_result.success, "{}", copy_result.output);
2740        assert_eq!(fs::read_to_string(&copy_destination).unwrap(), "content");
2741        let copy_output = result_json(&copy_result);
2742        assert_eq!(copy_output["mutation_performed"], true);
2743        assert_eq!(copy_output["copied"], true);
2744
2745        let move_result = MovePathTool::new()
2746            .execute(
2747                serde_json::json!({
2748                    "source_path": source.to_string_lossy(),
2749                    "destination_path": move_destination.to_string_lossy(),
2750                    "create_parent_dirs": true
2751                }),
2752                mutation_context("move_path", dir.path()),
2753            )
2754            .await;
2755        assert!(move_result.success, "{}", move_result.output);
2756        assert!(!source.exists());
2757        assert_eq!(fs::read_to_string(&move_destination).unwrap(), "content");
2758        let move_output = result_json(&move_result);
2759        assert_eq!(move_output["mutation_performed"], true);
2760        assert_eq!(move_output["moved"], true);
2761
2762        let delete_result = DeletePathTool::new()
2763            .execute(
2764                serde_json::json!({"path": move_destination.to_string_lossy()}),
2765                mutation_context("delete_path", dir.path()),
2766            )
2767            .await;
2768        assert!(delete_result.success, "{}", delete_result.output);
2769        assert!(!move_destination.exists());
2770        let delete_output = result_json(&delete_result);
2771        assert_eq!(delete_output["mutation_performed"], true);
2772        assert_eq!(delete_output["deleted"], true);
2773    }
2774
2775    #[tokio::test]
2776    async fn move_path_rejects_same_source_and_destination() {
2777        let dir = tempdir().unwrap();
2778        let path = dir.path().join("same.txt");
2779        fs::write(&path, "content").unwrap();
2780        let result = MovePathTool::new()
2781            .execute(
2782                serde_json::json!({
2783                    "source_path": path.to_string_lossy(),
2784                    "destination_path": path.to_string_lossy(),
2785                    "overwrite": true
2786                }),
2787                mutation_context("move_path", dir.path()),
2788            )
2789            .await;
2790
2791        assert!(!result.success);
2792        assert!(result.output.contains("same path"));
2793        assert_eq!(fs::read_to_string(path).unwrap(), "content");
2794    }
2795
2796    #[tokio::test]
2797    async fn delete_path_explicit_dry_run_false_reports_applied_mutation() {
2798        let dir = tempdir().unwrap();
2799        let path = dir.path().join("delete.txt");
2800        fs::write(&path, "content").unwrap();
2801        let result = DeletePathTool::new()
2802            .execute(
2803                serde_json::json!({
2804                    "path": path.to_string_lossy(),
2805                    "dry_run": false
2806                }),
2807                mutation_context("delete_path", dir.path()),
2808            )
2809            .await;
2810        assert!(result.success, "{}", result.output);
2811        assert!(!path.exists());
2812        let output = result_json(&result);
2813        assert_eq!(output["mutation_performed"], true);
2814        assert_eq!(output["deleted"], true);
2815    }
2816
2817    #[tokio::test]
2818    async fn patch_omitted_dry_run_applies_and_records_version() {
2819        let dir = tempdir().unwrap();
2820        let path = dir.path().join("patch.txt");
2821        fs::write(&path, "old\n").unwrap();
2822        let versions = FileVersionStore::default();
2823        let result = PatchTool::with_version_store(versions.clone())
2824            .execute(
2825                serde_json::json!({
2826                    "base_path": dir.path().to_string_lossy(),
2827                    "patch": "--- a/patch.txt\n+++ b/patch.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n"
2828                }),
2829                mutation_context("patch", dir.path()),
2830            )
2831            .await;
2832        assert!(result.success, "{}", result.output);
2833        assert_eq!(fs::read_to_string(&path).unwrap(), "new\n");
2834        assert!(versions.get(&path).is_some());
2835        let output = result_json(&result);
2836        assert_eq!(output["mutation_performed"], true);
2837        assert_eq!(output["created"], false);
2838        assert_eq!(output["overwritten"], true);
2839        assert_eq!(output["bytes_written"], 4);
2840    }
2841
2842    #[test]
2843    fn patch_apply_failure_rolls_back_prior_writes() {
2844        let dir = tempdir().unwrap();
2845        let first = dir.path().join("first.txt");
2846        let invalid_parent = dir.path().join("not-a-directory");
2847        fs::write(&first, "old\n").unwrap();
2848        #[cfg(unix)]
2849        {
2850            use std::os::unix::fs::PermissionsExt;
2851            fs::set_permissions(&first, fs::Permissions::from_mode(0o600)).unwrap();
2852        }
2853        fs::write(&invalid_parent, "blocker\n").unwrap();
2854        let outputs = vec![
2855            PatchApply::Write {
2856                path: first.clone(),
2857                content: "new\n".to_string(),
2858                expected: ExpectedPathState::Present(read_file_snapshot(&first).unwrap()),
2859            },
2860            PatchApply::Write {
2861                path: invalid_parent.join("child.txt"),
2862                content: "child\n".to_string(),
2863                expected: ExpectedPathState::Absent,
2864            },
2865        ];
2866        let error = apply_patch_transaction(&outputs).unwrap_err();
2867        assert!(error.contains("no patch changes were retained"));
2868        assert_eq!(fs::read_to_string(&first).unwrap(), "old\n");
2869        #[cfg(unix)]
2870        {
2871            use std::os::unix::fs::PermissionsExt;
2872            assert_eq!(
2873                fs::metadata(&first).unwrap().permissions().mode() & 0o777,
2874                0o600
2875            );
2876        }
2877        assert!(!invalid_parent.join("child.txt").exists());
2878    }
2879
2880    #[test]
2881    fn patch_rejects_pre_apply_content_and_absence_conflicts() {
2882        let dir = tempdir().unwrap();
2883        let existing = dir.path().join("existing.txt");
2884        let created = dir.path().join("created.txt");
2885        fs::write(&existing, "original\n").unwrap();
2886        let existing_output = PatchApply::Write {
2887            path: existing.clone(),
2888            content: "patched\n".to_string(),
2889            expected: ExpectedPathState::Present(read_file_snapshot(&existing).unwrap()),
2890        };
2891        fs::write(&existing, "external\n").unwrap();
2892        let error = apply_patch_transaction(&[existing_output]).unwrap_err();
2893        assert!(error.contains("pre-apply conflict"));
2894        assert_eq!(fs::read_to_string(&existing).unwrap(), "external\n");
2895
2896        let created_output = PatchApply::Write {
2897            path: created.clone(),
2898            content: "patched\n".to_string(),
2899            expected: ExpectedPathState::Absent,
2900        };
2901        fs::write(&created, "external\n").unwrap();
2902        let error = apply_patch_transaction(&[created_output]).unwrap_err();
2903        assert!(error.contains("pre-apply conflict"));
2904        assert_eq!(fs::read_to_string(&created).unwrap(), "external\n");
2905    }
2906
2907    #[test]
2908    fn patch_rollback_conflict_preserves_external_content() {
2909        let dir = tempdir().unwrap();
2910        let path = dir.path().join("target.txt");
2911        fs::write(&path, "original\n").unwrap();
2912        let original = read_file_snapshot(&path).unwrap();
2913        fs::write(&path, "external\n").unwrap();
2914        let rollback = PatchRollback::RestoreWrite {
2915            path: path.clone(),
2916            original: original.clone(),
2917            written: b"transaction\n".to_vec(),
2918            written_permissions: original.permissions,
2919        };
2920        let error = rollback_patch(&[rollback], &[]).unwrap_err();
2921        assert!(error.contains("rollback conflict"));
2922        assert_eq!(fs::read_to_string(&path).unwrap(), "external\n");
2923    }
2924
2925    #[cfg(unix)]
2926    #[test]
2927    fn patch_rollback_conflict_preserves_external_permissions() {
2928        use std::os::unix::fs::PermissionsExt;
2929
2930        let dir = tempdir().unwrap();
2931        let path = dir.path().join("target.txt");
2932        fs::write(&path, "original\n").unwrap();
2933        fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
2934        let original = read_file_snapshot(&path).unwrap();
2935        fs::write(&path, "transaction\n").unwrap();
2936        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
2937        let rollback = PatchRollback::RestoreWrite {
2938            path: path.clone(),
2939            original: original.clone(),
2940            written: b"transaction\n".to_vec(),
2941            written_permissions: original.permissions,
2942        };
2943
2944        let error = rollback_patch(&[rollback], &[]).unwrap_err();
2945
2946        assert!(error.contains("permissions changed"));
2947        assert_eq!(fs::read_to_string(&path).unwrap(), "transaction\n");
2948        assert_eq!(
2949            fs::metadata(path).unwrap().permissions().mode() & 0o777,
2950            0o640
2951        );
2952    }
2953
2954    #[tokio::test]
2955    async fn patch_preflights_every_file_before_mutating_any_target() {
2956        let dir = tempdir().unwrap();
2957        let first = dir.path().join("first.txt");
2958        let second = dir.path().join("second.txt");
2959        fs::write(&first, "first old\n").unwrap();
2960        fs::write(&second, "second old\n").unwrap();
2961        let patch = concat!(
2962            "--- a/first.txt\n+++ b/first.txt\n@@ -1,1 +1,1 @@\n-first old\n+first new\n",
2963            "--- a/second.txt\n+++ b/second.txt\n@@ -1,1 +1,1 @@\n-not present\n+second new\n"
2964        );
2965        let result = PatchTool::new()
2966            .execute(
2967                serde_json::json!({
2968                    "base_path": dir.path().to_string_lossy(),
2969                    "patch": patch,
2970                    "dry_run": false
2971                }),
2972                mutation_context("patch", dir.path()),
2973            )
2974            .await;
2975        assert!(!result.success);
2976        assert_eq!(fs::read_to_string(&first).unwrap(), "first old\n");
2977        assert_eq!(fs::read_to_string(&second).unwrap(), "second old\n");
2978    }
2979
2980    #[tokio::test]
2981    async fn copy_path_rejects_missing_source_without_creating_destination() {
2982        let dir = tempdir().unwrap();
2983        let source = dir.path().join("missing.txt");
2984        let destination = dir.path().join("destination.txt");
2985        let result = CopyPathTool::new()
2986            .execute(
2987                serde_json::json!({
2988                    "source_path": source.to_string_lossy(),
2989                    "destination_path": destination.to_string_lossy(),
2990                    "dry_run": false
2991                }),
2992                mutation_context("copy_path", dir.path()),
2993            )
2994            .await;
2995
2996        assert!(!result.success);
2997        assert!(result.output.contains("source path does not exist"));
2998        assert!(!destination.exists());
2999    }
3000
3001    #[tokio::test]
3002    async fn move_path_rejects_missing_source_without_creating_destination() {
3003        let dir = tempdir().unwrap();
3004        let source = dir.path().join("missing.txt");
3005        let destination = dir.path().join("destination.txt");
3006        let result = MovePathTool::new()
3007            .execute(
3008                serde_json::json!({
3009                    "source_path": source.to_string_lossy(),
3010                    "destination_path": destination.to_string_lossy(),
3011                    "dry_run": false
3012                }),
3013                mutation_context("move_path", dir.path()),
3014            )
3015            .await;
3016
3017        assert!(!result.success);
3018        assert!(result.output.contains("source path does not exist"));
3019        assert!(!destination.exists());
3020    }
3021
3022    #[tokio::test]
3023    async fn delete_path_rejects_missing_path() {
3024        let dir = tempdir().unwrap();
3025        let path = dir.path().join("missing.txt");
3026        let result = DeletePathTool::new()
3027            .execute(
3028                serde_json::json!({
3029                    "path": path.to_string_lossy(),
3030                    "dry_run": false
3031                }),
3032                mutation_context("delete_path", dir.path()),
3033            )
3034            .await;
3035
3036        assert!(!result.success);
3037        assert!(result.output.contains("path does not exist"));
3038    }
3039
3040    #[tokio::test]
3041    async fn copy_path_collision_requires_overwrite() {
3042        let dir = tempdir().unwrap();
3043        let source = dir.path().join("source.txt");
3044        let destination = dir.path().join("destination.txt");
3045        fs::write(&source, "source").unwrap();
3046        fs::write(&destination, "destination").unwrap();
3047        let result = CopyPathTool::new()
3048            .execute(
3049                serde_json::json!({
3050                    "source_path": source.to_string_lossy(),
3051                    "destination_path": destination.to_string_lossy(),
3052                    "dry_run": false
3053                }),
3054                mutation_context("copy_path", dir.path()),
3055            )
3056            .await;
3057
3058        assert!(!result.success);
3059        assert!(result.output.contains("overwrite must be true"));
3060        assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3061        assert_eq!(fs::read_to_string(&destination).unwrap(), "destination");
3062    }
3063
3064    #[tokio::test]
3065    async fn move_path_collision_requires_overwrite() {
3066        let dir = tempdir().unwrap();
3067        let source = dir.path().join("source.txt");
3068        let destination = dir.path().join("destination.txt");
3069        fs::write(&source, "source").unwrap();
3070        fs::write(&destination, "destination").unwrap();
3071        let result = MovePathTool::new()
3072            .execute(
3073                serde_json::json!({
3074                    "source_path": source.to_string_lossy(),
3075                    "destination_path": destination.to_string_lossy(),
3076                    "dry_run": false
3077                }),
3078                mutation_context("move_path", dir.path()),
3079            )
3080            .await;
3081
3082        assert!(!result.success);
3083        assert!(result.output.contains("overwrite must be true"));
3084        assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3085        assert_eq!(fs::read_to_string(&destination).unwrap(), "destination");
3086    }
3087
3088    #[tokio::test]
3089    async fn copy_path_overwrite_replaces_existing_file_and_reports_effect() {
3090        let dir = tempdir().unwrap();
3091        let source = dir.path().join("source.txt");
3092        let destination = dir.path().join("destination.txt");
3093        fs::write(&source, "source").unwrap();
3094        fs::write(&destination, "old").unwrap();
3095        let result = CopyPathTool::new()
3096            .execute(
3097                serde_json::json!({
3098                    "source_path": source.to_string_lossy(),
3099                    "destination_path": destination.to_string_lossy(),
3100                    "overwrite": true,
3101                    "dry_run": false
3102                }),
3103                mutation_context("copy_path", dir.path()),
3104            )
3105            .await;
3106
3107        assert!(result.success, "{}", result.output);
3108        assert_eq!(fs::read_to_string(&source).unwrap(), "source");
3109        assert_eq!(fs::read_to_string(&destination).unwrap(), "source");
3110        let output = result_json(&result);
3111        assert_eq!(output["mutation_performed"], true);
3112        assert_eq!(output["copied"], true);
3113        assert_eq!(output["overwritten"], true);
3114        assert_eq!(output["bytes_affected"], 6);
3115    }
3116
3117    #[tokio::test]
3118    async fn copy_path_overwrite_replaces_existing_directory_tree() {
3119        let dir = tempdir().unwrap();
3120        let source = dir.path().join("source");
3121        let destination = dir.path().join("destination");
3122        fs::create_dir_all(source.join("nested")).unwrap();
3123        fs::create_dir_all(&destination).unwrap();
3124        fs::write(source.join("nested/new.txt"), "new").unwrap();
3125        fs::write(destination.join("stale.txt"), "stale").unwrap();
3126        let result = CopyPathTool::new()
3127            .execute(
3128                serde_json::json!({
3129                    "source_path": source.to_string_lossy(),
3130                    "destination_path": destination.to_string_lossy(),
3131                    "overwrite": true,
3132                    "dry_run": false
3133                }),
3134                mutation_context("copy_path", dir.path()),
3135            )
3136            .await;
3137
3138        assert!(result.success, "{}", result.output);
3139        assert_eq!(
3140            fs::read_to_string(destination.join("nested/new.txt")).unwrap(),
3141            "new"
3142        );
3143        assert!(!destination.join("stale.txt").exists());
3144        let output = result_json(&result);
3145        assert_eq!(output["mutation_performed"], true);
3146        assert_eq!(output["copied"], true);
3147        assert_eq!(output["overwritten"], true);
3148        assert_eq!(output["recursive"], true);
3149        assert_eq!(output["bytes_affected"], 3);
3150    }
3151
3152    #[tokio::test]
3153    async fn move_path_overwrite_replaces_existing_file_and_reports_effect() {
3154        let dir = tempdir().unwrap();
3155        let source = dir.path().join("source.txt");
3156        let destination = dir.path().join("destination.txt");
3157        fs::write(&source, "source").unwrap();
3158        fs::write(&destination, "old").unwrap();
3159        let result = MovePathTool::new()
3160            .execute(
3161                serde_json::json!({
3162                    "source_path": source.to_string_lossy(),
3163                    "destination_path": destination.to_string_lossy(),
3164                    "overwrite": true,
3165                    "dry_run": false
3166                }),
3167                mutation_context("move_path", dir.path()),
3168            )
3169            .await;
3170
3171        assert!(result.success, "{}", result.output);
3172        assert!(!source.exists());
3173        assert_eq!(fs::read_to_string(&destination).unwrap(), "source");
3174        let output = result_json(&result);
3175        assert_eq!(output["mutation_performed"], true);
3176        assert_eq!(output["moved"], true);
3177        assert_eq!(output["overwritten"], true);
3178        assert_eq!(output["bytes_affected"], 6);
3179    }
3180
3181    #[tokio::test]
3182    async fn move_path_overwrite_replaces_existing_directory_tree() {
3183        let dir = tempdir().unwrap();
3184        let source = dir.path().join("source");
3185        let destination = dir.path().join("destination");
3186        fs::create_dir_all(source.join("nested")).unwrap();
3187        fs::create_dir_all(&destination).unwrap();
3188        fs::write(source.join("nested/new.txt"), "new").unwrap();
3189        fs::write(destination.join("stale.txt"), "stale").unwrap();
3190        let result = MovePathTool::new()
3191            .execute(
3192                serde_json::json!({
3193                    "source_path": source.to_string_lossy(),
3194                    "destination_path": destination.to_string_lossy(),
3195                    "overwrite": true,
3196                    "dry_run": false
3197                }),
3198                mutation_context("move_path", dir.path()),
3199            )
3200            .await;
3201
3202        assert!(result.success, "{}", result.output);
3203        assert!(!source.exists());
3204        assert_eq!(
3205            fs::read_to_string(destination.join("nested/new.txt")).unwrap(),
3206            "new"
3207        );
3208        assert!(!destination.join("stale.txt").exists());
3209        let output = result_json(&result);
3210        assert_eq!(output["mutation_performed"], true);
3211        assert_eq!(output["moved"], true);
3212        assert_eq!(output["overwritten"], true);
3213        assert_eq!(output["recursive"], true);
3214        assert_eq!(output["bytes_affected"], 3);
3215    }
3216
3217    #[tokio::test]
3218    async fn copy_path_parent_creation_requires_request_opt_in() {
3219        let dir = tempdir().unwrap();
3220        let source = dir.path().join("source.txt");
3221        let destination = dir.path().join("missing/destination.txt");
3222        fs::write(&source, "source").unwrap();
3223        let result = CopyPathTool::new()
3224            .execute(
3225                serde_json::json!({
3226                    "source_path": source.to_string_lossy(),
3227                    "destination_path": destination.to_string_lossy(),
3228                    "create_parent_dirs": false,
3229                    "dry_run": false
3230                }),
3231                mutation_context("copy_path", dir.path()),
3232            )
3233            .await;
3234
3235        assert!(!result.success);
3236        assert!(result.output.contains("parent directory does not exist"));
3237        assert!(!destination.exists());
3238        assert!(!dir.path().join("missing").exists());
3239    }
3240
3241    #[tokio::test]
3242    async fn move_path_parent_creation_requires_request_opt_in() {
3243        let dir = tempdir().unwrap();
3244        let source = dir.path().join("source.txt");
3245        let destination = dir.path().join("missing/destination.txt");
3246        fs::write(&source, "source").unwrap();
3247        let result = MovePathTool::new()
3248            .execute(
3249                serde_json::json!({
3250                    "source_path": source.to_string_lossy(),
3251                    "destination_path": destination.to_string_lossy(),
3252                    "create_parent_dirs": false,
3253                    "dry_run": false
3254                }),
3255                mutation_context("move_path", dir.path()),
3256            )
3257            .await;
3258
3259        assert!(!result.success);
3260        assert!(result.output.contains("parent directory does not exist"));
3261        assert!(source.exists());
3262        assert!(!destination.exists());
3263        assert!(!dir.path().join("missing").exists());
3264    }
3265
3266    #[tokio::test]
3267    async fn copy_and_move_parent_creation_requires_policy_opt_in() {
3268        let dir = tempdir().unwrap();
3269        let root = dir.path().join("root");
3270        fs::create_dir_all(&root).unwrap();
3271        let copy_source = root.join("copy-source.txt");
3272        let move_source = root.join("move-source.txt");
3273        let copy_destination = root.join("copy-parent/destination.txt");
3274        let move_destination = root.join("move-parent/destination.txt");
3275        fs::write(&copy_source, "copy").unwrap();
3276        fs::write(&move_source, "move").unwrap();
3277        let mut copy_context = mutation_context("copy_path", &root);
3278        copy_context.policy_snapshot["create_parent_dirs"] = Value::Bool(false);
3279        let mut move_context = mutation_context("move_path", &root);
3280        move_context.policy_snapshot["create_parent_dirs"] = Value::Bool(false);
3281
3282        let copy_result = CopyPathTool::new()
3283            .execute(
3284                serde_json::json!({
3285                    "source_path": copy_source.to_string_lossy(),
3286                    "destination_path": copy_destination.to_string_lossy(),
3287                    "create_parent_dirs": true,
3288                    "dry_run": false
3289                }),
3290                copy_context,
3291            )
3292            .await;
3293        assert!(!copy_result.success);
3294        assert!(!copy_destination.exists());
3295        assert!(!root.join("copy-parent").exists());
3296
3297        let move_result = MovePathTool::new()
3298            .execute(
3299                serde_json::json!({
3300                    "source_path": move_source.to_string_lossy(),
3301                    "destination_path": move_destination.to_string_lossy(),
3302                    "create_parent_dirs": true,
3303                    "dry_run": false
3304                }),
3305                move_context,
3306            )
3307            .await;
3308        assert!(!move_result.success);
3309        assert!(move_source.exists());
3310        assert!(!move_destination.exists());
3311        assert!(!root.join("move-parent").exists());
3312    }
3313
3314    #[tokio::test]
3315    async fn copy_and_move_overwrite_require_policy_opt_in() {
3316        let dir = tempdir().unwrap();
3317        let copy_source = dir.path().join("copy-source.txt");
3318        let copy_destination = dir.path().join("copy-destination.txt");
3319        let move_source = dir.path().join("move-source.txt");
3320        let move_destination = dir.path().join("move-destination.txt");
3321        fs::write(&copy_source, "new copy").unwrap();
3322        fs::write(&copy_destination, "old copy").unwrap();
3323        fs::write(&move_source, "new move").unwrap();
3324        fs::write(&move_destination, "old move").unwrap();
3325        let mut copy_context = mutation_context("copy_path", dir.path());
3326        copy_context.policy_snapshot["overwrite_existing"] = Value::Bool(false);
3327        let mut move_context = mutation_context("move_path", dir.path());
3328        move_context.policy_snapshot["overwrite_existing"] = Value::Bool(false);
3329
3330        let copy_result = CopyPathTool::new()
3331            .execute(
3332                serde_json::json!({
3333                    "source_path": copy_source.to_string_lossy(),
3334                    "destination_path": copy_destination.to_string_lossy(),
3335                    "overwrite": true,
3336                    "dry_run": false
3337                }),
3338                copy_context,
3339            )
3340            .await;
3341        assert!(!copy_result.success);
3342        assert!(
3343            copy_result
3344                .output
3345                .contains("overwrite_existing policy is false")
3346        );
3347        assert_eq!(fs::read_to_string(&copy_destination).unwrap(), "old copy");
3348
3349        let move_result = MovePathTool::new()
3350            .execute(
3351                serde_json::json!({
3352                    "source_path": move_source.to_string_lossy(),
3353                    "destination_path": move_destination.to_string_lossy(),
3354                    "overwrite": true,
3355                    "dry_run": false
3356                }),
3357                move_context,
3358            )
3359            .await;
3360        assert!(!move_result.success);
3361        assert!(
3362            move_result
3363                .output
3364                .contains("overwrite_existing policy is false")
3365        );
3366        assert!(move_source.exists());
3367        assert_eq!(fs::read_to_string(&move_destination).unwrap(), "old move");
3368    }
3369
3370    #[tokio::test]
3371    async fn delete_path_recursively_removes_directory_and_reports_effect() {
3372        let dir = tempdir().unwrap();
3373        let path = dir.path().join("tree");
3374        fs::create_dir_all(path.join("nested")).unwrap();
3375        fs::write(path.join("one.txt"), "one").unwrap();
3376        fs::write(path.join("nested/two.txt"), "two").unwrap();
3377        let result = DeletePathTool::new()
3378            .execute(
3379                serde_json::json!({
3380                    "path": path.to_string_lossy(),
3381                    "recursive": true,
3382                    "dry_run": false
3383                }),
3384                mutation_context("delete_path", dir.path()),
3385            )
3386            .await;
3387
3388        assert!(result.success, "{}", result.output);
3389        assert!(!path.exists());
3390        let output = result_json(&result);
3391        assert_eq!(output["dry_run"], false);
3392        assert_eq!(output["mutation_performed"], true);
3393        assert_eq!(output["deleted"], true);
3394        assert_eq!(output["recursive"], true);
3395        assert_eq!(output["bytes_affected"], 6);
3396        assert_eq!(output["approval_required"], false);
3397    }
3398
3399    #[tokio::test]
3400    async fn file_write_allows_creation_beneath_missing_absolute_root() {
3401        let dir = tempdir().unwrap();
3402        let root = dir.path().join("missing-root");
3403        let path = root.join("nested/file.txt");
3404        let result = FileWriteTool::new()
3405            .execute(
3406                serde_json::json!({
3407                    "path": path.to_string_lossy(),
3408                    "content": "created",
3409                    "create_parent_dirs": true,
3410                    "dry_run": false
3411                }),
3412                mutation_context("file_write", &root),
3413            )
3414            .await;
3415
3416        assert!(result.success, "{}", result.output);
3417        assert_eq!(fs::read_to_string(path).unwrap(), "created");
3418    }
3419
3420    #[cfg(unix)]
3421    #[tokio::test]
3422    async fn file_write_rejects_dangling_allowed_root() {
3423        use std::os::unix::fs::symlink;
3424
3425        let dir = tempdir().unwrap();
3426        let missing_target = dir.path().join("missing-target");
3427        let root = dir.path().join("dangling-root");
3428        symlink(&missing_target, &root).unwrap();
3429        let path = root.join("file.txt");
3430        let result = FileWriteTool::new()
3431            .execute(
3432                serde_json::json!({
3433                    "path": path.to_string_lossy(),
3434                    "content": "blocked",
3435                    "create_parent_dirs": true,
3436                    "dry_run": false
3437                }),
3438                mutation_context("file_write", &root),
3439            )
3440            .await;
3441
3442        assert!(!result.success);
3443        assert!(result.output.contains("Path policy resolution failed"));
3444        assert!(!missing_target.exists());
3445    }
3446
3447    #[cfg(unix)]
3448    #[tokio::test]
3449    async fn copy_path_rejects_blocked_source_through_symlink_alias() {
3450        use std::os::unix::fs::symlink;
3451
3452        let dir = tempdir().unwrap();
3453        let root = dir.path().join("root");
3454        let blocked = dir.path().join("blocked");
3455        fs::create_dir(&root).unwrap();
3456        fs::create_dir(&blocked).unwrap();
3457        fs::write(blocked.join("source.txt"), "source").unwrap();
3458        symlink(&blocked, root.join("alias")).unwrap();
3459        let destination = root.join("destination.txt");
3460        let mut context = mutation_context("copy_path", &root);
3461        context.policy_snapshot["blocked_paths"] = serde_json::json!([blocked.to_string_lossy()]);
3462
3463        let result = CopyPathTool::new()
3464            .execute(
3465                serde_json::json!({
3466                    "source_path": root.join("alias/source.txt").to_string_lossy(),
3467                    "destination_path": destination.to_string_lossy(),
3468                    "dry_run": false
3469                }),
3470                context,
3471            )
3472            .await;
3473
3474        assert!(!result.success);
3475        assert!(result.output.contains("source path is blocked"));
3476        assert!(!destination.exists());
3477    }
3478
3479    #[tokio::test]
3480    async fn mutation_tools_reject_paths_outside_write_root() {
3481        let dir = tempdir().unwrap();
3482        let root = dir.path().join("root");
3483        let outside = dir.path().join("outside");
3484        fs::create_dir_all(&root).unwrap();
3485        fs::create_dir_all(&outside).unwrap();
3486        let copy_source = root.join("copy-source.txt");
3487        let move_source = root.join("move-source.txt");
3488        let delete_target = outside.join("delete-target.txt");
3489        fs::write(&copy_source, "copy").unwrap();
3490        fs::write(&move_source, "move").unwrap();
3491        fs::write(&delete_target, "delete").unwrap();
3492
3493        let copy_result = CopyPathTool::new()
3494            .execute(
3495                serde_json::json!({
3496                    "source_path": copy_source.to_string_lossy(),
3497                    "destination_path": outside.join("copied.txt").to_string_lossy(),
3498                    "dry_run": false
3499                }),
3500                mutation_context("copy_path", &root),
3501            )
3502            .await;
3503        assert!(!copy_result.success);
3504        assert!(copy_result.output.contains("allowed write root"));
3505        assert!(!outside.join("copied.txt").exists());
3506
3507        let move_result = MovePathTool::new()
3508            .execute(
3509                serde_json::json!({
3510                    "source_path": move_source.to_string_lossy(),
3511                    "destination_path": outside.join("moved.txt").to_string_lossy(),
3512                    "dry_run": false
3513                }),
3514                mutation_context("move_path", &root),
3515            )
3516            .await;
3517        assert!(!move_result.success);
3518        assert!(move_result.output.contains("allowed write root"));
3519        assert!(move_source.exists());
3520        assert!(!outside.join("moved.txt").exists());
3521
3522        let delete_result = DeletePathTool::new()
3523            .execute(
3524                serde_json::json!({
3525                    "path": delete_target.to_string_lossy(),
3526                    "dry_run": false
3527                }),
3528                mutation_context("delete_path", &root),
3529            )
3530            .await;
3531        assert!(!delete_result.success);
3532        assert!(delete_result.output.contains("allowed write root"));
3533        assert_eq!(fs::read_to_string(delete_target).unwrap(), "delete");
3534    }
3535
3536    #[cfg(unix)]
3537    #[tokio::test]
3538    async fn mutation_tools_reject_symlink_write_escape() {
3539        use std::os::unix::fs::symlink;
3540
3541        let dir = tempdir().unwrap();
3542        let root = dir.path().join("root");
3543        let outside = dir.path().join("outside");
3544        fs::create_dir_all(&root).unwrap();
3545        fs::create_dir_all(&outside).unwrap();
3546        symlink(&outside, root.join("escape")).unwrap();
3547        let copy_source = root.join("copy-source.txt");
3548        let move_source = root.join("move-source.txt");
3549        let delete_target = outside.join("delete-target.txt");
3550        fs::write(&copy_source, "copy").unwrap();
3551        fs::write(&move_source, "move").unwrap();
3552        fs::write(&delete_target, "delete").unwrap();
3553
3554        let copy_result = CopyPathTool::new()
3555            .execute(
3556                serde_json::json!({
3557                    "source_path": copy_source.to_string_lossy(),
3558                    "destination_path": root.join("escape/copied.txt").to_string_lossy(),
3559                    "dry_run": false
3560                }),
3561                mutation_context("copy_path", &root),
3562            )
3563            .await;
3564        assert!(!copy_result.success);
3565        assert!(!outside.join("copied.txt").exists());
3566
3567        let move_result = MovePathTool::new()
3568            .execute(
3569                serde_json::json!({
3570                    "source_path": move_source.to_string_lossy(),
3571                    "destination_path": root.join("escape/moved.txt").to_string_lossy(),
3572                    "dry_run": false
3573                }),
3574                mutation_context("move_path", &root),
3575            )
3576            .await;
3577        assert!(!move_result.success);
3578        assert!(move_source.exists());
3579        assert!(!outside.join("moved.txt").exists());
3580
3581        let delete_result = DeletePathTool::new()
3582            .execute(
3583                serde_json::json!({
3584                    "path": root.join("escape/delete-target.txt").to_string_lossy(),
3585                    "dry_run": false
3586                }),
3587                mutation_context("delete_path", &root),
3588            )
3589            .await;
3590        assert!(!delete_result.success);
3591        assert_eq!(fs::read_to_string(delete_target).unwrap(), "delete");
3592    }
3593
3594    #[cfg(unix)]
3595    #[tokio::test]
3596    async fn copy_path_rejects_broken_destination_symlink_escape() {
3597        use std::os::unix::fs::symlink;
3598
3599        let dir = tempdir().unwrap();
3600        let root = dir.path().join("root");
3601        let outside = dir.path().join("outside");
3602        fs::create_dir_all(&root).unwrap();
3603        fs::create_dir_all(&outside).unwrap();
3604        let source = root.join("source.txt");
3605        let escaped = outside.join("escaped.txt");
3606        let destination = root.join("destination.txt");
3607        fs::write(&source, "source").unwrap();
3608        symlink(&escaped, &destination).unwrap();
3609
3610        let result = CopyPathTool::new()
3611            .execute(
3612                serde_json::json!({
3613                    "source_path": source.to_string_lossy(),
3614                    "destination_path": destination.to_string_lossy(),
3615                    "overwrite": true,
3616                    "dry_run": false
3617                }),
3618                mutation_context("copy_path", &root),
3619            )
3620            .await;
3621
3622        assert!(!result.success);
3623        assert!(!escaped.exists());
3624        assert!(
3625            fs::symlink_metadata(destination)
3626                .unwrap()
3627                .file_type()
3628                .is_symlink()
3629        );
3630    }
3631
3632    #[tokio::test]
3633    async fn delete_path_rejects_configured_write_root() {
3634        let dir = tempdir().unwrap();
3635        let root = dir.path().join("root");
3636        fs::create_dir_all(&root).unwrap();
3637        fs::write(root.join("keep.txt"), "keep").unwrap();
3638        let result = DeletePathTool::new()
3639            .execute(
3640                serde_json::json!({
3641                    "path": root.to_string_lossy(),
3642                    "recursive": true,
3643                    "dry_run": false
3644                }),
3645                mutation_context("delete_path", &root),
3646            )
3647            .await;
3648
3649        assert!(!result.success);
3650        assert!(result.output.contains("write root"));
3651        assert_eq!(fs::read_to_string(root.join("keep.txt")).unwrap(), "keep");
3652    }
3653
3654    #[test]
3655    fn replacement_leaves_destination_unchanged_when_backup_rename_fails() {
3656        let dir = tempdir().unwrap();
3657        let prepared = dir.path().join("prepared.txt");
3658        let destination = dir.path().join("missing.txt");
3659        fs::write(&prepared, "new").unwrap();
3660
3661        let outcome = replace_existing_path(&prepared, &destination);
3662
3663        assert!(matches!(outcome, ReplacementOutcome::Unchanged(_)));
3664        assert_eq!(fs::read_to_string(prepared).unwrap(), "new");
3665        assert!(!destination.exists());
3666    }
3667
3668    #[test]
3669    fn replacement_restores_destination_when_commit_rename_fails() {
3670        let dir = tempdir().unwrap();
3671        let prepared = dir.path().join("missing-prepared.txt");
3672        let destination = dir.path().join("destination.txt");
3673        fs::write(&destination, "old").unwrap();
3674
3675        let outcome = replace_existing_path(&prepared, &destination);
3676
3677        assert!(matches!(outcome, ReplacementOutcome::Unchanged(_)));
3678        assert_eq!(fs::read_to_string(destination).unwrap(), "old");
3679    }
3680
3681    #[test]
3682    fn replacement_reports_committed_mutation_when_cleanup_fails() {
3683        let dir = tempdir().unwrap();
3684        let prepared = dir.path().join("prepared.txt");
3685        let destination = dir.path().join("destination.txt");
3686        fs::write(&prepared, "new").unwrap();
3687        fs::write(&destination, "old").unwrap();
3688
3689        let outcome = replace_existing_path_with(&prepared, &destination, |_| {
3690            Err(std::io::Error::other("injected cleanup failure"))
3691        });
3692
3693        let ReplacementOutcome::Committed {
3694            cleanup_warning,
3695            retained_backup_path,
3696        } = outcome
3697        else {
3698            panic!("replacement must remain committed");
3699        };
3700        assert_eq!(fs::read_to_string(destination).unwrap(), "new");
3701        assert!(cleanup_warning.unwrap().contains("cleanup failed"));
3702        let backup = PathBuf::from(retained_backup_path.unwrap());
3703        assert_eq!(fs::read_to_string(backup).unwrap(), "old");
3704    }
3705}