Skip to main content

ipfrs_storage/
snapshot_diff.rs

1//! Storage snapshot diff — computes and represents differences between two
2//! point-in-time storage snapshots.
3//!
4//! Enables incremental sync, changelog generation, and rollback planning by
5//! identifying which content-addressed entries were added, removed, modified,
6//! or left unchanged between an *old* and a *new* snapshot.
7
8use std::collections::HashMap;
9
10// ---------------------------------------------------------------------------
11// SnapshotEntry
12// ---------------------------------------------------------------------------
13
14/// A single content-addressed entry recorded in a storage snapshot.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct SnapshotEntry {
17    /// Content identifier (e.g. a CIDv1 string).
18    pub cid: String,
19    /// Raw byte size of the block.
20    pub size_bytes: u64,
21    /// Logical clock / sequence number at which this entry was last modified.
22    pub tick: u64,
23}
24
25impl SnapshotEntry {
26    /// Construct a new entry.
27    pub fn new(cid: impl Into<String>, size_bytes: u64, tick: u64) -> Self {
28        Self {
29            cid: cid.into(),
30            size_bytes,
31            tick,
32        }
33    }
34}
35
36// ---------------------------------------------------------------------------
37// DiffKind
38// ---------------------------------------------------------------------------
39
40/// Describes how a content-addressed entry changed between two snapshots.
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub enum DiffKind {
43    /// Present in the new snapshot but not in the old one.
44    Added,
45    /// Present in the old snapshot but not in the new one.
46    Removed,
47    /// Present in both snapshots, but `size_bytes` or `tick` differs.
48    Modified,
49    /// Present in both snapshots, identical in every field.
50    Unchanged,
51}
52
53// ---------------------------------------------------------------------------
54// DiffEntry
55// ---------------------------------------------------------------------------
56
57/// A single diff record comparing one CID across two snapshots.
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct DiffEntry {
60    /// The content identifier this record refers to.
61    pub cid: String,
62    /// How this entry changed.
63    pub kind: DiffKind,
64    /// The entry as it appeared in the *old* snapshot; `None` for `Added`.
65    pub old_entry: Option<SnapshotEntry>,
66    /// The entry as it appeared in the *new* snapshot; `None` for `Removed`.
67    pub new_entry: Option<SnapshotEntry>,
68}
69
70impl DiffEntry {
71    /// Signed byte-size change: `new_size - old_size`.
72    ///
73    /// Missing sides contribute `0`, so:
74    /// - `Added`   → `+new_size`
75    /// - `Removed` → `-old_size`
76    /// - `Modified` / `Unchanged` → difference in size (may be 0)
77    pub fn size_delta(&self) -> i64 {
78        let new_sz = self.new_entry.as_ref().map(|e| e.size_bytes).unwrap_or(0) as i64;
79        let old_sz = self.old_entry.as_ref().map(|e| e.size_bytes).unwrap_or(0) as i64;
80        new_sz - old_sz
81    }
82}
83
84// ---------------------------------------------------------------------------
85// SnapshotDiffResult
86// ---------------------------------------------------------------------------
87
88/// The complete result of comparing two snapshots.
89#[derive(Clone, Debug, Default)]
90pub struct SnapshotDiffResult {
91    /// Entries that exist only in the new snapshot.
92    pub added: Vec<DiffEntry>,
93    /// Entries that exist only in the old snapshot.
94    pub removed: Vec<DiffEntry>,
95    /// Entries present in both but whose `size_bytes` or `tick` changed.
96    pub modified: Vec<DiffEntry>,
97    /// Entries present in both and completely identical.
98    pub unchanged: Vec<DiffEntry>,
99}
100
101impl SnapshotDiffResult {
102    /// Sum of `size_delta()` across all `added`, `modified`, and `removed` entries.
103    pub fn total_size_delta(&self) -> i64 {
104        let sum_group = |v: &[DiffEntry]| -> i64 { v.iter().map(|e| e.size_delta()).sum() };
105        sum_group(&self.added) + sum_group(&self.modified) + sum_group(&self.removed)
106    }
107
108    /// Returns `true` when at least one add, remove, or modification exists.
109    pub fn has_changes(&self) -> bool {
110        !self.added.is_empty() || !self.removed.is_empty() || !self.modified.is_empty()
111    }
112
113    /// Total number of adds, removes, and modifications.
114    pub fn change_count(&self) -> usize {
115        self.added.len() + self.removed.len() + self.modified.len()
116    }
117}
118
119// ---------------------------------------------------------------------------
120// DiffStats
121// ---------------------------------------------------------------------------
122
123/// Cumulative statistics gathered across all diff operations performed by a
124/// [`StorageSnapshotDiff`] instance.
125#[derive(Clone, Debug, Default)]
126pub struct DiffStats {
127    /// Number of times [`StorageSnapshotDiff::diff`] has been called.
128    pub total_diffs_computed: u64,
129    /// Total number of unique CIDs examined across all diffs.
130    pub total_entries_compared: u64,
131    /// Total number of changes (adds + removes + modifications) found.
132    pub total_changes_found: u64,
133}
134
135// ---------------------------------------------------------------------------
136// StorageSnapshotDiff
137// ---------------------------------------------------------------------------
138
139/// Computes and represents differences between storage snapshots.
140///
141/// Maintains cumulative [`DiffStats`] across repeated calls so callers can
142/// observe aggregate diff activity without re-scanning results.
143#[derive(Debug, Default)]
144pub struct StorageSnapshotDiff {
145    stats: DiffStats,
146}
147
148impl StorageSnapshotDiff {
149    /// Create a new differ with zeroed statistics.
150    pub fn new() -> Self {
151        Self::default()
152    }
153
154    /// Compute the diff between `old` and `new` snapshots.
155    ///
156    /// Each of the four result buckets is sorted by CID ascending for
157    /// deterministic output.  Statistics are updated atomically after the
158    /// comparison is complete.
159    pub fn diff(
160        &mut self,
161        old: &HashMap<String, SnapshotEntry>,
162        new: &HashMap<String, SnapshotEntry>,
163    ) -> SnapshotDiffResult {
164        let mut added = Vec::new();
165        let mut removed = Vec::new();
166        let mut modified = Vec::new();
167        let mut unchanged = Vec::new();
168
169        // Walk the old snapshot — each CID is either unchanged, modified, or removed.
170        for (cid, old_entry) in old {
171            match new.get(cid) {
172                Some(new_entry) => {
173                    let kind = if old_entry.size_bytes == new_entry.size_bytes
174                        && old_entry.tick == new_entry.tick
175                    {
176                        DiffKind::Unchanged
177                    } else {
178                        DiffKind::Modified
179                    };
180                    let entry = DiffEntry {
181                        cid: cid.clone(),
182                        kind: kind.clone(),
183                        old_entry: Some(old_entry.clone()),
184                        new_entry: Some(new_entry.clone()),
185                    };
186                    if kind == DiffKind::Unchanged {
187                        unchanged.push(entry);
188                    } else {
189                        modified.push(entry);
190                    }
191                }
192                None => removed.push(DiffEntry {
193                    cid: cid.clone(),
194                    kind: DiffKind::Removed,
195                    old_entry: Some(old_entry.clone()),
196                    new_entry: None,
197                }),
198            }
199        }
200
201        // Walk the new snapshot — only CIDs absent from old are added.
202        for (cid, new_entry) in new {
203            if !old.contains_key(cid) {
204                added.push(DiffEntry {
205                    cid: cid.clone(),
206                    kind: DiffKind::Added,
207                    old_entry: None,
208                    new_entry: Some(new_entry.clone()),
209                });
210            }
211        }
212
213        // Sort each bucket by CID for deterministic ordering.
214        added.sort_by(|a, b| a.cid.cmp(&b.cid));
215        removed.sort_by(|a, b| a.cid.cmp(&b.cid));
216        modified.sort_by(|a, b| a.cid.cmp(&b.cid));
217        unchanged.sort_by(|a, b| a.cid.cmp(&b.cid));
218
219        // Compute unique CIDs examined: old ∪ new.
220        let unique_cids = {
221            let mut set: std::collections::HashSet<&str> = old.keys().map(String::as_str).collect();
222            set.extend(new.keys().map(String::as_str));
223            set.len() as u64
224        };
225
226        let changes = (added.len() + removed.len() + modified.len()) as u64;
227
228        self.stats.total_diffs_computed += 1;
229        self.stats.total_entries_compared += unique_cids;
230        self.stats.total_changes_found += changes;
231
232        SnapshotDiffResult {
233            added,
234            removed,
235            modified,
236            unchanged,
237        }
238    }
239
240    /// Apply a previously-computed diff to a mutable base snapshot map,
241    /// bringing it forward to the state described by the diff.
242    ///
243    /// - `Added`    → insert the new entry into `base`.
244    /// - `Removed`  → remove the entry from `base`.
245    /// - `Modified` → replace the entry in `base` with the new version.
246    /// - `Unchanged` → no-op.
247    pub fn apply_patch(
248        &self,
249        base: &mut HashMap<String, SnapshotEntry>,
250        diff: &SnapshotDiffResult,
251    ) {
252        for entry in &diff.added {
253            if let Some(new_e) = &entry.new_entry {
254                base.insert(entry.cid.clone(), new_e.clone());
255            }
256        }
257        for entry in &diff.removed {
258            base.remove(&entry.cid);
259        }
260        for entry in &diff.modified {
261            if let Some(new_e) = &entry.new_entry {
262                base.insert(entry.cid.clone(), new_e.clone());
263            }
264        }
265        // Unchanged entries require no action.
266    }
267
268    /// Return a reference to the cumulative diff statistics.
269    pub fn stats(&self) -> &DiffStats {
270        &self.stats
271    }
272}
273
274// ---------------------------------------------------------------------------
275// Tests
276// ---------------------------------------------------------------------------
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use std::collections::HashMap;
282
283    // -----------------------------------------------------------------------
284    // Helpers
285    // -----------------------------------------------------------------------
286
287    fn make_entry(cid: &str, size: u64, tick: u64) -> SnapshotEntry {
288        SnapshotEntry::new(cid, size, tick)
289    }
290
291    fn single_entry_map(cid: &str, size: u64, tick: u64) -> HashMap<String, SnapshotEntry> {
292        let mut m = HashMap::new();
293        m.insert(cid.to_owned(), make_entry(cid, size, tick));
294        m
295    }
296
297    // -----------------------------------------------------------------------
298    // 1. Added entry detected
299    // -----------------------------------------------------------------------
300
301    #[test]
302    fn test_diff_added_detected() {
303        let old: HashMap<String, SnapshotEntry> = HashMap::new();
304        let new = single_entry_map("bafy001", 100, 1);
305        let mut differ = StorageSnapshotDiff::new();
306        let result = differ.diff(&old, &new);
307        assert_eq!(result.added.len(), 1);
308        assert_eq!(result.added[0].cid, "bafy001");
309        assert_eq!(result.added[0].kind, DiffKind::Added);
310        assert!(result.added[0].old_entry.is_none());
311        assert!(result.added[0].new_entry.is_some());
312    }
313
314    // -----------------------------------------------------------------------
315    // 2. Removed entry detected
316    // -----------------------------------------------------------------------
317
318    #[test]
319    fn test_diff_removed_detected() {
320        let old = single_entry_map("bafy001", 100, 1);
321        let new: HashMap<String, SnapshotEntry> = HashMap::new();
322        let mut differ = StorageSnapshotDiff::new();
323        let result = differ.diff(&old, &new);
324        assert_eq!(result.removed.len(), 1);
325        assert_eq!(result.removed[0].cid, "bafy001");
326        assert_eq!(result.removed[0].kind, DiffKind::Removed);
327        assert!(result.removed[0].old_entry.is_some());
328        assert!(result.removed[0].new_entry.is_none());
329    }
330
331    // -----------------------------------------------------------------------
332    // 3. Modified — size changed
333    // -----------------------------------------------------------------------
334
335    #[test]
336    fn test_diff_modified_size_changed() {
337        let old = single_entry_map("bafy001", 100, 5);
338        let new = single_entry_map("bafy001", 200, 5);
339        let mut differ = StorageSnapshotDiff::new();
340        let result = differ.diff(&old, &new);
341        assert_eq!(result.modified.len(), 1);
342        assert_eq!(result.modified[0].kind, DiffKind::Modified);
343    }
344
345    // -----------------------------------------------------------------------
346    // 4. Modified — tick changed
347    // -----------------------------------------------------------------------
348
349    #[test]
350    fn test_diff_modified_tick_changed() {
351        let old = single_entry_map("bafy001", 100, 5);
352        let new = single_entry_map("bafy001", 100, 6);
353        let mut differ = StorageSnapshotDiff::new();
354        let result = differ.diff(&old, &new);
355        assert_eq!(result.modified.len(), 1);
356        assert_eq!(result.modified[0].kind, DiffKind::Modified);
357        assert!(result.modified[0].old_entry.is_some());
358        assert!(result.modified[0].new_entry.is_some());
359    }
360
361    // -----------------------------------------------------------------------
362    // 5. Unchanged detected
363    // -----------------------------------------------------------------------
364
365    #[test]
366    fn test_diff_unchanged_detected() {
367        let old = single_entry_map("bafy001", 100, 5);
368        let new = single_entry_map("bafy001", 100, 5);
369        let mut differ = StorageSnapshotDiff::new();
370        let result = differ.diff(&old, &new);
371        assert_eq!(result.unchanged.len(), 1);
372        assert_eq!(result.unchanged[0].kind, DiffKind::Unchanged);
373        assert!(result.removed.is_empty());
374        assert!(result.added.is_empty());
375        assert!(result.modified.is_empty());
376    }
377
378    // -----------------------------------------------------------------------
379    // 6. Empty old → all Added
380    // -----------------------------------------------------------------------
381
382    #[test]
383    fn test_diff_empty_old_all_added() {
384        let old: HashMap<String, SnapshotEntry> = HashMap::new();
385        let mut new = HashMap::new();
386        new.insert("a".to_owned(), make_entry("a", 10, 1));
387        new.insert("b".to_owned(), make_entry("b", 20, 2));
388        new.insert("c".to_owned(), make_entry("c", 30, 3));
389        let mut differ = StorageSnapshotDiff::new();
390        let result = differ.diff(&old, &new);
391        assert_eq!(result.added.len(), 3);
392        assert!(result.removed.is_empty());
393        assert!(result.modified.is_empty());
394        assert!(result.unchanged.is_empty());
395    }
396
397    // -----------------------------------------------------------------------
398    // 7. Empty new → all Removed
399    // -----------------------------------------------------------------------
400
401    #[test]
402    fn test_diff_empty_new_all_removed() {
403        let mut old = HashMap::new();
404        old.insert("a".to_owned(), make_entry("a", 10, 1));
405        old.insert("b".to_owned(), make_entry("b", 20, 2));
406        let new: HashMap<String, SnapshotEntry> = HashMap::new();
407        let mut differ = StorageSnapshotDiff::new();
408        let result = differ.diff(&old, &new);
409        assert_eq!(result.removed.len(), 2);
410        assert!(result.added.is_empty());
411        assert!(result.modified.is_empty());
412        assert!(result.unchanged.is_empty());
413    }
414
415    // -----------------------------------------------------------------------
416    // 8. Identical snapshots → all Unchanged
417    // -----------------------------------------------------------------------
418
419    #[test]
420    fn test_diff_identical_snapshots_all_unchanged() {
421        let mut snap = HashMap::new();
422        snap.insert("a".to_owned(), make_entry("a", 10, 1));
423        snap.insert("b".to_owned(), make_entry("b", 20, 2));
424        let mut differ = StorageSnapshotDiff::new();
425        let result = differ.diff(&snap, &snap);
426        assert_eq!(result.unchanged.len(), 2);
427        assert!(!result.has_changes());
428    }
429
430    // -----------------------------------------------------------------------
431    // 9. total_size_delta positive
432    // -----------------------------------------------------------------------
433
434    #[test]
435    fn test_total_size_delta_positive() {
436        let old: HashMap<String, SnapshotEntry> = HashMap::new();
437        let new = single_entry_map("bafy001", 500, 1);
438        let mut differ = StorageSnapshotDiff::new();
439        let result = differ.diff(&old, &new);
440        assert_eq!(result.total_size_delta(), 500);
441    }
442
443    // -----------------------------------------------------------------------
444    // 10. total_size_delta negative
445    // -----------------------------------------------------------------------
446
447    #[test]
448    fn test_total_size_delta_negative() {
449        let old = single_entry_map("bafy001", 500, 1);
450        let new: HashMap<String, SnapshotEntry> = HashMap::new();
451        let mut differ = StorageSnapshotDiff::new();
452        let result = differ.diff(&old, &new);
453        assert_eq!(result.total_size_delta(), -500);
454    }
455
456    // -----------------------------------------------------------------------
457    // 11. total_size_delta mixed
458    // -----------------------------------------------------------------------
459
460    #[test]
461    fn test_total_size_delta_mixed() {
462        let mut old = HashMap::new();
463        old.insert("a".to_owned(), make_entry("a", 100, 1));
464        old.insert("b".to_owned(), make_entry("b", 200, 1));
465        let mut new = HashMap::new();
466        // "a" removed → -100
467        new.insert("b".to_owned(), make_entry("b", 300, 2)); // modified → +100
468        new.insert("c".to_owned(), make_entry("c", 50, 1)); // added → +50
469        let mut differ = StorageSnapshotDiff::new();
470        let result = differ.diff(&old, &new);
471        // delta = -100 + 100 + 50 = 50
472        assert_eq!(result.total_size_delta(), 50);
473    }
474
475    // -----------------------------------------------------------------------
476    // 12. has_changes true
477    // -----------------------------------------------------------------------
478
479    #[test]
480    fn test_has_changes_true() {
481        let old: HashMap<String, SnapshotEntry> = HashMap::new();
482        let new = single_entry_map("bafy001", 100, 1);
483        let mut differ = StorageSnapshotDiff::new();
484        let result = differ.diff(&old, &new);
485        assert!(result.has_changes());
486    }
487
488    // -----------------------------------------------------------------------
489    // 13. has_changes false
490    // -----------------------------------------------------------------------
491
492    #[test]
493    fn test_has_changes_false() {
494        let snap = single_entry_map("bafy001", 100, 1);
495        let mut differ = StorageSnapshotDiff::new();
496        let result = differ.diff(&snap, &snap);
497        assert!(!result.has_changes());
498    }
499
500    // -----------------------------------------------------------------------
501    // 14. change_count correct
502    // -----------------------------------------------------------------------
503
504    #[test]
505    fn test_change_count_correct() {
506        let mut old = HashMap::new();
507        old.insert("a".to_owned(), make_entry("a", 100, 1)); // removed
508        old.insert("b".to_owned(), make_entry("b", 200, 1)); // modified
509        let mut new = HashMap::new();
510        new.insert("b".to_owned(), make_entry("b", 300, 2));
511        new.insert("c".to_owned(), make_entry("c", 50, 1)); // added
512        let mut differ = StorageSnapshotDiff::new();
513        let result = differ.diff(&old, &new);
514        // 1 removed + 1 modified + 1 added = 3
515        assert_eq!(result.change_count(), 3);
516    }
517
518    // -----------------------------------------------------------------------
519    // 15. apply_patch — added entries inserted into base
520    // -----------------------------------------------------------------------
521
522    #[test]
523    fn test_apply_patch_adds_entries() {
524        let old: HashMap<String, SnapshotEntry> = HashMap::new();
525        let new = single_entry_map("bafy001", 100, 1);
526        let mut differ = StorageSnapshotDiff::new();
527        let diff = differ.diff(&old, &new);
528        let mut base: HashMap<String, SnapshotEntry> = HashMap::new();
529        differ.apply_patch(&mut base, &diff);
530        assert!(base.contains_key("bafy001"));
531        assert_eq!(base["bafy001"].size_bytes, 100);
532    }
533
534    // -----------------------------------------------------------------------
535    // 16. apply_patch — removed entries deleted from base
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_apply_patch_removes_entries() {
540        let old = single_entry_map("bafy001", 100, 1);
541        let new: HashMap<String, SnapshotEntry> = HashMap::new();
542        let mut differ = StorageSnapshotDiff::new();
543        let diff = differ.diff(&old, &new);
544        let mut base = old.clone();
545        differ.apply_patch(&mut base, &diff);
546        assert!(!base.contains_key("bafy001"));
547    }
548
549    // -----------------------------------------------------------------------
550    // 17. apply_patch — modified entries updated in base
551    // -----------------------------------------------------------------------
552
553    #[test]
554    fn test_apply_patch_modifies_entries() {
555        let old = single_entry_map("bafy001", 100, 1);
556        let new = single_entry_map("bafy001", 999, 2);
557        let mut differ = StorageSnapshotDiff::new();
558        let diff = differ.diff(&old, &new);
559        let mut base = old.clone();
560        differ.apply_patch(&mut base, &diff);
561        assert_eq!(base["bafy001"].size_bytes, 999);
562        assert_eq!(base["bafy001"].tick, 2);
563    }
564
565    // -----------------------------------------------------------------------
566    // 18. apply_patch — unchanged entries stay in base
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn test_apply_patch_unchanged_stays() {
571        let snap = single_entry_map("bafy001", 100, 1);
572        let mut differ = StorageSnapshotDiff::new();
573        let diff = differ.diff(&snap, &snap);
574        let mut base = snap.clone();
575        differ.apply_patch(&mut base, &diff);
576        assert_eq!(base["bafy001"].size_bytes, 100);
577    }
578
579    // -----------------------------------------------------------------------
580    // 19. apply_patch — combined operations
581    // -----------------------------------------------------------------------
582
583    #[test]
584    fn test_apply_patch_combined() {
585        let mut old = HashMap::new();
586        old.insert("keep".to_owned(), make_entry("keep", 10, 1));
587        old.insert("del".to_owned(), make_entry("del", 20, 1));
588        old.insert("upd".to_owned(), make_entry("upd", 30, 1));
589        let mut new = HashMap::new();
590        new.insert("keep".to_owned(), make_entry("keep", 10, 1));
591        new.insert("upd".to_owned(), make_entry("upd", 99, 2));
592        new.insert("fresh".to_owned(), make_entry("fresh", 50, 3));
593        let mut differ = StorageSnapshotDiff::new();
594        let diff = differ.diff(&old, &new);
595        let mut base = old;
596        differ.apply_patch(&mut base, &diff);
597        // "del" should be gone
598        assert!(!base.contains_key("del"));
599        // "keep" untouched
600        assert_eq!(base["keep"].size_bytes, 10);
601        // "upd" updated
602        assert_eq!(base["upd"].size_bytes, 99);
603        // "fresh" inserted
604        assert_eq!(base["fresh"].size_bytes, 50);
605    }
606
607    // -----------------------------------------------------------------------
608    // 20. Sorted by cid ascending — added
609    // -----------------------------------------------------------------------
610
611    #[test]
612    fn test_added_sorted_by_cid() {
613        let old: HashMap<String, SnapshotEntry> = HashMap::new();
614        let mut new = HashMap::new();
615        for &cid in &["zzz", "aaa", "mmm", "bbb"] {
616            new.insert(cid.to_owned(), make_entry(cid, 1, 1));
617        }
618        let mut differ = StorageSnapshotDiff::new();
619        let result = differ.diff(&old, &new);
620        let cids: Vec<&str> = result.added.iter().map(|e| e.cid.as_str()).collect();
621        let mut sorted = cids.clone();
622        sorted.sort_unstable();
623        assert_eq!(cids, sorted);
624    }
625
626    // -----------------------------------------------------------------------
627    // 21. Sorted by cid ascending — removed
628    // -----------------------------------------------------------------------
629
630    #[test]
631    fn test_removed_sorted_by_cid() {
632        let mut old = HashMap::new();
633        for &cid in &["zzz", "aaa", "mmm"] {
634            old.insert(cid.to_owned(), make_entry(cid, 1, 1));
635        }
636        let new: HashMap<String, SnapshotEntry> = HashMap::new();
637        let mut differ = StorageSnapshotDiff::new();
638        let result = differ.diff(&old, &new);
639        let cids: Vec<&str> = result.removed.iter().map(|e| e.cid.as_str()).collect();
640        let mut sorted = cids.clone();
641        sorted.sort_unstable();
642        assert_eq!(cids, sorted);
643    }
644
645    // -----------------------------------------------------------------------
646    // 22. Sorted by cid ascending — modified
647    // -----------------------------------------------------------------------
648
649    #[test]
650    fn test_modified_sorted_by_cid() {
651        let mut old = HashMap::new();
652        let mut new = HashMap::new();
653        for &cid in &["zzz", "aaa", "mmm"] {
654            old.insert(cid.to_owned(), make_entry(cid, 1, 1));
655            new.insert(cid.to_owned(), make_entry(cid, 2, 1));
656        }
657        let mut differ = StorageSnapshotDiff::new();
658        let result = differ.diff(&old, &new);
659        let cids: Vec<&str> = result.modified.iter().map(|e| e.cid.as_str()).collect();
660        let mut sorted = cids.clone();
661        sorted.sort_unstable();
662        assert_eq!(cids, sorted);
663    }
664
665    // -----------------------------------------------------------------------
666    // 23. Sorted by cid ascending — unchanged
667    // -----------------------------------------------------------------------
668
669    #[test]
670    fn test_unchanged_sorted_by_cid() {
671        let mut snap = HashMap::new();
672        for &cid in &["zzz", "aaa", "mmm"] {
673            snap.insert(cid.to_owned(), make_entry(cid, 1, 1));
674        }
675        let mut differ = StorageSnapshotDiff::new();
676        let result = differ.diff(&snap, &snap);
677        let cids: Vec<&str> = result.unchanged.iter().map(|e| e.cid.as_str()).collect();
678        let mut sorted = cids.clone();
679        sorted.sort_unstable();
680        assert_eq!(cids, sorted);
681    }
682
683    // -----------------------------------------------------------------------
684    // 24. Stats accumulate across multiple diffs
685    // -----------------------------------------------------------------------
686
687    #[test]
688    fn test_stats_accumulate() {
689        let mut differ = StorageSnapshotDiff::new();
690
691        // First diff: 1 added
692        let old1: HashMap<String, SnapshotEntry> = HashMap::new();
693        let new1 = single_entry_map("a", 10, 1);
694        differ.diff(&old1, &new1);
695
696        // Second diff: 1 removed, 1 unchanged
697        let mut old2 = HashMap::new();
698        old2.insert("b".to_owned(), make_entry("b", 20, 1));
699        old2.insert("c".to_owned(), make_entry("c", 30, 1));
700        let mut new2 = HashMap::new();
701        new2.insert("c".to_owned(), make_entry("c", 30, 1));
702        differ.diff(&old2, &new2);
703
704        let s = differ.stats();
705        assert_eq!(s.total_diffs_computed, 2);
706        // First diff: 1 unique CID; second diff: 2 unique CIDs
707        assert_eq!(s.total_entries_compared, 3);
708        // First diff: 1 change (added); second diff: 1 change (removed)
709        assert_eq!(s.total_changes_found, 2);
710    }
711
712    // -----------------------------------------------------------------------
713    // 25. stats() returns reference with correct initial state
714    // -----------------------------------------------------------------------
715
716    #[test]
717    fn test_stats_initial_state() {
718        let differ = StorageSnapshotDiff::new();
719        let s = differ.stats();
720        assert_eq!(s.total_diffs_computed, 0);
721        assert_eq!(s.total_entries_compared, 0);
722        assert_eq!(s.total_changes_found, 0);
723    }
724
725    // -----------------------------------------------------------------------
726    // 26. size_delta for Added is +new_size
727    // -----------------------------------------------------------------------
728
729    #[test]
730    fn test_size_delta_added() {
731        let entry = DiffEntry {
732            cid: "x".to_owned(),
733            kind: DiffKind::Added,
734            old_entry: None,
735            new_entry: Some(make_entry("x", 300, 1)),
736        };
737        assert_eq!(entry.size_delta(), 300);
738    }
739
740    // -----------------------------------------------------------------------
741    // 27. size_delta for Removed is -old_size
742    // -----------------------------------------------------------------------
743
744    #[test]
745    fn test_size_delta_removed() {
746        let entry = DiffEntry {
747            cid: "x".to_owned(),
748            kind: DiffKind::Removed,
749            old_entry: Some(make_entry("x", 300, 1)),
750            new_entry: None,
751        };
752        assert_eq!(entry.size_delta(), -300);
753    }
754
755    // -----------------------------------------------------------------------
756    // 28. change_count is zero for identical snapshots
757    // -----------------------------------------------------------------------
758
759    #[test]
760    fn test_change_count_zero_identical() {
761        let snap = single_entry_map("a", 1, 1);
762        let mut differ = StorageSnapshotDiff::new();
763        let result = differ.diff(&snap, &snap);
764        assert_eq!(result.change_count(), 0);
765    }
766}