Skip to main content

punch_types/
patch.rs

1//! Patch application engine — combo patches and move corrections.
2//!
3//! Provides a unified diff parser, applicator, validator, and generator so agents
4//! can land precise code changes without rewriting entire files. Think of it as a
5//! carefully choreographed combo: each hunk is a move in the sequence, and the
6//! engine ensures every hit connects cleanly.
7
8use crate::error::{PunchError, PunchResult};
9use serde::{Deserialize, Serialize};
10
11// ---------------------------------------------------------------------------
12// Core types
13// ---------------------------------------------------------------------------
14
15/// A single line in a patch hunk — context, removal, or addition.
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub enum PatchLine {
18    /// Unchanged context line (space prefix in unified diff).
19    Context(String),
20    /// Removed line (- prefix in unified diff).
21    Remove(String),
22    /// Added line (+ prefix in unified diff).
23    Add(String),
24}
25
26/// A contiguous region of changes within a file patch — one move in the combo.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PatchHunk {
29    /// Starting line number in the original file (1-based).
30    pub old_start: usize,
31    /// Number of lines from the original covered by this hunk.
32    pub old_count: usize,
33    /// Starting line number in the new file (1-based).
34    pub new_start: usize,
35    /// Number of lines in the new version covered by this hunk.
36    pub new_count: usize,
37    /// The diff lines that make up this hunk.
38    pub lines: Vec<PatchLine>,
39}
40
41/// A patch for a single file — the full combo sequence for one target.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct FilePatch {
44    /// Path of the original file.
45    pub old_path: String,
46    /// Path of the new file (may differ for renames).
47    pub new_path: String,
48    /// The ordered hunks (moves) that compose this patch.
49    pub hunks: Vec<PatchHunk>,
50    /// Whether this patch creates a brand-new file.
51    pub is_new_file: bool,
52    /// Whether this patch deletes the file entirely.
53    pub is_deleted: bool,
54}
55
56/// A collection of file patches — a full combo chain across the codebase.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct PatchSet {
59    /// Individual file patches in this set.
60    pub patches: Vec<FilePatch>,
61    /// Optional description of what this patch set accomplishes.
62    pub description: Option<String>,
63}
64
65/// A detected conflict when validating a patch against file content.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct PatchConflict {
68    /// Index of the hunk that conflicts.
69    pub hunk_index: usize,
70    /// The line content the patch expected to find.
71    pub expected_line: String,
72    /// The line content actually present in the file.
73    pub actual_line: String,
74    /// The line number (1-based) in the original where the conflict was found.
75    pub line_number: usize,
76    /// What kind of conflict was detected.
77    pub conflict_type: ConflictType,
78}
79
80/// Classification of a patch conflict — how badly the move missed.
81#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
82pub enum ConflictType {
83    /// A context line does not match the original content.
84    ContextMismatch,
85    /// A line expected for removal was not found at the expected position.
86    LineNotFound,
87    /// The hunk could be applied at a different offset (signed line count).
88    OffsetApplied(i32),
89}
90
91// ---------------------------------------------------------------------------
92// Parsing
93// ---------------------------------------------------------------------------
94
95/// Parse a unified diff string into a `PatchSet` — decode the combo notation.
96///
97/// Handles standard unified diff format with `---`/`+++` file headers and
98/// `@@ -old_start,old_count +new_start,new_count @@` hunk headers.
99pub fn parse_unified_diff(diff_text: &str) -> PunchResult<PatchSet> {
100    let lines: Vec<&str> = diff_text.lines().collect();
101
102    if lines.is_empty() {
103        return Ok(PatchSet {
104            patches: Vec::new(),
105            description: None,
106        });
107    }
108
109    let mut patches = Vec::new();
110    let mut i = 0;
111
112    while i < lines.len() {
113        // Skip blank lines and any leading text that isn't a file header.
114        if !lines[i].starts_with("--- ") {
115            // Check for "diff --git" lines — skip them.
116            i += 1;
117            continue;
118        }
119
120        // Parse file header pair: --- and +++
121        let old_header = lines[i];
122        i += 1;
123        if i >= lines.len() || !lines[i].starts_with("+++ ") {
124            return Err(PunchError::Tool {
125                tool: "patch".into(),
126                message: format!("expected '+++ ' header after '--- ' at line {}", i),
127            });
128        }
129        let new_header = lines[i];
130        i += 1;
131
132        let old_path = parse_file_path(old_header, "--- ");
133        let new_path = parse_file_path(new_header, "+++ ");
134
135        let is_new_file = old_path == "/dev/null";
136        let is_deleted = new_path == "/dev/null";
137
138        let mut hunks = Vec::new();
139
140        // Parse hunks for this file.
141        while i < lines.len() && lines[i].starts_with("@@ ") {
142            let (hunk, consumed) = parse_hunk(&lines[i..])?;
143            hunks.push(hunk);
144            i += consumed;
145        }
146
147        patches.push(FilePatch {
148            old_path: old_path.to_string(),
149            new_path: new_path.to_string(),
150            hunks,
151            is_new_file,
152            is_deleted,
153        });
154    }
155
156    Ok(PatchSet {
157        patches,
158        description: None,
159    })
160}
161
162/// Extract the file path from a `--- ` or `+++ ` header line.
163fn parse_file_path<'a>(line: &'a str, prefix: &str) -> &'a str {
164    let path = &line[prefix.len()..];
165    // Strip the a/ or b/ prefix if present.
166    if let Some(stripped) = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")) {
167        stripped
168    } else {
169        path
170    }
171}
172
173/// Parse a single hunk starting from an `@@ ... @@` header.
174/// Returns the parsed hunk and the number of lines consumed.
175fn parse_hunk(lines: &[&str]) -> PunchResult<(PatchHunk, usize)> {
176    let header = lines[0];
177    let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
178
179    let mut patch_lines = Vec::new();
180    let mut i = 1;
181
182    while i < lines.len() {
183        let line = lines[i];
184
185        // Stop at a new hunk header or a new file header.
186        if line.starts_with("@@ ") || line.starts_with("--- ") || line.starts_with("diff ") {
187            break;
188        }
189
190        // Handle "\ No newline at end of file" — skip it.
191        if line.starts_with("\\ ") {
192            i += 1;
193            continue;
194        }
195
196        if let Some(content) = line.strip_prefix(' ') {
197            patch_lines.push(PatchLine::Context(content.to_string()));
198        } else if let Some(content) = line.strip_prefix('-') {
199            patch_lines.push(PatchLine::Remove(content.to_string()));
200        } else if let Some(content) = line.strip_prefix('+') {
201            patch_lines.push(PatchLine::Add(content.to_string()));
202        } else if line.is_empty() {
203            // An empty line in a diff is a context line with empty content.
204            patch_lines.push(PatchLine::Context(String::new()));
205        } else {
206            // Unrecognized line — treat as context (some diffs omit the space prefix
207            // for empty context lines).
208            patch_lines.push(PatchLine::Context(line.to_string()));
209        }
210
211        i += 1;
212    }
213
214    Ok((
215        PatchHunk {
216            old_start,
217            old_count,
218            new_start,
219            new_count,
220            lines: patch_lines,
221        },
222        i,
223    ))
224}
225
226/// Parse the `@@ -old_start,old_count +new_start,new_count @@` header.
227fn parse_hunk_header(header: &str) -> PunchResult<(usize, usize, usize, usize)> {
228    // Strip the leading @@ and trailing @@ (and any trailing context text).
229    let inner = header
230        .strip_prefix("@@ ")
231        .and_then(|s| s.find(" @@").map(|pos| &s[..pos]))
232        .ok_or_else(|| PunchError::Tool {
233            tool: "patch".into(),
234            message: format!("malformed hunk header: {}", header),
235        })?;
236
237    // inner looks like: "-old_start,old_count +new_start,new_count"
238    let parts: Vec<&str> = inner.split_whitespace().collect();
239    if parts.len() != 2 {
240        return Err(PunchError::Tool {
241            tool: "patch".into(),
242            message: format!("malformed hunk header ranges: {}", header),
243        });
244    }
245
246    let (old_start, old_count) = parse_range(parts[0], '-')?;
247    let (new_start, new_count) = parse_range(parts[1], '+')?;
248
249    Ok((old_start, old_count, new_start, new_count))
250}
251
252/// Parse a range like `-3,7` or `+1,4` or `-3` (implicit count = 1) or `+0,0`.
253fn parse_range(s: &str, prefix: char) -> PunchResult<(usize, usize)> {
254    let s = s.strip_prefix(prefix).ok_or_else(|| PunchError::Tool {
255        tool: "patch".into(),
256        message: format!("expected '{}' prefix in range '{}'", prefix, s),
257    })?;
258
259    if let Some((start_str, count_str)) = s.split_once(',') {
260        let start = start_str.parse::<usize>().map_err(|e| PunchError::Tool {
261            tool: "patch".into(),
262            message: format!("invalid range start '{}': {}", start_str, e),
263        })?;
264        let count = count_str.parse::<usize>().map_err(|e| PunchError::Tool {
265            tool: "patch".into(),
266            message: format!("invalid range count '{}': {}", count_str, e),
267        })?;
268        Ok((start, count))
269    } else {
270        // No comma — single line, count is implicitly 1.
271        let start = s.parse::<usize>().map_err(|e| PunchError::Tool {
272            tool: "patch".into(),
273            message: format!("invalid range '{}': {}", s, e),
274        })?;
275        Ok((start, 1))
276    }
277}
278
279// ---------------------------------------------------------------------------
280// Application
281// ---------------------------------------------------------------------------
282
283/// Apply a single file patch to the original content — execute the combo.
284///
285/// Each hunk is applied in order. Context lines are verified against the original
286/// to ensure the move connects. Returns the modified content as a string.
287pub fn apply_patch(original: &str, patch: &FilePatch) -> PunchResult<String> {
288    if patch.is_new_file {
289        // New file: original should be empty, just collect added lines.
290        let mut result = String::new();
291        for hunk in &patch.hunks {
292            for line in &hunk.lines {
293                if let PatchLine::Add(content) = line {
294                    if !result.is_empty() {
295                        result.push('\n');
296                    }
297                    result.push_str(content);
298                }
299            }
300        }
301        return Ok(result);
302    }
303
304    let orig_lines: Vec<&str> = if original.is_empty() {
305        Vec::new()
306    } else {
307        original.lines().collect()
308    };
309
310    let mut result_lines: Vec<String> = Vec::new();
311    // Current position in the original file (0-based index).
312    let mut orig_pos: usize = 0;
313
314    for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
315        // hunk.old_start is 1-based.
316        let hunk_start = if hunk.old_start == 0 {
317            0
318        } else {
319            hunk.old_start - 1
320        };
321
322        // Copy any lines before this hunk that we haven't consumed yet.
323        while orig_pos < hunk_start && orig_pos < orig_lines.len() {
324            result_lines.push(orig_lines[orig_pos].to_string());
325            orig_pos += 1;
326        }
327
328        // Apply the hunk lines.
329        for line in &hunk.lines {
330            match line {
331                PatchLine::Context(content) => {
332                    if orig_pos < orig_lines.len() {
333                        if orig_lines[orig_pos] != content.as_str() {
334                            return Err(PunchError::Tool {
335                                tool: "patch".into(),
336                                message: format!(
337                                    "combo broken at hunk {}: context mismatch at line {} — \
338                                     expected {:?}, found {:?}",
339                                    hunk_idx + 1,
340                                    orig_pos + 1,
341                                    content,
342                                    orig_lines[orig_pos]
343                                ),
344                            });
345                        }
346                        result_lines.push(orig_lines[orig_pos].to_string());
347                        orig_pos += 1;
348                    } else {
349                        return Err(PunchError::Tool {
350                            tool: "patch".into(),
351                            message: format!(
352                                "combo broken at hunk {}: ran out of original lines at context line",
353                                hunk_idx + 1,
354                            ),
355                        });
356                    }
357                }
358                PatchLine::Remove(content) => {
359                    if orig_pos < orig_lines.len() {
360                        if orig_lines[orig_pos] != content.as_str() {
361                            return Err(PunchError::Tool {
362                                tool: "patch".into(),
363                                message: format!(
364                                    "combo broken at hunk {}: remove mismatch at line {} — \
365                                     expected {:?}, found {:?}",
366                                    hunk_idx + 1,
367                                    orig_pos + 1,
368                                    content,
369                                    orig_lines[orig_pos]
370                                ),
371                            });
372                        }
373                        orig_pos += 1;
374                    } else {
375                        return Err(PunchError::Tool {
376                            tool: "patch".into(),
377                            message: format!(
378                                "combo broken at hunk {}: ran out of original lines for removal",
379                                hunk_idx + 1,
380                            ),
381                        });
382                    }
383                }
384                PatchLine::Add(content) => {
385                    result_lines.push(content.clone());
386                }
387            }
388        }
389    }
390
391    // Copy remaining lines after the last hunk.
392    while orig_pos < orig_lines.len() {
393        result_lines.push(orig_lines[orig_pos].to_string());
394        orig_pos += 1;
395    }
396
397    Ok(result_lines.join("\n"))
398}
399
400/// Apply a file patch with fuzzy matching — allow hunks to land at a nearby offset.
401///
402/// The `fuzz_factor` specifies how many lines of offset to search in each direction
403/// when a hunk doesn't land cleanly at its expected position. This is the
404/// equivalent of a loose combo that still connects.
405pub fn apply_patch_fuzzy(
406    original: &str,
407    patch: &FilePatch,
408    fuzz_factor: usize,
409) -> PunchResult<String> {
410    if patch.is_new_file {
411        return apply_patch(original, patch);
412    }
413
414    let orig_lines: Vec<&str> = if original.is_empty() {
415        Vec::new()
416    } else {
417        original.lines().collect()
418    };
419
420    let mut result_lines: Vec<String> = Vec::new();
421    let mut orig_pos: usize = 0;
422
423    for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
424        let nominal_start = if hunk.old_start == 0 {
425            0
426        } else {
427            hunk.old_start - 1
428        };
429
430        // Try to find where the hunk context actually matches.
431        let actual_start = find_hunk_match(&orig_lines, hunk, nominal_start, fuzz_factor)
432            .ok_or_else(|| PunchError::Tool {
433                tool: "patch".into(),
434                message: format!(
435                    "combo broken at hunk {}: could not find matching context within fuzz factor {}",
436                    hunk_idx + 1,
437                    fuzz_factor
438                ),
439            })?;
440
441        // Copy lines between current position and hunk start.
442        while orig_pos < actual_start && orig_pos < orig_lines.len() {
443            result_lines.push(orig_lines[orig_pos].to_string());
444            orig_pos += 1;
445        }
446
447        // Apply the hunk.
448        for line in &hunk.lines {
449            match line {
450                PatchLine::Context(_) => {
451                    if orig_pos < orig_lines.len() {
452                        result_lines.push(orig_lines[orig_pos].to_string());
453                        orig_pos += 1;
454                    }
455                }
456                PatchLine::Remove(_) => {
457                    if orig_pos < orig_lines.len() {
458                        orig_pos += 1;
459                    }
460                }
461                PatchLine::Add(content) => {
462                    result_lines.push(content.clone());
463                }
464            }
465        }
466    }
467
468    // Copy remaining original lines.
469    while orig_pos < orig_lines.len() {
470        result_lines.push(orig_lines[orig_pos].to_string());
471        orig_pos += 1;
472    }
473
474    Ok(result_lines.join("\n"))
475}
476
477/// Try to find where a hunk's context/remove lines match in the original,
478/// searching around `nominal_start` within `fuzz_factor` lines.
479fn find_hunk_match(
480    orig_lines: &[&str],
481    hunk: &PatchHunk,
482    nominal_start: usize,
483    fuzz_factor: usize,
484) -> Option<usize> {
485    // Collect the lines the hunk expects to see in the original (Context + Remove).
486    let expected: Vec<&str> = hunk
487        .lines
488        .iter()
489        .filter_map(|l| match l {
490            PatchLine::Context(s) => Some(s.as_str()),
491            PatchLine::Remove(s) => Some(s.as_str()),
492            _ => None,
493        })
494        .collect();
495
496    if expected.is_empty() {
497        // Pure addition hunk — matches anywhere.
498        return Some(nominal_start.min(orig_lines.len()));
499    }
500
501    // Try offset 0 first, then expanding outward.
502    for offset in 0..=fuzz_factor {
503        // Try at nominal_start + offset
504        if let Some(start) = nominal_start.checked_add(offset)
505            && matches_at(orig_lines, &expected, start)
506        {
507            return Some(start);
508        }
509        // Try at nominal_start - offset (if offset > 0)
510        if offset > 0
511            && let Some(start) = nominal_start.checked_sub(offset)
512            && matches_at(orig_lines, &expected, start)
513        {
514            return Some(start);
515        }
516    }
517
518    None
519}
520
521/// Check if the expected lines match the original starting at `start`.
522fn matches_at(orig_lines: &[&str], expected: &[&str], start: usize) -> bool {
523    if start + expected.len() > orig_lines.len() {
524        return false;
525    }
526    expected
527        .iter()
528        .zip(&orig_lines[start..start + expected.len()])
529        .all(|(exp, orig)| exp == orig)
530}
531
532// ---------------------------------------------------------------------------
533// Validation
534// ---------------------------------------------------------------------------
535
536/// Validate a patch against the original content without applying it.
537///
538/// Returns a list of conflicts — an empty vec means the combo will land cleanly.
539pub fn validate_patch(original: &str, patch: &FilePatch) -> Vec<PatchConflict> {
540    let orig_lines: Vec<&str> = if original.is_empty() {
541        Vec::new()
542    } else {
543        original.lines().collect()
544    };
545
546    let mut conflicts = Vec::new();
547
548    for (hunk_idx, hunk) in patch.hunks.iter().enumerate() {
549        let nominal_start = if hunk.old_start == 0 {
550            0
551        } else {
552            hunk.old_start - 1
553        };
554        let mut line_pos = nominal_start;
555
556        for line in &hunk.lines {
557            match line {
558                PatchLine::Context(expected) => {
559                    if line_pos >= orig_lines.len() {
560                        conflicts.push(PatchConflict {
561                            hunk_index: hunk_idx,
562                            expected_line: expected.clone(),
563                            actual_line: String::new(),
564                            line_number: line_pos + 1,
565                            conflict_type: ConflictType::LineNotFound,
566                        });
567                    } else if orig_lines[line_pos] != expected.as_str() {
568                        // Check if it can be found at a nearby offset.
569                        let found_offset = find_line_nearby(&orig_lines, expected, line_pos, 10);
570                        let conflict_type = if let Some(actual_pos) = found_offset {
571                            ConflictType::OffsetApplied(actual_pos as i32 - line_pos as i32)
572                        } else {
573                            ConflictType::ContextMismatch
574                        };
575                        conflicts.push(PatchConflict {
576                            hunk_index: hunk_idx,
577                            expected_line: expected.clone(),
578                            actual_line: orig_lines[line_pos].to_string(),
579                            line_number: line_pos + 1,
580                            conflict_type,
581                        });
582                    }
583                    line_pos += 1;
584                }
585                PatchLine::Remove(expected) => {
586                    if line_pos >= orig_lines.len() {
587                        conflicts.push(PatchConflict {
588                            hunk_index: hunk_idx,
589                            expected_line: expected.clone(),
590                            actual_line: String::new(),
591                            line_number: line_pos + 1,
592                            conflict_type: ConflictType::LineNotFound,
593                        });
594                    } else if orig_lines[line_pos] != expected.as_str() {
595                        conflicts.push(PatchConflict {
596                            hunk_index: hunk_idx,
597                            expected_line: expected.clone(),
598                            actual_line: orig_lines[line_pos].to_string(),
599                            line_number: line_pos + 1,
600                            conflict_type: ConflictType::ContextMismatch,
601                        });
602                    }
603                    line_pos += 1;
604                }
605                PatchLine::Add(_) => {
606                    // Additions don't consume original lines — no conflict possible.
607                }
608            }
609        }
610    }
611
612    conflicts
613}
614
615/// Search for a line near `pos` within `radius` lines.
616fn find_line_nearby(orig_lines: &[&str], target: &str, pos: usize, radius: usize) -> Option<usize> {
617    for offset in 1..=radius {
618        if let Some(p) = pos.checked_add(offset)
619            && p < orig_lines.len()
620            && orig_lines[p] == target
621        {
622            return Some(p);
623        }
624        if let Some(p) = pos.checked_sub(offset)
625            && orig_lines[p] == target
626        {
627            return Some(p);
628        }
629    }
630    None
631}
632
633// ---------------------------------------------------------------------------
634// Diff generation — simplified Myers-style algorithm
635// ---------------------------------------------------------------------------
636
637/// Generate a unified diff from two strings — choreograph the combo notation.
638///
639/// Uses a simplified line-by-line diff algorithm based on longest common
640/// subsequence (LCS) to produce standard unified diff output with context lines.
641pub fn generate_unified_diff(
642    old_content: &str,
643    new_content: &str,
644    old_path: &str,
645    new_path: &str,
646) -> String {
647    let old_lines: Vec<&str> = if old_content.is_empty() {
648        Vec::new()
649    } else {
650        old_content.lines().collect()
651    };
652    let new_lines: Vec<&str> = if new_content.is_empty() {
653        Vec::new()
654    } else {
655        new_content.lines().collect()
656    };
657
658    let edits = compute_edit_script(&old_lines, &new_lines);
659    let hunks = edits_to_hunks(&old_lines, &new_lines, &edits, 3);
660
661    if hunks.is_empty() {
662        return String::new();
663    }
664
665    let mut output = String::new();
666    output.push_str(&format!("--- a/{}\n", old_path));
667    output.push_str(&format!("+++ b/{}\n", new_path));
668
669    for hunk in &hunks {
670        output.push_str(&format!(
671            "@@ -{},{} +{},{} @@\n",
672            hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
673        ));
674        for line in &hunk.lines {
675            match line {
676                PatchLine::Context(s) => {
677                    output.push(' ');
678                    output.push_str(s);
679                    output.push('\n');
680                }
681                PatchLine::Remove(s) => {
682                    output.push('-');
683                    output.push_str(s);
684                    output.push('\n');
685                }
686                PatchLine::Add(s) => {
687                    output.push('+');
688                    output.push_str(s);
689                    output.push('\n');
690                }
691            }
692        }
693    }
694
695    output
696}
697
698/// Edit operation for the diff algorithm.
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum EditOp {
701    /// Line is the same in both files.
702    Equal,
703    /// Line was removed from the old file.
704    Delete,
705    /// Line was inserted in the new file.
706    Insert,
707}
708
709/// Compute a line-level edit script using LCS.
710fn compute_edit_script<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<(EditOp, usize, usize)> {
711    let m = old.len();
712    let n = new.len();
713
714    // Build LCS table.
715    let mut dp = vec![vec![0usize; n + 1]; m + 1];
716    for i in 1..=m {
717        for j in 1..=n {
718            if old[i - 1] == new[j - 1] {
719                dp[i][j] = dp[i - 1][j - 1] + 1;
720            } else {
721                dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
722            }
723        }
724    }
725
726    // Backtrack to produce the edit script.
727    let mut edits = Vec::new();
728    let mut i = m;
729    let mut j = n;
730
731    while i > 0 || j > 0 {
732        if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
733            edits.push((EditOp::Equal, i - 1, j - 1));
734            i -= 1;
735            j -= 1;
736        } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
737            edits.push((EditOp::Insert, i, j - 1));
738            j -= 1;
739        } else if i > 0 {
740            edits.push((EditOp::Delete, i - 1, j));
741            i -= 1;
742        }
743    }
744
745    edits.reverse();
746    edits
747}
748
749/// Group edit operations into hunks with context lines.
750fn edits_to_hunks(
751    old_lines: &[&str],
752    new_lines: &[&str],
753    edits: &[(EditOp, usize, usize)],
754    context: usize,
755) -> Vec<PatchHunk> {
756    if edits.is_empty() {
757        return Vec::new();
758    }
759
760    // Find ranges of non-equal edits, then expand with context.
761    let mut change_ranges: Vec<(usize, usize)> = Vec::new(); // (start_idx, end_idx) in edits
762    let mut i = 0;
763    while i < edits.len() {
764        if edits[i].0 != EditOp::Equal {
765            let start = i;
766            while i < edits.len() && edits[i].0 != EditOp::Equal {
767                i += 1;
768            }
769            change_ranges.push((start, i));
770        } else {
771            i += 1;
772        }
773    }
774
775    if change_ranges.is_empty() {
776        return Vec::new();
777    }
778
779    // Merge nearby change ranges and build hunks with context.
780    let mut hunks = Vec::new();
781    let mut range_idx = 0;
782
783    while range_idx < change_ranges.len() {
784        let (first_start, _) = change_ranges[range_idx];
785
786        // Find how many ranges to merge (those within 2*context of each other).
787        let mut last_end = change_ranges[range_idx].1;
788        let mut merge_end = range_idx;
789        while merge_end + 1 < change_ranges.len() {
790            let next_start = change_ranges[merge_end + 1].0;
791            // If the gap between ranges is <= 2*context, merge them.
792            if next_start - last_end <= 2 * context {
793                merge_end += 1;
794                last_end = change_ranges[merge_end].1;
795            } else {
796                break;
797            }
798        }
799
800        // Build a hunk covering first_start..last_end with context.
801        let hunk_edit_start = first_start.saturating_sub(context).max(0);
802        let hunk_edit_end = last_end
803            .min(edits.len())
804            .saturating_add(context)
805            .min(edits.len());
806
807        let mut hunk_lines = Vec::new();
808        let mut old_line_start = usize::MAX;
809        let mut old_count = 0usize;
810        let mut new_line_start = usize::MAX;
811        let mut new_count = 0usize;
812
813        for edit_idx in hunk_edit_start..hunk_edit_end {
814            if edit_idx >= edits.len() {
815                break;
816            }
817            let (op, old_idx, new_idx) = edits[edit_idx];
818
819            match op {
820                EditOp::Equal => {
821                    if old_idx < old_lines.len() {
822                        hunk_lines.push(PatchLine::Context(old_lines[old_idx].to_string()));
823                        if old_line_start == usize::MAX {
824                            old_line_start = old_idx;
825                            new_line_start = new_idx;
826                        }
827                        old_count += 1;
828                        new_count += 1;
829                    }
830                }
831                EditOp::Delete => {
832                    if old_idx < old_lines.len() {
833                        hunk_lines.push(PatchLine::Remove(old_lines[old_idx].to_string()));
834                        if old_line_start == usize::MAX {
835                            old_line_start = old_idx;
836                            new_line_start = new_idx;
837                        }
838                        old_count += 1;
839                    }
840                }
841                EditOp::Insert => {
842                    if new_idx < new_lines.len() {
843                        hunk_lines.push(PatchLine::Add(new_lines[new_idx].to_string()));
844                        if old_line_start == usize::MAX {
845                            old_line_start = old_idx;
846                            new_line_start = new_idx;
847                        }
848                        new_count += 1;
849                    }
850                }
851            }
852        }
853
854        if !hunk_lines.is_empty() {
855            hunks.push(PatchHunk {
856                old_start: if old_line_start == usize::MAX {
857                    0
858                } else {
859                    old_line_start + 1
860                },
861                old_count,
862                new_start: if new_line_start == usize::MAX {
863                    0
864                } else {
865                    new_line_start + 1
866                },
867                new_count,
868                lines: hunk_lines,
869            });
870        }
871
872        range_idx = merge_end + 1;
873    }
874
875    hunks
876}
877
878// ---------------------------------------------------------------------------
879// Rollback — reverse the combo
880// ---------------------------------------------------------------------------
881
882/// Create a reverse patch — swap additions and removals, old and new paths.
883///
884/// Applying the reversed patch to the patched content yields the original.
885/// When a combo goes wrong, this is how you rewind the tape.
886pub fn reverse_patch(patch: &FilePatch) -> FilePatch {
887    let reversed_hunks: Vec<PatchHunk> = patch
888        .hunks
889        .iter()
890        .map(|hunk| {
891            let reversed_lines: Vec<PatchLine> = hunk
892                .lines
893                .iter()
894                .map(|line| match line {
895                    PatchLine::Context(s) => PatchLine::Context(s.clone()),
896                    PatchLine::Remove(s) => PatchLine::Add(s.clone()),
897                    PatchLine::Add(s) => PatchLine::Remove(s.clone()),
898                })
899                .collect();
900
901            PatchHunk {
902                old_start: hunk.new_start,
903                old_count: hunk.new_count,
904                new_start: hunk.old_start,
905                new_count: hunk.old_count,
906                lines: reversed_lines,
907            }
908        })
909        .collect();
910
911    FilePatch {
912        old_path: patch.new_path.clone(),
913        new_path: patch.old_path.clone(),
914        hunks: reversed_hunks,
915        is_new_file: patch.is_deleted,
916        is_deleted: patch.is_new_file,
917    }
918}
919
920// ---------------------------------------------------------------------------
921// Tests
922// ---------------------------------------------------------------------------
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    #[test]
929    fn test_parse_simple_unified_diff() {
930        let diff = "\
931--- a/hello.rs
932+++ b/hello.rs
933@@ -1,3 +1,4 @@
934 fn main() {
935-    println!(\"hello\");
936+    println!(\"hello, world\");
937+    println!(\"goodbye\");
938 }
939";
940        let ps = parse_unified_diff(diff).expect("should parse");
941        assert_eq!(ps.patches.len(), 1);
942        let fp = &ps.patches[0];
943        assert_eq!(fp.old_path, "hello.rs");
944        assert_eq!(fp.new_path, "hello.rs");
945        assert!(!fp.is_new_file);
946        assert!(!fp.is_deleted);
947        assert_eq!(fp.hunks.len(), 1);
948        let h = &fp.hunks[0];
949        assert_eq!(h.old_start, 1);
950        assert_eq!(h.old_count, 3);
951        assert_eq!(h.new_start, 1);
952        assert_eq!(h.new_count, 4);
953        assert_eq!(h.lines.len(), 5);
954    }
955
956    #[test]
957    fn test_parse_multi_hunk_diff() {
958        let diff = "\
959--- a/lib.rs
960+++ b/lib.rs
961@@ -1,3 +1,3 @@
962 fn a() {
963-    old_a();
964+    new_a();
965 }
966@@ -10,3 +10,3 @@
967 fn b() {
968-    old_b();
969+    new_b();
970 }
971";
972        let ps = parse_unified_diff(diff).expect("should parse");
973        assert_eq!(ps.patches.len(), 1);
974        assert_eq!(ps.patches[0].hunks.len(), 2);
975        assert_eq!(ps.patches[0].hunks[0].old_start, 1);
976        assert_eq!(ps.patches[0].hunks[1].old_start, 10);
977    }
978
979    #[test]
980    fn test_parse_new_file_diff() {
981        let diff = "\
982--- /dev/null
983+++ b/new_file.rs
984@@ -0,0 +1,3 @@
985+fn new_func() {
986+    // brand new
987+}
988";
989        let ps = parse_unified_diff(diff).expect("should parse");
990        assert_eq!(ps.patches.len(), 1);
991        assert!(ps.patches[0].is_new_file);
992        assert!(!ps.patches[0].is_deleted);
993        assert_eq!(ps.patches[0].new_path, "new_file.rs");
994    }
995
996    #[test]
997    fn test_parse_deleted_file_diff() {
998        let diff = "\
999--- a/dead.rs
1000+++ /dev/null
1001@@ -1,3 +0,0 @@
1002-fn old() {
1003-    // going away
1004-}
1005";
1006        let ps = parse_unified_diff(diff).expect("should parse");
1007        assert_eq!(ps.patches.len(), 1);
1008        assert!(!ps.patches[0].is_new_file);
1009        assert!(ps.patches[0].is_deleted);
1010        assert_eq!(ps.patches[0].old_path, "dead.rs");
1011    }
1012
1013    #[test]
1014    fn test_apply_simple_addition() {
1015        let original = "line1\nline2\nline3";
1016        let patch = FilePatch {
1017            old_path: "f.txt".into(),
1018            new_path: "f.txt".into(),
1019            hunks: vec![PatchHunk {
1020                old_start: 2,
1021                old_count: 1,
1022                new_start: 2,
1023                new_count: 2,
1024                lines: vec![
1025                    PatchLine::Context("line2".into()),
1026                    PatchLine::Add("inserted".into()),
1027                ],
1028            }],
1029            is_new_file: false,
1030            is_deleted: false,
1031        };
1032        let result = apply_patch(original, &patch).expect("should apply");
1033        assert_eq!(result, "line1\nline2\ninserted\nline3");
1034    }
1035
1036    #[test]
1037    fn test_apply_simple_deletion() {
1038        let original = "line1\nline2\nline3";
1039        let patch = FilePatch {
1040            old_path: "f.txt".into(),
1041            new_path: "f.txt".into(),
1042            hunks: vec![PatchHunk {
1043                old_start: 1,
1044                old_count: 3,
1045                new_start: 1,
1046                new_count: 2,
1047                lines: vec![
1048                    PatchLine::Context("line1".into()),
1049                    PatchLine::Remove("line2".into()),
1050                    PatchLine::Context("line3".into()),
1051                ],
1052            }],
1053            is_new_file: false,
1054            is_deleted: false,
1055        };
1056        let result = apply_patch(original, &patch).expect("should apply");
1057        assert_eq!(result, "line1\nline3");
1058    }
1059
1060    #[test]
1061    fn test_apply_modification() {
1062        let original = "fn main() {\n    println!(\"old\");\n}";
1063        let patch = FilePatch {
1064            old_path: "f.rs".into(),
1065            new_path: "f.rs".into(),
1066            hunks: vec![PatchHunk {
1067                old_start: 1,
1068                old_count: 3,
1069                new_start: 1,
1070                new_count: 3,
1071                lines: vec![
1072                    PatchLine::Context("fn main() {".into()),
1073                    PatchLine::Remove("    println!(\"old\");".into()),
1074                    PatchLine::Add("    println!(\"new\");".into()),
1075                    PatchLine::Context("}".into()),
1076                ],
1077            }],
1078            is_new_file: false,
1079            is_deleted: false,
1080        };
1081        let result = apply_patch(original, &patch).expect("should apply");
1082        assert_eq!(result, "fn main() {\n    println!(\"new\");\n}");
1083    }
1084
1085    #[test]
1086    fn test_apply_multi_hunk() {
1087        let original = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj";
1088        let patch = FilePatch {
1089            old_path: "f.txt".into(),
1090            new_path: "f.txt".into(),
1091            hunks: vec![
1092                PatchHunk {
1093                    old_start: 2,
1094                    old_count: 1,
1095                    new_start: 2,
1096                    new_count: 1,
1097                    lines: vec![PatchLine::Remove("b".into()), PatchLine::Add("B".into())],
1098                },
1099                PatchHunk {
1100                    old_start: 8,
1101                    old_count: 1,
1102                    new_start: 8,
1103                    new_count: 1,
1104                    lines: vec![PatchLine::Remove("h".into()), PatchLine::Add("H".into())],
1105                },
1106            ],
1107            is_new_file: false,
1108            is_deleted: false,
1109        };
1110        let result = apply_patch(original, &patch).expect("should apply");
1111        assert_eq!(result, "a\nB\nc\nd\ne\nf\ng\nH\ni\nj");
1112    }
1113
1114    #[test]
1115    fn test_apply_new_file_patch() {
1116        let patch = FilePatch {
1117            old_path: "/dev/null".into(),
1118            new_path: "new.rs".into(),
1119            hunks: vec![PatchHunk {
1120                old_start: 0,
1121                old_count: 0,
1122                new_start: 1,
1123                new_count: 2,
1124                lines: vec![
1125                    PatchLine::Add("// new file".into()),
1126                    PatchLine::Add("fn hello() {}".into()),
1127                ],
1128            }],
1129            is_new_file: true,
1130            is_deleted: false,
1131        };
1132        let result = apply_patch("", &patch).expect("should apply");
1133        assert_eq!(result, "// new file\nfn hello() {}");
1134    }
1135
1136    #[test]
1137    fn test_fuzzy_matching_with_offset() {
1138        // The hunk says it starts at line 3, but the content has shifted by 2 lines.
1139        let original = "extra1\nextra2\na\nb\nc";
1140        let patch = FilePatch {
1141            old_path: "f.txt".into(),
1142            new_path: "f.txt".into(),
1143            hunks: vec![PatchHunk {
1144                old_start: 1,
1145                old_count: 2,
1146                new_start: 1,
1147                new_count: 2,
1148                lines: vec![
1149                    PatchLine::Context("a".into()),
1150                    PatchLine::Remove("b".into()),
1151                    PatchLine::Add("B".into()),
1152                ],
1153            }],
1154            is_new_file: false,
1155            is_deleted: false,
1156        };
1157        // Strict apply should fail.
1158        assert!(apply_patch(original, &patch).is_err());
1159        // Fuzzy with fuzz_factor=3 should succeed.
1160        let result = apply_patch_fuzzy(original, &patch, 3).expect("should apply fuzzy");
1161        assert_eq!(result, "extra1\nextra2\na\nB\nc");
1162    }
1163
1164    #[test]
1165    fn test_validate_clean_patch() {
1166        let original = "line1\nline2\nline3";
1167        let patch = FilePatch {
1168            old_path: "f.txt".into(),
1169            new_path: "f.txt".into(),
1170            hunks: vec![PatchHunk {
1171                old_start: 1,
1172                old_count: 3,
1173                new_start: 1,
1174                new_count: 3,
1175                lines: vec![
1176                    PatchLine::Context("line1".into()),
1177                    PatchLine::Remove("line2".into()),
1178                    PatchLine::Add("LINE2".into()),
1179                    PatchLine::Context("line3".into()),
1180                ],
1181            }],
1182            is_new_file: false,
1183            is_deleted: false,
1184        };
1185        let conflicts = validate_patch(original, &patch);
1186        assert!(
1187            conflicts.is_empty(),
1188            "expected no conflicts, got {:?}",
1189            conflicts
1190        );
1191    }
1192
1193    #[test]
1194    fn test_validate_conflicting_patch() {
1195        let original = "line1\nDIFFERENT\nline3";
1196        let patch = FilePatch {
1197            old_path: "f.txt".into(),
1198            new_path: "f.txt".into(),
1199            hunks: vec![PatchHunk {
1200                old_start: 1,
1201                old_count: 3,
1202                new_start: 1,
1203                new_count: 3,
1204                lines: vec![
1205                    PatchLine::Context("line1".into()),
1206                    PatchLine::Remove("line2".into()),
1207                    PatchLine::Add("LINE2".into()),
1208                    PatchLine::Context("line3".into()),
1209                ],
1210            }],
1211            is_new_file: false,
1212            is_deleted: false,
1213        };
1214        let conflicts = validate_patch(original, &patch);
1215        assert_eq!(conflicts.len(), 1);
1216        assert_eq!(conflicts[0].expected_line, "line2");
1217        assert_eq!(conflicts[0].actual_line, "DIFFERENT");
1218        assert_eq!(conflicts[0].conflict_type, ConflictType::ContextMismatch);
1219    }
1220
1221    #[test]
1222    fn test_generate_diff_from_two_strings() {
1223        let old = "line1\nline2\nline3";
1224        let new = "line1\nmodified\nline3";
1225        let diff = generate_unified_diff(old, new, "file.txt", "file.txt");
1226        assert!(diff.contains("--- a/file.txt"));
1227        assert!(diff.contains("+++ b/file.txt"));
1228        assert!(diff.contains("-line2"));
1229        assert!(diff.contains("+modified"));
1230    }
1231
1232    #[test]
1233    fn test_generated_diff_can_be_parsed_back() {
1234        let old = "alpha\nbeta\ngamma\ndelta";
1235        let new = "alpha\nBETA\ngamma\ndelta";
1236        let diff = generate_unified_diff(old, new, "test.txt", "test.txt");
1237        let parsed = parse_unified_diff(&diff).expect("should parse generated diff");
1238        assert_eq!(parsed.patches.len(), 1);
1239        assert!(!parsed.patches[0].hunks.is_empty());
1240    }
1241
1242    #[test]
1243    fn test_round_trip_generate_parse_apply() {
1244        let old = "fn main() {\n    println!(\"hello\");\n    let x = 1;\n}";
1245        let new = "fn main() {\n    println!(\"world\");\n    let x = 1;\n    let y = 2;\n}";
1246        let diff = generate_unified_diff(old, new, "main.rs", "main.rs");
1247        let parsed = parse_unified_diff(&diff).expect("should parse");
1248        assert_eq!(parsed.patches.len(), 1);
1249        let result = apply_patch(old, &parsed.patches[0]).expect("should apply");
1250        assert_eq!(result, new);
1251    }
1252
1253    #[test]
1254    fn test_reverse_patch_roundtrip() {
1255        let original = "line1\nline2\nline3";
1256        let patch = FilePatch {
1257            old_path: "f.txt".into(),
1258            new_path: "f.txt".into(),
1259            hunks: vec![PatchHunk {
1260                old_start: 1,
1261                old_count: 3,
1262                new_start: 1,
1263                new_count: 3,
1264                lines: vec![
1265                    PatchLine::Context("line1".into()),
1266                    PatchLine::Remove("line2".into()),
1267                    PatchLine::Add("CHANGED".into()),
1268                    PatchLine::Context("line3".into()),
1269                ],
1270            }],
1271            is_new_file: false,
1272            is_deleted: false,
1273        };
1274        let patched = apply_patch(original, &patch).expect("should apply");
1275        assert_eq!(patched, "line1\nCHANGED\nline3");
1276
1277        let reversed = reverse_patch(&patch);
1278        let restored = apply_patch(&patched, &reversed).expect("should apply reverse");
1279        assert_eq!(restored, original);
1280    }
1281
1282    #[test]
1283    fn test_empty_diff() {
1284        let ps = parse_unified_diff("").expect("should parse empty");
1285        assert!(ps.patches.is_empty());
1286    }
1287
1288    #[test]
1289    fn test_hunk_header_various_formats() {
1290        // Standard format with comma.
1291        let (s, c, ns, nc) = parse_hunk_header("@@ -1,3 +1,4 @@").expect("should parse");
1292        assert_eq!((s, c, ns, nc), (1, 3, 1, 4));
1293
1294        // No comma (single line).
1295        let (s, c, ns, nc) = parse_hunk_header("@@ -1 +1 @@").expect("should parse");
1296        assert_eq!((s, c, ns, nc), (1, 1, 1, 1));
1297
1298        // With trailing context text after @@.
1299        let (s, c, ns, nc) =
1300            parse_hunk_header("@@ -10,5 +12,7 @@ fn some_function()").expect("should parse");
1301        assert_eq!((s, c, ns, nc), (10, 5, 12, 7));
1302
1303        // Zero-line ranges (new file / deleted file).
1304        let (s, c, ns, nc) = parse_hunk_header("@@ -0,0 +1,3 @@").expect("should parse");
1305        assert_eq!((s, c, ns, nc), (0, 0, 1, 3));
1306    }
1307
1308    #[test]
1309    fn test_parse_diff_with_git_prefix() {
1310        let diff = "\
1311diff --git a/foo.rs b/foo.rs
1312index abc123..def456 100644
1313--- a/foo.rs
1314+++ b/foo.rs
1315@@ -1,2 +1,2 @@
1316 line1
1317-old
1318+new
1319";
1320        let ps = parse_unified_diff(diff).expect("should parse with git prefix");
1321        assert_eq!(ps.patches.len(), 1);
1322        assert_eq!(ps.patches[0].old_path, "foo.rs");
1323        assert_eq!(ps.patches[0].hunks.len(), 1);
1324    }
1325
1326    #[test]
1327    fn test_generate_empty_diff_for_identical_content() {
1328        let content = "same\ncontent\nhere";
1329        let diff = generate_unified_diff(content, content, "f.txt", "f.txt");
1330        assert!(
1331            diff.is_empty(),
1332            "identical content should produce empty diff"
1333        );
1334    }
1335}