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