1use super::compute::split_line_slices;
10use super::parse::{parse_hunk_range, trim_line_ending};
11use super::{DiffDocument, DiffHunk, DiffLineKind, ParseDiffError};
12
13pub const MAX_REVERSE_FILE_BYTES: usize = 8 * 1024 * 1024;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct UnifiedFile {
20 pub old_path: Option<String>,
22 pub new_path: Option<String>,
24 pub hunks: Vec<DiffHunk>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum ReverseApplyError {
32 EmptyDiff,
34 TooLarge,
36 EmptyNewSide {
38 hunk: usize,
40 },
41 NoMatch {
44 hunk: usize,
46 },
47 Ambiguous {
50 hunk: usize,
52 },
53 OverlappingSpans,
55}
56
57impl std::fmt::Display for ReverseApplyError {
58 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 match self {
60 Self::EmptyDiff => formatter.write_str("no hunks to reverse"),
61 Self::TooLarge => formatter.write_str("file exceeds the reversal size bound"),
62 Self::EmptyNewSide { hunk } => write!(formatter, "hunk {hunk} has no new-side lines"),
63 Self::NoMatch { hunk } => {
64 write!(formatter, "hunk {hunk} does not match the current content")
65 }
66 Self::Ambiguous { hunk } => {
67 write!(formatter, "hunk {hunk} matches the current content more than once")
68 }
69 Self::OverlappingSpans => formatter.write_str("hunk matches overlap or run out of order"),
70 }
71 }
72}
73
74impl std::error::Error for ReverseApplyError {}
75
76pub fn split_unified_files(input: &str) -> Result<Vec<UnifiedFile>, ParseDiffError> {
91 for raw in split_line_slices(input) {
92 let trimmed = trim_line_ending(raw);
93 if trimmed == "\\ No newline at end of file" {
94 return Err(ParseDiffError::new("missing-newline marker cannot be reversed exactly"));
95 }
96 let body_prefixed = matches!(trimmed.as_bytes().first(), Some(b' ' | b'+' | b'-'));
99 if !body_prefixed && super::parse::parse_omitted_line_count(trimmed).is_some() {
100 return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
101 }
102 }
103
104 let mut sections: Vec<Section> = Vec::new();
110 let mut section_has_hunk = false;
111 let mut current = Section::default();
112 let mut expected_old = 0usize;
113 let mut expected_new = 0usize;
114 let mut seen_old = 0usize;
115 let mut seen_new = 0usize;
116 let mut in_hunk = false;
117
118 for raw in split_line_slices(input) {
119 let hunk_complete = !in_hunk || (seen_old >= expected_old && seen_new >= expected_new);
120 let trimmed = trim_line_ending(raw);
121 if trimmed.starts_with("@@") {
122 let (_, old_count, _, new_count) =
123 parse_hunk_range(trimmed).ok_or_else(|| ParseDiffError::new("invalid hunk header"))?;
124 in_hunk = true;
125 section_has_hunk = true;
126 expected_old = old_count;
127 expected_new = new_count;
128 seen_old = 0;
129 seen_new = 0;
130 current.lines.push(raw);
131 continue;
132 }
133 if trimmed.starts_with("diff --git ") {
134 if !current.lines.is_empty() {
137 sections.push(std::mem::take(&mut current));
138 section_has_hunk = false;
139 }
140 in_hunk = false;
141 if current.git.is_none() {
142 current.git = trimmed.strip_prefix("diff --git ");
143 }
144 current.lines.push(raw);
145 continue;
146 }
147 if hunk_complete && trimmed.starts_with("--- ") {
148 if !current.lines.is_empty() && (current.old.is_some() || section_has_hunk) {
153 sections.push(std::mem::take(&mut current));
154 section_has_hunk = false;
155 }
156 in_hunk = false;
157 if current.old.is_none() {
158 current.old = trimmed.strip_prefix("--- ");
159 }
160 current.lines.push(raw);
161 continue;
162 }
163 if hunk_complete && trimmed.starts_with("+++ ") && current.new.is_none() {
164 current.new = trimmed.strip_prefix("+++ ");
165 }
166 if in_hunk && !hunk_complete {
167 match trimmed.as_bytes().first().copied() {
168 Some(b'-') => seen_old += 1,
169 Some(b'+') => seen_new += 1,
170 Some(b' ') => {
171 seen_old += 1;
172 seen_new += 1;
173 }
174 _ => {}
175 }
176 }
177 current.lines.push(raw);
178 }
179 if !current.lines.is_empty() {
180 sections.push(current);
181 }
182
183 let mut files = Vec::with_capacity(sections.len());
184 for section in §ions {
185 let text: String = section.lines.concat();
186 let document = DiffDocument::from_unified(&text)?;
187 if document.stats.omitted_rows > 0 {
188 return Err(ParseDiffError::new("omitted rows cannot be reversed exactly"));
189 }
190 if document.hunks.is_empty() {
191 return Err(ParseDiffError::new("file section without hunks"));
192 }
193 let (old_path, new_path) = section_paths(section)?;
194 files.push(UnifiedFile { old_path, new_path, hunks: document.hunks });
195 }
196 Ok(files)
197}
198
199#[derive(Default)]
200struct Section<'a> {
201 lines: Vec<&'a str>,
202 old: Option<&'a str>,
203 new: Option<&'a str>,
204 git: Option<&'a str>,
205}
206
207pub fn reverse_apply_hunks(current: &str, hunks: &[DiffHunk]) -> Result<String, ReverseApplyError> {
223 if hunks.is_empty() {
224 return Err(ReverseApplyError::EmptyDiff);
225 }
226 if current.len() > MAX_REVERSE_FILE_BYTES {
227 return Err(ReverseApplyError::TooLarge);
228 }
229 let segments = split_line_slices(current);
230 let mut spans: Vec<(usize, usize, Vec<&str>)> = Vec::with_capacity(hunks.len());
233 for (index, hunk) in hunks.iter().enumerate() {
234 let new_block: Vec<&str> = hunk
235 .lines
236 .iter()
237 .filter(|line| line.kind != DiffLineKind::Deletion)
238 .map(|line| line.text.as_str())
239 .collect();
240 if new_block.is_empty() {
241 return Err(ReverseApplyError::EmptyNewSide { hunk: index });
242 }
243 let matches: Vec<usize> = segments
246 .windows(new_block.len())
247 .enumerate()
248 .filter_map(|(start, window)| (window == new_block.as_slice()).then_some(start))
249 .take(2)
250 .collect();
251 let [start] = matches.as_slice() else {
252 if matches.is_empty() {
253 return Err(ReverseApplyError::NoMatch { hunk: index });
254 }
255 return Err(ReverseApplyError::Ambiguous { hunk: index });
256 };
257 let old_block: Vec<&str> = hunk
258 .lines
259 .iter()
260 .filter(|line| line.kind != DiffLineKind::Addition)
261 .map(|line| line.text.as_str())
262 .collect();
263 spans.push((*start, start + new_block.len(), old_block));
264 }
265 for pair in spans.windows(2) {
266 let [(.., previous_end, _), (start, ..)] = pair else {
267 continue;
268 };
269 if start < previous_end {
270 return Err(ReverseApplyError::OverlappingSpans);
271 }
272 }
273
274 let mut restored = String::with_capacity(current.len());
275 let mut cursor = 0usize;
276 for (start, end, old_block) in &spans {
277 for segment in &segments[cursor..*start] {
278 restored.push_str(segment);
279 }
280 for line in old_block {
281 restored.push_str(line);
282 }
283 cursor = *end;
284 }
285 for segment in &segments[cursor..] {
286 restored.push_str(segment);
287 }
288 Ok(restored)
289}
290
291fn section_paths(section: &Section<'_>) -> Result<(Option<String>, Option<String>), ParseDiffError> {
292 if section.old.is_some() || section.new.is_some() {
293 let old_path = section.old.map(normalize_diff_path).transpose()?.flatten();
294 let new_path = section.new.map(normalize_diff_path).transpose()?.flatten();
295 return Ok((old_path, new_path));
296 }
297 if let Some(git) = section.git {
298 let (old, new) = split_git_header(git)?;
299 return Ok((normalize_diff_path(old)?, normalize_diff_path(new)?));
300 }
301 Ok((None, None))
302}
303
304fn split_git_header(rest: &str) -> Result<(&str, &str), ParseDiffError> {
305 let rest = rest.trim_end();
306 let (old, new) = if let Some(quoted) = rest.strip_prefix('"') {
307 let end = quoted
308 .find('"')
309 .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
310 let new_start = quoted[end + 1..]
311 .find('"')
312 .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
313 let new_quoted = "ed[end + 1 + new_start + 1..];
314 let new_end = new_quoted
315 .find('"')
316 .ok_or_else(|| ParseDiffError::new("unterminated quoted diff path"))?;
317 ("ed[..end], &new_quoted[..new_end])
318 } else {
319 let mut parts = rest.splitn(2, ' ');
320 let old = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
321 let new = parts.next().ok_or_else(|| ParseDiffError::new("malformed diff header"))?;
322 (old, new)
323 };
324 if old.is_empty() || new.is_empty() {
325 return Err(ParseDiffError::new("malformed diff header"));
326 }
327 Ok((old, new))
328}
329
330fn normalize_diff_path(path: &str) -> Result<Option<String>, ParseDiffError> {
331 let path = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
332 if path == "/dev/null" {
333 return Ok(None);
334 }
335 if path.contains('\\') {
336 return Err(ParseDiffError::new("unsupported escaped diff path"));
338 }
339 Ok(Some(path.to_owned()))
340}