lean_ctx/core/context_snapshot/
builder.rs1use 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
26pub struct SnapshotOptions {
28 pub project_root: String,
30 pub sign: bool,
32}
33
34pub 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
63pub 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
70fn 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
148fn 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 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 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}