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/// Unchanged lines shared by a file's two endpoint snapshots — what the
105/// page's expand-context control reveals inside hunk gaps. Returns up to
106/// `count` [`Line::Ctx`] rows starting at `old_start`/`new_start` (1-based,
107/// pre- and post-image), clamped at either file's end. The requested region
108/// must be text-identical at both endpoints — a gap between hunks always is
109/// — so expansion can never invent or hide a change; a mismatch is rejected
110/// loudly. `old_path`/`new_path` may differ (renamed files).
111pub fn context_slice(
112  snap: &RangeSnapshots, old_path: &str, new_path: &str, old_start: u32, new_start: u32, count: u32,
113) -> Result<Vec<Line>, ModelError> {
114  if old_start == 0 || new_start == 0 {
115    return Err(ModelError::InvalidRange("line numbers are 1-based".to_string()));
116  }
117  let (Some(base), Some(head)) = (snap.boundaries.first(), snap.boundaries.last()) else {
118    return Err(ModelError::InvalidRange("snapshot store has no boundaries".to_string()));
119  };
120  let text_at = |boundary: &Boundary, path: &str, side: &str| -> Result<String, ModelError> {
121    let id = boundary
122      .files
123      .get(path)
124      .ok_or_else(|| ModelError::InvalidRange(format!("{path} does not exist at the {side} boundary")))?;
125    let blob = snap
126      .blobs
127      .get(id)
128      .ok_or_else(|| ModelError::InvalidRange(format!("blob {id} missing from the snapshot store")))?;
129    blob.clone().ok_or_else(|| ModelError::InvalidRange(format!("{path} was not snapshotted (binary or oversized)")))
130  };
131  let old_text = text_at(base, old_path, "base")?;
132  let new_text = text_at(head, new_path, "head")?;
133  let old_lines: Vec<&str> = old_text.lines().collect();
134  let new_lines: Vec<&str> = new_text.lines().collect();
135  let mut out = Vec::new();
136  for i in 0..count {
137    let (Some(old_no), Some(new_no)) = (old_start.checked_add(i), new_start.checked_add(i)) else { break };
138    let (Some(old), Some(new)) = (old_lines.get(old_no as usize - 1), new_lines.get(new_no as usize - 1)) else {
139      break; // clamped: one side ran out of file
140    };
141    if old != new {
142      return Err(ModelError::InvalidRange(format!(
143        "old line {old_no} and new line {new_no} differ between the endpoints — not an unchanged region"
144      )));
145    }
146    out.push(Line::Ctx { old: old_no, new: new_no, text: (*old).to_string() });
147  }
148  Ok(out)
149}
150
151/// Diff two texts line-wise into hunks with `context` unchanged lines around
152/// each change — the same [`Hunk`]/[`Line`] shapes `parse_unified_diff`
153/// produces. Identical texts yield no hunks.
154pub fn diff_lines(old: &str, new: &str, context: usize) -> Vec<Hunk> {
155  let a: Vec<&str> = old.lines().collect();
156  let b: Vec<&str> = new.lines().collect();
157  let edits = myers_edits(&a, &b);
158  hunks_from(&records(&a, &b, &edits), context)
159}
160
161/// One step of the edit script: keep a common line, delete from the
162/// pre-image, or add from the post-image.
163#[derive(Clone, Copy, PartialEq, Eq, Debug)]
164enum Edit {
165  Keep,
166  Del,
167  Add,
168}
169
170/// Myers' O(ND) shortest edit script over lines (the greedy forward variant
171/// with a full trace for backtracking).
172fn myers_edits(a: &[&str], b: &[&str]) -> Vec<Edit> {
173  let n = a.len() as isize;
174  let m = b.len() as isize;
175  let max = n + m;
176  if max == 0 {
177    return Vec::new();
178  }
179  let offset = max;
180  let idx = |k: isize| (k + offset) as usize;
181  let mut v = vec![0isize; (2 * max + 1) as usize];
182  let mut trace: Vec<Vec<isize>> = Vec::new();
183  'search: for d in 0..=max {
184    trace.push(v.clone());
185    let mut k = -d;
186    while k <= d {
187      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 };
188      let mut y = x - k;
189      while x < n && y < m && a[x as usize] == b[y as usize] {
190        x += 1;
191        y += 1;
192      }
193      v[idx(k)] = x;
194      if x >= n && y >= m {
195        break 'search;
196      }
197      k += 2;
198    }
199  }
200
201  // Backtrack from (n, m); trace[d] is the V state entering round d.
202  let mut edits = Vec::new();
203  let (mut x, mut y) = (n, m);
204  for (d, v) in trace.iter().enumerate().rev() {
205    let d = d as isize;
206    let k = x - y;
207    let prev_k = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) { k + 1 } else { k - 1 };
208    let prev_x = v[idx(prev_k)];
209    let prev_y = prev_x - prev_k;
210    while x > prev_x && y > prev_y {
211      edits.push(Edit::Keep);
212      x -= 1;
213      y -= 1;
214    }
215    if d > 0 {
216      edits.push(if x == prev_x { Edit::Add } else { Edit::Del });
217    }
218    x = prev_x;
219    y = prev_y;
220  }
221  edits.reverse();
222  edits
223}
224
225/// A typed diff line plus the 1-based counters as they stood BEFORE the
226/// line, which is what hunk headers are computed from.
227struct Rec {
228  line: Line,
229  old_before: u32,
230  new_before: u32,
231  changed: bool,
232}
233
234fn records(a: &[&str], b: &[&str], edits: &[Edit]) -> Vec<Rec> {
235  let (mut ai, mut bi) = (0usize, 0usize);
236  let (mut old_no, mut new_no) = (1u32, 1u32);
237  let mut recs = Vec::with_capacity(edits.len());
238  for e in edits {
239    match e {
240      Edit::Keep => {
241        recs.push(Rec {
242          line: Line::Ctx { old: old_no, new: new_no, text: a[ai].to_string() },
243          old_before: old_no,
244          new_before: new_no,
245          changed: false,
246        });
247        ai += 1;
248        bi += 1;
249        old_no += 1;
250        new_no += 1;
251      }
252      Edit::Del => {
253        recs.push(Rec {
254          line: Line::Del { old: old_no, text: a[ai].to_string() },
255          old_before: old_no,
256          new_before: new_no,
257          changed: true,
258        });
259        ai += 1;
260        old_no += 1;
261      }
262      Edit::Add => {
263        recs.push(Rec {
264          line: Line::Add { new: new_no, text: b[bi].to_string() },
265          old_before: old_no,
266          new_before: new_no,
267          changed: true,
268        });
269        bi += 1;
270        new_no += 1;
271      }
272    }
273  }
274  recs
275}
276
277/// Group records into hunks: every changed line plus up to `context`
278/// neighbors; overlapping windows merge. Headers use the explicit
279/// `@@ -start,count +start,count @@` form (count included even when 1); a
280/// side with no lines gets git's convention of `start = line before, count 0`.
281fn hunks_from(recs: &[Rec], context: usize) -> Vec<Hunk> {
282  let mut include = vec![false; recs.len()];
283  for (i, r) in recs.iter().enumerate() {
284    if r.changed {
285      let lo = i.saturating_sub(context);
286      let hi = (i + context).min(recs.len() - 1);
287      for flag in &mut include[lo..=hi] {
288        *flag = true;
289      }
290    }
291  }
292  let mut hunks = Vec::new();
293  let mut i = 0;
294  while i < recs.len() {
295    if !include[i] {
296      i += 1;
297      continue;
298    }
299    let start = i;
300    while i < recs.len() && include[i] {
301      i += 1;
302    }
303    let slice = &recs[start..i];
304    let old_count = slice.iter().filter(|r| matches!(r.line, Line::Del { .. } | Line::Ctx { .. })).count();
305    let new_count = slice.iter().filter(|r| matches!(r.line, Line::Add { .. } | Line::Ctx { .. })).count();
306    let old_start = if old_count > 0 { slice[0].old_before } else { slice[0].old_before.saturating_sub(1) };
307    let new_start = if new_count > 0 { slice[0].new_before } else { slice[0].new_before.saturating_sub(1) };
308    hunks.push(Hunk {
309      header: format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@"),
310      lines: slice.iter().map(|r| r.line.clone()).collect(),
311    });
312  }
313  hunks
314}
315
316#[cfg(test)]
317mod tests {
318  use super::*;
319
320  fn snap() -> RangeSnapshots {
321    let blob = |s: &str| Some(s.to_string());
322    RangeSnapshots {
323      blobs: BTreeMap::from([
324        ("b-one".into(), blob("alpha\nbeta\ngamma\n")),
325        ("b-two".into(), blob("alpha\nBETA\ngamma\n")),
326        ("b-new".into(), blob("fresh\n")),
327        ("b-bin".into(), None),
328      ]),
329      boundaries: vec![
330        Boundary {
331          sha: "s0".into(),
332          files: BTreeMap::from([("keep.txt".into(), "b-one".into()), ("gone.txt".into(), "b-one".into())]),
333        },
334        Boundary {
335          sha: "s1".into(),
336          files: BTreeMap::from([("keep.txt".into(), "b-two".into()), ("gone.txt".into(), "b-one".into())]),
337        },
338        Boundary {
339          sha: "s2".into(),
340          files: BTreeMap::from([
341            ("keep.txt".into(), "b-one".into()),
342            ("new.txt".into(), "b-new".into()),
343            ("blob.bin".into(), "b-bin".into()),
344          ]),
345        },
346      ],
347    }
348  }
349
350  #[test]
351  fn single_commit_diff() {
352    let files = range_diff(&snap(), 0, 1, 3).unwrap();
353    assert_eq!(files.len(), 1);
354    let f = &files[0];
355    assert_eq!(f.new_path.as_deref(), Some("keep.txt"));
356    assert_eq!(f.status, FileStatus::Modified);
357    assert_eq!((f.additions, f.deletions), (1, 1));
358    assert_eq!(f.hunks[0].header, "@@ -1,3 +1,3 @@");
359    assert_eq!(f.hunks[0].lines[1], Line::Del { old: 2, text: "beta".into() });
360    assert_eq!(f.hunks[0].lines[2], Line::Add { new: 2, text: "BETA".into() });
361  }
362
363  #[test]
364  fn full_range_hides_a_change_that_was_reverted() {
365    // keep.txt: one → two → back to one; the 0..2 diff must not mention it.
366    let files = range_diff(&snap(), 0, 2, 3).unwrap();
367    let paths: Vec<&str> = files.iter().map(|f| f.anchor_path()).collect();
368    assert_eq!(paths, vec!["blob.bin", "gone.txt", "new.txt"]);
369    // ...while the 1..2 sub-range shows the revert itself.
370    let files = range_diff(&snap(), 1, 2, 3).unwrap();
371    assert!(files.iter().any(|f| f.anchor_path() == "keep.txt"));
372  }
373
374  #[test]
375  fn added_deleted_and_binary_statuses() {
376    let files = range_diff(&snap(), 0, 2, 3).unwrap();
377    let by_path = |p: &str| files.iter().find(|f| f.anchor_path() == p).unwrap();
378    let added = by_path("new.txt");
379    assert_eq!(added.status, FileStatus::Added);
380    assert_eq!(added.old_path, None);
381    assert_eq!(added.hunks[0].header, "@@ -0,0 +1,1 @@");
382    let deleted = by_path("gone.txt");
383    assert_eq!(deleted.status, FileStatus::Deleted);
384    assert_eq!(deleted.new_path, None);
385    assert_eq!(deleted.deletions, 3);
386    let binary = by_path("blob.bin");
387    assert!(binary.binary);
388    assert!(binary.hunks.is_empty());
389  }
390
391  #[test]
392  fn invalid_ranges_and_missing_blobs_are_loud() {
393    assert!(matches!(range_diff(&snap(), 1, 1, 3), Err(ModelError::InvalidRange(_))));
394    assert!(matches!(range_diff(&snap(), 2, 1, 3), Err(ModelError::InvalidRange(_))));
395    assert!(matches!(range_diff(&snap(), 0, 9, 3), Err(ModelError::InvalidRange(_))));
396    let mut broken = snap();
397    broken.blobs.remove("b-two");
398    assert!(matches!(range_diff(&broken, 0, 1, 3), Err(ModelError::InvalidRange(_))));
399  }
400
401  #[test]
402  fn context_slice_returns_clamped_ctx_lines() {
403    // keep.txt is b-one at BOTH endpoints (s0 and s2) of the fixture.
404    let lines = context_slice(&snap(), "keep.txt", "keep.txt", 1, 1, 10).unwrap();
405    assert_eq!(lines.len(), 3, "clamped at end of file");
406    assert_eq!(lines[0], Line::Ctx { old: 1, new: 1, text: "alpha".into() });
407    assert_eq!(lines[2], Line::Ctx { old: 3, new: 3, text: "gamma".into() });
408    let one = context_slice(&snap(), "keep.txt", "keep.txt", 2, 2, 1).unwrap();
409    assert_eq!(one, vec![Line::Ctx { old: 2, new: 2, text: "beta".into() }]);
410    assert!(context_slice(&snap(), "keep.txt", "keep.txt", 9, 9, 5).unwrap().is_empty(), "past EOF: empty, not error");
411  }
412
413  #[test]
414  fn context_slice_follows_renames_and_offset_numbering() {
415    let s = RangeSnapshots {
416      blobs: BTreeMap::from([("b".into(), Some("one\ntwo\nthree\nfour\n".to_string()))]),
417      boundaries: vec![
418        Boundary { sha: "s0".into(), files: BTreeMap::from([("old name.txt".into(), "b".into())]) },
419        Boundary { sha: "s1".into(), files: BTreeMap::from([("new name.txt".into(), "b".into())]) },
420      ],
421    };
422    // Renamed file: the pre-image path resolves at the base boundary, the
423    // post-image path at the head boundary.
424    let lines = context_slice(&s, "old name.txt", "new name.txt", 3, 3, 2).unwrap();
425    assert_eq!(lines[0], Line::Ctx { old: 3, new: 3, text: "three".into() });
426  }
427
428  #[test]
429  fn context_slice_rejects_changed_regions_and_bad_input() {
430    let differing = RangeSnapshots {
431      blobs: BTreeMap::from([
432        ("b-one".into(), Some("alpha\nbeta\n".to_string())),
433        ("b-two".into(), Some("alpha\nBETA\n".to_string())),
434        ("b-bin".into(), None),
435      ]),
436      boundaries: vec![
437        Boundary {
438          sha: "s0".into(),
439          files: BTreeMap::from([("f.txt".into(), "b-one".into()), ("blob.bin".into(), "b-bin".into())]),
440        },
441        Boundary {
442          sha: "s1".into(),
443          files: BTreeMap::from([("f.txt".into(), "b-two".into()), ("blob.bin".into(), "b-bin".into())]),
444        },
445      ],
446    };
447    // Line 1 is shared, line 2 differs: the slice must fail rather than
448    // present a changed line as context.
449    assert!(context_slice(&differing, "f.txt", "f.txt", 1, 1, 1).is_ok());
450    assert!(matches!(context_slice(&differing, "f.txt", "f.txt", 1, 1, 2), Err(ModelError::InvalidRange(_))));
451    // Unsnapshotted, missing, and 0-based requests are loud errors.
452    assert!(matches!(context_slice(&differing, "blob.bin", "blob.bin", 1, 1, 1), Err(ModelError::InvalidRange(_))));
453    assert!(matches!(context_slice(&differing, "nope", "nope", 1, 1, 1), Err(ModelError::InvalidRange(_))));
454    assert!(matches!(context_slice(&differing, "f.txt", "f.txt", 0, 1, 1), Err(ModelError::InvalidRange(_))));
455  }
456
457  #[test]
458  fn diff_lines_context_and_merging() {
459    let old = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
460    // Two changes far apart → two hunks at context 1; one merged hunk at 3.
461    let new = "a\nB\nc\nd\ne\nf\ng\nh\nI\nj\n";
462    let hunks = diff_lines(old, new, 1);
463    assert_eq!(hunks.len(), 2);
464    assert_eq!(hunks[0].header, "@@ -1,3 +1,3 @@");
465    assert_eq!(hunks[1].header, "@@ -8,3 +8,3 @@");
466    let hunks = diff_lines(old, new, 3);
467    assert_eq!(hunks.len(), 1, "windows overlap and merge");
468    assert_eq!(hunks[0].header, "@@ -1,10 +1,10 @@");
469  }
470
471  #[test]
472  fn diff_lines_edge_cases() {
473    assert!(diff_lines("", "", 3).is_empty());
474    assert!(diff_lines("same\n", "same\n", 3).is_empty());
475    let from_empty = diff_lines("", "one\ntwo\n", 3);
476    assert_eq!(from_empty[0].header, "@@ -0,0 +1,2 @@");
477    let to_empty = diff_lines("one\ntwo\n", "", 3);
478    assert_eq!(to_empty[0].header, "@@ -1,2 +0,0 @@");
479  }
480
481  #[test]
482  fn myers_produces_a_minimal_script() {
483    // The classic ABCABBA → CBABAC example: shortest script has 5 edits.
484    let a: Vec<&str> = "A B C A B B A".split(' ').collect();
485    let b: Vec<&str> = "C B A B A C".split(' ').collect();
486    let edits = myers_edits(&a, &b);
487    let changes = edits.iter().filter(|e| **e != Edit::Keep).count();
488    assert_eq!(changes, 5);
489    let keeps = edits.iter().filter(|e| **e == Edit::Keep).count();
490    assert_eq!(keeps, 4);
491    // The script replays a into b.
492    let (mut ai, mut bi) = (0, 0);
493    let mut out: Vec<&str> = Vec::new();
494    for e in &edits {
495      match e {
496        Edit::Keep => {
497          out.push(a[ai]);
498          ai += 1;
499          bi += 1;
500        }
501        Edit::Del => ai += 1,
502        Edit::Add => {
503          out.push(b[bi]);
504          bi += 1;
505        }
506      }
507    }
508    assert_eq!(out, b);
509    assert_eq!((ai, bi), (a.len(), b.len()));
510  }
511
512  #[test]
513  fn snapshots_roundtrip_and_reject_unknown_fields() {
514    let s = snap();
515    let json = serde_json::to_string(&s).unwrap();
516    let back: RangeSnapshots = serde_json::from_str(&json).unwrap();
517    assert_eq!(back.boundaries.len(), 3);
518    let sneaky = json.replacen("{\"blobs\"", "{\"sneaky\":true,\"blobs\"", 1);
519    assert!(serde_json::from_str::<RangeSnapshots>(&sneaky).is_err(), "unknown fields are strict-rejected");
520  }
521}