1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
//! Checkpoint creation, listing, cross-source diff, and cleanup.
use anyhow::{anyhow, Result};
use super::source::SnapshotItemSource;
use super::types::{
Checkpoint, CrossSourceDiff, DiffResult, DiffSummary, SnapshotTrigger, SourceDescriptor,
};
use super::DiffEngine;
impl<S: SnapshotItemSource> DiffEngine<S> {
/// Create a checkpoint (git tag at HEAD) grouping the latest snapshot per
/// supplied source.
///
/// Any source lacking a snapshot is given one (with
/// [`SnapshotTrigger::Manual`]) so the checkpoint has a baseline for every
/// source. The caller passes the sources to baseline — the diff layer does
/// not own the source registry.
pub fn create_checkpoint(
&self,
label: &str,
sources: &[SourceDescriptor],
) -> Result<Checkpoint> {
// Snapshot any source that doesn't have one yet.
{
let ledger = self.ledger()?;
let lacking: Vec<&SourceDescriptor> = sources
.iter()
.filter_map(|s| match ledger.snapshot_count_for_source(&s.id) {
Ok(0) => Some(Ok(s)),
Ok(_) => None,
Err(e) => Some(Err(e)),
})
.collect::<Result<Vec<_>>>()?;
// Drop the ledger handle before taking snapshots (each opens its own).
drop(ledger);
for source in lacking {
self.take_snapshot(source, SnapshotTrigger::Manual)?;
}
}
let checkpoint_id = format!("ckpt_{}", uuid::Uuid::new_v4());
let created_at_ms = self.now_ms();
let ledger = self.ledger()?;
let mut snapshot_ids = Vec::new();
for source in sources {
if let Some(snap) = ledger
.latest_snapshots_for_source(&source.id, 1)?
.into_iter()
.next()
{
snapshot_ids.push(snap.id);
}
}
ledger.create_checkpoint(&checkpoint_id, label, &snapshot_ids, created_at_ms)?;
Ok(Checkpoint {
id: checkpoint_id,
label: label.to_string(),
created_at_ms,
snapshot_ids,
})
}
/// List checkpoints newest-first, up to `limit`.
pub fn list_checkpoints(&self, limit: u32) -> Result<Vec<Checkpoint>> {
let ledger = self.ledger()?;
ledger.list_checkpoints(limit)
}
/// Compute a cross-source diff: everything that changed since a checkpoint.
///
/// For each baseline snapshot in the checkpoint, the source's current head
/// is diffed against it. Sources unchanged since the checkpoint are omitted.
pub fn diff_since_checkpoint(
&self,
checkpoint_id: &str,
include_text_diff: bool,
) -> Result<CrossSourceDiff> {
let ledger = self.ledger()?;
let checkpoint = ledger
.get_checkpoint(checkpoint_id)?
.ok_or_else(|| anyhow!("checkpoint not found: {checkpoint_id}"))?;
let computed_at_ms = self.now_ms();
let mut per_source = Vec::new();
let mut agg = DiffSummary::default();
for snap_id in &checkpoint.snapshot_ids {
let Some(base) = ledger.get_snapshot(snap_id)? else {
continue;
};
let Some(head) = ledger
.latest_snapshots_for_source(&base.source_id, 1)?
.into_iter()
.next()
else {
continue;
};
if head.id == base.id {
continue; // unchanged since the checkpoint
}
let (changes, summary) = ledger.compute_changes(
Some(&base.id),
&head.id,
&head.source_id,
head.item_count,
include_text_diff,
)?;
agg.added += summary.added;
agg.removed += summary.removed;
agg.modified += summary.modified;
agg.unchanged += summary.unchanged;
per_source.push(DiffResult {
source_id: head.source_id.clone(),
source_kind: head.source_kind.clone(),
source_label: head.label.clone(),
from_snapshot_id: Some(base.id.clone()),
to_snapshot_id: head.id.clone(),
summary,
changes,
});
}
Ok(CrossSourceDiff {
checkpoint_id: Some(checkpoint.id),
computed_at_ms,
summary: agg,
per_source,
})
}
/// Delete checkpoint tags older than `older_than_days`.
///
/// Snapshot commits are retained — git history *is* the ledger — so cleanup
/// only prunes named baselines. Returns the number of checkpoints deleted.
pub fn cleanup(&self, older_than_days: u32) -> Result<u64> {
let cutoff = self.now_ms() - (older_than_days as i64 * 24 * 60 * 60 * 1000);
let ledger = self.ledger()?;
ledger.cleanup_checkpoints(cutoff)
}
}