Skip to main content

recursive/tools/
apply_patch.rs

1//! `apply_patch`: apply a structured multi-file patch atomically.
2//!
3//! The patch format is the **V4A** flavour popularised by Anthropic and used
4//! by OpenAI's Codex CLI: no line numbers, context lines locate the change
5//! in the file. This is materially more robust than unified diff for
6//! LLM-authored edits — models reliably mis-count line numbers, but they
7//! reliably preserve a few lines of context.
8//!
9//! ```text
10//! *** Begin Patch
11//! *** Update File: src/foo.rs
12//! @@ optional anchor
13//!  context line (unchanged, must match the file exactly)
14//! -line to remove (must match the file exactly)
15//! +line to add
16//!  context line
17//! *** Add File: new/file.rs
18//! +line one
19//! +line two
20//! *** Delete File: doomed/file.rs
21//! *** End Patch
22//! ```
23//!
24//! Reliability strategy:
25//!   - **Strict matching.** Context+remove lines must match the live file
26//!     exactly. No fuzzy matching.
27//!   - **Single-match.** If a hunk's pattern appears 0 or >1 times in the
28//!     file, the patch is rejected (the model can add an `@@ anchor` for
29//!     disambiguation or include more context).
30//!   - **Atomic.** All hunks are applied in memory first; only after every
31//!     file's final state is computed do we touch the disk. A failure in
32//!     hunk N rolls the whole patch back; nothing on disk changes.
33//!   - **Sandboxed.** Every path goes through `resolve_within`.
34//!   - **Useful errors.** The tool returns structured diagnostics (which
35//!     file, which hunk, what was being matched) so the model can self-correct.
36
37use async_trait::async_trait;
38use serde_json::{json, Value};
39use std::collections::HashSet;
40use std::path::PathBuf;
41
42use super::{resolve_within, Tool};
43use crate::error::{Error, Result};
44use crate::llm::ToolSpec;
45
46const BEGIN: &str = "*** Begin Patch";
47const END: &str = "*** End Patch";
48const UPDATE: &str = "*** Update File: ";
49const ADD: &str = "*** Add File: ";
50const DELETE: &str = "*** Delete File: ";
51const HUNK_SEP: &str = "@@";
52
53// ---- Tool ------------------------------------------------------------------
54
55#[derive(Debug, Clone)]
56pub struct ApplyPatch {
57    pub root: PathBuf,
58}
59
60impl ApplyPatch {
61    pub fn new(root: impl Into<PathBuf>) -> Self {
62        Self { root: root.into() }
63    }
64}
65
66#[async_trait]
67impl Tool for ApplyPatch {
68    fn spec(&self) -> ToolSpec {
69        ToolSpec {
70            name: "apply_patch".into(),
71            description: concat!(
72                "Apply a V4A-format patch atomically. Format:\n",
73                "*** Begin Patch\n",
74                "*** Update File: <path>\n",
75                "[@@ optional_anchor_line]\n",
76                " context_line\n",
77                "-line_to_remove\n",
78                "+line_to_add\n",
79                " context_line\n",
80                "*** Add File: <path>\n",
81                "+line_one\n",
82                "+line_two\n",
83                "*** Delete File: <path>\n",
84                "*** End Patch\n",
85                "Context lines must match the file exactly. If a hunk's pattern ",
86                "appears multiple times in the file, add an @@ anchor (some unique ",
87                "line that appears earlier in the file) before the hunk."
88            )
89            .into(),
90            parameters: json!({
91                "type": "object",
92                "properties": {
93                    "patch": {"type": "string", "description": "The V4A patch text"}
94                },
95                "required": ["patch"]
96            }),
97        }
98    }
99
100    async fn execute(&self, args: Value) -> Result<String> {
101        let input = args["patch"].as_str().ok_or_else(|| Error::BadToolArgs {
102            name: "apply_patch".into(),
103            message: "missing `patch`".into(),
104        })?;
105        let patch = parse_patch(input).map_err(|e| Error::Tool {
106            name: "apply_patch".into(),
107            message: e,
108        })?;
109        let writes = stage(&patch, &self.root).await.map_err(|e| Error::Tool {
110            name: "apply_patch".into(),
111            message: e,
112        })?;
113        commit(writes).await.map_err(|e| Error::Tool {
114            name: "apply_patch".into(),
115            message: e,
116        })
117    }
118}
119
120// ---- AST -------------------------------------------------------------------
121
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub(crate) enum HunkLine {
124    Context(String),
125    Add(String),
126    Remove(String),
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub(crate) struct Hunk {
131    pub anchor: Option<String>,
132    pub lines: Vec<HunkLine>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub(crate) enum FileOp {
137    Update { path: String, hunks: Vec<Hunk> },
138    Add { path: String, content: String },
139    Delete { path: String },
140}
141
142#[derive(Debug, Clone, Default, PartialEq, Eq)]
143pub(crate) struct Patch {
144    pub ops: Vec<FileOp>,
145}
146
147// ---- Parser ----------------------------------------------------------------
148
149pub(crate) fn parse_patch(input: &str) -> std::result::Result<Patch, String> {
150    let mut lines = input.lines().peekable();
151
152    // Skip leading blank lines.
153    while matches!(lines.peek(), Some(l) if l.trim().is_empty()) {
154        lines.next();
155    }
156
157    let begin = lines.next().ok_or("empty patch")?;
158    if begin.trim() != BEGIN {
159        return Err(format!("first line must be `{BEGIN}`, got `{begin}`"));
160    }
161
162    let mut patch = Patch::default();
163    let mut current_file: Option<FileOp> = None;
164
165    loop {
166        let Some(line) = lines.next() else {
167            return Err(format!("patch terminated without `{END}`"));
168        };
169
170        if line.trim() == END {
171            if let Some(op) = current_file.take() {
172                patch.ops.push(op);
173            }
174            break;
175        }
176
177        // File header? Flush whatever we were building and start a new op.
178        if let Some(path) = line.strip_prefix(UPDATE) {
179            if let Some(op) = current_file.take() {
180                patch.ops.push(op);
181            }
182            let path = path.trim().to_string();
183            if path.is_empty() {
184                return Err(format!("`{UPDATE}` with empty path"));
185            }
186            current_file = Some(FileOp::Update {
187                path,
188                hunks: vec![Hunk {
189                    anchor: None,
190                    lines: vec![],
191                }],
192            });
193            continue;
194        }
195        if let Some(path) = line.strip_prefix(ADD) {
196            if let Some(op) = current_file.take() {
197                patch.ops.push(op);
198            }
199            let path = path.trim().to_string();
200            if path.is_empty() {
201                return Err(format!("`{ADD}` with empty path"));
202            }
203            current_file = Some(FileOp::Add {
204                path,
205                content: String::new(),
206            });
207            continue;
208        }
209        if let Some(path) = line.strip_prefix(DELETE) {
210            if let Some(op) = current_file.take() {
211                patch.ops.push(op);
212            }
213            let path = path.trim().to_string();
214            if path.is_empty() {
215                return Err(format!("`{DELETE}` with empty path"));
216            }
217            current_file = Some(FileOp::Delete { path });
218            continue;
219        }
220
221        // Body of the current file.
222        let op = current_file.as_mut().ok_or_else(|| {
223            format!("body line `{line}` before any `*** Update/Add/Delete File:` header")
224        })?;
225
226        match op {
227            FileOp::Update { hunks, .. } => {
228                if let Some(rest) = line.strip_prefix(HUNK_SEP) {
229                    // New hunk; first hunk is pre-allocated above, so we only push if the current is non-empty.
230                    let anchor_str = rest.trim();
231                    let anchor = if anchor_str.is_empty() {
232                        None
233                    } else {
234                        Some(anchor_str.to_string())
235                    };
236                    let last = hunks.last_mut().unwrap();
237                    if last.lines.is_empty() {
238                        last.anchor = anchor;
239                    } else {
240                        hunks.push(Hunk {
241                            anchor,
242                            lines: vec![],
243                        });
244                    }
245                    continue;
246                }
247
248                let parsed = if let Some(rest) = line.strip_prefix('-') {
249                    HunkLine::Remove(rest.to_string())
250                } else if let Some(rest) = line.strip_prefix('+') {
251                    HunkLine::Add(rest.to_string())
252                } else if let Some(rest) = line.strip_prefix(' ') {
253                    HunkLine::Context(rest.to_string())
254                } else if line.is_empty() {
255                    // Unprefixed blank lines are treated as a blank context line.
256                    // (Models sometimes emit `\n` instead of ` \n`.)
257                    HunkLine::Context(String::new())
258                } else {
259                    return Err(format!("unexpected line in Update hunk: `{line}`"));
260                };
261                hunks.last_mut().unwrap().lines.push(parsed);
262            }
263            FileOp::Add { content, .. } => {
264                let body_line = if let Some(rest) = line.strip_prefix('+') {
265                    rest
266                } else if line.is_empty() {
267                    ""
268                } else {
269                    return Err(format!(
270                        "Add File body lines must start with `+`, got `{line}`"
271                    ));
272                };
273                if !content.is_empty() {
274                    content.push('\n');
275                }
276                content.push_str(body_line);
277            }
278            FileOp::Delete { .. } => {
279                // No body allowed.
280                if !line.trim().is_empty() {
281                    return Err(format!("Delete File body must be empty, got `{line}`"));
282                }
283            }
284        }
285    }
286
287    // Validation: paths unique, hunks non-empty for Update.
288    let mut seen: HashSet<&String> = HashSet::new();
289    for op in &patch.ops {
290        let path = match op {
291            FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
292                path
293            }
294        };
295        if !seen.insert(path) {
296            return Err(format!("path `{path}` appears in patch more than once"));
297        }
298        if let FileOp::Update { hunks, .. } = op {
299            if hunks.iter().all(|h| h.lines.is_empty()) {
300                return Err(format!("Update File `{path}` has no hunks"));
301            }
302            for (i, h) in hunks.iter().enumerate() {
303                if h.lines.is_empty() {
304                    return Err(format!("Update File `{path}` hunk {} is empty", i + 1));
305                }
306                if !h
307                    .lines
308                    .iter()
309                    .any(|l| matches!(l, HunkLine::Add(_) | HunkLine::Remove(_)))
310                {
311                    return Err(format!(
312                        "Update File `{path}` hunk {} has no +/- lines",
313                        i + 1
314                    ));
315                }
316            }
317        }
318    }
319
320    Ok(patch)
321}
322
323// ---- Applier ---------------------------------------------------------------
324
325struct StagedWrite {
326    abs_path: PathBuf,
327    rel_path: String,
328    kind: StagedKind,
329}
330
331enum StagedKind {
332    WriteText(String),
333    Delete,
334}
335
336async fn stage(
337    patch: &Patch,
338    root: &std::path::Path,
339) -> std::result::Result<Vec<StagedWrite>, String> {
340    let mut staged = Vec::new();
341    for op in &patch.ops {
342        let path = match op {
343            FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
344                path
345            }
346        };
347        let abs = resolve_within(root, path).map_err(|e| e.to_string())?;
348
349        match op {
350            FileOp::Update { hunks, .. } => {
351                let current = tokio::fs::read_to_string(&abs)
352                    .await
353                    .map_err(|e| format!("update `{path}`: {e}"))?;
354                let updated =
355                    apply_hunks(&current, hunks).map_err(|e| format!("update `{path}`: {e}"))?;
356                staged.push(StagedWrite {
357                    abs_path: abs,
358                    rel_path: path.clone(),
359                    kind: StagedKind::WriteText(updated),
360                });
361            }
362            FileOp::Add { content, .. } => {
363                if abs.exists() {
364                    return Err(format!("add `{path}`: file already exists"));
365                }
366                staged.push(StagedWrite {
367                    abs_path: abs,
368                    rel_path: path.clone(),
369                    kind: StagedKind::WriteText(content.clone()),
370                });
371            }
372            FileOp::Delete { .. } => {
373                if !abs.exists() {
374                    return Err(format!("delete `{path}`: file does not exist"));
375                }
376                staged.push(StagedWrite {
377                    abs_path: abs,
378                    rel_path: path.clone(),
379                    kind: StagedKind::Delete,
380                });
381            }
382        }
383    }
384    Ok(staged)
385}
386
387async fn commit(staged: Vec<StagedWrite>) -> std::result::Result<String, String> {
388    let mut summary = Vec::new();
389    for w in &staged {
390        match &w.kind {
391            StagedKind::WriteText(content) => {
392                if let Some(parent) = w.abs_path.parent() {
393                    tokio::fs::create_dir_all(parent)
394                        .await
395                        .map_err(|e| format!("mkdir for `{}`: {e}", w.rel_path))?;
396                }
397                tokio::fs::write(&w.abs_path, content)
398                    .await
399                    .map_err(|e| format!("write `{}`: {e}", w.rel_path))?;
400                summary.push(format!("wrote {} ({} bytes)", w.rel_path, content.len()));
401            }
402            StagedKind::Delete => {
403                tokio::fs::remove_file(&w.abs_path)
404                    .await
405                    .map_err(|e| format!("delete `{}`: {e}", w.rel_path))?;
406                summary.push(format!("deleted {}", w.rel_path));
407            }
408        }
409    }
410    Ok(format!(
411        "applied {} change(s):\n{}",
412        staged.len(),
413        summary.join("\n")
414    ))
415}
416
417fn apply_hunks(content: &str, hunks: &[Hunk]) -> std::result::Result<String, String> {
418    let trailing_newline = content.ends_with('\n');
419    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
420
421    for (idx, hunk) in hunks.iter().enumerate() {
422        // Pattern: context + remove lines in order. Replacement: context + add.
423        let pattern: Vec<&str> = hunk
424            .lines
425            .iter()
426            .filter_map(|l| match l {
427                HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
428                HunkLine::Add(_) => None,
429            })
430            .collect();
431
432        let replacement: Vec<String> = hunk
433            .lines
434            .iter()
435            .filter_map(|l| match l {
436                HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
437                HunkLine::Remove(_) => None,
438            })
439            .collect();
440
441        if pattern.is_empty() {
442            // Pure-add hunk with no anchor: prepend to file. Discouraged but allowed.
443            // We only allow this if the file is currently empty AND there's no anchor.
444            if !lines.is_empty() || hunk.anchor.is_some() {
445                return Err(format!(
446                    "hunk {} has no context/remove lines; need at least one for matching",
447                    idx + 1
448                ));
449            }
450            lines.extend(replacement);
451            continue;
452        }
453
454        // Find all positions where pattern matches.
455        let mut matches: Vec<usize> = Vec::new();
456        if pattern.len() <= lines.len() {
457            for start in 0..=(lines.len() - pattern.len()) {
458                if lines[start..start + pattern.len()]
459                    .iter()
460                    .zip(pattern.iter())
461                    .all(|(actual, expected)| actual == expected)
462                {
463                    matches.push(start);
464                }
465            }
466        }
467
468        // Anchor filtering: the anchor text must appear in some line at or
469        // before the END of the matched region. Spec'd anchors sit *above*
470        // the hunk, but models sometimes inline them into the first context
471        // line — both should disambiguate equally well.
472        if let Some(anchor) = &hunk.anchor {
473            matches.retain(|&start| {
474                let end = start + pattern.len();
475                lines[..end].iter().any(|l| l.contains(anchor.as_str()))
476            });
477        }
478
479        let pos = match matches.len() {
480            0 => {
481                return Err(format!(
482                    "hunk {} pattern not found{}.\nFirst 3 pattern lines:\n{}",
483                    idx + 1,
484                    hunk.anchor
485                        .as_deref()
486                        .map(|a| format!(" (anchor `{a}`)"))
487                        .unwrap_or_default(),
488                    pattern
489                        .iter()
490                        .take(3)
491                        .map(|l| format!("    {l}"))
492                        .collect::<Vec<_>>()
493                        .join("\n")
494                ));
495            }
496            1 => matches[0],
497            n => {
498                return Err(format!(
499                    "hunk {} pattern matches {} locations; add an `@@ anchor` line above the hunk to disambiguate",
500                    idx + 1, n
501                ));
502            }
503        };
504
505        let end = pos + pattern.len();
506        lines.splice(pos..end, replacement);
507    }
508
509    let mut result = lines.join("\n");
510    if trailing_newline {
511        result.push('\n');
512    }
513    Ok(result)
514}
515
516// ---- Tests -----------------------------------------------------------------
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use tempfile::TempDir;
522
523    fn p(input: &str) -> Patch {
524        parse_patch(input).expect("parse")
525    }
526
527    // -- parser ----------------------------------------------------------
528
529    #[test]
530    fn parse_update_single_hunk() {
531        let patch = p("\
532*** Begin Patch
533*** Update File: a.txt
534 keep
535-old
536+new
537*** End Patch
538");
539        assert_eq!(patch.ops.len(), 1);
540        match &patch.ops[0] {
541            FileOp::Update { path, hunks } => {
542                assert_eq!(path, "a.txt");
543                assert_eq!(hunks.len(), 1);
544                assert_eq!(hunks[0].anchor, None);
545                assert_eq!(
546                    hunks[0].lines,
547                    vec![
548                        HunkLine::Context("keep".into()),
549                        HunkLine::Remove("old".into()),
550                        HunkLine::Add("new".into()),
551                    ]
552                );
553            }
554            _ => panic!("wrong op"),
555        }
556    }
557
558    #[test]
559    fn parse_multi_hunk_with_anchors() {
560        let patch = p("\
561*** Begin Patch
562*** Update File: a.txt
563@@ fn foo
564 a
565-b
566+B
567@@ fn bar
568 c
569-d
570+D
571*** End Patch
572");
573        match &patch.ops[0] {
574            FileOp::Update { hunks, .. } => {
575                assert_eq!(hunks.len(), 2);
576                assert_eq!(hunks[0].anchor.as_deref(), Some("fn foo"));
577                assert_eq!(hunks[1].anchor.as_deref(), Some("fn bar"));
578            }
579            _ => panic!(),
580        }
581    }
582
583    #[test]
584    fn parse_add_file_collects_plus_lines() {
585        let patch = p("\
586*** Begin Patch
587*** Add File: new.txt
588+hello
589+world
590*** End Patch
591");
592        match &patch.ops[0] {
593            FileOp::Add { path, content } => {
594                assert_eq!(path, "new.txt");
595                assert_eq!(content, "hello\nworld");
596            }
597            _ => panic!(),
598        }
599    }
600
601    #[test]
602    fn parse_delete_file() {
603        let patch = p("\
604*** Begin Patch
605*** Delete File: doomed.txt
606*** End Patch
607");
608        assert!(matches!(&patch.ops[0], FileOp::Delete { path, .. } if path == "doomed.txt"));
609    }
610
611    #[test]
612    fn parse_rejects_missing_begin() {
613        let e = parse_patch("hello\n*** End Patch\n").unwrap_err();
614        assert!(e.contains("first line"));
615    }
616
617    #[test]
618    fn parse_rejects_missing_end() {
619        let e = parse_patch("*** Begin Patch\n*** Update File: a\n keep\n").unwrap_err();
620        assert!(e.contains("terminated"));
621    }
622
623    #[test]
624    fn parse_rejects_duplicate_path() {
625        let e = parse_patch(
626            "\
627*** Begin Patch
628*** Add File: a.txt
629+x
630*** Delete File: a.txt
631*** End Patch
632",
633        )
634        .unwrap_err();
635        assert!(e.contains("more than once"));
636    }
637
638    #[test]
639    fn parse_rejects_body_without_header() {
640        let e = parse_patch("*** Begin Patch\n+stray\n*** End Patch\n").unwrap_err();
641        assert!(e.contains("before any"));
642    }
643
644    #[test]
645    fn parse_rejects_hunk_without_changes() {
646        let e = parse_patch(
647            "\
648*** Begin Patch
649*** Update File: a
650 just
651 context
652*** End Patch
653",
654        )
655        .unwrap_err();
656        assert!(e.contains("no +/- lines"));
657    }
658
659    // -- applier (in-memory) --------------------------------------------
660
661    #[test]
662    fn applies_single_hunk_round_trip() {
663        let original = "line1\nold\nline3\n";
664        let patch = p("\
665*** Begin Patch
666*** Update File: f
667 line1
668-old
669+new
670 line3
671*** End Patch
672");
673        let FileOp::Update { hunks, .. } = &patch.ops[0] else {
674            panic!()
675        };
676        let updated = apply_hunks(original, hunks).unwrap();
677        assert_eq!(updated, "line1\nnew\nline3\n");
678    }
679
680    #[test]
681    fn applies_multi_hunk() {
682        let original = "alpha\nbeta\ngamma\ndelta\n";
683        let hunks = vec![
684            Hunk {
685                anchor: None,
686                lines: vec![
687                    HunkLine::Context("alpha".into()),
688                    HunkLine::Remove("beta".into()),
689                    HunkLine::Add("BETA".into()),
690                ],
691            },
692            Hunk {
693                anchor: None,
694                lines: vec![
695                    HunkLine::Context("gamma".into()),
696                    HunkLine::Remove("delta".into()),
697                    HunkLine::Add("DELTA".into()),
698                ],
699            },
700        ];
701        assert_eq!(
702            apply_hunks(original, &hunks).unwrap(),
703            "alpha\nBETA\ngamma\nDELTA\n"
704        );
705    }
706
707    #[test]
708    fn errors_when_pattern_not_found() {
709        let e = apply_hunks(
710            "hello\nworld\n",
711            &[Hunk {
712                anchor: None,
713                lines: vec![
714                    HunkLine::Context("foo".into()),
715                    HunkLine::Remove("bar".into()),
716                    HunkLine::Add("baz".into()),
717                ],
718            }],
719        )
720        .unwrap_err();
721        assert!(e.contains("not found"));
722    }
723
724    #[test]
725    fn errors_when_pattern_ambiguous() {
726        let original = "x\ny\nx\ny\n";
727        let e = apply_hunks(
728            original,
729            &[Hunk {
730                anchor: None,
731                lines: vec![
732                    HunkLine::Context("x".into()),
733                    HunkLine::Remove("y".into()),
734                    HunkLine::Add("Y".into()),
735                ],
736            }],
737        )
738        .unwrap_err();
739        assert!(e.contains("matches"));
740    }
741
742    #[test]
743    fn anchor_disambiguates_repeated_context() {
744        // Two regions, same shape, anchor picks the second.
745        let original = "fn foo {\n  x\n  y\n}\nfn bar {\n  x\n  y\n}\n";
746        let hunks = vec![Hunk {
747            anchor: Some("fn bar".into()),
748            lines: vec![
749                HunkLine::Context("  x".into()),
750                HunkLine::Remove("  y".into()),
751                HunkLine::Add("  Y".into()),
752            ],
753        }];
754        let out = apply_hunks(original, &hunks).unwrap();
755        assert_eq!(out, "fn foo {\n  x\n  y\n}\nfn bar {\n  x\n  Y\n}\n");
756    }
757
758    #[test]
759    fn preserves_no_trailing_newline() {
760        let original = "a\nb";
761        let hunks = vec![Hunk {
762            anchor: None,
763            lines: vec![
764                HunkLine::Remove("a".into()),
765                HunkLine::Add("A".into()),
766                HunkLine::Context("b".into()),
767            ],
768        }];
769        assert_eq!(apply_hunks(original, &hunks).unwrap(), "A\nb");
770    }
771
772    // -- end-to-end (tool) ---------------------------------------------
773
774    #[tokio::test]
775    async fn tool_updates_existing_file() {
776        let tmp = TempDir::new().unwrap();
777        std::fs::write(tmp.path().join("hello.txt"), "before\n").unwrap();
778        let tool = ApplyPatch::new(tmp.path());
779        let result = tool
780            .execute(json!({"patch": "\
781*** Begin Patch
782*** Update File: hello.txt
783-before
784+after
785*** End Patch
786"}))
787            .await
788            .unwrap();
789        assert!(result.contains("wrote hello.txt"));
790        assert_eq!(
791            std::fs::read_to_string(tmp.path().join("hello.txt")).unwrap(),
792            "after\n"
793        );
794    }
795
796    #[tokio::test]
797    async fn tool_adds_new_file() {
798        let tmp = TempDir::new().unwrap();
799        let tool = ApplyPatch::new(tmp.path());
800        tool.execute(json!({"patch": "\
801*** Begin Patch
802*** Add File: sub/new.txt
803+hi
804+there
805*** End Patch
806"}))
807            .await
808            .unwrap();
809        assert_eq!(
810            std::fs::read_to_string(tmp.path().join("sub/new.txt")).unwrap(),
811            "hi\nthere"
812        );
813    }
814
815    #[tokio::test]
816    async fn tool_deletes_existing_file() {
817        let tmp = TempDir::new().unwrap();
818        std::fs::write(tmp.path().join("doomed.txt"), "x").unwrap();
819        ApplyPatch::new(tmp.path())
820            .execute(json!({"patch": "\
821*** Begin Patch
822*** Delete File: doomed.txt
823*** End Patch
824"}))
825            .await
826            .unwrap();
827        assert!(!tmp.path().join("doomed.txt").exists());
828    }
829
830    #[tokio::test]
831    async fn tool_is_atomic_on_failure() {
832        // Two-file patch: first hunk succeeds (would update a.txt), second fails.
833        // After apply, neither file should be modified.
834        let tmp = TempDir::new().unwrap();
835        std::fs::write(tmp.path().join("a.txt"), "original-a\n").unwrap();
836        std::fs::write(tmp.path().join("b.txt"), "original-b\n").unwrap();
837        let err = ApplyPatch::new(tmp.path())
838            .execute(json!({"patch": "\
839*** Begin Patch
840*** Update File: a.txt
841-original-a
842+changed-a
843*** Update File: b.txt
844-nonexistent-line
845+changed-b
846*** End Patch
847"}))
848            .await
849            .unwrap_err();
850        assert!(matches!(err, Error::Tool { .. }));
851        assert_eq!(
852            std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
853            "original-a\n"
854        );
855        assert_eq!(
856            std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
857            "original-b\n"
858        );
859    }
860
861    #[tokio::test]
862    async fn tool_rejects_add_when_file_exists() {
863        let tmp = TempDir::new().unwrap();
864        std::fs::write(tmp.path().join("exists.txt"), "x").unwrap();
865        let err = ApplyPatch::new(tmp.path())
866            .execute(json!({"patch": "\
867*** Begin Patch
868*** Add File: exists.txt
869+content
870*** End Patch
871"}))
872            .await
873            .unwrap_err();
874        assert!(matches!(err, Error::Tool { .. }));
875    }
876
877    #[tokio::test]
878    async fn tool_rejects_delete_when_file_missing() {
879        let tmp = TempDir::new().unwrap();
880        let err = ApplyPatch::new(tmp.path())
881            .execute(json!({"patch": "\
882*** Begin Patch
883*** Delete File: ghost.txt
884*** End Patch
885"}))
886            .await
887            .unwrap_err();
888        assert!(matches!(err, Error::Tool { .. }));
889    }
890
891    #[tokio::test]
892    async fn tool_rejects_sandbox_escape() {
893        let tmp = TempDir::new().unwrap();
894        let err = ApplyPatch::new(tmp.path())
895            .execute(json!({"patch": "\
896*** Begin Patch
897*** Add File: ../escape.txt
898+evil
899*** End Patch
900"}))
901            .await
902            .unwrap_err();
903        // Wrapped as a Tool error (resolve_within returned BadToolArgs internally,
904        // but we re-wrapped it). Just check it failed.
905        assert!(matches!(err, Error::Tool { .. }));
906        assert!(!tmp.path().parent().unwrap().join("escape.txt").exists());
907    }
908
909    #[tokio::test]
910    async fn tool_round_trips_a_real_change() {
911        let tmp = TempDir::new().unwrap();
912        std::fs::write(
913            tmp.path().join("lib.rs"),
914            "\
915fn add(a: i32, b: i32) -> i32 {
916    a + b
917}
918
919fn sub(a: i32, b: i32) -> i32 {
920    a - b
921}
922",
923        )
924        .unwrap();
925        let tool = ApplyPatch::new(tmp.path());
926        tool.execute(json!({"patch": "\
927*** Begin Patch
928*** Update File: lib.rs
929@@ fn sub
930 fn sub(a: i32, b: i32) -> i32 {
931-    a - b
932+    a.saturating_sub(b)
933 }
934*** End Patch
935"}))
936            .await
937            .unwrap();
938        let got = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
939        assert!(got.contains("a.saturating_sub(b)"));
940        assert!(got.contains("a + b"), "add() unchanged");
941    }
942}