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/// Normalize a unified-diff-style hunk header to a V4A anchor.
54///
55/// Unified-diff headers look like `@@ -14,6 +14,28 @@ anchor text` or
56/// `@@ -14 +14 @@ anchor text`. The line-number range is discarded; only
57/// the trailing text after the second `@@` is returned as the anchor.
58///
59/// Returns `None` when the trailing text is empty or whitespace-only.
60fn normalize_hunk_header(rest: &str) -> Option<String> {
61    let trimmed = rest.trim();
62    if trimmed.is_empty() {
63        return None;
64    }
65
66    // Check for unified-diff pattern: `-N[,M] +N[,M] @@ [trailing]`
67    // We scan manually (no regex crate).
68    let bytes = trimmed.as_bytes();
69    let len = bytes.len();
70    let mut i = 0;
71
72    // Skip leading whitespace.
73    while i < len && bytes[i].is_ascii_whitespace() {
74        i += 1;
75    }
76
77    // Must start with '-'.
78    if i < len && bytes[i] == b'-' {
79        i += 1;
80        // Consume digits and optional comma+digits (the "old" range).
81        let mut has_digits = false;
82        while i < len && bytes[i].is_ascii_digit() {
83            has_digits = true;
84            i += 1;
85        }
86        if i < len && bytes[i] == b',' {
87            i += 1;
88            while i < len && bytes[i].is_ascii_digit() {
89                has_digits = true;
90                i += 1;
91            }
92        }
93        if !has_digits {
94            // Not a unified-diff header after all; return whole trimmed.
95            return Some(trimmed.to_string());
96        }
97
98        // Skip whitespace.
99        while i < len && bytes[i].is_ascii_whitespace() {
100            i += 1;
101        }
102
103        // Must have '+'.
104        if i < len && bytes[i] == b'+' {
105            i += 1;
106            // Consume digits and optional comma+digits (the "new" range).
107            let mut has_digits2 = false;
108            while i < len && bytes[i].is_ascii_digit() {
109                has_digits2 = true;
110                i += 1;
111            }
112            if i < len && bytes[i] == b',' {
113                i += 1;
114                while i < len && bytes[i].is_ascii_digit() {
115                    has_digits2 = true;
116                    i += 1;
117                }
118            }
119            if !has_digits2 {
120                return Some(trimmed.to_string());
121            }
122
123            // Skip whitespace.
124            while i < len && bytes[i].is_ascii_whitespace() {
125                i += 1;
126            }
127
128            // Must have '@@'.
129            if i + 1 < len && bytes[i] == b'@' && bytes[i + 1] == b'@' {
130                i += 2;
131                // Skip whitespace after the second @@.
132                while i < len && bytes[i].is_ascii_whitespace() {
133                    i += 1;
134                }
135                let trailing = trimmed[i..].trim().to_string();
136                if trailing.is_empty() {
137                    return None;
138                }
139                return Some(trailing);
140            }
141        }
142    }
143
144    // Not a unified-diff header; return the whole trimmed string.
145    Some(trimmed.to_string())
146}
147
148// ---- Tool ------------------------------------------------------------------
149
150#[derive(Debug, Clone)]
151pub struct ApplyPatch {
152    pub root: PathBuf,
153}
154
155impl ApplyPatch {
156    pub fn new(root: impl Into<PathBuf>) -> Self {
157        Self { root: root.into() }
158    }
159}
160
161#[async_trait]
162impl Tool for ApplyPatch {
163    fn spec(&self) -> ToolSpec {
164        ToolSpec {
165            name: "apply_patch".into(),
166            description: concat!(
167                "Apply a V4A-format patch atomically. Format:\n",
168                "*** Begin Patch\n",
169                "*** Update File: <path>\n",
170                "[@@ optional_anchor_line]\n",
171                " context_line\n",
172                "-line_to_remove\n",
173                "+line_to_add\n",
174                " context_line\n",
175                "*** Add File: <path>\n",
176                "+line_one\n",
177                "+line_two\n",
178                "*** Delete File: <path>\n",
179                "*** End Patch\n",
180                "Context lines must match the file exactly. If a hunk's pattern ",
181                "appears multiple times in the file, add an @@ anchor (some unique ",
182                "line that appears earlier in the file) before the hunk. ",
183                "Both `@@ <unique_line>` (V4A) and ",
184                "`@@ -N,M +N,M @@ <unique_line>` (unified-diff style) headers are ",
185                "accepted; the line-number range is ignored, only the anchor text ",
186                "after the final `@@` is used to locate the hunk."
187            )
188            .into(),
189            parameters: json!({
190                "type": "object",
191                "properties": {
192                    "patch": {"type": "string", "description": "The V4A patch text"},
193                    "dry_run": {"type": "boolean", "description": "If true, validate and resolve the patch but do not write any files. Returns the same success/error message it would produce in a normal run.", "default": false}
194                },
195                "required": ["patch"]
196            }),
197        }
198    }
199
200    async fn execute(&self, args: Value) -> Result<String> {
201        let input = args["patch"].as_str().ok_or_else(|| Error::BadToolArgs {
202            name: "apply_patch".into(),
203            message: "missing `patch`".into(),
204        })?;
205        let dry_run = args
206            .get("dry_run")
207            .and_then(|v| v.as_bool())
208            .unwrap_or(false);
209        let patch = parse_patch(input).map_err(|e| Error::Tool {
210            name: "apply_patch".into(),
211            message: e,
212        })?;
213        let writes = stage(&patch, &self.root).await.map_err(|e| Error::Tool {
214            name: "apply_patch".into(),
215            message: e,
216        })?;
217        if dry_run {
218            let file_count = writes.len();
219            let hunk_count: usize = patch
220                .ops
221                .iter()
222                .map(|op| match op {
223                    FileOp::Update { hunks, .. } => hunks.len(),
224                    _ => 1,
225                })
226                .sum();
227            let paths: Vec<&str> = writes.iter().map(|w| w.rel_path.as_str()).collect();
228            return Ok(format!(
229                "dry-run: would apply {} hunk(s) across {} file(s): {}",
230                hunk_count,
231                file_count,
232                paths.join(", ")
233            ));
234        }
235        commit(writes).await.map_err(|e| Error::Tool {
236            name: "apply_patch".into(),
237            message: e,
238        })
239    }
240}
241
242// ---- AST -------------------------------------------------------------------
243
244#[derive(Debug, Clone, PartialEq, Eq)]
245pub(crate) enum HunkLine {
246    Context(String),
247    Add(String),
248    Remove(String),
249}
250
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub(crate) struct Hunk {
253    pub anchor: Option<String>,
254    pub lines: Vec<HunkLine>,
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub(crate) enum FileOp {
259    Update { path: String, hunks: Vec<Hunk> },
260    Add { path: String, content: String },
261    Delete { path: String },
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Eq)]
265pub(crate) struct Patch {
266    pub ops: Vec<FileOp>,
267}
268
269// ---- Parser ----------------------------------------------------------------
270
271pub(crate) fn parse_patch(input: &str) -> std::result::Result<Patch, String> {
272    let mut lines = input.lines().peekable();
273
274    // Skip leading blank lines.
275    while matches!(lines.peek(), Some(l) if l.trim().is_empty()) {
276        lines.next();
277    }
278
279    let begin = lines.next().ok_or("empty patch")?;
280    if begin.trim() != BEGIN {
281        return Err(format!("first line must be `{BEGIN}`, got `{begin}`"));
282    }
283
284    let mut patch = Patch::default();
285    let mut current_file: Option<FileOp> = None;
286
287    loop {
288        let Some(line) = lines.next() else {
289            return Err(format!("patch terminated without `{END}`"));
290        };
291
292        if line.trim() == END {
293            if let Some(op) = current_file.take() {
294                patch.ops.push(op);
295            }
296            break;
297        }
298
299        // File header? Flush whatever we were building and start a new op.
300        if let Some(path) = line.strip_prefix(UPDATE) {
301            if let Some(op) = current_file.take() {
302                patch.ops.push(op);
303            }
304            let path = path.trim().to_string();
305            if path.is_empty() {
306                return Err(format!("`{UPDATE}` with empty path"));
307            }
308            current_file = Some(FileOp::Update {
309                path,
310                hunks: vec![Hunk {
311                    anchor: None,
312                    lines: vec![],
313                }],
314            });
315            continue;
316        }
317        if let Some(path) = line.strip_prefix(ADD) {
318            if let Some(op) = current_file.take() {
319                patch.ops.push(op);
320            }
321            let path = path.trim().to_string();
322            if path.is_empty() {
323                return Err(format!("`{ADD}` with empty path"));
324            }
325            current_file = Some(FileOp::Add {
326                path,
327                content: String::new(),
328            });
329            continue;
330        }
331        if let Some(path) = line.strip_prefix(DELETE) {
332            if let Some(op) = current_file.take() {
333                patch.ops.push(op);
334            }
335            let path = path.trim().to_string();
336            if path.is_empty() {
337                return Err(format!("`{DELETE}` with empty path"));
338            }
339            current_file = Some(FileOp::Delete { path });
340            continue;
341        }
342
343        // Body of the current file.
344        let op = current_file.as_mut().ok_or_else(|| {
345            format!("body line `{line}` before any `*** Update/Add/Delete File:` header")
346        })?;
347
348        match op {
349            FileOp::Update { hunks, .. } => {
350                if let Some(rest) = line.strip_prefix(HUNK_SEP) {
351                    // New hunk; first hunk is pre-allocated above, so we only push if the current is non-empty.
352                    let anchor = normalize_hunk_header(rest);
353                    let last = hunks.last_mut().unwrap();
354                    if last.lines.is_empty() {
355                        last.anchor = anchor;
356                    } else {
357                        hunks.push(Hunk {
358                            anchor,
359                            lines: vec![],
360                        });
361                    }
362                    continue;
363                }
364
365                let parsed = if let Some(rest) = line.strip_prefix('-') {
366                    HunkLine::Remove(rest.to_string())
367                } else if let Some(rest) = line.strip_prefix('+') {
368                    HunkLine::Add(rest.to_string())
369                } else if let Some(rest) = line.strip_prefix(' ') {
370                    HunkLine::Context(rest.to_string())
371                } else if line.is_empty() {
372                    // Unprefixed blank lines are treated as a blank context line.
373                    // (Models sometimes emit `\n` instead of ` \n`.)
374                    HunkLine::Context(String::new())
375                } else {
376                    return Err(format!("unexpected line in Update hunk: `{line}`"));
377                };
378                hunks.last_mut().unwrap().lines.push(parsed);
379            }
380            FileOp::Add { content, .. } => {
381                let body_line = if let Some(rest) = line.strip_prefix('+') {
382                    rest
383                } else if line.is_empty() {
384                    ""
385                } else {
386                    return Err(format!(
387                        "Add File body lines must start with `+`, got `{line}`"
388                    ));
389                };
390                if !content.is_empty() {
391                    content.push('\n');
392                }
393                content.push_str(body_line);
394            }
395            FileOp::Delete { .. } => {
396                // No body allowed.
397                if !line.trim().is_empty() {
398                    return Err(format!("Delete File body must be empty, got `{line}`"));
399                }
400            }
401        }
402    }
403
404    // Validation: paths unique, hunks non-empty for Update.
405    let mut seen: HashSet<&String> = HashSet::new();
406    for op in &patch.ops {
407        let path = match op {
408            FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
409                path
410            }
411        };
412        if !seen.insert(path) {
413            return Err(format!("path `{path}` appears in patch more than once"));
414        }
415        if let FileOp::Update { hunks, .. } = op {
416            if hunks.iter().all(|h| h.lines.is_empty()) {
417                return Err(format!("Update File `{path}` has no hunks"));
418            }
419            for (i, h) in hunks.iter().enumerate() {
420                if h.lines.is_empty() {
421                    return Err(format!("Update File `{path}` hunk {} is empty", i + 1));
422                }
423                if !h
424                    .lines
425                    .iter()
426                    .any(|l| matches!(l, HunkLine::Add(_) | HunkLine::Remove(_)))
427                {
428                    return Err(format!(
429                        "Update File `{path}` hunk {} has no +/- lines",
430                        i + 1
431                    ));
432                }
433            }
434        }
435    }
436
437    Ok(patch)
438}
439
440// ---- Applier ---------------------------------------------------------------
441
442struct StagedWrite {
443    abs_path: PathBuf,
444    rel_path: String,
445    kind: StagedKind,
446}
447
448enum StagedKind {
449    WriteText(String),
450    Delete,
451}
452
453async fn stage(
454    patch: &Patch,
455    root: &std::path::Path,
456) -> std::result::Result<Vec<StagedWrite>, String> {
457    let mut staged = Vec::new();
458    for op in &patch.ops {
459        let path = match op {
460            FileOp::Update { path, .. } | FileOp::Add { path, .. } | FileOp::Delete { path } => {
461                path
462            }
463        };
464        let abs = resolve_within(root, path).map_err(|e| e.to_string())?;
465
466        match op {
467            FileOp::Update { hunks, .. } => {
468                let current = tokio::fs::read_to_string(&abs)
469                    .await
470                    .map_err(|e| format!("update `{path}`: {e}"))?;
471                let updated =
472                    apply_hunks(&current, hunks).map_err(|e| format!("update `{path}`: {e}"))?;
473                staged.push(StagedWrite {
474                    abs_path: abs,
475                    rel_path: path.clone(),
476                    kind: StagedKind::WriteText(updated),
477                });
478            }
479            FileOp::Add { content, .. } => {
480                if abs.exists() {
481                    return Err(format!("add `{path}`: file already exists"));
482                }
483                staged.push(StagedWrite {
484                    abs_path: abs,
485                    rel_path: path.clone(),
486                    kind: StagedKind::WriteText(content.clone()),
487                });
488            }
489            FileOp::Delete { .. } => {
490                if !abs.exists() {
491                    return Err(format!("delete `{path}`: file does not exist"));
492                }
493                staged.push(StagedWrite {
494                    abs_path: abs,
495                    rel_path: path.clone(),
496                    kind: StagedKind::Delete,
497                });
498            }
499        }
500    }
501    Ok(staged)
502}
503
504async fn commit(staged: Vec<StagedWrite>) -> std::result::Result<String, String> {
505    let mut summary = Vec::new();
506    for w in &staged {
507        match &w.kind {
508            StagedKind::WriteText(content) => {
509                if let Some(parent) = w.abs_path.parent() {
510                    tokio::fs::create_dir_all(parent)
511                        .await
512                        .map_err(|e| format!("mkdir for `{}`: {e}", w.rel_path))?;
513                }
514                tokio::fs::write(&w.abs_path, content)
515                    .await
516                    .map_err(|e| format!("write `{}`: {e}", w.rel_path))?;
517                summary.push(format!("wrote {} ({} bytes)", w.rel_path, content.len()));
518            }
519            StagedKind::Delete => {
520                tokio::fs::remove_file(&w.abs_path)
521                    .await
522                    .map_err(|e| format!("delete `{}`: {e}", w.rel_path))?;
523                summary.push(format!("deleted {}", w.rel_path));
524            }
525        }
526    }
527    Ok(format!(
528        "applied {} change(s):\n{}",
529        staged.len(),
530        summary.join("\n")
531    ))
532}
533
534/// Find up to 3 unique lines near the match location that could serve as anchors.
535/// We look 5 lines before and 5 lines after the first matched location,
536/// and keep only lines that appear exactly once in the whole file.
537fn find_unique_anchor_suggestions(
538    lines: &[String],
539    match_start: usize,
540    pattern: &[&str],
541) -> Vec<String> {
542    let mut suggestions = Vec::new();
543    let window = 5;
544
545    // Collect candidate lines: 5 before through 5 after the match location
546    let start = match_start.saturating_sub(window);
547    let end = (match_start + pattern.len() + window).min(lines.len());
548
549    for i in start..end {
550        let line = &lines[i];
551        if line.is_empty() {
552            continue;
553        }
554
555        // Count occurrences of this exact line in the whole file
556        let count = lines.iter().filter(|l| l.as_str() == line.as_str()).count();
557        if count == 1 {
558            suggestions.push(line.clone());
559        }
560
561        if suggestions.len() >= 3 {
562            break;
563        }
564    }
565
566    suggestions
567}
568
569fn apply_hunks(content: &str, hunks: &[Hunk]) -> std::result::Result<String, String> {
570    let trailing_newline = content.ends_with('\n');
571    let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
572
573    for (idx, hunk) in hunks.iter().enumerate() {
574        // Pattern: context + remove lines in order. Replacement: context + add.
575        let pattern: Vec<&str> = hunk
576            .lines
577            .iter()
578            .filter_map(|l| match l {
579                HunkLine::Context(s) | HunkLine::Remove(s) => Some(s.as_str()),
580                HunkLine::Add(_) => None,
581            })
582            .collect();
583
584        let replacement: Vec<String> = hunk
585            .lines
586            .iter()
587            .filter_map(|l| match l {
588                HunkLine::Context(s) | HunkLine::Add(s) => Some(s.clone()),
589                HunkLine::Remove(_) => None,
590            })
591            .collect();
592
593        if pattern.is_empty() {
594            // Pure-add hunk with no anchor: prepend to file. Discouraged but allowed.
595            // We only allow this if the file is currently empty AND there's no anchor.
596            if !lines.is_empty() || hunk.anchor.is_some() {
597                return Err(format!(
598                    "hunk {} has no context/remove lines; need at least one for matching",
599                    idx + 1
600                ));
601            }
602            lines.extend(replacement);
603            continue;
604        }
605
606        // Find all positions where pattern matches.
607        let mut matches: Vec<usize> = Vec::new();
608        if pattern.len() <= lines.len() {
609            for start in 0..=(lines.len() - pattern.len()) {
610                if lines[start..start + pattern.len()]
611                    .iter()
612                    .zip(pattern.iter())
613                    .all(|(actual, expected)| actual == expected)
614                {
615                    matches.push(start);
616                }
617            }
618        }
619
620        // Anchor filtering: the anchor text must appear in some line at or
621        // before the END of the matched region. Spec'd anchors sit *above*
622        // the hunk, but models sometimes inline them into the first context
623        // line — both should disambiguate equally well.
624        if let Some(anchor) = &hunk.anchor {
625            matches.retain(|&start| {
626                let end = start + pattern.len();
627                lines[..end].iter().any(|l| l.contains(anchor.as_str()))
628            });
629        }
630
631        let pos = match matches.len() {
632            0 => {
633                return Err(format!(
634                    "hunk {} pattern not found{}.\nFirst 3 pattern lines:\n{}",
635                    idx + 1,
636                    hunk.anchor
637                        .as_deref()
638                        .map(|a| format!(" (anchor `{a}`)"))
639                        .unwrap_or_default(),
640                    pattern
641                        .iter()
642                        .take(3)
643                        .map(|l| format!("    {l}"))
644                        .collect::<Vec<_>>()
645                        .join("\n")
646                ));
647            }
648            1 => matches[0],
649            n => {
650                // Find unique anchor suggestions around the first match location
651                let suggestions = find_unique_anchor_suggestions(&lines, matches[0], &pattern);
652
653                let suggestion_text = if suggestions.is_empty() {
654                    String::new()
655                } else {
656                    format!(
657                        "\nAdd an `@@ <anchor>` line above the hunk with one of these unique nearby lines:\n{}",
658                        suggestions
659                            .iter()
660                            .map(|s| format!("  @@ {}", s))
661                            .collect::<Vec<_>>()
662                            .join("\n")
663                    )
664                };
665
666                return Err(format!(
667                    "hunk {} pattern matches {} locations; add an `@@ anchor` line above the hunk to disambiguate{}",
668                    idx + 1, n, suggestion_text
669                ));
670            }
671        };
672
673        let end = pos + pattern.len();
674        lines.splice(pos..end, replacement);
675    }
676
677    let mut result = lines.join("\n");
678    if trailing_newline {
679        result.push('\n');
680    }
681    Ok(result)
682}
683
684// ---- Tests -----------------------------------------------------------------
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use tempfile::TempDir;
690
691    fn p(input: &str) -> Patch {
692        parse_patch(input).expect("parse")
693    }
694
695    // -- normalize_hunk_header ------------------------------------------
696
697    #[test]
698    fn parses_v4a_anchor() {
699        assert_eq!(normalize_hunk_header("fn foo"), Some("fn foo".to_string()));
700    }
701
702    #[test]
703    fn parses_unified_header_with_anchor() {
704        assert_eq!(
705            normalize_hunk_header("-10,5 +10,7 @@ pub use foo;"),
706            Some("pub use foo;".to_string())
707        );
708    }
709
710    #[test]
711    fn parses_unified_header_without_anchor() {
712        assert_eq!(normalize_hunk_header("-10,5 +10,7 @@"), None);
713    }
714
715    #[test]
716    fn parses_unified_header_singular_counts() {
717        assert_eq!(
718            normalize_hunk_header("-10 +10 @@ fn bar"),
719            Some("fn bar".to_string())
720        );
721    }
722
723    #[test]
724    fn normalize_empty_returns_none() {
725        assert_eq!(normalize_hunk_header(""), None);
726        assert_eq!(normalize_hunk_header("  "), None);
727    }
728
729    #[test]
730    fn normalize_non_unified_returns_whole() {
731        assert_eq!(
732            normalize_hunk_header("some random text"),
733            Some("some random text".to_string())
734        );
735    }
736
737    #[test]
738    fn normalize_unified_with_extra_whitespace() {
739        assert_eq!(
740            normalize_hunk_header("  -14,6 +14,28 @@  pub use mock::MockProvider;  "),
741            Some("pub use mock::MockProvider;".to_string())
742        );
743    }
744
745    // -- parser ----------------------------------------------------------
746
747    #[test]
748    fn parse_update_single_hunk() {
749        let patch = p("\
750*** Begin Patch
751*** Update File: a.txt
752 keep
753-old
754+new
755*** End Patch
756");
757        assert_eq!(patch.ops.len(), 1);
758        match &patch.ops[0] {
759            FileOp::Update { path, hunks } => {
760                assert_eq!(path, "a.txt");
761                assert_eq!(hunks.len(), 1);
762                assert_eq!(hunks[0].anchor, None);
763                assert_eq!(
764                    hunks[0].lines,
765                    vec![
766                        HunkLine::Context("keep".into()),
767                        HunkLine::Remove("old".into()),
768                        HunkLine::Add("new".into()),
769                    ]
770                );
771            }
772            _ => panic!("wrong op"),
773        }
774    }
775
776    #[test]
777    fn parse_multi_hunk_with_anchors() {
778        let patch = p("\
779*** Begin Patch
780*** Update File: a.txt
781@@ fn foo
782 a
783-b
784+B
785@@ fn bar
786 c
787-d
788+D
789*** End Patch
790");
791        match &patch.ops[0] {
792            FileOp::Update { hunks, .. } => {
793                assert_eq!(hunks.len(), 2);
794                assert_eq!(hunks[0].anchor.as_deref(), Some("fn foo"));
795                assert_eq!(hunks[1].anchor.as_deref(), Some("fn bar"));
796            }
797            _ => panic!(),
798        }
799    }
800
801    #[test]
802    fn parse_unified_header_hunk() {
803        // A hunk header in unified-diff style should be accepted.
804        let patch = p("\
805*** Begin Patch
806*** Update File: a.txt
807@@ -10,5 +10,7 @@ pub use foo;
808 a
809-b
810+B
811*** End Patch
812");
813        match &patch.ops[0] {
814            FileOp::Update { hunks, .. } => {
815                assert_eq!(hunks.len(), 1);
816                assert_eq!(hunks[0].anchor.as_deref(), Some("pub use foo;"));
817            }
818            _ => panic!(),
819        }
820    }
821
822    #[test]
823    fn parse_unified_header_no_anchor() {
824        let patch = p("\
825*** Begin Patch
826*** Update File: a.txt
827@@ -10,5 +10,7 @@
828 a
829-b
830+B
831*** End Patch
832");
833        match &patch.ops[0] {
834            FileOp::Update { hunks, .. } => {
835                assert_eq!(hunks.len(), 1);
836                assert_eq!(hunks[0].anchor, None);
837            }
838            _ => panic!(),
839        }
840    }
841
842    #[test]
843    fn parse_add_file_collects_plus_lines() {
844        let patch = p("\
845*** Begin Patch
846*** Add File: new.txt
847+hello
848+world
849*** End Patch
850");
851        match &patch.ops[0] {
852            FileOp::Add { path, content } => {
853                assert_eq!(path, "new.txt");
854                assert_eq!(content, "hello\nworld");
855            }
856            _ => panic!(),
857        }
858    }
859
860    #[test]
861    fn parse_delete_file() {
862        let patch = p("\
863*** Begin Patch
864*** Delete File: doomed.txt
865*** End Patch
866");
867        assert!(matches!(&patch.ops[0], FileOp::Delete { path, .. } if path == "doomed.txt"));
868    }
869
870    #[test]
871    fn parse_rejects_missing_begin() {
872        let e = parse_patch("hello\n*** End Patch\n").unwrap_err();
873        assert!(e.contains("first line"));
874    }
875
876    #[test]
877    fn parse_rejects_missing_end() {
878        let e = parse_patch("*** Begin Patch\n*** Update File: a\n keep\n").unwrap_err();
879        assert!(e.contains("terminated"));
880    }
881
882    #[test]
883    fn parse_rejects_duplicate_path() {
884        let e = parse_patch(
885            "\
886*** Begin Patch
887*** Add File: a.txt
888+x
889*** Delete File: a.txt
890*** End Patch
891",
892        )
893        .unwrap_err();
894        assert!(e.contains("more than once"));
895    }
896
897    #[test]
898    fn parse_rejects_body_without_header() {
899        let e = parse_patch("*** Begin Patch\n+stray\n*** End Patch\n").unwrap_err();
900        assert!(e.contains("before any"));
901    }
902
903    #[test]
904    fn parse_rejects_hunk_without_changes() {
905        let e = parse_patch(
906            "\
907*** Begin Patch
908*** Update File: a
909 just
910 context
911*** End Patch
912",
913        )
914        .unwrap_err();
915        assert!(e.contains("no +/- lines"));
916    }
917
918    // -- applier (in-memory) --------------------------------------------
919
920    #[test]
921    fn applies_single_hunk_round_trip() {
922        let original = "line1\nold\nline3\n";
923        let patch = p("\
924*** Begin Patch
925*** Update File: f
926 line1
927-old
928+new
929 line3
930*** End Patch
931");
932        let FileOp::Update { hunks, .. } = &patch.ops[0] else {
933            panic!()
934        };
935        let updated = apply_hunks(original, hunks).unwrap();
936        assert_eq!(updated, "line1\nnew\nline3\n");
937    }
938
939    #[test]
940    fn applies_multi_hunk() {
941        let original = "alpha\nbeta\ngamma\ndelta\n";
942        let hunks = vec![
943            Hunk {
944                anchor: None,
945                lines: vec![
946                    HunkLine::Context("alpha".into()),
947                    HunkLine::Remove("beta".into()),
948                    HunkLine::Add("BETA".into()),
949                ],
950            },
951            Hunk {
952                anchor: None,
953                lines: vec![
954                    HunkLine::Context("gamma".into()),
955                    HunkLine::Remove("delta".into()),
956                    HunkLine::Add("DELTA".into()),
957                ],
958            },
959        ];
960        assert_eq!(
961            apply_hunks(original, &hunks).unwrap(),
962            "alpha\nBETA\ngamma\nDELTA\n"
963        );
964    }
965
966    #[test]
967    fn errors_when_pattern_not_found() {
968        let e = apply_hunks(
969            "hello\nworld\n",
970            &[Hunk {
971                anchor: None,
972                lines: vec![
973                    HunkLine::Context("foo".into()),
974                    HunkLine::Remove("bar".into()),
975                    HunkLine::Add("baz".into()),
976                ],
977            }],
978        )
979        .unwrap_err();
980        assert!(e.contains("not found"));
981    }
982
983    #[test]
984    fn errors_when_pattern_ambiguous() {
985        let original = "x\ny\nx\ny\n";
986        let e = apply_hunks(
987            original,
988            &[Hunk {
989                anchor: None,
990                lines: vec![
991                    HunkLine::Context("x".into()),
992                    HunkLine::Remove("y".into()),
993                    HunkLine::Add("Y".into()),
994                ],
995            }],
996        )
997        .unwrap_err();
998        assert!(e.contains("matches"));
999    }
1000
1001    #[test]
1002    fn ambiguous_error_shows_unique_anchor_suggestions() {
1003        // File with duplicated context and unique function definitions nearby
1004        let original = "UNIQUE_FUNC_A()\n    x\n    y\n}\nUNIQUE_FUNC_B()\n    x\n    y\n}\nUNIQUE_FUNC_C() {}\n";
1005        let hunks = vec![Hunk {
1006            anchor: None,
1007            lines: vec![
1008                HunkLine::Context("    x".into()),
1009                HunkLine::Remove("    y".into()),
1010                HunkLine::Add("    Y".into()),
1011            ],
1012        }];
1013        let e = apply_hunks(original, &hunks).unwrap_err();
1014
1015        // Should mention multiple matches
1016        assert!(e.contains("matches"));
1017        // Should contain anchor suggestion section
1018        assert!(e.contains("@@ <anchor>"));
1019        // Should contain at least one unique suggestion that exists in the original
1020        assert!(
1021            e.contains("UNIQUE_FUNC_A")
1022                || e.contains("UNIQUE_FUNC_B")
1023                || e.contains("UNIQUE_FUNC_C")
1024        );
1025    }
1026
1027    #[test]
1028    fn anchor_disambiguates_repeated_context() {
1029        // Two regions, same shape, anchor picks the second.
1030        let original = "fn foo {\n  x\n  y\n}\nfn bar {\n  x\n  y\n}\n";
1031        let hunks = vec![Hunk {
1032            anchor: Some("fn bar".into()),
1033            lines: vec![
1034                HunkLine::Context("  x".into()),
1035                HunkLine::Remove("  y".into()),
1036                HunkLine::Add("  Y".into()),
1037            ],
1038        }];
1039        let out = apply_hunks(original, &hunks).unwrap();
1040        assert_eq!(out, "fn foo {\n  x\n  y\n}\nfn bar {\n  x\n  Y\n}\n");
1041    }
1042
1043    #[test]
1044    fn preserves_no_trailing_newline() {
1045        let original = "a\nb";
1046        let hunks = vec![Hunk {
1047            anchor: None,
1048            lines: vec![
1049                HunkLine::Remove("a".into()),
1050                HunkLine::Add("A".into()),
1051                HunkLine::Context("b".into()),
1052            ],
1053        }];
1054        assert_eq!(apply_hunks(original, &hunks).unwrap(), "A\nb");
1055    }
1056
1057    // -- end-to-end (tool) ---------------------------------------------
1058
1059    #[tokio::test]
1060    async fn tool_updates_existing_file() {
1061        let tmp = TempDir::new().unwrap();
1062        std::fs::write(tmp.path().join("hello.txt"), "before\n").unwrap();
1063        let tool = ApplyPatch::new(tmp.path());
1064        let result = tool
1065            .execute(json!({"patch": "\
1066*** Begin Patch
1067*** Update File: hello.txt
1068-before
1069+after
1070*** End Patch
1071"}))
1072            .await
1073            .unwrap();
1074        assert!(result.contains("wrote hello.txt"));
1075        assert_eq!(
1076            std::fs::read_to_string(tmp.path().join("hello.txt")).unwrap(),
1077            "after\n"
1078        );
1079    }
1080
1081    #[tokio::test]
1082    async fn tool_adds_new_file() {
1083        let tmp = TempDir::new().unwrap();
1084        let tool = ApplyPatch::new(tmp.path());
1085        tool.execute(json!({"patch": "\
1086*** Begin Patch
1087*** Add File: sub/new.txt
1088+hi
1089+there
1090*** End Patch
1091"}))
1092            .await
1093            .unwrap();
1094        assert_eq!(
1095            std::fs::read_to_string(tmp.path().join("sub/new.txt")).unwrap(),
1096            "hi\nthere"
1097        );
1098    }
1099
1100    #[tokio::test]
1101    async fn tool_deletes_existing_file() {
1102        let tmp = TempDir::new().unwrap();
1103        std::fs::write(tmp.path().join("doomed.txt"), "x").unwrap();
1104        ApplyPatch::new(tmp.path())
1105            .execute(json!({"patch": "\
1106*** Begin Patch
1107*** Delete File: doomed.txt
1108*** End Patch
1109"}))
1110            .await
1111            .unwrap();
1112        assert!(!tmp.path().join("doomed.txt").exists());
1113    }
1114
1115    #[tokio::test]
1116    async fn tool_is_atomic_on_failure() {
1117        // Two-file patch: first hunk succeeds (would update a.txt), second fails.
1118        // After apply, neither file should be modified.
1119        let tmp = TempDir::new().unwrap();
1120        std::fs::write(tmp.path().join("a.txt"), "original-a\n").unwrap();
1121        std::fs::write(tmp.path().join("b.txt"), "original-b\n").unwrap();
1122        let err = ApplyPatch::new(tmp.path())
1123            .execute(json!({"patch": "\
1124*** Begin Patch
1125*** Update File: a.txt
1126-original-a
1127+changed-a
1128*** Update File: b.txt
1129-nonexistent-line
1130+changed-b
1131*** End Patch
1132"}))
1133            .await
1134            .unwrap_err();
1135        assert!(matches!(err, Error::Tool { .. }));
1136        assert_eq!(
1137            std::fs::read_to_string(tmp.path().join("a.txt")).unwrap(),
1138            "original-a\n"
1139        );
1140        assert_eq!(
1141            std::fs::read_to_string(tmp.path().join("b.txt")).unwrap(),
1142            "original-b\n"
1143        );
1144    }
1145
1146    #[tokio::test]
1147    async fn tool_rejects_add_when_file_exists() {
1148        let tmp = TempDir::new().unwrap();
1149        std::fs::write(tmp.path().join("exists.txt"), "x").unwrap();
1150        let err = ApplyPatch::new(tmp.path())
1151            .execute(json!({"patch": "\
1152*** Begin Patch
1153*** Add File: exists.txt
1154+content
1155*** End Patch
1156"}))
1157            .await
1158            .unwrap_err();
1159        assert!(matches!(err, Error::Tool { .. }));
1160    }
1161
1162    #[tokio::test]
1163    async fn tool_rejects_delete_when_file_missing() {
1164        let tmp = TempDir::new().unwrap();
1165        let err = ApplyPatch::new(tmp.path())
1166            .execute(json!({"patch": "\
1167*** Begin Patch
1168*** Delete File: ghost.txt
1169*** End Patch
1170"}))
1171            .await
1172            .unwrap_err();
1173        assert!(matches!(err, Error::Tool { .. }));
1174    }
1175
1176    #[tokio::test]
1177    async fn tool_rejects_sandbox_escape() {
1178        let tmp = TempDir::new().unwrap();
1179        let err = ApplyPatch::new(tmp.path())
1180            .execute(json!({"patch": "\
1181*** Begin Patch
1182*** Add File: ../escape.txt
1183+evil
1184*** End Patch
1185"}))
1186            .await
1187            .unwrap_err();
1188        // Wrapped as a Tool error (resolve_within returned BadToolArgs internally,
1189        // but we re-wrapped it). Just check it failed.
1190        assert!(matches!(err, Error::Tool { .. }));
1191        assert!(!tmp.path().parent().unwrap().join("escape.txt").exists());
1192    }
1193
1194    #[tokio::test]
1195    async fn tool_round_trips_a_real_change() {
1196        let tmp = TempDir::new().unwrap();
1197        std::fs::write(
1198            tmp.path().join("lib.rs"),
1199            "\
1200fn add(a: i32, b: i32) -> i32 {
1201    a + b
1202}
1203
1204fn sub(a: i32, b: i32) -> i32 {
1205    a - b
1206}
1207",
1208        )
1209        .unwrap();
1210        let tool = ApplyPatch::new(tmp.path());
1211        tool.execute(json!({"patch": "\
1212*** Begin Patch
1213*** Update File: lib.rs
1214@@ fn sub
1215 fn sub(a: i32, b: i32) -> i32 {
1216-    a - b
1217+    a.saturating_sub(b)
1218 }
1219*** End Patch
1220"}))
1221            .await
1222            .unwrap();
1223        let got = std::fs::read_to_string(tmp.path().join("lib.rs")).unwrap();
1224        assert!(got.contains("a.saturating_sub(b)"));
1225        assert!(got.contains("a + b"), "add() unchanged");
1226    }
1227
1228    #[tokio::test]
1229    async fn tool_applies_patch_with_unified_header() {
1230        // End-to-end test using a unified-diff-style header.
1231        let tmp = TempDir::new().unwrap();
1232        std::fs::write(tmp.path().join("greeting.txt"), "hello\nworld\n").unwrap();
1233        let tool = ApplyPatch::new(tmp.path());
1234        tool.execute(json!({"patch": "\
1235*** Begin Patch
1236*** Update File: greeting.txt
1237@@ -1,3 +1,4 @@ hello
1238 hello
1239-world
1240+earth
1241*** End Patch
1242"}))
1243            .await
1244            .unwrap();
1245        let got = std::fs::read_to_string(tmp.path().join("greeting.txt")).unwrap();
1246        assert_eq!(got, "hello\nearth\n");
1247    }
1248
1249    #[tokio::test]
1250    async fn dry_run_returns_summary_and_does_not_write() {
1251        let tmp = TempDir::new().unwrap();
1252        std::fs::write(tmp.path().join("target.txt"), "before\n").unwrap();
1253        let tool = ApplyPatch::new(tmp.path());
1254        let result = tool
1255            .execute(json!({"patch": "\
1256*** Begin Patch
1257*** Update File: target.txt
1258-before
1259+after
1260*** End Patch
1261", "dry_run": true}))
1262            .await
1263            .unwrap();
1264        assert!(result.contains("dry-run: would apply"));
1265        assert!(result.contains("target.txt"));
1266        // File must be unchanged
1267        assert_eq!(
1268            std::fs::read_to_string(tmp.path().join("target.txt")).unwrap(),
1269            "before\n"
1270        );
1271    }
1272
1273    #[tokio::test]
1274    async fn dry_run_errors_on_ambiguous_anchor() {
1275        let tmp = TempDir::new().unwrap();
1276        std::fs::write(tmp.path().join("dup.txt"), "x\ny\nx\ny\n").unwrap();
1277        let tool = ApplyPatch::new(tmp.path());
1278        let err = tool
1279            .execute(json!({"patch": "\
1280*** Begin Patch
1281*** Update File: dup.txt
1282 x
1283-y
1284+Y
1285*** End Patch
1286", "dry_run": true}))
1287            .await
1288            .unwrap_err();
1289        assert!(matches!(err, Error::Tool { .. }));
1290        let msg = format!("{}", err);
1291        assert!(
1292            msg.contains("matches"),
1293            "error should mention multiple matches: {msg}"
1294        );
1295    }
1296
1297    #[tokio::test]
1298    async fn dry_run_false_is_same_as_omitting_field() {
1299        let tmp = TempDir::new().unwrap();
1300        std::fs::write(tmp.path().join("test.txt"), "old\n").unwrap();
1301        let tool = ApplyPatch::new(tmp.path());
1302        tool.execute(json!({"patch": "\
1303*** Begin Patch
1304*** Update File: test.txt
1305-old
1306+new
1307*** End Patch
1308", "dry_run": false}))
1309            .await
1310            .unwrap();
1311        assert_eq!(
1312            std::fs::read_to_string(tmp.path().join("test.txt")).unwrap(),
1313            "new\n"
1314        );
1315    }
1316}