1use std::collections::{BTreeMap, BTreeSet};
10
11use serde::{Deserialize, Serialize};
12
13use crate::diff::{FileDiff, FileStatus, Hunk, Line};
14use crate::ModelError;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(deny_unknown_fields)]
21pub struct RangeSnapshots {
22 pub blobs: BTreeMap<String, Option<String>>,
26 pub boundaries: Vec<Boundary>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Boundary {
35 pub sha: String,
37 pub files: BTreeMap<String, String>,
40}
41
42pub 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; }
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 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; }
99 files.push(FileDiff { old_path, new_path, status, binary: false, hunks, additions, deletions, notes: Vec::new() });
100 }
101 Ok(files)
102}
103
104pub 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#[derive(Clone, Copy, PartialEq, Eq, Debug)]
117enum Edit {
118 Keep,
119 Del,
120 Add,
121}
122
123fn 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 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
178struct 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
230fn 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 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 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 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 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 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}