1use std::collections::HashMap;
7
8#[derive(Clone, Debug, PartialEq)]
14pub enum BlockOp {
15 Put { cid: String, size_bytes: u64 },
17 Delete { cid: String },
19 Unchanged { cid: String },
21}
22
23impl BlockOp {
24 pub fn cid(&self) -> &str {
26 match self {
27 BlockOp::Put { cid, .. } => cid.as_str(),
28 BlockOp::Delete { cid } => cid.as_str(),
29 BlockOp::Unchanged { cid } => cid.as_str(),
30 }
31 }
32}
33
34#[derive(Clone, Debug, PartialEq)]
40pub struct SnapshotEntry {
41 pub cid: String,
43 pub size_bytes: u64,
45 pub created_at_secs: u64,
47 pub tags: Vec<String>,
49}
50
51#[derive(Clone, Debug, PartialEq)]
57pub struct Snapshot {
58 pub id: String,
60 pub taken_at_secs: u64,
62 pub entries: Vec<SnapshotEntry>,
64}
65
66impl Snapshot {
67 pub fn entry_count(&self) -> usize {
69 self.entries.len()
70 }
71
72 pub fn total_bytes(&self) -> u64 {
74 self.entries.iter().map(|e| e.size_bytes).sum()
75 }
76}
77
78#[derive(Clone, Debug, PartialEq)]
84pub struct DiffResult {
85 pub ops: Vec<BlockOp>,
90 pub puts: usize,
92 pub deletes: usize,
94 pub unchanged: usize,
96 pub bytes_added: u64,
98 pub bytes_removed: u64,
100}
101
102impl DiffResult {
103 pub fn net_bytes(&self) -> i64 {
105 self.bytes_added as i64 - self.bytes_removed as i64
106 }
107
108 pub fn has_changes(&self) -> bool {
110 self.puts > 0 || self.deletes > 0
111 }
112}
113
114#[derive(Clone, Debug)]
121pub struct SnapshotDiffer {
122 pub include_unchanged: bool,
125}
126
127impl SnapshotDiffer {
128 pub fn new(include_unchanged: bool) -> Self {
130 Self { include_unchanged }
131 }
132
133 pub fn diff(&self, old: &Snapshot, new: &Snapshot) -> DiffResult {
135 let old_map: HashMap<&str, &SnapshotEntry> =
137 old.entries.iter().map(|e| (e.cid.as_str(), e)).collect();
138
139 let new_cids: HashMap<&str, ()> =
141 new.entries.iter().map(|e| (e.cid.as_str(), ())).collect();
142
143 let mut ops: Vec<BlockOp> = Vec::new();
144 let mut puts: usize = 0;
145 let mut deletes: usize = 0;
146 let mut unchanged: usize = 0;
147 let mut bytes_added: u64 = 0;
148 let mut bytes_removed: u64 = 0;
149
150 for entry in &new.entries {
152 if old_map.contains_key(entry.cid.as_str()) {
153 unchanged += 1;
154 if self.include_unchanged {
155 ops.push(BlockOp::Unchanged {
156 cid: entry.cid.clone(),
157 });
158 }
159 } else {
160 puts += 1;
161 bytes_added += entry.size_bytes;
162 ops.push(BlockOp::Put {
163 cid: entry.cid.clone(),
164 size_bytes: entry.size_bytes,
165 });
166 }
167 }
168
169 for entry in &old.entries {
171 if !new_cids.contains_key(entry.cid.as_str()) {
172 deletes += 1;
173 bytes_removed += entry.size_bytes;
174 ops.push(BlockOp::Delete {
175 cid: entry.cid.clone(),
176 });
177 }
178 }
179
180 ops.sort_by(|a, b| a.cid().cmp(b.cid()));
182
183 DiffResult {
184 ops,
185 puts,
186 deletes,
187 unchanged,
188 bytes_added,
189 bytes_removed,
190 }
191 }
192
193 pub fn apply_ops(base: &Snapshot, ops: &[BlockOp]) -> Snapshot {
201 let mut entries: Vec<SnapshotEntry> = base.entries.clone();
203
204 for op in ops {
205 match op {
206 BlockOp::Delete { cid } => {
207 entries.retain(|e| &e.cid != cid);
208 }
209 BlockOp::Put { cid, size_bytes } => {
210 if !entries.iter().any(|e| &e.cid == cid) {
212 entries.push(SnapshotEntry {
213 cid: cid.clone(),
214 size_bytes: *size_bytes,
215 created_at_secs: 0,
216 tags: Vec::new(),
217 });
218 }
219 }
220 BlockOp::Unchanged { .. } => {
221 }
223 }
224 }
225
226 Snapshot {
227 id: "applied".to_string(),
228 taken_at_secs: 0,
229 entries,
230 }
231 }
232
233 pub fn chain_diff(&self, snapshots: &[Snapshot]) -> Vec<DiffResult> {
238 if snapshots.len() < 2 {
239 return Vec::new();
240 }
241 snapshots
242 .windows(2)
243 .map(|pair| self.diff(&pair[0], &pair[1]))
244 .collect()
245 }
246}
247
248#[cfg(test)]
253mod tests {
254 use super::*;
255
256 fn make_entry(cid: &str, size: u64) -> SnapshotEntry {
257 SnapshotEntry {
258 cid: cid.to_string(),
259 size_bytes: size,
260 created_at_secs: 1_000,
261 tags: vec!["pinned".to_string()],
262 }
263 }
264
265 fn make_snapshot(id: &str, entries: Vec<SnapshotEntry>) -> Snapshot {
266 Snapshot {
267 id: id.to_string(),
268 taken_at_secs: 1_000,
269 entries,
270 }
271 }
272
273 #[test]
278 fn new_include_unchanged_true() {
279 let d = SnapshotDiffer::new(true);
280 assert!(d.include_unchanged);
281 }
282
283 #[test]
284 fn new_include_unchanged_false() {
285 let d = SnapshotDiffer::new(false);
286 assert!(!d.include_unchanged);
287 }
288
289 #[test]
294 fn diff_both_empty() {
295 let d = SnapshotDiffer::new(true);
296 let old = make_snapshot("old", vec![]);
297 let new = make_snapshot("new", vec![]);
298 let result = d.diff(&old, &new);
299 assert_eq!(result.ops.len(), 0);
300 assert_eq!(result.puts, 0);
301 assert_eq!(result.deletes, 0);
302 assert_eq!(result.unchanged, 0);
303 }
304
305 #[test]
306 fn diff_empty_old_new_entries_all_put() {
307 let d = SnapshotDiffer::new(true);
308 let old = make_snapshot("old", vec![]);
309 let new = make_snapshot(
310 "new",
311 vec![make_entry("cid-a", 100), make_entry("cid-b", 200)],
312 );
313 let result = d.diff(&old, &new);
314 assert_eq!(result.puts, 2);
315 assert_eq!(result.deletes, 0);
316 assert_eq!(result.unchanged, 0);
317 assert!(result
318 .ops
319 .iter()
320 .all(|op| matches!(op, BlockOp::Put { .. })));
321 }
322
323 #[test]
324 fn diff_old_entries_empty_new_all_delete() {
325 let d = SnapshotDiffer::new(true);
326 let old = make_snapshot(
327 "old",
328 vec![make_entry("cid-a", 100), make_entry("cid-b", 200)],
329 );
330 let new = make_snapshot("new", vec![]);
331 let result = d.diff(&old, &new);
332 assert_eq!(result.puts, 0);
333 assert_eq!(result.deletes, 2);
334 assert_eq!(result.unchanged, 0);
335 assert!(result
336 .ops
337 .iter()
338 .all(|op| matches!(op, BlockOp::Delete { .. })));
339 }
340
341 #[test]
342 fn diff_identical_snapshots_all_unchanged() {
343 let d = SnapshotDiffer::new(true);
344 let entries = vec![make_entry("cid-a", 100), make_entry("cid-b", 200)];
345 let old = make_snapshot("old", entries.clone());
346 let new = make_snapshot("new", entries);
347 let result = d.diff(&old, &new);
348 assert_eq!(result.puts, 0);
349 assert_eq!(result.deletes, 0);
350 assert_eq!(result.unchanged, 2);
351 assert!(result
352 .ops
353 .iter()
354 .all(|op| matches!(op, BlockOp::Unchanged { .. })));
355 }
356
357 #[test]
362 fn diff_one_added_one_removed_one_unchanged() {
363 let d = SnapshotDiffer::new(true);
364 let old = make_snapshot(
365 "old",
366 vec![make_entry("cid-keep", 50), make_entry("cid-old", 100)],
367 );
368 let new = make_snapshot(
369 "new",
370 vec![make_entry("cid-keep", 50), make_entry("cid-new", 200)],
371 );
372 let result = d.diff(&old, &new);
373 assert_eq!(result.puts, 1);
374 assert_eq!(result.deletes, 1);
375 assert_eq!(result.unchanged, 1);
376 }
377
378 #[test]
379 fn diff_result_sorted_by_cid() {
380 let d = SnapshotDiffer::new(true);
381 let old = make_snapshot("old", vec![make_entry("zzz", 10)]);
382 let new = make_snapshot("new", vec![make_entry("bbb", 20), make_entry("aaa", 30)]);
383 let result = d.diff(&old, &new);
384 let cids: Vec<&str> = result.ops.iter().map(|op| op.cid()).collect();
385 let mut sorted = cids.clone();
386 sorted.sort_unstable();
387 assert_eq!(cids, sorted, "ops must be sorted by CID");
388 }
389
390 #[test]
391 fn diff_bytes_added_removed_correct() {
392 let d = SnapshotDiffer::new(true);
393 let old = make_snapshot("old", vec![make_entry("del", 300)]);
394 let new = make_snapshot("new", vec![make_entry("put", 500)]);
395 let result = d.diff(&old, &new);
396 assert_eq!(result.bytes_added, 500);
397 assert_eq!(result.bytes_removed, 300);
398 }
399
400 #[test]
401 fn diff_net_bytes_positive_when_more_added() {
402 let d = SnapshotDiffer::new(true);
403 let old = make_snapshot("old", vec![make_entry("del", 100)]);
404 let new = make_snapshot("new", vec![make_entry("put", 900)]);
405 let result = d.diff(&old, &new);
406 assert!(result.net_bytes() > 0);
407 assert_eq!(result.net_bytes(), 800_i64);
408 }
409
410 #[test]
411 fn diff_has_changes_true() {
412 let d = SnapshotDiffer::new(true);
413 let old = make_snapshot("old", vec![make_entry("a", 1)]);
414 let new = make_snapshot("new", vec![make_entry("b", 1)]);
415 assert!(d.diff(&old, &new).has_changes());
416 }
417
418 #[test]
419 fn diff_has_changes_false_when_identical() {
420 let d = SnapshotDiffer::new(true);
421 let entries = vec![make_entry("a", 1)];
422 let old = make_snapshot("old", entries.clone());
423 let new = make_snapshot("new", entries);
424 assert!(!d.diff(&old, &new).has_changes());
425 }
426
427 #[test]
432 fn diff_include_unchanged_false_excludes_unchanged_from_ops() {
433 let d = SnapshotDiffer::new(false);
434 let entries = vec![make_entry("cid-keep", 50)];
435 let old = make_snapshot("old", entries.clone());
436 let new = make_snapshot("new", entries);
437 let result = d.diff(&old, &new);
438 assert_eq!(result.ops.len(), 0);
440 }
441
442 #[test]
443 fn diff_include_unchanged_false_still_counts_unchanged() {
444 let d = SnapshotDiffer::new(false);
445 let old = make_snapshot(
446 "old",
447 vec![make_entry("keep", 10), make_entry("old-only", 20)],
448 );
449 let new = make_snapshot(
450 "new",
451 vec![make_entry("keep", 10), make_entry("new-only", 30)],
452 );
453 let result = d.diff(&old, &new);
454 assert_eq!(result.unchanged, 1);
455 assert_eq!(result.puts, 1);
456 assert_eq!(result.deletes, 1);
457 assert!(result
459 .ops
460 .iter()
461 .all(|op| !matches!(op, BlockOp::Unchanged { .. })));
462 }
463
464 #[test]
469 fn apply_ops_put_adds_entry() {
470 let base = make_snapshot("base", vec![]);
471 let ops = vec![BlockOp::Put {
472 cid: "new-cid".to_string(),
473 size_bytes: 42,
474 }];
475 let result = SnapshotDiffer::apply_ops(&base, &ops);
476 assert_eq!(result.entry_count(), 1);
477 assert_eq!(result.entries[0].cid, "new-cid");
478 assert_eq!(result.entries[0].size_bytes, 42);
479 }
480
481 #[test]
482 fn apply_ops_delete_removes_entry() {
483 let base = make_snapshot("base", vec![make_entry("to-delete", 100)]);
484 let ops = vec![BlockOp::Delete {
485 cid: "to-delete".to_string(),
486 }];
487 let result = SnapshotDiffer::apply_ops(&base, &ops);
488 assert_eq!(result.entry_count(), 0);
489 }
490
491 #[test]
492 fn apply_ops_unchanged_has_no_effect() {
493 let base = make_snapshot("base", vec![make_entry("stable", 50)]);
494 let ops = vec![BlockOp::Unchanged {
495 cid: "stable".to_string(),
496 }];
497 let result = SnapshotDiffer::apply_ops(&base, &ops);
498 assert_eq!(result.entry_count(), 1);
499 assert_eq!(result.entries[0].cid, "stable");
500 }
501
502 #[test]
507 fn chain_diff_empty_input_returns_empty() {
508 let d = SnapshotDiffer::new(true);
509 let result = d.chain_diff(&[]);
510 assert!(result.is_empty());
511 }
512
513 #[test]
514 fn chain_diff_single_snapshot_returns_empty() {
515 let d = SnapshotDiffer::new(true);
516 let s = make_snapshot("only", vec![make_entry("cid", 1)]);
517 let result = d.chain_diff(&[s]);
518 assert!(result.is_empty());
519 }
520
521 #[test]
522 fn chain_diff_two_snapshots_returns_one_result() {
523 let d = SnapshotDiffer::new(true);
524 let s0 = make_snapshot("s0", vec![make_entry("a", 10)]);
525 let s1 = make_snapshot("s1", vec![make_entry("b", 20)]);
526 let results = d.chain_diff(&[s0, s1]);
527 assert_eq!(results.len(), 1);
528 assert_eq!(results[0].puts, 1);
529 assert_eq!(results[0].deletes, 1);
530 }
531}