Skip to main content

packdiff_dto/
snapshot.rs

1//! Commit-boundary file snapshots and the pure line diff over them — what
2//! lets the generated page re-diff any contiguous commit sub-range without
3//! touching git: the CLI collects contents once, the page (via WASM) calls
4//! [`range_diff`] with a pair of boundary indices.
5//!
6//! Nothing here shells out; snapshots arrive as data. Renames are not
7//! re-detected inside a sub-range — a rename shows as a delete plus an add.
8
9use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::diff::{FileDiff, FileStatus, Hunk, Line};
14use crate::ModelError;
15
16/// File contents pinned at every commit boundary of the diffed range,
17/// deduplicated by git blob id. Only paths touched by some commit in the
18/// range are tracked.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct RangeSnapshots {
22  /// Blob id → content. `None` marks content that was not snapshotted
23  /// (binary, not UTF-8, or oversized); sub-range diffs render such files as
24  /// "contents not shown".
25  pub blobs: BTreeMap<String, Option<String>>,
26  /// One entry per boundary, oldest first: index 0 is the diff's start
27  /// (the merge base), index `k > 0` is the state after the k-th commit.
28  pub boundaries: Vec<Boundary>,
29}
30
31/// The tracked files' state at one commit boundary.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Boundary {
35  /// The commit this boundary is the state at (full SHA).
36  pub sha: String,
37  /// Path → blob id for every tracked path that exists at this boundary; a
38  /// missing path does not exist here.
39  pub files: BTreeMap<String, String>,
40}
41
42/// The diff between boundaries `from` and `to` (`from < to`, indices into
43/// `boundaries`), as the same [`FileDiff`] shape the build-time parser
44/// emits. Selecting commit `k` alone is `range_diff(snap, k - 1, k, …)`.
45pub fn range_diff(snap: &RangeSnapshots, from: usize, to: usize, context: usize) -> Result<Vec<FileDiff>, ModelError> {
46  if from >= to || to >= snap.boundaries.len() {
47    return Err(ModelError::InvalidRange(format!(
48      "from {from} / to {to} against {} boundaries",
49      snap.boundaries.len()
50    )));
51  }
52  let lo = &snap.boundaries[from];
53  let hi = &snap.boundaries[to];
54  let mut paths: BTreeSet<&String> = lo.files.keys().collect();
55  paths.extend(hi.files.keys());
56
57  let content = |id: &String| -> Result<&Option<String>, ModelError> {
58    snap.blobs.get(id).ok_or_else(|| ModelError::InvalidRange(format!("blob {id} missing from the snapshot store")))
59  };
60
61  let mut files = Vec::new();
62  for path in paths {
63    let old_id = lo.files.get(path);
64    let new_id = hi.files.get(path);
65    if old_id == new_id {
66      continue; // same blob (or absent on both sides) → unchanged in this sub-range
67    }
68    let status = match (old_id, new_id) {
69      (None, Some(_)) => FileStatus::Added,
70      (Some(_), None) => FileStatus::Deleted,
71      _ => FileStatus::Modified,
72    };
73    let old_blob = old_id.map(content).transpose()?;
74    let new_blob = new_id.map(content).transpose()?;
75    let old_path = old_id.map(|_| path.clone());
76    let new_path = new_id.map(|_| path.clone());
77    // Unsnapshotted content on a present side → shown like a binary file.
78    if matches!(old_blob, Some(None)) || matches!(new_blob, Some(None)) {
79      files.push(FileDiff {
80        old_path,
81        new_path,
82        status,
83        binary: true,
84        hunks: Vec::new(),
85        additions: 0,
86        deletions: 0,
87        notes: Vec::new(),
88      });
89      continue;
90    }
91    let old_text = old_blob.and_then(|b| b.as_deref()).unwrap_or("");
92    let new_text = new_blob.and_then(|b| b.as_deref()).unwrap_or("");
93    let hunks = diff_lines(old_text, new_text, context);
94    let additions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Add { .. })).count() as u32;
95    let deletions = hunks.iter().flat_map(|h| &h.lines).filter(|l| matches!(l, Line::Del { .. })).count() as u32;
96    if hunks.is_empty() {
97      continue; // distinct blob ids never carry identical content, but stay defensive
98    }
99    files.push(FileDiff { old_path, new_path, status, binary: false, hunks, additions, deletions, notes: Vec::new() });
100  }
101  Ok(files)
102}
103
104/// Diff two texts line-wise into hunks with `context` unchanged lines around
105/// each change — the same [`Hunk`]/[`Line`] shapes `parse_unified_diff`
106/// produces. Identical texts yield no hunks.
107pub fn diff_lines(old: &str, new: &str, context: usize) -> Vec<Hunk> {
108  let a: Vec<&str> = old.lines().collect();
109  let b: Vec<&str> = new.lines().collect();
110  let edits = myers_edits(&a, &b);
111  hunks_from(&records(&a, &b, &edits), context)
112}
113
114/// One step of the edit script: keep a common line, delete from the
115/// pre-image, or add from the post-image.
116#[derive(Clone, Copy, PartialEq, Eq, Debug)]
117enum Edit {
118  Keep,
119  Del,
120  Add,
121}
122
123/// Myers' O(ND) shortest edit script over lines (the greedy forward variant
124/// with a full trace for backtracking).
125fn myers_edits(a: &[&str], b: &[&str]) -> Vec<Edit> {
126  let n = a.len() as isize;
127  let m = b.len() as isize;
128  let max = n + m;
129  if max == 0 {
130    return Vec::new();
131  }
132  let offset = max;
133  let idx = |k: isize| (k + offset) as usize;
134  let mut v = vec![0isize; (2 * max + 1) as usize];
135  let mut trace: Vec<Vec<isize>> = Vec::new();
136  'search: for d in 0..=max {
137    trace.push(v.clone());
138    let mut k = -d;
139    while k <= d {
140      let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { v[idx(k + 1)] } else { v[idx(k - 1)] + 1 };
141      let mut y = x - k;
142      while x < n && y < m && a[x as usize] == b[y as usize] {
143        x += 1;
144        y += 1;
145      }
146      v[idx(k)] = x;
147      if x >= n && y >= m {
148        break 'search;
149      }
150      k += 2;
151    }
152  }
153
154  // Backtrack from (n, m); trace[d] is the V state entering round d.
155  let mut edits = Vec::new();
156  let (mut x, mut y) = (n, m);
157  for (d, v) in trace.iter().enumerate().rev() {
158    let d = d as isize;
159    let k = x - y;
160    let prev_k = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { k + 1 } else { k - 1 };
161    let prev_x = v[idx(prev_k)];
162    let prev_y = prev_x - prev_k;
163    while x > prev_x && y > prev_y {
164      edits.push(Edit::Keep);
165      x -= 1;
166      y -= 1;
167    }
168    if d > 0 {
169      edits.push(if x == prev_x { Edit::Add } else { Edit::Del });
170    }
171    x = prev_x;
172    y = prev_y;
173  }
174  edits.reverse();
175  edits
176}
177
178/// A typed diff line plus the 1-based counters as they stood BEFORE the
179/// line, which is what hunk headers are computed from.
180struct Rec {
181  line: Line,
182  old_before: u32,
183  new_before: u32,
184  changed: bool,
185}
186
187fn records(a: &[&str], b: &[&str], edits: &[Edit]) -> Vec<Rec> {
188  let (mut ai, mut bi) = (0usize, 0usize);
189  let (mut old_no, mut new_no) = (1u32, 1u32);
190  let mut recs = Vec::with_capacity(edits.len());
191  for e in edits {
192    match e {
193      Edit::Keep => {
194        recs.push(Rec {
195          line: Line::Ctx { old: old_no, new: new_no, text: a[ai].to_string() },
196          old_before: old_no,
197          new_before: new_no,
198          changed: false,
199        });
200        ai += 1;
201        bi += 1;
202        old_no += 1;
203        new_no += 1;
204      }
205      Edit::Del => {
206        recs.push(Rec {
207          line: Line::Del { old: old_no, text: a[ai].to_string() },
208          old_before: old_no,
209          new_before: new_no,
210          changed: true,
211        });
212        ai += 1;
213        old_no += 1;
214      }
215      Edit::Add => {
216        recs.push(Rec {
217          line: Line::Add { new: new_no, text: b[bi].to_string() },
218          old_before: old_no,
219          new_before: new_no,
220          changed: true,
221        });
222        bi += 1;
223        new_no += 1;
224      }
225    }
226  }
227  recs
228}
229
230/// Group records into hunks: every changed line plus up to `context`
231/// neighbors; overlapping windows merge. Headers use the explicit
232/// `@@ -start,count +start,count @@` form (count included even when 1); a
233/// side with no lines gets git's convention of `start = line before, count 0`.
234fn hunks_from(recs: &[Rec], context: usize) -> Vec<Hunk> {
235  let mut include = vec![false; recs.len()];
236  for (i, r) in recs.iter().enumerate() {
237    if r.changed {
238      let lo = i.saturating_sub(context);
239      let hi = (i + context).min(recs.len() - 1);
240      for flag in &mut include[lo..=hi] {
241        *flag = true;
242      }
243    }
244  }
245  let mut hunks = Vec::new();
246  let mut i = 0;
247  while i < recs.len() {
248    if !include[i] {
249      i += 1;
250      continue;
251    }
252    let start = i;
253    while i < recs.len() && include[i] {
254      i += 1;
255    }
256    let slice = &recs[start..i];
257    let old_count = slice.iter().filter(|r| matches!(r.line, Line::Del { .. } | Line::Ctx { .. })).count();
258    let new_count = slice.iter().filter(|r| matches!(r.line, Line::Add { .. } | Line::Ctx { .. })).count();
259    let old_start = if old_count > 0 { slice[0].old_before } else { slice[0].old_before.saturating_sub(1) };
260    let new_start = if new_count > 0 { slice[0].new_before } else { slice[0].new_before.saturating_sub(1) };
261    hunks.push(Hunk {
262      header: format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@"),
263      lines: slice.iter().map(|r| r.line.clone()).collect(),
264    });
265  }
266  hunks
267}
268
269#[cfg(test)]
270mod tests {
271  use super::*;
272
273  fn snap() -> RangeSnapshots {
274    let blob = |s: &str| Some(s.to_string());
275    RangeSnapshots {
276      blobs: BTreeMap::from([
277        ("b-one".into(), blob("alpha\nbeta\ngamma\n")),
278        ("b-two".into(), blob("alpha\nBETA\ngamma\n")),
279        ("b-new".into(), blob("fresh\n")),
280        ("b-bin".into(), None),
281      ]),
282      boundaries: vec![
283        Boundary {
284          sha: "s0".into(),
285          files: BTreeMap::from([("keep.txt".into(), "b-one".into()), ("gone.txt".into(), "b-one".into())]),
286        },
287        Boundary {
288          sha: "s1".into(),
289          files: BTreeMap::from([("keep.txt".into(), "b-two".into()), ("gone.txt".into(), "b-one".into())]),
290        },
291        Boundary {
292          sha: "s2".into(),
293          files: BTreeMap::from([
294            ("keep.txt".into(), "b-one".into()),
295            ("new.txt".into(), "b-new".into()),
296            ("blob.bin".into(), "b-bin".into()),
297          ]),
298        },
299      ],
300    }
301  }
302
303  #[test]
304  fn single_commit_diff() {
305    let files = range_diff(&snap(), 0, 1, 3).unwrap();
306    assert_eq!(files.len(), 1);
307    let f = &files[0];
308    assert_eq!(f.new_path.as_deref(), Some("keep.txt"));
309    assert_eq!(f.status, FileStatus::Modified);
310    assert_eq!((f.additions, f.deletions), (1, 1));
311    assert_eq!(f.hunks[0].header, "@@ -1,3 +1,3 @@");
312    assert_eq!(f.hunks[0].lines[1], Line::Del { old: 2, text: "beta".into() });
313    assert_eq!(f.hunks[0].lines[2], Line::Add { new: 2, text: "BETA".into() });
314  }
315
316  #[test]
317  fn full_range_hides_a_change_that_was_reverted() {
318    // keep.txt: one → two → back to one; the 0..2 diff must not mention it.
319    let files = range_diff(&snap(), 0, 2, 3).unwrap();
320    let paths: Vec<&str> = files.iter().map(|f| f.anchor_path()).collect();
321    assert_eq!(paths, vec!["blob.bin", "gone.txt", "new.txt"]);
322    // ...while the 1..2 sub-range shows the revert itself.
323    let files = range_diff(&snap(), 1, 2, 3).unwrap();
324    assert!(files.iter().any(|f| f.anchor_path() == "keep.txt"));
325  }
326
327  #[test]
328  fn added_deleted_and_binary_statuses() {
329    let files = range_diff(&snap(), 0, 2, 3).unwrap();
330    let by_path = |p: &str| files.iter().find(|f| f.anchor_path() == p).unwrap();
331    let added = by_path("new.txt");
332    assert_eq!(added.status, FileStatus::Added);
333    assert_eq!(added.old_path, None);
334    assert_eq!(added.hunks[0].header, "@@ -0,0 +1,1 @@");
335    let deleted = by_path("gone.txt");
336    assert_eq!(deleted.status, FileStatus::Deleted);
337    assert_eq!(deleted.new_path, None);
338    assert_eq!(deleted.deletions, 3);
339    let binary = by_path("blob.bin");
340    assert!(binary.binary);
341    assert!(binary.hunks.is_empty());
342  }
343
344  #[test]
345  fn invalid_ranges_and_missing_blobs_are_loud() {
346    assert!(matches!(range_diff(&snap(), 1, 1, 3), Err(ModelError::InvalidRange(_))));
347    assert!(matches!(range_diff(&snap(), 2, 1, 3), Err(ModelError::InvalidRange(_))));
348    assert!(matches!(range_diff(&snap(), 0, 9, 3), Err(ModelError::InvalidRange(_))));
349    let mut broken = snap();
350    broken.blobs.remove("b-two");
351    assert!(matches!(range_diff(&broken, 0, 1, 3), Err(ModelError::InvalidRange(_))));
352  }
353
354  #[test]
355  fn diff_lines_context_and_merging() {
356    let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
357    // Two changes far apart → two hunks at context 1; one merged hunk at 3.
358    let new = "a\nB\nc\nd\ne\nf\ng\nh\nI\nj\n";
359    let hunks = diff_lines(old, new, 1);
360    assert_eq!(hunks.len(), 2);
361    assert_eq!(hunks[0].header, "@@ -1,3 +1,3 @@");
362    assert_eq!(hunks[1].header, "@@ -8,3 +8,3 @@");
363    let hunks = diff_lines(old, new, 3);
364    assert_eq!(hunks.len(), 1, "windows overlap and merge");
365    assert_eq!(hunks[0].header, "@@ -1,10 +1,10 @@");
366  }
367
368  #[test]
369  fn diff_lines_edge_cases() {
370    assert!(diff_lines("", "", 3).is_empty());
371    assert!(diff_lines("same\n", "same\n", 3).is_empty());
372    let from_empty = diff_lines("", "one\ntwo\n", 3);
373    assert_eq!(from_empty[0].header, "@@ -0,0 +1,2 @@");
374    let to_empty = diff_lines("one\ntwo\n", "", 3);
375    assert_eq!(to_empty[0].header, "@@ -1,2 +0,0 @@");
376  }
377
378  #[test]
379  fn myers_produces_a_minimal_script() {
380    // The classic ABCABBA → CBABAC example: shortest script has 5 edits.
381    let a: Vec<&str> = "A B C A B B A".split(' ').collect();
382    let b: Vec<&str> = "C B A B A C".split(' ').collect();
383    let edits = myers_edits(&a, &b);
384    let changes = edits.iter().filter(|e| **e != Edit::Keep).count();
385    assert_eq!(changes, 5);
386    let keeps = edits.iter().filter(|e| **e == Edit::Keep).count();
387    assert_eq!(keeps, 4);
388    // The script replays a into b.
389    let (mut ai, mut bi) = (0, 0);
390    let mut out: Vec<&str> = Vec::new();
391    for e in &edits {
392      match e {
393        Edit::Keep => {
394          out.push(a[ai]);
395          ai += 1;
396          bi += 1;
397        }
398        Edit::Del => ai += 1,
399        Edit::Add => {
400          out.push(b[bi]);
401          bi += 1;
402        }
403      }
404    }
405    assert_eq!(out, b);
406    assert_eq!((ai, bi), (a.len(), b.len()));
407  }
408
409  #[test]
410  fn snapshots_roundtrip_and_reject_unknown_fields() {
411    let s = snap();
412    let json = serde_json::to_string(&s).unwrap();
413    let back: RangeSnapshots = serde_json::from_str(&json).unwrap();
414    assert_eq!(back.boundaries.len(), 3);
415    let sneaky = json.replacen("{\"blobs\"", "{\"sneaky\":true,\"blobs\"", 1);
416    assert!(serde_json::from_str::<RangeSnapshots>(&sneaky).is_err(), "unknown fields are strict-rejected");
417  }
418}