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