steid

@jamesgill /

29.0 KBCode·Blame·Raw
2268309feat: a commit is a page, and two revisions can be compared17h
1//! Reading git's unified diff format.
2//!
3//! Pure: bytes in, a structure out, no ports and no I/O. That is deliberate — a diff
4//! parser is the kind of code that is wrong in ways only a captured sample reveals, and
5//! keeping it a plain function means every one of those samples is a unit test rather
6//! than a fixture repository.
7//!
8//! # What the patch is trusted for, and what it is not
9//!
10//! The **counts** come from git's `--numstat`, never from the patch: numstat is written
11//! before the patch and therefore survives the byte cap that a very large diff runs
12//! into, so a commit whose patch cannot be shown still has a complete file list with
13//! real numbers beside it. The **lines** come from the patch, because that is the only
14//! place they exist.
15//!
16//! # Paths
17//!
18//! git writes a path several times per file — in the `diff --git` header, in `---` and
19//! `+++`, and in `rename from`/`rename to`. Only the last three are unambiguous: the
20//! header is `a/<old> b/<new>` with a space between two names that may themselves
21//! contain spaces, and nothing in the format says where the split is. So the header is
22//! read as a first guess and every later line overrides it.
23
24use std::fmt;
25
26/// How many rows of one file's diff are rendered before the rest is a link.
27///
28/// A thousand lines is far past what anyone reads in a page — beyond it the browser is
29/// laying out a file nobody is looking at. The blob at that revision is the way to see
30/// the rest, and the page says so rather than silently stopping.
31pub const MAX_FILE_DIFF_LINES: usize = 1000;
32
33/// What happened to a file in a commit.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FileChange {
36 Added,
37 Deleted,
38 Modified,
39 /// Includes a copy, which git reports the same way and which reads identically.
40 Renamed,
41}
42
43impl FileChange {
44 pub fn as_str(self) -> &'static str {
45 match self {
46 Self::Added => "added",
47 Self::Deleted => "deleted",
48 Self::Modified => "modified",
49 Self::Renamed => "renamed",
50 }
51 }
52}
53
54/// What one row of a rendered diff is.
55///
56/// Hunk headers and git's "no newline" note are rows rather than structure around the
57/// rows, so a file's diff is one flat list and the table that renders it is one loop.
58/// Nesting hunks would buy nothing: nothing is ever asked about a hunk as a unit.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LineKind {
61 /// `@@ -a,b +c,d @@`, and whatever git appended to it.
62 Hunk,
63 Context,
64 Added,
65 Removed,
66 /// `\ No newline at end of file`. Belongs to the line above it and has no number of
67 /// its own.
68 Note,
69}
70
71/// One row of a file's diff.
72///
73/// The numbers are `Option` because a row genuinely has one, both, or neither: an added
74/// line exists only in the new file, a hunk header in neither.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct DiffLine {
77 pub kind: LineKind,
78 pub old: Option<u32>,
79 pub new: Option<u32>,
80 /// The line's content, without the leading marker git prefixes it with.
81 pub text: String,
82}
83
84/// One file's worth of a patch.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct FileDiff {
87 /// The path as it is after the change — and for a deleted file, as it was.
88 pub path: String,
89 /// Where a renamed file came from. `None` for everything else, which is what makes
90 /// it the thing a header checks before drawing an arrow.
91 pub old_path: Option<String>,
92 pub change: FileChange,
93 /// Whether git declined to describe the change as lines.
94 pub binary: bool,
95 pub added: u32,
96 pub removed: u32,
97 /// At most [`MAX_FILE_DIFF_LINES`] of them.
98 pub rows: Vec<DiffLine>,
99 /// How many rows the file's diff really has, so a truncated one can say what it is
100 /// not showing.
101 pub total_rows: usize,
102}
103
104impl FileDiff {
105 /// Whether the rows are only the beginning of this file's diff.
106 pub fn truncated(&self) -> bool {
107 self.total_rows > self.rows.len()
108 }
109}
110
111/// One line of `--numstat`: what changed in a file, without any of the change itself.
112///
113/// The counts are `None` for a binary file, which is how git spells it — `-` in both
114/// columns — and a page reports that rather than printing zeroes, which would read as
115/// "nothing changed".
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct FileStat {
118 pub path: String,
119 pub old_path: Option<String>,
120 pub added: Option<u32>,
121 pub removed: Option<u32>,
122}
123
124impl FileStat {
125 pub fn is_binary(&self) -> bool {
126 self.added.is_none()
127 }
128}
129
130/// A whole diff, ready to render.
131#[derive(Debug, Clone, Default, PartialEq, Eq)]
132pub struct Diff {
133 /// Every changed file with its counts. Complete even when the patch is not — this
134 /// is what the page falls back to when [`truncated`](Self::truncated) is set.
135 pub stats: Vec<FileStat>,
136 /// The patch itself, per file. **Empty when the diff was truncated**: half a patch
137 /// is not a smaller patch, and rendering the files that happened to fit while
138 /// silently dropping the rest would be a lie about what the commit did.
139 pub files: Vec<FileDiff>,
140 pub truncated: bool,
141}
142
143impl Diff {
144 pub fn is_empty(&self) -> bool {
145 self.stats.is_empty()
146 }
147
148 pub fn files_changed(&self) -> usize {
149 self.stats.len()
150 }
151
152 /// Lines added across every file, from git's own counts.
153 pub fn added(&self) -> u32 {
154 self.stats.iter().filter_map(|file| file.added).sum()
155 }
156
157 pub fn removed(&self) -> u32 {
158 self.stats.iter().filter_map(|file| file.removed).sum()
159 }
160}
161
162/// Builds a whole diff from git's two outputs.
163///
164/// `truncated` is the port's answer about the *patch*; the numstat is always whole.
165pub fn parse_diff(numstat: &[u8], patch: &[u8], truncated: bool) -> Diff {
166 Diff {
167 stats: parse_numstat(numstat),
168 files: if truncated {
169 Vec::new()
170 } else {
171 parse_patch(patch)
172 },
173 truncated,
174 }
175}
176
177/// Parses `--numstat`: `<added> TAB <removed> TAB <path>` per line.
178///
179/// A rename arrives as one line whose path is `old => new`, factored where git can:
180/// `src/{a.rs => b.rs}` means `src/a.rs` became `src/b.rs`. Anything unparseable is
181/// skipped rather than failed on — this decorates a page, and one odd line is not a
182/// reason to refuse the commit.
183pub fn parse_numstat(numstat: &[u8]) -> Vec<FileStat> {
184 let mut stats = Vec::new();
185
186 for line in String::from_utf8_lossy(numstat).lines() {
187 let line = line.trim_end_matches('\r');
188
189 if line.is_empty() {
190 continue;
191 }
192
193 let mut fields = line.splitn(3, '\t');
194 let (Some(added), Some(removed), Some(path)) =
195 (fields.next(), fields.next(), fields.next())
196 else {
197 continue;
198 };
199
200 // `-` in both columns is git's way of saying "binary", not zero.
201 let count = |field: &str| field.parse::<u32>().ok();
202 let (old_path, path) = split_rename(&unquote(path));
203
204 stats.push(FileStat {
205 path,
206 old_path,
207 added: count(added),
208 removed: count(removed),
209 });
210 }
211
212 stats
213}
214
215/// Expands numstat's rename spelling into the two paths it means.
216///
217/// `src/{a.rs => b.rs}` and `a.rs => b.rs` are the two forms; the braces mark the part
218/// that differs when the paths share a prefix and a suffix.
219fn split_rename(path: &str) -> (Option<String>, String) {
220 let Some(arrow) = path.find(" => ") else {
221 return (None, path.to_owned());
222 };
223
224 match (path.find('{'), path.find('}')) {
225 // `prefix{old => new}suffix`, with the braces bracketing the arrow.
226 (Some(open), Some(close)) if open < arrow && arrow < close => {
227 let prefix = &path[..open];
228 let suffix = &path[close + 1..];
229 let old = &path[open + 1..arrow];
230 let new = &path[arrow + 4..close];
231
232 (
233 Some(format!("{prefix}{old}{suffix}")),
234 format!("{prefix}{new}{suffix}"),
235 )
236 }
237 _ => (Some(path[..arrow].to_owned()), path[arrow + 4..].to_owned()),
238 }
239}
240
241/// Parses a unified patch into one entry per file.
242///
243/// The hunk line counts in `@@ -a,b +c,d @@` are tracked rather than ignored, so the
244/// parser always knows whether it is inside a hunk. That is not fussiness: a patch *of
245/// a patch* contains lines reading `diff --git …` and `+++ b/…` as ordinary content,
246/// and a parser that matched those prefixes anywhere would split one file into several
247/// and attribute the rest of the commit to a file that does not exist.
248pub fn parse_patch(patch: &[u8]) -> Vec<FileDiff> {
249 let text = String::from_utf8_lossy(patch);
250 let mut files: Vec<FileDiff> = Vec::new();
251 let mut state = Numbering::default();
252
253 for line in text.lines() {
254 if !state.in_hunk()
255 && let Some(header) = line.strip_prefix("diff --git ")
256 {
257 let (old, new) = header_paths(header);
258
259 files.push(FileDiff {
260 path: new,
261 old_path: None,
262 change: FileChange::Modified,
263 binary: false,
264 added: 0,
265 removed: 0,
266 rows: Vec::new(),
267 total_rows: 0,
268 });
269
270 // The header's guess at the old path is kept only until a `---` or a
271 // `rename from` says better; a modified file's two paths are the same, so it
272 // is only ever wrong for a rename, which always has those lines.
273 state = Numbering {
274 header_old: old,
275 ..Numbering::default()
276 };
277
278 continue;
279 }
280
281 let Some(file) = files.last_mut() else {
282 // Anything before the first file header is not part of a patch. Skipped
283 // rather than an error: a caller may hand over a fragment.
284 continue;
285 };
286
287 read_line(file, &mut state, line);
288 }
289
290 files
291}
292
293/// Where the parser is inside the current file.
294#[derive(Debug, Default)]
295struct Numbering {
296 old: u32,
297 new: u32,
298 /// How many lines of the current hunk's old and new sides are still to come. Both
299 /// zero means the hunk is over and the next line is metadata again.
300 old_left: u32,
301 new_left: u32,
302 header_old: String,
303}
304
305impl Numbering {
306 fn in_hunk(&self) -> bool {
307 self.old_left > 0 || self.new_left > 0
308 }
309}
310
311/// Reads one line of a patch into the file it belongs to.
312fn read_line(file: &mut FileDiff, state: &mut Numbering, line: &str) {
313 // git's "no newline" note sits *after* the last line of a hunk, so it arrives with
314 // the hunk already counted out. It is matched first for that reason.
315 if line.starts_with('\\') && file.total_rows > 0 {
316 push(
317 file,
318 DiffLine {
319 kind: LineKind::Note,
320 old: None,
321 new: None,
322 text: line.trim_start_matches('\\').trim().to_owned(),
323 },
324 );
325 return;
326 }
327
328 if state.in_hunk() {
329 read_body(file, state, line);
330 return;
331 }
332
333 // Metadata: every one of these is a fixed prefix git writes before the hunks, and
334 // reaching them means the parser is not inside one.
335 if let Some(from) = line.strip_prefix("rename from ") {
336 file.old_path = Some(unquote(from));
337 file.change = FileChange::Renamed;
338 return;
339 }
340
341 if let Some(to) = line.strip_prefix("rename to ") {
342 file.path = unquote(to);
343 file.change = FileChange::Renamed;
344 return;
345 }
346
347 if line.starts_with("new file mode ") {
348 file.change = FileChange::Added;
349 return;
350 }
351
352 if line.starts_with("deleted file mode ") {
353 file.change = FileChange::Deleted;
354 return;
355 }
356
357 // Two spellings, depending on whether git was asked for a readable diff or a binary
358 // patch. Both mean the same thing to a page.
359 if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
360 file.binary = true;
361 return;
362 }
363
364 if let Some(path) = line.strip_prefix("--- ") {
365 // `/dev/null` is git's spelling of "this file did not exist", which is what
366 // makes an addition an addition even when the mode line was not seen.
367 if path == "/dev/null" {
368 file.change = FileChange::Added;
369 } else if file.change != FileChange::Renamed {
370 state.header_old = strip_prefix_marker(path);
371 }
372 return;
373 }
374
375 if let Some(path) = line.strip_prefix("+++ ") {
376 if path == "/dev/null" {
377 file.change = FileChange::Deleted;
378 // A deleted file's own name is the one it had, which `+++` does not carry.
379 file.path = state.header_old.clone();
380 } else if file.change != FileChange::Renamed {
381 file.path = strip_prefix_marker(path);
382 }
383 return;
384 }
385
386 if let Some((old, new, old_left, new_left)) = hunk_start(line) {
387 state.old = old;
388 state.new = new;
389 state.old_left = old_left;
390 state.new_left = new_left;
391
392 push(
393 file,
394 DiffLine {
395 kind: LineKind::Hunk,
396 old: None,
397 new: None,
398 text: line.to_owned(),
399 },
400 );
401 }
402
403 // Anything else between files — `index`, a mode line, a similarity score, or a
404 // blank — says nothing a page shows.
405}
406
407/// Reads one line from inside a hunk, where the first character decides everything.
408fn read_body(file: &mut FileDiff, state: &mut Numbering, line: &str) {
409 match line.as_bytes().first() {
410 Some(b'+') => {
411 push(
412 file,
413 DiffLine {
414 kind: LineKind::Added,
415 old: None,
416 new: Some(state.new),
417 text: line[1..].to_owned(),
418 },
419 );
420 state.new += 1;
421 state.new_left = state.new_left.saturating_sub(1);
422 file.added += 1;
423 }
424 Some(b'-') => {
425 push(
426 file,
427 DiffLine {
428 kind: LineKind::Removed,
429 old: Some(state.old),
430 new: None,
431 text: line[1..].to_owned(),
432 },
433 );
434 state.old += 1;
435 state.old_left = state.old_left.saturating_sub(1);
436 file.removed += 1;
437 }
438 // A context line is a leading space — and an entirely empty line, which some
439 // tools write where git would write a single space.
440 _ => {
441 push(
442 file,
443 DiffLine {
444 kind: LineKind::Context,
445 old: Some(state.old),
446 new: Some(state.new),
447 text: line.strip_prefix(' ').unwrap_or(line).to_owned(),
448 },
449 );
450 state.old += 1;
451 state.new += 1;
452 state.old_left = state.old_left.saturating_sub(1);
453 state.new_left = state.new_left.saturating_sub(1);
454 }
455 }
456}
457
458/// Adds a row, counting it even once the cap stops it being kept.
459///
460/// The count is what lets the page say how much it is not showing, and counting without
461/// keeping is what stops a hundred-thousand-line file being held in memory to render a
462/// thousand of it.
463fn push(file: &mut FileDiff, row: DiffLine) {
464 file.total_rows += 1;
465
466 if file.rows.len() < MAX_FILE_DIFF_LINES {
467 file.rows.push(row);
468 }
469}
470
471/// Reads `@@ -a,b +c,d @@` into the two starting line numbers and the two lengths.
472///
473/// A count git omits is 1, which is what the format means by leaving it out.
474fn hunk_start(line: &str) -> Option<(u32, u32, u32, u32)> {
475 let inner = line.strip_prefix("@@ ")?;
476 let inner = inner.split(" @@").next()?;
477 let mut parts = inner.split_whitespace();
478
479 let old = parts.next()?.strip_prefix('-')?;
480 let new = parts.next()?.strip_prefix('+')?;
481
482 fn range(value: &str) -> Option<(u32, u32)> {
483 let mut fields = value.split(',');
484 let start = fields.next()?.parse::<u32>().ok()?;
485 let length = match fields.next() {
486 Some(length) => length.parse::<u32>().ok()?,
487 None => 1,
488 };
489
490 // A hunk that writes into an empty file starts at 0; the first line it writes
491 // is 1, so a zero start is nudged rather than trusted. git writes `-0,0` for
492 // exactly that, and a naive read numbers the file from zero.
493 Some((start.max(1), length))
494 }
495
496 let (old_start, old_len) = range(old)?;
497 let (new_start, new_len) = range(new)?;
498
499 Some((old_start, new_start, old_len, new_len))
500}
501
502/// Splits `a/<old> b/<new>` as best the format allows.
503///
504/// Ambiguous by construction when a path contains a space, which is why this is only
505/// ever a first guess — see the module note. The common case, where both names are the
506/// same, is resolved exactly: the header is then two equal halves.
507fn header_paths(header: &str) -> (String, String) {
508 if let Some(rest) = header.strip_prefix("a/") {
509 // `<old> b/<new>`. When the names match, the split is at the midpoint and the
510 // arithmetic is exact regardless of what the name contains.
511 let midpoint = (rest.len().saturating_sub(3)) / 2;
512
513 if rest.len() > 3
514 && rest[midpoint..].starts_with(" b/")
515 && rest[..midpoint] == rest[midpoint + 3..]
516 {
517 let path = unquote(&rest[..midpoint]);
518 return (path.clone(), path);
519 }
520
521 if let Some(split) = rest.find(" b/") {
522 return (unquote(&rest[..split]), unquote(&rest[split + 3..]));
523 }
524 }
525
526 // Not a shape we understand. The `---`/`+++` lines will correct it, and for a
527 // binary file with no such lines the header itself is all there is.
528 let guess = unquote(header);
529 (guess.clone(), guess)
530}
531
532/// Drops the `a/` or `b/` git puts in front of a path in `---` and `+++`.
533fn strip_prefix_marker(path: &str) -> String {
534 let path = unquote(path);
535
536 path.strip_prefix("a/")
537 .or_else(|| path.strip_prefix("b/"))
538 .unwrap_or(&path)
539 .to_owned()
540}
541
542/// Undoes the C-style quoting git applies to a path it cannot write literally.
543///
544/// A path containing a quote, a backslash, a tab or a newline arrives wrapped in double
545/// quotes with those bytes escaped, and non-ASCII bytes as three-digit octal. Left
546/// quoted, such a path would be shown with its escapes visible and linked to with a
547/// name that is not its name.
548fn unquote(path: &str) -> String {
549 let Some(inner) = path
550 .strip_prefix('"')
551 .and_then(|rest| rest.strip_suffix('"'))
552 else {
553 return path.to_owned();
554 };
555
556 let mut bytes = Vec::with_capacity(inner.len());
557 let mut chars = inner.chars();
558
559 while let Some(char) = chars.next() {
560 if char != '\\' {
561 let mut buffer = [0u8; 4];
562 bytes.extend_from_slice(char.encode_utf8(&mut buffer).as_bytes());
563 continue;
564 }
565
566 match chars.next() {
567 Some('n') => bytes.push(b'\n'),
568 Some('t') => bytes.push(b'\t'),
569 Some('r') => bytes.push(b'\r'),
570 Some('"') => bytes.push(b'"'),
571 Some('\\') => bytes.push(b'\\'),
572 // Three octal digits, which is how a byte outside ASCII is written.
573 Some(digit @ '0'..='7') => {
574 let mut octal = String::from(digit);
575
576 for _ in 0..2 {
577 match chars.next() {
578 Some(next @ '0'..='7') => octal.push(next),
579 Some(other) => {
580 bytes.extend_from_slice(other.to_string().as_bytes());
581 break;
582 }
583 None => break,
584 }
585 }
586
587 match u8::from_str_radix(&octal, 8) {
588 Ok(byte) => bytes.push(byte),
589 Err(_) => bytes.extend_from_slice(octal.as_bytes()),
590 }
591 }
592 Some(other) => {
593 let mut buffer = [0u8; 4];
594 bytes.extend_from_slice(other.encode_utf8(&mut buffer).as_bytes());
595 }
596 None => bytes.push(b'\\'),
597 }
598 }
599
600 String::from_utf8_lossy(&bytes).into_owned()
601}
602
603impl fmt::Display for FileChange {
604 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
605 f.write_str(self.as_str())
606 }
607}
608
609#[cfg(test)]
610mod tests {
611 use super::*;
612
613 /// Captured from `git diff-tree --no-commit-id -p -M --numstat --no-color`, which
614 /// is exactly what the adapter runs. Every sample below is real git output rather
615 /// than a hand-written approximation of it — the whole reason this parser is
616 /// testable without a repository.
617 const RENAME: &str = "\
618diff --git a/src/x.txt b/src/y.txt
619similarity index 83%
620rename from src/x.txt
621rename to src/y.txt
622index 6f195b4..cbe236c 100644
623--- a/src/x.txt
624+++ b/src/y.txt
625@@ -3,3 +3,4 @@ bbb
626 ccc
627 ddd
628 eee
629+fff
630";
631
632 const MIXED: &str = "\
633diff --git a/a.txt b/a.txt
634deleted file mode 100644
635index 4cb29ea..0000000
636--- a/a.txt
637+++ /dev/null
638@@ -1,3 +0,0 @@
639-one
640-two
641-three
642diff --git a/b.txt b/b.txt
643new file mode 100644
644index 0000000..f59d6df
645--- /dev/null
646+++ b/b.txt
647@@ -0,0 +1,2 @@
648+one
649+two
650\\ No newline at end of file
651diff --git a/logo.bin b/logo.bin
652index ad2f385..fe25227 100644
653Binary files a/logo.bin and b/logo.bin differ
654";
655
656 const MODIFIED: &str = "\
657diff --git a/Cargo.toml b/Cargo.toml
658index 937d41c..3c72429 100644
659--- a/Cargo.toml
660+++ b/Cargo.toml
661@@ -17,7 +17,7 @@ serde = { version = \"1.0.229\" }
662 sha2 = \"0.10\"
663 sqlx = \"0.9.0\"
664 subtle = \"2.6.1\"
665-tokio = { features = [\"process\"] }
666+tokio = { features = [\"process\", \"time\"] }
667 tokio-util = \"0.7\"
668 topcoat = \"0.5.0\"
669 uuid = \"1.24.0\"
670";
671
672 // --- numstat ------------------------------------------------------------------
673
674 #[test]
675 fn numstat_counts_a_plain_change() {
676 let stats = parse_numstat(b"1\t1\tCargo.toml\n10\t0\tplans/progress.md\n");
677
678 assert_eq!(stats.len(), 2);
679 assert_eq!(stats[0].path, "Cargo.toml");
680 assert_eq!((stats[0].added, stats[0].removed), (Some(1), Some(1)));
681 assert!(stats[0].old_path.is_none());
682 assert_eq!(stats[1].path, "plans/progress.md");
683 }
684
685 #[test]
686 fn a_binary_file_has_no_counts_rather_than_zero_ones() {
687 // Zeroes would read as "nothing changed", which is a different claim.
688 let stats = parse_numstat(b"-\t-\tlogo.png\n");
689
690 assert!(stats[0].is_binary());
691 assert_eq!(stats[0].added, None);
692 }
693
694 #[test]
695 fn a_rename_with_a_shared_prefix_expands_to_both_paths() {
696 let stats = parse_numstat(b"1\t0\tsrc/{x.txt => y.txt}\n");
697
698 assert_eq!(stats[0].old_path.as_deref(), Some("src/x.txt"));
699 assert_eq!(stats[0].path, "src/y.txt");
700 }
701
702 #[test]
703 fn a_rename_with_nothing_in_common_expands_too() {
704 let stats = parse_numstat(b"2\t2\told.rs => new.rs\n");
705
706 assert_eq!(stats[0].old_path.as_deref(), Some("old.rs"));
707 assert_eq!(stats[0].path, "new.rs");
708 }
709
710 #[test]
711 fn a_rename_within_a_directory_keeps_the_suffix() {
712 let stats = parse_numstat(b"0\t0\tsrc/{a => b}/mod.rs\n");
713
714 assert_eq!(stats[0].old_path.as_deref(), Some("src/a/mod.rs"));
715 assert_eq!(stats[0].path, "src/b/mod.rs");
716 }
717
718 #[test]
719 fn a_quoted_path_comes_back_as_its_real_name() {
720 let stats = parse_numstat("1\t0\t\"docs/caf\\303\\251.md\"\n".as_bytes());
721
722 assert_eq!(stats[0].path, "docs/café.md");
723 }
724
725 // --- the patch ----------------------------------------------------------------
726
727 #[test]
728 fn a_modification_numbers_both_sides() {
729 let files = parse_patch(MODIFIED.as_bytes());
730
731 assert_eq!(files.len(), 1);
732 let file = &files[0];
733
734 assert_eq!(file.path, "Cargo.toml");
735 assert_eq!(file.change, FileChange::Modified);
736 assert_eq!((file.added, file.removed), (1, 1));
737
738 // The hunk header, then three context lines, then the pair, then three more.
739 assert_eq!(file.rows[0].kind, LineKind::Hunk);
740 assert_eq!(file.rows[1].kind, LineKind::Context);
741 assert_eq!(file.rows[1].old, Some(17));
742 assert_eq!(file.rows[1].new, Some(17));
743
744 let removed = file
745 .rows
746 .iter()
747 .find(|row| row.kind == LineKind::Removed)
748 .expect("a removed line");
749 assert_eq!(removed.old, Some(20));
750 assert_eq!(removed.new, None);
751
752 let added = file
753 .rows
754 .iter()
755 .find(|row| row.kind == LineKind::Added)
756 .expect("an added line");
757 assert_eq!(added.old, None);
758 assert_eq!(added.new, Some(20));
759 assert_eq!(added.text, "tokio = { features = [\"process\", \"time\"] }");
760
761 // Numbering resumes together after the pair.
762 let last = file.rows.last().expect("a last row");
763 assert_eq!((last.old, last.new), (Some(23), Some(23)));
764 }
765
766 #[test]
767 fn a_rename_carries_both_paths() {
768 let files = parse_patch(RENAME.as_bytes());
769
770 assert_eq!(files.len(), 1);
771 assert_eq!(files[0].change, FileChange::Renamed);
772 assert_eq!(files[0].old_path.as_deref(), Some("src/x.txt"));
773 assert_eq!(files[0].path, "src/y.txt");
774 assert_eq!((files[0].added, files[0].removed), (1, 0));
775 }
776
777 #[test]
778 fn a_deletion_a_creation_and_a_binary_file_are_told_apart() {
779 let files = parse_patch(MIXED.as_bytes());
780
781 assert_eq!(files.len(), 3);
782
783 assert_eq!(files[0].path, "a.txt");
784 assert_eq!(files[0].change, FileChange::Deleted);
785 assert_eq!((files[0].added, files[0].removed), (0, 3));
786
787 assert_eq!(files[1].path, "b.txt");
788 assert_eq!(files[1].change, FileChange::Added);
789 assert_eq!((files[1].added, files[1].removed), (2, 0));
790
791 assert_eq!(files[2].path, "logo.bin");
792 assert!(files[2].binary);
793 assert!(files[2].rows.is_empty());
794 }
795
796 #[test]
797 fn a_missing_final_newline_is_a_note_rather_than_a_line() {
798 // It has no number of its own: it describes the line above it.
799 let files = parse_patch(MIXED.as_bytes());
800 let note = files[1].rows.last().expect("a last row");
801
802 assert_eq!(note.kind, LineKind::Note);
803 assert_eq!(note.old, None);
804 assert_eq!(note.new, None);
805 assert_eq!(note.text, "No newline at end of file");
806 // And it is not counted as an addition.
807 assert_eq!(files[1].added, 2);
808 }
809
810 #[test]
811 fn a_new_file_starts_numbering_at_one_not_zero() {
812 // git writes `@@ -0,0 +1,2 @@`, and a naive read of the old side gives 0.
813 let files = parse_patch(MIXED.as_bytes());
814 let first = files[1]
815 .rows
816 .iter()
817 .find(|row| row.kind == LineKind::Added)
818 .expect("an added line");
819
820 assert_eq!(first.new, Some(1));
821 }
822
823 #[test]
824 fn a_line_that_looks_like_metadata_inside_a_hunk_is_still_a_line() {
825 // A patch of a patch: `+++ b/x` inside a hunk is content, not a header. It is
826 // an addition because a body line's first character decides, and the metadata
827 // checks only ever run before the first `@@`.
828 let patch = "\
829diff --git a/p.diff b/p.diff
830--- a/p.diff
831+++ b/p.diff
832@@ -1,1 +1,2 @@
833 context
834+++ b/inner
835";
836 let files = parse_patch(patch.as_bytes());
837
838 assert_eq!(files[0].added, 1);
839 assert_eq!(files[0].rows.last().expect("a row").text, "++ b/inner");
840 }
841
842 #[test]
843 fn a_long_file_keeps_its_first_rows_and_counts_the_rest() {
844 let mut patch = String::from(
845 "diff --git a/big.txt b/big.txt\n--- a/big.txt\n+++ b/big.txt\n@@ -1,0 +1,5000 @@\n",
846 );
847 for index in 0..5000 {
848 patch.push_str(&format!("+line {index}\n"));
849 }
850
851 let files = parse_patch(patch.as_bytes());
852
853 assert_eq!(files[0].rows.len(), MAX_FILE_DIFF_LINES);
854 assert_eq!(files[0].total_rows, 5001);
855 assert!(files[0].truncated());
856 // The counts are of the whole file, not of what was kept.
857 assert_eq!(files[0].added, 5000);
858 }
859
860 // --- the whole thing ----------------------------------------------------------
861
862 #[test]
863 fn the_totals_come_from_numstat_not_from_the_patch() {
864 let diff = parse_diff(
865 b"0\t3\ta.txt\n2\t0\tb.txt\n-\t-\tlogo.bin\n",
866 MIXED.as_bytes(),
867 false,
868 );
869
870 assert_eq!(diff.files_changed(), 3);
871 assert_eq!(diff.added(), 2);
872 assert_eq!(diff.removed(), 3);
873 assert_eq!(diff.files.len(), 3);
874 }
875
876 #[test]
877 fn a_truncated_diff_keeps_its_file_list_and_drops_its_patch() {
878 // The point of asking git for both in one run: the counts outlive the cap.
879 let diff = parse_diff(b"0\t3\ta.txt\n2\t0\tb.txt\n", MIXED.as_bytes(), true);
880
881 assert!(diff.truncated);
882 assert_eq!(diff.files_changed(), 2);
883 assert_eq!(diff.added(), 2);
884 assert!(diff.files.is_empty());
885 }
886
887 #[test]
888 fn a_commit_that_changed_nothing_is_empty_rather_than_broken() {
889 let diff = parse_diff(b"", b"", false);
890
891 assert!(diff.is_empty());
892 assert_eq!(diff.files_changed(), 0);
893 }
894}