Skip to main content

vcs_diff/
diff.rs

1//! The unified-diff model and parser, shared by `vcs-git` and `vcs-jj`.
2//!
3//! `git diff` and `jj diff --git` emit the same git-format unified diff, so a
4//! single parser serves both. (They're byte-identical for ASCII paths; they differ
5//! only in how a non-ASCII filename is rendered — git's default `core.quotePath`
6//! octal-C-quotes it, jj writes raw UTF-8 — and the parser decodes both.) Pure
7//! functions over arbitrary text — no process execution.
8
9use std::path::PathBuf;
10
11use crate::pathbytes::path_from_bytes;
12
13/// What a diff call compares — the working tree/copy, or a specific
14/// revision/revset (or range).
15///
16/// Shared by the `vcs-git` and `vcs-jj` wrappers (re-exported as
17/// `vcs_git::DiffSpec` / `vcs_jj::DiffSpec`); each backend interprets it against
18/// its own CLI (`git diff …` / `jj diff -r …`).
19///
20/// Deliberately **not** `#[non_exhaustive]`: each backend's `diff` interpreter
21/// must handle every variant, so adding one is a (pre-1.0) breaking change that
22/// fails the wrappers' exhaustive matches at compile time rather than slipping
23/// through a runtime catch-all.
24#[derive(Debug, Clone)]
25pub enum DiffSpec {
26    /// All tracked changes in the working tree/copy vs the last commit — staged
27    /// or not, excluding untracked files (`git diff HEAD`; `jj diff -r @`).
28    WorkingTree,
29    /// A specific revision/revset or range, e.g. `HEAD~1` / `main..HEAD`
30    /// (`git diff <rev>`) or `@-` / `main..@` (`jj diff -r <revset>`).
31    Rev(String),
32}
33
34/// Aggregate line/file counts from a diff stat (`git diff --shortstat`,
35/// `jj diff --stat`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37#[cfg_attr(feature = "serde", derive(serde::Serialize))]
38#[non_exhaustive]
39pub struct DiffStat {
40    /// Number of files changed.
41    pub files_changed: usize,
42    /// Lines added (`insertions(+)`).
43    pub insertions: usize,
44    /// Lines removed (`deletions(-)`).
45    pub deletions: usize,
46}
47
48impl DiffStat {
49    /// Build a [`DiffStat`]. (A constructor, because the struct is
50    /// `#[non_exhaustive]` — the parser crates and tests can't use struct-literal
51    /// syntax across the crate boundary.)
52    pub fn new(files_changed: usize, insertions: usize, deletions: usize) -> Self {
53        Self {
54            files_changed,
55            insertions,
56            deletions,
57        }
58    }
59}
60
61/// How a file changed in a unified diff.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63#[cfg_attr(feature = "serde", derive(serde::Serialize))]
64#[non_exhaustive]
65pub enum ChangeKind {
66    /// A new file (`new file mode …`).
67    Added,
68    /// An existing file's contents changed.
69    Modified,
70    /// The file was removed (`deleted file mode …`).
71    Deleted,
72    /// The file was renamed (`rename from …` / `rename to …`).
73    Renamed,
74}
75
76/// One line inside a [`Hunk`], tagged by its role. The stored text excludes the
77/// leading ` `/`+`/`-` marker **and the line terminator** — a CRLF-origin diff's
78/// trailing `\r` is stripped along with the `\n`, so reconstruct exact bytes
79/// from [`FileDiff::raw`], not from these lines.
80#[derive(Debug, Clone, PartialEq, Eq)]
81#[cfg_attr(feature = "serde", derive(serde::Serialize))]
82#[non_exhaustive]
83pub enum DiffLine {
84    /// Unchanged context line (leading ` `).
85    Context(String),
86    /// Added line (leading `+`).
87    Added(String),
88    /// Removed line (leading `-`).
89    Removed(String),
90}
91
92/// A single `@@ … @@` hunk within a [`FileDiff`].
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[cfg_attr(feature = "serde", derive(serde::Serialize))]
95#[non_exhaustive]
96pub struct Hunk {
97    /// Start line in the old file (the `-<start>` of the `@@` header).
98    pub old_start: usize,
99    /// Line count in the old file (defaults to 1 when the `,<count>` is omitted).
100    pub old_lines: usize,
101    /// Start line in the new file (the `+<start>` of the `@@` header).
102    pub new_start: usize,
103    /// Line count in the new file (defaults to 1 when the `,<count>` is omitted).
104    pub new_lines: usize,
105    /// Text after the closing `@@` (the function/section heading); empty when none.
106    pub section: String,
107    /// The hunk body, one entry per `+`/`-`/` ` line.
108    pub lines: Vec<DiffLine>,
109}
110
111/// One file's entry in a parsed git-format unified diff (`git diff` or
112/// `jj diff --git`).
113#[derive(Debug, Clone, PartialEq, Eq)]
114#[cfg_attr(feature = "serde", derive(serde::Serialize))]
115#[non_exhaustive]
116pub struct FileDiff {
117    /// How the file changed.
118    pub change: ChangeKind,
119    /// The file's path — the *new* path for a rename — forward-slash normalised.
120    ///
121    /// A [`PathBuf`] (not a `String`) so a non-UTF-8 filename is carried
122    /// losslessly: git C-quotes a non-ASCII path into octal escapes that decode
123    /// back to the exact bytes, kept here via [`path_from_bytes`] rather than
124    /// substituted with `U+FFFD`. (For jj's raw-UTF-8 `--git` diff a non-UTF-8
125    /// path is still subject to the surrounding text layer's decode; the
126    /// byte-faithful cross-backend round-trip is the status/conflict path, which
127    /// carries `PathBuf` end to end.)
128    pub path: PathBuf,
129    /// For a rename, the original path (forward-slash normalised); `None` otherwise.
130    pub old_path: Option<PathBuf>,
131    /// The `@@` hunks; empty for a binary file or a pure rename with no edits.
132    pub hunks: Vec<Hunk>,
133    /// The verbatim diff section for this file (the `diff --git …` block through
134    /// to the next file), for callers that display the raw text.
135    pub raw: String,
136}
137
138/// Parse a git-format unified diff into one [`FileDiff`] per file. Works on
139/// `git diff` and `jj diff --git` output alike. Public so a consumer can parse
140/// diff text it obtained by other means.
141///
142/// Paths are read from the unambiguous single-path lines (`+++ b/…`, `--- a/…`,
143/// `rename to …`) rather than the space-ambiguous `diff --git a/… b/…` header,
144/// and normalised to forward slashes. Ported from the `vcs-flow-commit` parser.
145pub fn parse_diff(diff: &str) -> Vec<FileDiff> {
146    diff_sections(diff).filter_map(parse_section).collect()
147}
148
149/// Slice a git-format diff into per-file sections (each starts at `diff --git`).
150fn diff_sections(full: &str) -> impl Iterator<Item = &str> {
151    let mut bounds = Vec::new();
152    let mut idx = 0;
153    for line in full.split_inclusive('\n') {
154        if line.starts_with("diff --git ") {
155            bounds.push(idx);
156        }
157        idx += line.len();
158    }
159    let ends = bounds
160        .iter()
161        .skip(1)
162        .copied()
163        .chain(std::iter::once(full.len()));
164    bounds
165        .clone()
166        .into_iter()
167        .zip(ends)
168        .map(move |(s, e)| &full[s..e])
169        .collect::<Vec<_>>()
170        .into_iter()
171}
172
173/// Determine the [`FileDiff`] for one `diff --git` section: change kind and path
174/// from the header lines, plus every `@@` hunk and its body.
175fn parse_section(section: &str) -> Option<FileDiff> {
176    let mut kind = ChangeKind::Modified;
177    // Paths are accumulated as raw bytes (not `String`) so a git C-quoted
178    // non-ASCII path decodes to its exact bytes and reaches `path_from_bytes`
179    // without a lossy round-trip through `String`.
180    let mut new_path: Option<Vec<u8>> = None;
181    let mut minus_path: Option<Vec<u8>> = None;
182    let mut rename_to: Option<Vec<u8>> = None;
183    let mut rename_from: Option<Vec<u8>> = None;
184    let mut hunks: Vec<Hunk> = Vec::new();
185    let mut current: Option<Hunk> = None;
186
187    for line in section.lines() {
188        if let Some(hunk) = parse_hunk_header(line) {
189            if let Some(done) = current.replace(hunk) {
190                hunks.push(done);
191            }
192            continue;
193        }
194        if let Some(hunk) = current.as_mut() {
195            // Inside a hunk body: classify by the leading marker. `\ No newline at
196            // end of file` annotations and any stray blank line are dropped.
197            match line.as_bytes().first() {
198                Some(b' ') => hunk.lines.push(DiffLine::Context(line[1..].to_string())),
199                Some(b'+') => hunk.lines.push(DiffLine::Added(line[1..].to_string())),
200                Some(b'-') => hunk.lines.push(DiffLine::Removed(line[1..].to_string())),
201                _ => {}
202            }
203            continue;
204        }
205        // Header region (before the first `@@`).
206        if line.starts_with("new file") {
207            kind = ChangeKind::Added;
208        } else if line.starts_with("deleted file") {
209            kind = ChangeKind::Deleted;
210        } else if let Some(p) = line.strip_prefix("rename to ") {
211            // `rename to`/`from` carry a *bare* path (no `a/`/`b/`), possibly git-
212            // C-quoted when it has a non-ASCII/tab/quote/backslash byte.
213            rename_to = Some(unquote_git_path(p.trim_end()));
214        } else if let Some(p) = line.strip_prefix("rename from ") {
215            rename_from = Some(unquote_git_path(p.trim_end()));
216        } else if let Some(rest) = line.strip_prefix("+++ ") {
217            // `b/<path>`, or `"b/<path>"` quoted (the `b/` is *inside* the quotes),
218            // or `/dev/null` (deleted side). Unquote, then strip the `b/` — a
219            // `/dev/null` (no `b/`) yields `None`, leaving `new_path` unset.
220            new_path = strip_side_prefix(unquote_git_path(rest.trim_end()), b"b/");
221        } else if let Some(rest) = line.strip_prefix("--- ") {
222            minus_path = strip_side_prefix(unquote_git_path(rest.trim_end()), b"a/");
223        }
224    }
225    if let Some(done) = current.take() {
226        hunks.push(done);
227    }
228
229    // A rename keeps its old path so a caller can record the deletion too.
230    let old_path = if rename_to.is_some() {
231        kind = ChangeKind::Renamed;
232        rename_from.map(normalize_slashes)
233    } else {
234        None
235    };
236    // Resolve the path by priority (rename target → `+++ b/` → `--- a/` → the
237    // `diff --git` header), skipping any source that is present-but-empty so a
238    // malformed `+++ b/`-with-no-path falls through rather than yielding a FileDiff
239    // with an empty path. If every source is absent/empty, the section is dropped.
240    let path = [rename_to, new_path, minus_path]
241        .into_iter()
242        .flatten()
243        .find(|p| !p.is_empty())
244        .or_else(|| header_b_path(section))?;
245    Some(FileDiff {
246        change: kind,
247        path: path_from_bytes(&normalize_slashes(path)),
248        old_path: old_path.map(|p| path_from_bytes(&p)),
249        hunks,
250        raw: section.to_string(),
251    })
252}
253
254/// Strip a leading `a/` / `b/` (or any) prefix from a raw path, byte-wise;
255/// `None` when it is absent (so a `/dev/null` side yields no path).
256fn strip_side_prefix(path: Vec<u8>, prefix: &[u8]) -> Option<Vec<u8>> {
257    path.strip_prefix(prefix).map(<[u8]>::to_vec)
258}
259
260/// Normalise `\` path separators to `/` on the raw bytes (git renders a Windows
261/// path with backslashes; the DTO is forward-slash normalised across backends).
262fn normalize_slashes(path: Vec<u8>) -> Vec<u8> {
263    path.into_iter()
264        .map(|b| if b == b'\\' { b'/' } else { b })
265        .collect()
266}
267
268/// Parse a hunk header `@@ -<os>[,<ol>] +<ns>[,<nl>] @@[ <section>]` into an empty
269/// [`Hunk`]; `None` for any other line.
270fn parse_hunk_header(line: &str) -> Option<Hunk> {
271    let rest = line.strip_prefix("@@ ")?;
272    let (ranges, section) = rest.split_once(" @@")?;
273    let mut parts = ranges.split_whitespace();
274    let (old_start, old_lines) = parse_hunk_range(parts.next()?.strip_prefix('-')?);
275    let (new_start, new_lines) = parse_hunk_range(parts.next()?.strip_prefix('+')?);
276    Some(Hunk {
277        old_start,
278        old_lines,
279        new_start,
280        new_lines,
281        section: section.strip_prefix(' ').unwrap_or(section).to_string(),
282        lines: Vec::new(),
283    })
284}
285
286/// Parse a `<start>[,<count>]` hunk range; an omitted count means 1 line.
287fn parse_hunk_range(range: &str) -> (usize, usize) {
288    match range.split_once(',') {
289        Some((start, count)) => (start.parse().unwrap_or(0), count.parse().unwrap_or(0)),
290        None => (range.parse().unwrap_or(0), 1),
291    }
292}
293
294/// Fallback path extraction for sections with no `+++`/`---`/`rename` lines
295/// (e.g. binary files): the `b/<new>` of the `diff --git` header. Handles both the
296/// unquoted `a/<p> b/<p>` form and git's C-quoted `"a/<p>" "b/<p>"` form (a
297/// non-ASCII / special-byte path). The unquoted form is ambiguous only when a path
298/// contains the literal `" b/"`, which binary-with-spaces makes rare.
299fn header_b_path(section: &str) -> Option<Vec<u8>> {
300    let first = section.lines().next()?;
301    let s = first.strip_prefix("diff --git ")?;
302    // Quoted header: the b-side is the last `"b/…"` token (for the binary/mode-only
303    // sections this fallback serves, both sides share one path and one quoting).
304    let path = if let Some(q) = s.rfind("\"b/") {
305        strip_side_prefix(unquote_git_path(&s[q..]), b"b/").unwrap_or_default()
306    } else {
307        let idx = s.find(" b/")?;
308        strip_side_prefix(unquote_git_path(&s[idx + 1..]), b"b/").unwrap_or_default()
309    };
310    // A `diff --git a/x b/` with no path after `b/` yields nothing, not an empty
311    // path — so a malformed header drops the section instead of an empty FileDiff.
312    (!path.is_empty()).then_some(path)
313}
314
315/// Decode a git **C-quoted** path. git wraps a path in double quotes and C-escapes
316/// it when it contains a control byte, a `"`, a `\`, or — with the default
317/// `core.quotePath=true` — any non-ASCII (high) byte (e.g. `é` → `\303\251`). A path
318/// that is *not* quoted (no leading `"`) is returned unchanged, so callers can apply
319/// this unconditionally. Octal escapes decode to raw bytes, so a multi-byte UTF-8
320/// filename round-trips; the **raw decoded bytes** are returned (the caller builds
321/// a lossless [`PathBuf`] via [`path_from_bytes`]) instead of a lossily-decoded
322/// `String` — a non-UTF-8 path would otherwise be corrupted to `U+FFFD` here.
323/// Decoding stops at the first unescaped closing quote (trailing bytes are ignored).
324fn unquote_git_path(s: &str) -> Vec<u8> {
325    let bytes = s.as_bytes();
326    if bytes.first() != Some(&b'"') {
327        return bytes.to_vec();
328    }
329    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
330    let mut i = 1; // skip the opening quote
331    while i < bytes.len() {
332        match bytes[i] {
333            b'"' => break, // unescaped closing quote
334            b'\\' if i + 1 < bytes.len() => {
335                i += 1;
336                match bytes[i] {
337                    b'a' => out.push(0x07),
338                    b'b' => out.push(0x08),
339                    b't' => out.push(b'\t'),
340                    b'n' => out.push(b'\n'),
341                    b'v' => out.push(0x0b),
342                    b'f' => out.push(0x0c),
343                    b'r' => out.push(b'\r'),
344                    b'"' => out.push(b'"'),
345                    b'\\' => out.push(b'\\'),
346                    d @ b'0'..=b'7' => {
347                        // Up to 3 octal digits → one byte (`\NNN`, NNN ≤ 0o377).
348                        let mut val = u32::from(d - b'0');
349                        let mut taken = 0;
350                        while taken < 2
351                            && i + 1 < bytes.len()
352                            && (b'0'..=b'7').contains(&bytes[i + 1])
353                        {
354                            i += 1;
355                            val = val * 8 + u32::from(bytes[i] - b'0');
356                            taken += 1;
357                        }
358                        out.push(val as u8);
359                    }
360                    other => out.push(other), // unknown escape: keep the byte
361                }
362                i += 1;
363            }
364            b => {
365                out.push(b);
366                i += 1;
367            }
368        }
369    }
370    out
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn diff_covers_add_modify_delete_rename() {
379        // Add (new), modify (mod), delete (gone), and a directory-changing rename
380        // (old/f -> new/f). Ported from the vcs-flow section-parser test.
381        let full = concat!(
382            "diff --git a/new b/new\n",
383            "new file mode 100644\n--- /dev/null\n+++ b/new\n@@ -0,0 +1 @@\n+n\n",
384            "diff --git a/mod b/mod\n",
385            "--- a/mod\n+++ b/mod\n@@ -1 +1 @@\n-a\n+b\n",
386            "diff --git a/gone b/gone\n",
387            "deleted file mode 100644\n--- a/gone\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n",
388            "diff --git a/old/f.txt b/new/f.txt\n",
389            "similarity index 100%\nrename from old/f.txt\nrename to new/f.txt\n",
390        );
391        let files = parse_diff(full);
392        let kinds: Vec<_> = files
393            .iter()
394            .map(|f| (f.path.to_str().unwrap(), f.change))
395            .collect();
396        assert_eq!(
397            kinds,
398            vec![
399                ("new", ChangeKind::Added),
400                ("mod", ChangeKind::Modified),
401                ("gone", ChangeKind::Deleted),
402                ("new/f.txt", ChangeKind::Renamed),
403            ]
404        );
405        // The rename carries its old path so the deletion is recorded too.
406        let rename = files
407            .iter()
408            .find(|f| f.change == ChangeKind::Renamed)
409            .unwrap();
410        assert_eq!(
411            rename.old_path.as_deref(),
412            Some(std::path::Path::new("old/f.txt"))
413        );
414    }
415
416    #[test]
417    fn diff_handles_space_paths() {
418        // git appends a trailing tab to `+++`/`---` paths containing spaces; the
419        // path must survive intact (the `diff --git` header is ambiguous here).
420        let full = "diff --git a/a b/c.txt b/a b/c.txt\n--- a/a b/c.txt\t\n+++ b/a b/c.txt\t\n@@ -1 +1 @@\n-x\n+y\n";
421        let files = parse_diff(full);
422        assert_eq!(files.len(), 1);
423        assert_eq!(files[0].path, std::path::Path::new("a b/c.txt"));
424    }
425
426    // git C-quotes a path with a non-ASCII byte (default `core.quotePath=true`).
427    // These fixtures are verbatim `git diff` output for a file named `café.txt`
428    // (`é` = UTF-8 0xC3 0xA9 = octal \303\251). The parser must unquote them rather
429    // than dropping the file. (Captured from real git 2.x.)
430    #[test]
431    fn diff_unquotes_non_ascii_modify() {
432        let full = concat!(
433            "diff --git \"a/caf\\303\\251.txt\" \"b/caf\\303\\251.txt\"\n",
434            "index 45b983b..b023018 100644\n",
435            "--- \"a/caf\\303\\251.txt\"\n",
436            "+++ \"b/caf\\303\\251.txt\"\n",
437            "@@ -1 +1 @@\n-hi\n+bye\n",
438        );
439        let files = parse_diff(full);
440        assert_eq!(files.len(), 1, "the non-ASCII file must not be dropped");
441        assert_eq!(files[0].path, std::path::Path::new("café.txt"));
442        assert_eq!(files[0].change, ChangeKind::Modified);
443    }
444
445    #[test]
446    fn diff_unquotes_non_ascii_rename() {
447        let full = concat!(
448            "diff --git \"a/caf\\303\\251.txt\" \"b/r\\303\\251sum\\303\\251.txt\"\n",
449            "similarity index 100%\n",
450            "rename from \"caf\\303\\251.txt\"\n",
451            "rename to \"r\\303\\251sum\\303\\251.txt\"\n",
452        );
453        let files = parse_diff(full);
454        assert_eq!(files.len(), 1);
455        assert_eq!(files[0].path, std::path::Path::new("résumé.txt"));
456        assert_eq!(files[0].change, ChangeKind::Renamed);
457        assert_eq!(
458            files[0].old_path.as_deref(),
459            Some(std::path::Path::new("café.txt"))
460        );
461    }
462
463    // A binary/mode-only quoted section (no `+++`/`---`/rename lines) resolves its
464    // path from the quoted `diff --git` header via `header_b_path`.
465    #[test]
466    fn diff_unquotes_quoted_header_fallback() {
467        let full = concat!(
468            "diff --git \"a/caf\\303\\251.bin\" \"b/caf\\303\\251.bin\"\n",
469            "index 0000000..1111111 100644\n",
470            "Binary files \"a/caf\\303\\251.bin\" and \"b/caf\\303\\251.bin\" differ\n",
471        );
472        let files = parse_diff(full);
473        assert_eq!(files.len(), 1);
474        assert_eq!(files[0].path, std::path::Path::new("café.bin"));
475    }
476
477    // A path with a literal tab is also C-quoted (`\t`), independent of quotePath.
478    #[test]
479    fn diff_unquotes_escaped_tab_path() {
480        let full = "diff --git \"a/a\\tb.txt\" \"b/a\\tb.txt\"\n--- \"a/a\\tb.txt\"\n+++ \"b/a\\tb.txt\"\n@@ -1 +1 @@\n-x\n+y\n";
481        let files = parse_diff(full);
482        assert_eq!(files.len(), 1);
483        assert_eq!(files[0].path, std::path::Path::new("a\tb.txt"));
484    }
485
486    #[test]
487    fn unquote_git_path_decodes_escapes_and_passes_through_plain() {
488        // The decoder now yields raw bytes (the caller builds a lossless PathBuf).
489        assert_eq!(unquote_git_path("b/plain.txt"), b"b/plain.txt".to_vec()); // not quoted
490        assert_eq!(
491            unquote_git_path("\"b/caf\\303\\251.txt\""),
492            "b/café.txt".as_bytes().to_vec()
493        ); // octal → the exact UTF-8 bytes
494        assert_eq!(unquote_git_path("\"a\\tb\""), b"a\tb".to_vec()); // \t
495        assert_eq!(unquote_git_path("\"a\\\\b\""), b"a\\b".to_vec()); // \\
496        assert_eq!(unquote_git_path("\"a\\\"b\""), b"a\"b".to_vec()); // \"
497        // A non-UTF-8 octal escape (0xFF) survives byte-for-byte — the whole point.
498        assert_eq!(unquote_git_path("\"\\377.bin\""), b"\xff.bin".to_vec());
499    }
500
501    #[test]
502    fn diff_drops_sections_with_no_resolvable_path() {
503        // A header whose `b/` carries no path, and no `+++`/`---`/rename lines:
504        // there is no usable path, so the section is dropped (no empty-path FileDiff).
505        let bad = "diff --git a/x b/\nbinary files differ\n";
506        assert!(parse_diff(bad).is_empty());
507        // An empty `+++ b/` (and no `--- a/`) falls through to the header's real
508        // `b/<path>` rather than producing an empty path.
509        let recover = "diff --git a/real.txt b/real.txt\n+++ b/\nbinary files differ\n";
510        let files = parse_diff(recover);
511        assert_eq!(files.len(), 1);
512        assert_eq!(files[0].path, std::path::Path::new("real.txt"));
513        // A mode-only change (no +++/---/rename, no hunks) still keeps its path via
514        // the header fallback — the path-resolution change must not drop it.
515        let mode_only = "diff --git a/f.sh b/f.sh\nold mode 100644\nnew mode 100755\n";
516        let files = parse_diff(mode_only);
517        assert_eq!(files.len(), 1);
518        assert_eq!(files[0].path, std::path::Path::new("f.sh"));
519    }
520
521    #[test]
522    fn diff_parses_hunk_ranges_and_body() {
523        let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,2 +1,3 @@ fn main()\n ctx\n-old\n+new\n+added\n";
524        let files = parse_diff(full);
525        assert_eq!(files.len(), 1);
526        // The verbatim section is preserved for display.
527        assert_eq!(files[0].raw, full);
528        let hunk = &files[0].hunks[0];
529        assert_eq!(
530            (
531                hunk.old_start,
532                hunk.old_lines,
533                hunk.new_start,
534                hunk.new_lines
535            ),
536            (1, 2, 1, 3)
537        );
538        assert_eq!(hunk.section, "fn main()");
539        assert_eq!(
540            hunk.lines,
541            vec![
542                DiffLine::Context("ctx".into()),
543                DiffLine::Removed("old".into()),
544                DiffLine::Added("new".into()),
545                DiffLine::Added("added".into()),
546            ]
547        );
548    }
549
550    #[test]
551    fn diff_omitted_count_defaults_to_one() {
552        // `@@ -3 +3 @@` (no `,count`) means a single line on each side.
553        let full = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -3 +3 @@\n-a\n+b\n";
554        let hunk = &parse_diff(full)[0].hunks[0];
555        assert_eq!((hunk.old_start, hunk.old_lines), (3, 1));
556        assert_eq!((hunk.new_start, hunk.new_lines), (3, 1));
557    }
558}
559
560// Property-based fuzzing: `parse_diff` is a pure function over *arbitrary* CLI
561// text (a git/jj on the user's machine we don't control), so the load-bearing
562// invariant is "never panic, whatever the bytes" — the byte-offset slicing in
563// `parse_section`/`header_b_path` must stay char-boundary-safe.
564#[cfg(test)]
565mod proptests {
566    use super::*;
567    use proptest::prelude::*;
568
569    /// A line drawn from a git-format diff's structural vocabulary plus multibyte
570    /// text, so a joined document reaches the byte-offset branches.
571    fn diff_line() -> impl Strategy<Value = String> {
572        prop_oneof![
573            Just("diff --git a/f b/f\n".to_string()),
574            Just("--- a/f\n".to_string()),
575            Just("+++ b/f\n".to_string()),
576            Just("@@ -1,2 +3,4 @@ ctx\n".to_string()),
577            Just("@@ -1 +1 @@\n".to_string()),
578            Just("new file mode 100644\n".to_string()),
579            Just("deleted file mode 100644\n".to_string()),
580            Just("rename from {old => new}.rs\n".to_string()),
581            Just("rename to é/r.rs\n".to_string()),
582            "[-+ ]?[a-zé\t]{0,12}\n", // diff body / text incl. multibyte
583        ]
584    }
585
586    fn diff_doc() -> impl Strategy<Value = String> {
587        prop::collection::vec(diff_line(), 0..40).prop_map(|lines| lines.concat())
588    }
589
590    proptest! {
591        // Panic-freedom on completely arbitrary input.
592        #[test]
593        fn parse_diff_never_panics_on_arbitrary_text(s in any::<String>()) {
594            let _ = parse_diff(&s);
595        }
596
597        // …and on structure-biased input that reaches the parsing branches.
598        #[test]
599        fn parse_diff_never_panics_on_structured_text(s in diff_doc()) {
600            let _ = parse_diff(&s);
601        }
602
603        // parse_diff never invents files it can't render the marker for: every
604        // returned FileDiff carries a raw section starting with `diff --git`.
605        #[test]
606        fn parse_diff_sections_are_well_formed(s in diff_doc()) {
607            for file in parse_diff(&s) {
608                prop_assert!(file.raw.starts_with("diff --git"));
609            }
610        }
611    }
612}
613
614// The optional `serde` feature derives `Serialize` on the public model.
615#[cfg(all(test, feature = "serde"))]
616mod serde_tests {
617    use super::*;
618
619    #[test]
620    fn diff_stat_and_change_kind_serialize() {
621        assert_eq!(
622            serde_json::to_value(DiffStat::new(3, 12, 4)).unwrap(),
623            serde_json::json!({"files_changed": 3, "insertions": 12, "deletions": 4})
624        );
625        // Field-less enum variants serialize as their name.
626        assert_eq!(
627            serde_json::to_value(ChangeKind::Renamed).unwrap(),
628            serde_json::json!("Renamed")
629        );
630    }
631}