Skip to main content

packdiff_dto/
diff.rs

1//! The diff document: what a build extracted from git, plus the parser that
2//! turns raw `git diff` text into typed data.
3//!
4//! [`parse_unified_diff`] is a pure function `&str -> Vec<FileDiff>` — the CLI
5//! feeds it `git diff --no-color --no-ext-diff --find-renames` output. Nothing
6//! here shells out.
7
8use serde::{Deserialize, Serialize};
9
10use crate::{RefInfo, SCHEMA_VERSION, TOOL};
11
12/// The immutable build artifact: one rendered diff between two pinned refs.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14#[serde(deny_unknown_fields)]
15pub struct DiffDocument {
16  /// Schema generation this document was written with; readers reject
17  /// documents newer than they understand ([`SCHEMA_VERSION`]).
18  pub schema_version: u32,
19  /// Producing tool identifier (`"packdiff"`); lets a reader distinguish this
20  /// document from look-alike JSON of other origins.
21  pub tool: String,
22  /// Repository directory name — not a path, so documents stay shareable
23  /// across machines.
24  pub repo: String,
25  /// The base ref, pinned to the SHA it resolved to at build time.
26  pub base: RefInfo,
27  /// The head ref, pinned to the SHA it resolved to at build time.
28  pub head: RefInfo,
29  /// The commit the diff actually starts from: `merge-base(base, head)` in
30  /// the default three-dot mode, or `base` itself in two-dot mode.
31  pub merge_base: String,
32  /// Build timestamp, RFC 3339 UTC — supplied by the caller (the model has
33  /// no clock); the only non-deterministic field in a build.
34  pub generated_at: String,
35  /// Commits in the diffed range, oldest first.
36  pub commits: Vec<Commit>,
37  /// Per-file changes, in the order git emitted them.
38  pub files: Vec<FileDiff>,
39  /// File snapshots at every commit boundary, enabling in-page filtering of
40  /// the diff to any contiguous commit sub-range. `None` (and omitted from
41  /// JSON) when not collected — on older builds, or ranges of fewer than
42  /// two commits, where there is nothing to filter.
43  #[serde(default, skip_serializing_if = "Option::is_none")]
44  pub snapshots: Option<crate::snapshot::RangeSnapshots>,
45}
46
47impl DiffDocument {
48  #[allow(clippy::too_many_arguments)]
49  pub fn new(
50    repo: String, base: RefInfo, head: RefInfo, merge_base: String, generated_at: String, commits: Vec<Commit>,
51    files: Vec<FileDiff>, snapshots: Option<crate::snapshot::RangeSnapshots>,
52  ) -> Self {
53    Self {
54      schema_version: SCHEMA_VERSION,
55      tool: TOOL.to_string(),
56      repo,
57      base,
58      head,
59      merge_base,
60      generated_at,
61      commits,
62      files,
63      snapshots,
64    }
65  }
66
67  pub fn additions(&self) -> u32 {
68    self.files.iter().map(|f| f.additions).sum()
69  }
70
71  pub fn deletions(&self) -> u32 {
72    self.files.iter().map(|f| f.deletions).sum()
73  }
74}
75
76/// One commit in the diffed range.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(deny_unknown_fields)]
79pub struct Commit {
80  /// Full 40-hex commit id — the stable identity of the commit.
81  pub sha: String,
82  /// Abbreviated id as git printed it, for human-facing display.
83  pub short: String,
84  /// Author name, verbatim from git (`%an`).
85  pub author: String,
86  /// Author email, verbatim from git (`%ae`); kept so exports can attribute
87  /// precisely even when display names collide.
88  pub email: String,
89  /// Author date, RFC 3339 (git `%aI`).
90  pub date: String,
91  /// First line of the commit message.
92  pub subject: String,
93}
94
95/// How a file changed. Serialized as the bare `CamelCase` variant name
96/// (`"Added"` / `"Deleted"` / `"Modified"` / `"Renamed"`).
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98pub enum FileStatus {
99  /// The file exists only in the post-image.
100  Added,
101  /// The file exists only in the pre-image.
102  Deleted,
103  /// Same path on both sides, contents differ.
104  Modified,
105  /// Detected rename (`--find-renames`); paths differ, content may too.
106  Renamed,
107}
108
109/// One file's change. `old_path`/`new_path` are `None` where the file does not
110/// exist on that side (added / deleted).
111#[derive(Debug, Clone, Serialize, Deserialize)]
112#[serde(deny_unknown_fields)]
113pub struct FileDiff {
114  /// Pre-image path; `None` for added files.
115  pub old_path: Option<String>,
116  /// Post-image path; `None` for deleted files.
117  pub new_path: Option<String>,
118  /// The kind of change; determines which of the paths are present.
119  pub status: FileStatus,
120  /// True when git reported binary content — such files carry no hunks.
121  pub binary: bool,
122  /// Textual change hunks, in file order; empty for binary or metadata-only
123  /// changes.
124  pub hunks: Vec<Hunk>,
125  /// Count of `+` lines across all hunks; denormalized so consumers need not
126  /// re-walk the hunks for totals.
127  pub additions: u32,
128  /// Count of `-` lines across all hunks; denormalized like `additions`.
129  pub deletions: u32,
130  /// Extended-header notes worth surfacing to a reviewer (mode changes).
131  pub notes: Vec<String>,
132}
133
134impl FileDiff {
135  /// Human-facing label: `old → new` for renames, otherwise the path.
136  pub fn display_path(&self) -> String {
137    match (self.status, &self.old_path, &self.new_path) {
138      (FileStatus::Renamed, Some(old), Some(new)) if old != new => format!("{old} → {new}"),
139      _ => self.anchor_path().to_string(),
140    }
141  }
142
143  /// The path comments anchor to: the post-image path when it exists.
144  pub fn anchor_path(&self) -> &str {
145    self.new_path.as_deref().or(self.old_path.as_deref()).unwrap_or("")
146  }
147}
148
149/// One contiguous run of diff lines.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(deny_unknown_fields)]
152pub struct Hunk {
153  /// The raw `@@ -a,b +c,d @@ context` header line, kept verbatim for display.
154  pub header: String,
155  /// The hunk's lines, in order.
156  pub lines: Vec<Line>,
157}
158
159/// One diff line. Encoded as a single-key union —
160/// `{ "Add": { "new": 2, "text": "…" } }` — with no discriminator field.
161/// Line numbers are 1-based and refer to the side named by the field
162/// (`old` = pre-image, `new` = post-image) — the same coordinates comments
163/// anchor to.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(deny_unknown_fields)]
166pub enum Line {
167  /// A line present only in the post-image (`+` in unified diff).
168  Add {
169    /// 1-based post-image line number.
170    new: u32,
171    /// Line content without the leading `+`.
172    text: String,
173  },
174  /// A line present only in the pre-image (`-` in unified diff).
175  Del {
176    /// 1-based pre-image line number.
177    old: u32,
178    /// Line content without the leading `-`.
179    text: String,
180  },
181  /// An unchanged context line, present on both sides.
182  Ctx {
183    /// 1-based pre-image line number.
184    old: u32,
185    /// 1-based post-image line number.
186    new: u32,
187    /// Line content without the leading space.
188    text: String,
189  },
190  /// A diff-level annotation such as `\ No newline at end of file`; carries
191  /// no line numbers.
192  Meta {
193    /// The annotation verbatim, including its leading backslash.
194    text: String,
195  },
196}
197
198/// Parse `git diff` unified output (with `--find-renames`) into typed files.
199pub fn parse_unified_diff(text: &str) -> Vec<FileDiff> {
200  let mut files: Vec<FileDiff> = Vec::new();
201  let mut cur: Option<FileDiff> = None;
202  let mut in_hunk = false;
203  let mut old_no: u32 = 0;
204  let mut new_no: u32 = 0;
205
206  for line in text.lines() {
207    if let Some(rest) = line.strip_prefix("diff --git ") {
208      if let Some(done) = cur.take() {
209        files.push(done);
210      }
211      let (old, new) = split_ab(rest);
212      cur = Some(FileDiff {
213        old_path: Some(old),
214        new_path: Some(new),
215        status: FileStatus::Modified,
216        binary: false,
217        hunks: Vec::new(),
218        additions: 0,
219        deletions: 0,
220        notes: Vec::new(),
221      });
222      in_hunk = false;
223      continue;
224    }
225    let Some(f) = cur.as_mut() else { continue };
226
227    if in_hunk {
228      // `in_hunk` is only set right after a hunk is pushed, so the expects
229      // below are provable invariants, not runtime fallibility.
230      if let Some(body) = line.strip_prefix('+') {
231        f.hunks
232          .last_mut()
233          .expect("in_hunk implies a current hunk")
234          .lines
235          .push(Line::Add { new: new_no, text: body.to_string() });
236        new_no += 1;
237        f.additions += 1;
238        continue;
239      }
240      if let Some(body) = line.strip_prefix('-') {
241        f.hunks
242          .last_mut()
243          .expect("in_hunk implies a current hunk")
244          .lines
245          .push(Line::Del { old: old_no, text: body.to_string() });
246        old_no += 1;
247        f.deletions += 1;
248        continue;
249      }
250      if let Some(body) = line.strip_prefix(' ') {
251        f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Ctx {
252          old: old_no,
253          new: new_no,
254          text: body.to_string(),
255        });
256        old_no += 1;
257        new_no += 1;
258        continue;
259      }
260      if line.starts_with('\\') {
261        f.hunks.last_mut().expect("in_hunk implies a current hunk").lines.push(Line::Meta { text: line.to_string() });
262        continue;
263      }
264      in_hunk = false; // fall through to header handling
265    }
266
267    if let Some((o, n)) = parse_hunk_header(line) {
268      old_no = o;
269      new_no = n;
270      f.hunks.push(Hunk { header: line.to_string(), lines: Vec::new() });
271      in_hunk = true;
272    } else if line.starts_with("new file mode") {
273      f.status = FileStatus::Added;
274      f.old_path = None;
275    } else if line.starts_with("deleted file mode") {
276      f.status = FileStatus::Deleted;
277      f.new_path = None;
278    } else if let Some(p) = line.strip_prefix("rename from ") {
279      f.status = FileStatus::Renamed;
280      f.old_path = Some(p.to_string());
281    } else if let Some(p) = line.strip_prefix("rename to ") {
282      f.status = FileStatus::Renamed;
283      f.new_path = Some(p.to_string());
284    } else if line.starts_with("old mode ") || line.starts_with("new mode ") {
285      f.notes.push(line.to_string());
286    } else if line.starts_with("Binary files ") || line.starts_with("GIT binary patch") {
287      f.binary = true;
288    } else if let Some(p) = line.strip_prefix("--- ") {
289      if let Some(stripped) = strip_prefix_path(p) {
290        f.old_path = Some(stripped);
291      } else if f.status != FileStatus::Added {
292        // `--- /dev/null` on a file we haven't classified yet.
293        f.status = FileStatus::Added;
294        f.old_path = None;
295      }
296    } else if let Some(p) = line.strip_prefix("+++ ") {
297      if let Some(stripped) = strip_prefix_path(p) {
298        f.new_path = Some(stripped);
299      } else if f.status != FileStatus::Deleted {
300        f.status = FileStatus::Deleted;
301        f.new_path = None;
302      }
303    }
304    // `index`, `similarity index`, `copy from/to` headers are ignored.
305  }
306  if let Some(done) = cur.take() {
307    files.push(done);
308  }
309  files
310}
311
312/// `@@ -a[,b] +c[,d] @@ …` → `(a, c)`, or None if the line is not a hunk header.
313fn parse_hunk_header(line: &str) -> Option<(u32, u32)> {
314  let rest = line.strip_prefix("@@ -")?;
315  let (old_part, rest) = rest.split_once(" +")?;
316  let (new_part, _) = rest.split_once(" @@")?;
317  let old = old_part.split(',').next()?.parse().ok()?;
318  let new = new_part.split(',').next()?.parse().ok()?;
319  Some((old, new))
320}
321
322/// Drop the `a/` / `b/` prefix; `None` for `/dev/null`.
323fn strip_prefix_path(path: &str) -> Option<String> {
324  if path == "/dev/null" {
325    return None;
326  }
327  let stripped = path.strip_prefix("a/").or_else(|| path.strip_prefix("b/")).unwrap_or(path);
328  Some(stripped.to_string())
329}
330
331/// Best-effort split of `a/old b/new` from a `diff --git` header. Exact for
332/// paths without spaces; paths WITH spaces are re-derived from the
333/// `---`/`+++`/`rename` headers that follow, so this only needs to not crash.
334fn split_ab(rest: &str) -> (String, String) {
335  if let Some(stripped) = rest.strip_prefix('"') {
336    // Quoted (unusual) paths: `"a/x" "b/y"`.
337    if let Some((old, new)) = stripped.split_once("\" \"") {
338      let old = old.strip_prefix("a/").unwrap_or(old);
339      let new = new.strip_prefix("b/").unwrap_or(new).trim_end_matches('"');
340      return (old.to_string(), new.to_string());
341    }
342  }
343  if let Some(idx) = rest.rfind(" b/") {
344    let old = strip_prefix_path(&rest[..idx]).unwrap_or_default();
345    let new = rest[idx + 3..].to_string();
346    return (old, new);
347  }
348  (rest.to_string(), rest.to_string())
349}
350
351#[cfg(test)]
352mod tests {
353  use super::*;
354
355  const SAMPLE: &str = "\
356diff --git a/hello.py b/hello.py
357index 1111111..2222222 100644
358--- a/hello.py
359+++ b/hello.py
360@@ -1,2 +1,4 @@
361 def hello():
362-    return 'hi'
363+    return 'hello'
364+
365+# trailing
366diff --git a/gone.txt b/gone.txt
367deleted file mode 100644
368index 3333333..0000000
369--- a/gone.txt
370+++ /dev/null
371@@ -1 +0,0 @@
372-obsolete
373\\ No newline at end of file
374diff --git a/old name.txt b/new name.txt
375similarity index 90%
376rename from old name.txt
377rename to new name.txt
378diff --git a/fresh.md b/fresh.md
379new file mode 100644
380index 0000000..4444444
381--- /dev/null
382+++ b/fresh.md
383@@ -0,0 +1,2 @@
384+# Fresh
385+body
386diff --git a/blob.bin b/blob.bin
387index 5555555..6666666 100644
388Binary files a/blob.bin and b/blob.bin differ
389";
390
391  fn parsed() -> Vec<FileDiff> {
392    parse_unified_diff(SAMPLE)
393  }
394
395  #[test]
396  fn parses_all_files() {
397    assert_eq!(parsed().len(), 5);
398  }
399
400  #[test]
401  fn modified_file_lines_and_numbers() {
402    let files = parsed();
403    let f = &files[0];
404    assert_eq!(f.status, FileStatus::Modified);
405    assert_eq!((f.additions, f.deletions), (3, 1));
406    let lines = &f.hunks[0].lines;
407    assert_eq!(lines[0], Line::Ctx { old: 1, new: 1, text: "def hello():".into() });
408    assert_eq!(lines[1], Line::Del { old: 2, text: "    return 'hi'".into() });
409    assert_eq!(lines[2], Line::Add { new: 2, text: "    return 'hello'".into() });
410    assert_eq!(lines[4], Line::Add { new: 4, text: "# trailing".into() });
411  }
412
413  #[test]
414  fn deleted_file() {
415    let files = parsed();
416    let f = &files[1];
417    assert_eq!(f.status, FileStatus::Deleted);
418    assert_eq!(f.new_path, None);
419    assert_eq!(f.anchor_path(), "gone.txt");
420    assert!(matches!(f.hunks[0].lines.last(), Some(Line::Meta { .. })));
421  }
422
423  #[test]
424  fn rename_with_spaces_in_paths() {
425    let files = parsed();
426    let f = &files[2];
427    assert_eq!(f.status, FileStatus::Renamed);
428    assert_eq!(f.old_path.as_deref(), Some("old name.txt"));
429    assert_eq!(f.new_path.as_deref(), Some("new name.txt"));
430    assert_eq!(f.display_path(), "old name.txt → new name.txt");
431    assert!(f.hunks.is_empty());
432  }
433
434  #[test]
435  fn added_file() {
436    let files = parsed();
437    let f = &files[3];
438    assert_eq!(f.status, FileStatus::Added);
439    assert_eq!(f.old_path, None);
440    assert_eq!(f.additions, 2);
441  }
442
443  #[test]
444  fn binary_file() {
445    let files = parsed();
446    assert!(files[4].binary);
447    assert!(files[4].hunks.is_empty());
448  }
449
450  #[test]
451  fn hunk_header_forms() {
452    assert_eq!(parse_hunk_header("@@ -1,2 +3,4 @@"), Some((1, 3)));
453    assert_eq!(parse_hunk_header("@@ -1 +0,0 @@"), Some((1, 0)));
454    assert_eq!(parse_hunk_header("@@ -10,5 +12,7 @@ fn ctx()"), Some((10, 12)));
455    assert_eq!(parse_hunk_header("not a hunk"), None);
456  }
457
458  #[test]
459  fn lines_encode_as_single_key_unions() {
460    let add = Line::Add { new: 2, text: "x".into() };
461    assert_eq!(serde_json::to_value(&add).unwrap(), serde_json::json!({ "Add": { "new": 2, "text": "x" } }));
462    assert_eq!(serde_json::to_value(FileStatus::Renamed).unwrap(), serde_json::json!("Renamed"));
463  }
464
465  #[test]
466  fn unknown_fields_are_rejected() {
467    let bad = r#"{ "Add": { "new": 2, "text": "x", "sneaky": true } }"#;
468    assert!(serde_json::from_str::<Line>(bad).is_err());
469  }
470
471  #[test]
472  fn document_roundtrips_through_json() {
473    let doc = DiffDocument::new(
474      "repo".into(),
475      RefInfo { name: "main".into(), sha: "a".repeat(40) },
476      RefInfo { name: "feat".into(), sha: "b".repeat(40) },
477      "c".repeat(40),
478      "2026-07-03T00:00:00Z".into(),
479      vec![],
480      parsed(),
481      None,
482    );
483    let json = serde_json::to_string(&doc).unwrap();
484    let back: DiffDocument = serde_json::from_str(&json).unwrap();
485    assert_eq!(back.schema_version, SCHEMA_VERSION);
486    assert_eq!(back.files.len(), 5);
487    assert_eq!(back.additions(), doc.additions());
488  }
489}