oxihuman_core/
patch_apply.rs1#![allow(dead_code)]
4
5#[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#[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#[derive(Debug, Clone, PartialEq)]
37pub enum HunkLine {
38 Context(String),
39 Removed(String),
40 Added(String),
41}
42
43#[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
79pub 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 } else if line.starts_with("@@ ") {
88 if let Some(h) = current_hunk.take() {
90 patch.hunks.push(h);
91 }
92 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
117pub 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
154pub fn can_apply_cleanly(original: &[&str], patch: &UnifiedPatch) -> bool {
156 apply_patch(original, patch).is_ok()
157}
158
159pub 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}