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