Skip to main content

lean_ctx/core/context_snapshot/
builder.rs

1//! Builds a [`ContextSnapshotV1`] from the live context stores (GL #1024).
2//!
3//! The builder is split into **pure projections** (`*_from_*` / `*_slice`),
4//! which map a loaded store into a bounded snapshot slice and are unit-tested
5//! with in-memory fixtures, and **impure anchors** (`git_anchor`,
6//! `project_slice`), which read git / the filesystem. [`build`] orchestrates
7//! them and finalizes the id (signing it when requested); [`create`] additionally
8//! persists the snapshot and appends it to the append-only timeline.
9
10use std::path::Path;
11use std::time::Duration;
12
13use crate::core::context_field::ContextState;
14use crate::core::context_ir::{ContextIrSourceKindV1, ContextIrTotalsV1, ContextIrV1};
15use crate::core::context_ledger::ContextLedger;
16use crate::core::session::SessionState;
17
18use super::digest::finalize_id;
19use super::signing::sign_snapshot;
20use super::types::{
21    ContextSnapshotV1, GitAnchorV1, MAX_SNAPSHOT_LEDGER_ITEMS, MAX_SNAPSHOT_LINEAGE_ITEMS,
22    MAX_SNAPSHOT_SESSION_LIST, SnapshotLedgerItemV1, SnapshotLedgerV1, SnapshotLineageItemV1,
23    SnapshotLineageV1, SnapshotProjectV1, SnapshotRoiV1, SnapshotSessionV1,
24};
25
26/// Inputs for building a snapshot.
27pub struct SnapshotOptions {
28    /// Project root the snapshot is anchored to.
29    pub project_root: String,
30    /// Sign the snapshot with the publisher keypair (else just finalize the id).
31    pub sign: bool,
32}
33
34/// Build an in-memory snapshot from the current store state, finalizing its id
35/// (and signing it when `opts.sign`). Does not persist anything.
36pub fn build(opts: &SnapshotOptions) -> Result<ContextSnapshotV1, String> {
37    let mut snap = ContextSnapshotV1::new(
38        chrono::Utc::now().to_rfc3339(),
39        env!("CARGO_PKG_VERSION").to_string(),
40    );
41    snap.git = git_anchor(&opts.project_root);
42    snap.project = project_slice(&opts.project_root);
43
44    let ir = ContextIrV1::load();
45    snap.roi = roi_from_totals(&ir.totals);
46    snap.lineage = lineage_from_ir(&ir);
47
48    snap.ledger = ledger_slice(&ContextLedger::load());
49    snap.session =
50        SessionState::load_latest_for_project_root(&opts.project_root).map(|s| session_slice(&s));
51
52    snap.parent_id = super::timeline::head_id(&opts.project_root);
53
54    if opts.sign {
55        let (key, _newly_created) = crate::core::context_package::keys::load_or_create()?;
56        sign_snapshot(&mut snap, &key)?;
57    } else {
58        finalize_id(&mut snap)?;
59    }
60    Ok(snap)
61}
62
63/// Build, persist, and append the snapshot to the project's timeline.
64pub fn create(opts: &SnapshotOptions) -> Result<ContextSnapshotV1, String> {
65    let snap = build(opts)?;
66    super::timeline::write_snapshot(&opts.project_root, &snap)?;
67    Ok(snap)
68}
69
70// --- pure projections -------------------------------------------------------
71
72fn roi_from_totals(t: &ContextIrTotalsV1) -> SnapshotRoiV1 {
73    let denom = t.input_tokens + t.tokens_saved;
74    let compression_rate = if denom == 0 {
75        0.0
76    } else {
77        t.tokens_saved as f64 / denom as f64
78    };
79    SnapshotRoiV1 {
80        input_tokens: t.input_tokens,
81        output_tokens: t.output_tokens,
82        tokens_saved: t.tokens_saved,
83        compression_rate,
84    }
85}
86
87fn lineage_from_ir(ir: &ContextIrV1) -> SnapshotLineageV1 {
88    let start = ir.items.len().saturating_sub(MAX_SNAPSHOT_LINEAGE_ITEMS);
89    let items = ir.items[start..]
90        .iter()
91        .map(|it| SnapshotLineageItemV1 {
92            seq: it.seq,
93            kind: kind_str(&it.source.kind).to_string(),
94            tool: it.source.tool.clone(),
95            path: it.source.path.clone(),
96            input_tokens: it.input_tokens as u64,
97            output_tokens: it.output_tokens as u64,
98            compression_ratio: it.compression_ratio,
99            content_hash: it.verification.content_md5.clone(),
100        })
101        .collect();
102    SnapshotLineageV1 {
103        items_recorded: ir.totals.items_recorded,
104        items,
105    }
106}
107
108fn ledger_slice(led: &ContextLedger) -> SnapshotLedgerV1 {
109    let start = led.entries.len().saturating_sub(MAX_SNAPSHOT_LEDGER_ITEMS);
110    let items = led.entries[start..]
111        .iter()
112        .map(|e| SnapshotLedgerItemV1 {
113            path: e.path.clone(),
114            state: state_str(e.state.unwrap_or(ContextState::Candidate)).to_string(),
115            phi: e.phi,
116            sent_tokens: e.sent_tokens,
117            original_tokens: e.original_tokens,
118        })
119        .collect();
120    SnapshotLedgerV1 {
121        window_size: led.window_size,
122        total_tokens_sent: led.total_tokens_sent,
123        total_tokens_saved: led.total_tokens_saved,
124        items,
125    }
126}
127
128fn session_slice(s: &SessionState) -> SnapshotSessionV1 {
129    SnapshotSessionV1 {
130        session_id: Some(s.id.clone()),
131        task: s.task.as_ref().map(|t| t.description.clone()),
132        decisions: s
133            .decisions
134            .iter()
135            .take(MAX_SNAPSHOT_SESSION_LIST)
136            .map(|d| d.summary.clone())
137            .collect(),
138        files_touched: s
139            .files_touched
140            .iter()
141            .take(MAX_SNAPSHOT_SESSION_LIST)
142            .map(|f| f.path.clone())
143            .collect(),
144        progress_pct: s.task.as_ref().and_then(|t| t.progress_pct),
145    }
146}
147
148// --- impure anchors ---------------------------------------------------------
149
150fn project_slice(project_root: &str) -> SnapshotProjectV1 {
151    SnapshotProjectV1 {
152        root_hash: Some(crate::core::project_hash::hash_project_root(project_root)),
153        identity_hash: crate::core::project_hash::project_identity(project_root)
154            .map(|id| crate::core::hasher::hash_str(&id)),
155    }
156}
157
158fn git_anchor(project_root: &str) -> GitAnchorV1 {
159    if !crate::core::git::git_available() {
160        return GitAnchorV1::default();
161    }
162    let root = Path::new(project_root);
163    let commit = git_str(root, &["rev-parse", "HEAD"]);
164    let branch = git_str(root, &["rev-parse", "--abbrev-ref", "HEAD"]);
165    let dirty = crate::core::git::run_git(
166        &["status", "--porcelain"],
167        root,
168        Duration::from_secs(5),
169        &[],
170    )
171    .is_ok_and(|o| o.success && !o.stdout.trim().is_empty());
172    GitAnchorV1 {
173        commit,
174        branch,
175        dirty,
176    }
177}
178
179fn git_str(root: &Path, args: &[&str]) -> Option<String> {
180    crate::core::git::run_git(args, root, Duration::from_secs(5), &[])
181        .ok()
182        .filter(|o| o.success)
183        .map(|o| o.stdout.trim().to_string())
184        .filter(|s| !s.is_empty())
185}
186
187fn kind_str(k: &ContextIrSourceKindV1) -> &'static str {
188    match k {
189        ContextIrSourceKindV1::Read => "read",
190        ContextIrSourceKindV1::Shell => "shell",
191        ContextIrSourceKindV1::Search => "search",
192        ContextIrSourceKindV1::Provider => "provider",
193        ContextIrSourceKindV1::Other => "other",
194    }
195}
196
197fn state_str(s: ContextState) -> &'static str {
198    match s {
199        ContextState::Candidate => "candidate",
200        ContextState::Included => "included",
201        ContextState::Excluded => "excluded",
202        ContextState::Pinned => "pinned",
203        ContextState::Stale => "stale",
204        ContextState::Shadowed => "shadowed",
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::core::context_ir::{
212        ContextIrItemV1, ContextIrSafetyV1, ContextIrSourceV1, ContextIrVerificationV1,
213    };
214    use crate::core::session::{Decision, FileTouched, TaskInfo};
215
216    fn ir_item(seq: u64, kind: ContextIrSourceKindV1, path: &str) -> ContextIrItemV1 {
217        ContextIrItemV1 {
218            seq,
219            created_at: "2026-06-28T00:00:00Z".into(),
220            source: ContextIrSourceV1 {
221                kind,
222                tool: "ctx_read".into(),
223                path: Some(path.into()),
224                ..Default::default()
225            },
226            input_tokens: 500,
227            output_tokens: 13,
228            duration_us: 0,
229            compression_ratio: 0.974,
230            content_excerpt: String::new(),
231            truncated: false,
232            safety: ContextIrSafetyV1::default(),
233            verification: ContextIrVerificationV1 {
234                content_md5: Some("c".repeat(64)),
235            },
236        }
237    }
238
239    #[test]
240    fn roi_handles_zero_and_normal() {
241        let zero = roi_from_totals(&ContextIrTotalsV1::default());
242        assert_eq!(zero.compression_rate, 0.0);
243
244        let t = ContextIrTotalsV1 {
245            items_recorded: 3,
246            input_tokens: 200,
247            output_tokens: 50,
248            tokens_saved: 600,
249        };
250        let roi = roi_from_totals(&t);
251        assert_eq!(roi.tokens_saved, 600);
252        assert!(
253            (roi.compression_rate - 0.75).abs() < 1e-9,
254            "600/(200+600)=0.75"
255        );
256    }
257
258    #[test]
259    fn lineage_maps_fields_and_bounds_to_cap() {
260        let mut ir = ContextIrV1::new();
261        ir.totals.items_recorded = 999;
262        for seq in 0..(MAX_SNAPSHOT_LINEAGE_ITEMS as u64 + 10) {
263            ir.items
264                .push(ir_item(seq, ContextIrSourceKindV1::Read, "src/a.rs"));
265        }
266        let slice = lineage_from_ir(&ir);
267        assert_eq!(slice.items_recorded, 999);
268        assert_eq!(slice.items.len(), MAX_SNAPSHOT_LINEAGE_ITEMS);
269        // The cap keeps the most recent items (tail of the IR ring).
270        assert_eq!(
271            slice.items.last().unwrap().seq,
272            MAX_SNAPSHOT_LINEAGE_ITEMS as u64 + 9
273        );
274        let first = &slice.items[0];
275        assert_eq!(first.kind, "read");
276        assert_eq!(first.tool, "ctx_read");
277        assert_eq!(first.path.as_deref(), Some("src/a.rs"));
278    }
279
280    #[test]
281    fn ledger_slice_maps_state_and_phi() {
282        let mut led = ContextLedger::new();
283        led.record("src/a.rs", "full", 500, 13);
284        led.update_phi("src/a.rs", 0.9);
285        led.set_state("src/a.rs", ContextState::Pinned);
286        led.record("src/b.rs", "signatures", 800, 40);
287
288        let slice = ledger_slice(&led);
289        assert_eq!(slice.items.len(), 2);
290        let a = slice.items.iter().find(|i| i.path == "src/a.rs").unwrap();
291        assert_eq!(a.state, "pinned");
292        assert_eq!(a.phi, Some(0.9));
293        // record() marks a fresh entry as included (it just entered the window);
294        // the candidate fallback only applies to entries with no recorded state.
295        let b = slice.items.iter().find(|i| i.path == "src/b.rs").unwrap();
296        assert_eq!(b.state, "included");
297    }
298
299    #[test]
300    fn session_slice_projects_task_and_lists() {
301        let mut s = SessionState::new();
302        s.task = Some(TaskInfo {
303            description: "Implement timeline".into(),
304            intent: None,
305            progress_pct: Some(40),
306        });
307        s.decisions.push(Decision {
308            summary: "JSONL append-only index".into(),
309            rationale: None,
310            timestamp: chrono::Utc::now(),
311        });
312        s.files_touched.push(FileTouched {
313            path: "rust/src/core/context_snapshot/timeline.rs".into(),
314            file_ref: None,
315            read_count: 1,
316            modified: true,
317            last_mode: "full".into(),
318            tokens: 100,
319            stale: false,
320            context_item_id: None,
321            summary: None,
322        });
323
324        let slice = session_slice(&s);
325        assert_eq!(slice.task.as_deref(), Some("Implement timeline"));
326        assert_eq!(slice.progress_pct, Some(40));
327        assert_eq!(slice.decisions, vec!["JSONL append-only index".to_string()]);
328        assert_eq!(
329            slice.files_touched,
330            vec!["rust/src/core/context_snapshot/timeline.rs".to_string()]
331        );
332    }
333
334    #[test]
335    fn kind_and_state_strings_are_snake_case() {
336        assert_eq!(kind_str(&ContextIrSourceKindV1::Provider), "provider");
337        assert_eq!(state_str(ContextState::Shadowed), "shadowed");
338    }
339}