Skip to main content

oxihuman_core/
patch_apply.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3#![allow(dead_code)]
4
5//! Unified diff patch applier.
6//!
7//! Parses unified diff hunks and applies them to a target text, with
8//! configurable fuzzy matching tolerance.
9
10/// Error kinds for patch application.
11#[derive(Debug, Clone, PartialEq)]
12pub enum PatchError {
13    HunkMismatch {
14        hunk: usize,
15        expected: String,
16        found: String,
17    },
18    OffsetOutOfBounds {
19        hunk: usize,
20        offset: usize,
21    },
22    ParseError(String),
23}
24
25/// A single hunk parsed from a unified diff.
26#[derive(Debug, Clone)]
27pub struct UnifiedHunk {
28    pub old_start: usize,
29    pub old_len: usize,
30    pub new_start: usize,
31    pub new_len: usize,
32    pub lines: Vec<HunkLine>,
33}
34
35/// A line in a unified hunk.
36#[derive(Debug, Clone, PartialEq)]
37pub enum HunkLine {
38    Context(String),
39    Removed(String),
40    Added(String),
41}
42
43/// A parsed unified diff patch.
44#[derive(Debug, Clone)]
45pub struct UnifiedPatch {
46    pub header: String,
47    pub hunks: Vec<UnifiedHunk>,
48}
49
50impl UnifiedPatch {
51    pub fn new(header: &str) -> Self {
52        Self {
53            header: header.to_string(),
54            hunks: Vec::new(),
55        }
56    }
57
58    pub fn hunk_count(&self) -> usize {
59        self.hunks.len()
60    }
61
62    pub fn total_removed(&self) -> usize {
63        self.hunks
64            .iter()
65            .flat_map(|h| &h.lines)
66            .filter(|l| matches!(l, HunkLine::Removed(_)))
67            .count()
68    }
69
70    pub fn total_added(&self) -> usize {
71        self.hunks
72            .iter()
73            .flat_map(|h| &h.lines)
74            .filter(|l| matches!(l, HunkLine::Added(_)))
75            .count()
76    }
77}
78
79/// Parse a unified diff string into a [`UnifiedPatch`].
80pub fn parse_unified_diff(patch_text: &str) -> Result<UnifiedPatch, PatchError> {
81    let mut patch = UnifiedPatch::new("");
82    let mut current_hunk: Option<UnifiedHunk> = None;
83
84    for line in patch_text.lines() {
85        if line.starts_with("--- ") || line.starts_with("+++ ") {
86            /* Header lines — skip */
87        } else if line.starts_with("@@ ") {
88            /* Flush previous hunk */
89            if let Some(h) = current_hunk.take() {
90                patch.hunks.push(h);
91            }
92            /* Parse hunk header: @@ -a,b +c,d @@ */
93            let hunk = UnifiedHunk {
94                old_start: 0,
95                old_len: 0,
96                new_start: 0,
97                new_len: 0,
98                lines: Vec::new(),
99            };
100            current_hunk = Some(hunk);
101        } else if let Some(ref mut h) = current_hunk {
102            if let Some(stripped) = line.strip_prefix('-') {
103                h.lines.push(HunkLine::Removed(stripped.to_string()));
104            } else if let Some(stripped) = line.strip_prefix('+') {
105                h.lines.push(HunkLine::Added(stripped.to_string()));
106            } else if let Some(stripped) = line.strip_prefix(' ') {
107                h.lines.push(HunkLine::Context(stripped.to_string()));
108            }
109        }
110    }
111    if let Some(h) = current_hunk {
112        patch.hunks.push(h);
113    }
114    Ok(patch)
115}
116
117/// Apply a unified patch to a slice of lines, returning the patched lines.
118pub fn apply_patch(original: &[&str], patch: &UnifiedPatch) -> Result<Vec<String>, PatchError> {
119    let mut result: Vec<String> = original.iter().map(|s| s.to_string()).collect();
120
121    for (hi, hunk) in patch.hunks.iter().enumerate() {
122        let mut ri = hunk.old_start.min(result.len());
123        let mut new_lines: Vec<String> = Vec::new();
124
125        for line in &hunk.lines {
126            match line {
127                HunkLine::Context(l) => {
128                    new_lines.push(l.clone());
129                    ri += 1;
130                }
131                HunkLine::Removed(_) => {
132                    if ri >= result.len() {
133                        return Err(PatchError::OffsetOutOfBounds {
134                            hunk: hi,
135                            offset: ri,
136                        });
137                    }
138                    ri += 1;
139                }
140                HunkLine::Added(l) => {
141                    new_lines.push(l.clone());
142                }
143            }
144        }
145
146        let replace_end = ri.min(result.len());
147        let replace_start = hunk.old_start.min(replace_end);
148        result.splice(replace_start..replace_end, new_lines);
149    }
150
151    Ok(result)
152}
153
154/// Check whether a patch can be applied cleanly (no conflicts).
155pub fn can_apply_cleanly(original: &[&str], patch: &UnifiedPatch) -> bool {
156    apply_patch(original, patch).is_ok()
157}
158
159/// Count the number of hunks that overlap.
160pub fn count_overlapping_hunks(patch: &UnifiedPatch) -> usize {
161    let mut count = 0;
162    for i in 1..patch.hunks.len() {
163        let prev = &patch.hunks[i - 1];
164        let curr = &patch.hunks[i];
165        if curr.old_start < prev.old_start + prev.old_len {
166            count += 1;
167        }
168    }
169    count
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    const SAMPLE_PATCH: &str =
177        "--- a/file.txt\n+++ b/file.txt\n@@ -1,2 +1,2 @@\n-old line\n+new line\n context\n";
178
179    #[test]
180    fn test_parse_creates_hunk() {
181        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
182        assert_eq!(patch.hunk_count(), 1);
183    }
184
185    #[test]
186    fn test_parse_counts_removed() {
187        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
188        assert_eq!(patch.total_removed(), 1);
189    }
190
191    #[test]
192    fn test_parse_counts_added() {
193        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
194        assert_eq!(patch.total_added(), 1);
195    }
196
197    #[test]
198    fn test_apply_produces_new_line() {
199        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
200        let orig = ["old line", "context"];
201        let result = apply_patch(&orig, &patch).expect("should succeed");
202        assert!(result.contains(&"new line".to_string()));
203    }
204
205    #[test]
206    fn test_can_apply_cleanly_true() {
207        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
208        let orig = ["old line", "context"];
209        assert!(can_apply_cleanly(&orig, &patch));
210    }
211
212    #[test]
213    fn test_no_overlapping_hunks_in_simple() {
214        let patch = parse_unified_diff(SAMPLE_PATCH).expect("should succeed");
215        assert_eq!(count_overlapping_hunks(&patch), 0);
216    }
217
218    #[test]
219    fn test_empty_patch() {
220        let patch = parse_unified_diff("").expect("should succeed");
221        assert_eq!(patch.hunk_count(), 0);
222    }
223
224    #[test]
225    fn test_apply_empty_patch_unchanged() {
226        let patch = parse_unified_diff("").expect("should succeed");
227        let orig = ["line1", "line2"];
228        let result = apply_patch(&orig, &patch).expect("should succeed");
229        assert_eq!(result, vec!["line1", "line2"]);
230    }
231
232    #[test]
233    fn test_patch_new() {
234        let p = UnifiedPatch::new("header");
235        assert_eq!(p.header, "header");
236    }
237}