1use std::collections::HashMap;
13
14use crate::EngineError;
15use crate::model::{DiffView, Disposition, FileChange, Hunk};
16use crate::paths::parse_diff_git_path;
17
18pub fn parse_canonical(
23 raw: &[u8],
24 dispositions: &HashMap<Vec<u8>, Disposition>,
25) -> Result<DiffView, EngineError> {
26 let lines: Vec<&[u8]> = split_lines(raw);
27 let mut files: Vec<FileChange> = Vec::new();
28 let mut hunks: Vec<Hunk> = Vec::new();
29 let mut header_disp: Vec<Option<Disposition>> = Vec::new();
31
32 let mut i = 0usize;
33 while i < lines.len() {
34 let line = lines[i];
35
36 if let Some(rest) = line.strip_prefix(b"diff --git ".as_slice()) {
37 let path = parse_diff_git_path(rest).ok_or_else(|| EngineError::Parse {
38 line: i + 1,
39 msg: format!("unparseable diff --git header: {}", lossy(line)),
40 })?;
41 files.push(FileChange {
42 path,
43 disposition: Disposition::Modified, new_mode: None,
45 old_mode: None,
46 binary: false,
47 submodule: None,
48 hunks: Vec::new(),
49 rename_similarity: None,
50 rename_from: None,
51 rename_to: None,
52 generated: None,
53 old_oid: None,
54 new_oid: None,
55 });
56 header_disp.push(None);
57 i += 1;
58 continue;
59 }
60
61 let Some(f) = files.last_mut() else {
62 if line.is_empty() {
65 i += 1;
66 continue;
67 }
68 return Err(EngineError::Parse {
69 line: i + 1,
70 msg: format!("content before first diff header: {}", lossy(line)),
71 });
72 };
73
74 if let Some(rest) = line.strip_prefix(b"old mode ".as_slice()) {
75 f.old_mode = Some(lossy(rest));
76 } else if let Some(rest) = line.strip_prefix(b"new mode ".as_slice()) {
77 f.new_mode = Some(lossy(rest));
78 } else if let Some(rest) = line.strip_prefix(b"new file mode ".as_slice()) {
79 f.new_mode = Some(lossy(rest));
80 *header_disp.last_mut().unwrap() = Some(Disposition::Added);
81 } else if let Some(rest) = line.strip_prefix(b"deleted file mode ".as_slice()) {
82 f.old_mode = Some(lossy(rest));
83 *header_disp.last_mut().unwrap() = Some(Disposition::Deleted);
84 } else if let Some(rest) = line.strip_prefix(b"index ".as_slice()) {
85 if let Some(pos) = rest.iter().rposition(|&b| b == b' ') {
87 let mode = &rest[pos + 1..];
88 if mode.len() == 6 && mode.iter().all(u8::is_ascii_digit) && f.new_mode.is_none() {
89 f.new_mode = Some(lossy(mode));
90 }
91 }
92 } else if line.starts_with(b"Binary files ") || line == b"GIT binary patch" {
93 f.binary = true;
94 } else if line.starts_with(b"--- ") || line.starts_with(b"+++ ") {
95 } else if line.starts_with(b"@@ ") {
97 let (old_start, old_count, new_start, new_count) =
98 parse_hunk_header(line).ok_or_else(|| EngineError::Parse {
99 line: i + 1,
100 msg: format!("unparseable hunk header: {}", lossy(line)),
101 })?;
102
103 let mut removed = Vec::with_capacity(old_count as usize);
104 let mut added = Vec::with_capacity(new_count as usize);
105 let mut nonl_old = false;
106 let mut nonl_new = false;
107 i += 1;
108
109 for _ in 0..old_count {
110 let body = *lines.get(i).ok_or_else(|| truncated(i))?;
111 let content =
112 body.strip_prefix(b"-".as_slice())
113 .ok_or_else(|| EngineError::Parse {
114 line: i + 1,
115 msg: format!("expected removed line, got: {}", lossy(body)),
116 })?;
117 removed.push(content.to_vec());
118 i += 1;
119 }
120 if old_count > 0 && lines.get(i).is_some_and(|l| l.starts_with(b"\\")) {
121 nonl_old = true;
122 i += 1;
123 }
124 for _ in 0..new_count {
125 let body = *lines.get(i).ok_or_else(|| truncated(i))?;
126 let content =
127 body.strip_prefix(b"+".as_slice())
128 .ok_or_else(|| EngineError::Parse {
129 line: i + 1,
130 msg: format!("expected added line, got: {}", lossy(body)),
131 })?;
132 added.push(content.to_vec());
133 i += 1;
134 }
135 if new_count > 0 && lines.get(i).is_some_and(|l| l.starts_with(b"\\")) {
136 nonl_new = true;
137 i += 1;
138 }
139
140 let file_idx = files.len() - 1;
141 files[file_idx].hunks.push(hunks.len());
142 hunks.push(Hunk {
143 file: file_idx,
144 old_start,
145 old_count,
146 new_start,
147 new_count,
148 removed,
149 added,
150 nonl_old,
151 nonl_new,
152 });
153 continue; } else if line.is_empty() {
155 if lines[i + 1..].iter().any(|l| !l.is_empty()) {
158 return Err(EngineError::Parse {
159 line: i + 1,
160 msg: "unexpected blank line inside patch".into(),
161 });
162 }
163 } else {
164 return Err(EngineError::Parse {
165 line: i + 1,
166 msg: format!("unrecognised patch line: {}", lossy(line)),
167 });
168 }
169 i += 1;
170 }
171
172 finalise(files, hunks, header_disp, dispositions)
173}
174
175fn finalise(
178 mut files: Vec<FileChange>,
179 mut hunks: Vec<Hunk>,
180 header_disp: Vec<Option<Disposition>>,
181 dispositions: &HashMap<Vec<u8>, Disposition>,
182) -> Result<DiffView, EngineError> {
183 for (f, hd) in files.iter_mut().zip(&header_disp) {
184 f.disposition = dispositions
185 .get(&f.path)
186 .copied()
187 .or(*hd)
188 .unwrap_or(Disposition::Modified);
189 }
190
191 let mut merged: Vec<FileChange> = Vec::with_capacity(files.len());
194 for f in files.into_iter() {
195 let continues_previous = merged.last().is_some_and(|prev| prev.path == f.path);
196 if continues_previous {
197 let idx = merged.len() - 1;
198 let prev = &mut merged[idx];
199 if f.new_mode.is_some() {
200 prev.new_mode = f.new_mode;
201 }
202 if prev.old_mode.is_none() {
203 prev.old_mode = f.old_mode;
204 }
205 prev.binary |= f.binary;
206 prev.disposition = dispositions
207 .get(&prev.path)
208 .copied()
209 .unwrap_or(Disposition::Modified);
210 for h in &f.hunks {
211 hunks[*h].file = idx;
212 prev.hunks.push(*h);
213 }
214 } else {
215 let idx = merged.len();
216 for h in &f.hunks {
217 hunks[*h].file = idx;
218 }
219 merged.push(f);
220 }
221 }
222
223 for f in merged.iter_mut() {
225 let is_gitlink =
226 f.new_mode.as_deref() == Some("160000") || f.old_mode.as_deref() == Some("160000");
227 if !is_gitlink {
228 continue;
229 }
230 let mut old = None;
231 let mut new = None;
232 for &hi in &f.hunks {
233 for l in &hunks[hi].removed {
234 if let Some(rest) = l.strip_prefix(b"Subproject commit ".as_slice()) {
235 old = Some(lossy(rest));
236 }
237 }
238 for l in &hunks[hi].added {
239 if let Some(rest) = l.strip_prefix(b"Subproject commit ".as_slice()) {
240 new = Some(lossy(rest));
241 }
242 }
243 }
244 f.submodule = Some((old, new));
245 }
246
247 Ok(DiffView {
248 files: merged,
249 hunks,
250 })
251}
252
253fn parse_hunk_header(line: &[u8]) -> Option<(u32, u32, u32, u32)> {
255 let rest = line.strip_prefix(b"@@ -".as_slice())?;
256 let (old_start, rest) = take_num(rest)?;
257 let (old_count, rest) = take_opt_count(rest)?;
258 let rest = rest.strip_prefix(b" +".as_slice())?;
259 let (new_start, rest) = take_num(rest)?;
260 let (new_count, rest) = take_opt_count(rest)?;
261 rest.starts_with(b" @@").then_some(())?;
262 Some((old_start, old_count, new_start, new_count))
263}
264
265fn take_num(s: &[u8]) -> Option<(u32, &[u8])> {
266 let end = s
267 .iter()
268 .position(|b| !b.is_ascii_digit())
269 .unwrap_or(s.len());
270 if end == 0 {
271 return None;
272 }
273 let n: u32 = std::str::from_utf8(&s[..end]).ok()?.parse().ok()?;
274 Some((n, &s[end..]))
275}
276
277fn take_opt_count(s: &[u8]) -> Option<(u32, &[u8])> {
278 match s.strip_prefix(b",".as_slice()) {
279 Some(rest) => take_num(rest),
280 None => Some((1, s)),
281 }
282}
283
284fn split_lines(raw: &[u8]) -> Vec<&[u8]> {
285 let mut v: Vec<&[u8]> = raw.split(|&b| b == b'\n').collect();
286 if v.last().is_some_and(|l| l.is_empty()) {
287 v.pop();
288 }
289 v
290}
291
292fn truncated(i: usize) -> EngineError {
293 EngineError::Parse {
294 line: i + 1,
295 msg: "patch truncated inside hunk body".into(),
296 }
297}
298
299fn lossy(b: &[u8]) -> String {
300 String::from_utf8_lossy(&b[..b.len().min(160)]).into_owned()
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn disp_map(entries: &[(&[u8], Disposition)]) -> HashMap<Vec<u8>, Disposition> {
308 entries.iter().map(|(p, d)| (p.to_vec(), *d)).collect()
309 }
310
311 #[test]
312 fn simple_modification() {
313 let raw = b"diff --git a/f.txt b/f.txt\n\
314index 000..111 100644\n\
315--- a/f.txt\n\
316+++ b/f.txt\n\
317@@ -2,1 +2,2 @@\n\
318-old line\n\
319+new line\n\
320+extra\n";
321 let v = parse_canonical(raw, &disp_map(&[(b"f.txt", Disposition::Modified)])).unwrap();
322 assert_eq!(v.files.len(), 1);
323 assert_eq!(v.hunks.len(), 1);
324 let h = &v.hunks[0];
325 assert_eq!(
326 (h.old_start, h.old_count, h.new_start, h.new_count),
327 (2, 1, 2, 2)
328 );
329 assert_eq!(h.removed, vec![b"old line".to_vec()]);
330 assert_eq!(h.added, vec![b"new line".to_vec(), b"extra".to_vec()]);
331 assert_eq!(v.files[0].new_mode.as_deref(), Some("100644"));
332 }
333
334 #[test]
335 fn deleted_line_starting_with_dashes_is_kept() {
336 let raw = b"diff --git a/cli.txt b/cli.txt\n\
339--- a/cli.txt\n\
340+++ b/cli.txt\n\
341@@ -1,2 +1,1 @@\n\
342---help\n\
343-+++weird\n\
344+usage\n";
345 let v = parse_canonical(raw, &Default::default()).unwrap();
346 let h = &v.hunks[0];
347 assert_eq!(h.removed, vec![b"--help".to_vec(), b"+++weird".to_vec()]);
348 assert_eq!(h.added, vec![b"usage".to_vec()]);
349 }
350
351 #[test]
352 fn count_elision_defaults_to_one() {
353 let raw = b"diff --git a/f b/f\n\
354--- a/f\n\
355+++ b/f\n\
356@@ -5 +5,2 @@ fn context()\n\
357-x\n\
358+y\n\
359+z\n";
360 let v = parse_canonical(raw, &Default::default()).unwrap();
361 let h = &v.hunks[0];
362 assert_eq!(
363 (h.old_start, h.old_count, h.new_start, h.new_count),
364 (5, 1, 5, 2)
365 );
366 }
367
368 #[test]
369 fn no_newline_markers_per_side() {
370 let raw = b"diff --git a/f b/f\n\
371--- a/f\n\
372+++ b/f\n\
373@@ -1,1 +1,1 @@\n\
374-old\n\
375\\ No newline at end of file\n\
376+new\n\
377\\ No newline at end of file\n";
378 let v = parse_canonical(raw, &Default::default()).unwrap();
379 let h = &v.hunks[0];
380 assert!(h.nonl_old);
381 assert!(h.nonl_new);
382 }
383
384 #[test]
385 fn nonl_old_only() {
386 let raw = b"diff --git a/f b/f\n\
388--- a/f\n\
389+++ b/f\n\
390@@ -1,1 +1,1 @@\n\
391-old\n\
392\\ No newline at end of file\n\
393+old\n";
394 let v = parse_canonical(raw, &Default::default()).unwrap();
395 assert!(v.hunks[0].nonl_old);
396 assert!(!v.hunks[0].nonl_new);
397 }
398
399 #[test]
400 fn binary_file_has_no_hunks() {
401 let raw = b"diff --git a/img.png b/img.png\n\
402new file mode 100644\n\
403index 000..111\n\
404Binary files /dev/null and b/img.png differ\n";
405 let v = parse_canonical(raw, &disp_map(&[(b"img.png", Disposition::Added)])).unwrap();
406 assert!(v.files[0].binary);
407 assert!(v.files[0].hunks.is_empty());
408 assert_eq!(v.files[0].disposition, Disposition::Added);
409 }
410
411 #[test]
412 fn mode_only_change_has_no_hunks() {
413 let raw = b"diff --git a/run.sh b/run.sh\n\
414old mode 100644\n\
415new mode 100755\n";
416 let v = parse_canonical(raw, &Default::default()).unwrap();
417 assert_eq!(v.files[0].old_mode.as_deref(), Some("100644"));
418 assert_eq!(v.files[0].new_mode.as_deref(), Some("100755"));
419 assert!(v.files[0].hunks.is_empty());
420 }
421
422 #[test]
423 fn submodule_pseudo_hunk_is_kept_and_ids_extracted() {
424 let raw = b"diff --git a/dep b/dep\n\
425index aaa..bbb 160000\n\
426--- a/dep\n\
427+++ b/dep\n\
428@@ -1 +1 @@\n\
429-Subproject commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n\
430+Subproject commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n";
431 let v = parse_canonical(raw, &Default::default()).unwrap();
432 assert_eq!(v.hunks.len(), 1, "pseudo-hunk stays in the canonical count");
433 let (old, new) = v.files[0].submodule.clone().unwrap();
434 assert_eq!(old.unwrap(), "a".repeat(40));
435 assert_eq!(new.unwrap(), "b".repeat(40));
436 }
437
438 #[test]
439 fn unrecognised_line_is_a_hard_error() {
440 let raw = b"diff --git a/f b/f\n\
441some garbage git will never emit\n";
442 assert!(parse_canonical(raw, &Default::default()).is_err());
443 }
444
445 #[test]
446 fn typechange_double_entry_merges() {
447 let raw = b"diff --git a/link b/link\n\
448deleted file mode 100644\n\
449--- a/link\n\
450+++ /dev/null\n\
451@@ -1,1 +0,0 @@\n\
452-real content\n\
453diff --git a/link b/link\n\
454new file mode 120000\n\
455--- /dev/null\n\
456+++ b/link\n\
457@@ -0,0 +1,1 @@\n\
458+target\n\
459\\ No newline at end of file\n";
460 let v = parse_canonical(raw, &disp_map(&[(b"link", Disposition::Modified)])).unwrap();
461 assert_eq!(v.files.len(), 1);
462 assert_eq!(v.files[0].hunks.len(), 2);
463 assert_eq!(v.files[0].old_mode.as_deref(), Some("100644"));
464 assert_eq!(v.files[0].new_mode.as_deref(), Some("120000"));
465 assert_eq!(v.hunks[1].file, 0);
466 }
467}