lean_ctx/core/context_snapshot/
timeline.rs1use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17
18use super::types::ContextSnapshotV1;
19
20const INDEX_FILENAME: &str = "index.jsonl";
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct TimelineEntry {
26 pub snapshot_id: String,
27 pub parent_id: Option<String>,
28 pub created_at: String,
29 pub git_commit: Option<String>,
30 pub git_branch: Option<String>,
31 pub tokens_saved: u64,
32 pub signed: bool,
33}
34
35impl TimelineEntry {
36 #[must_use]
38 pub fn from_snapshot(s: &ContextSnapshotV1) -> Self {
39 Self {
40 snapshot_id: s.snapshot_id.clone(),
41 parent_id: s.parent_id.clone(),
42 created_at: s.created_at.clone(),
43 git_commit: s.git.commit.clone(),
44 git_branch: s.git.branch.clone(),
45 tokens_saved: s.roi.tokens_saved,
46 signed: s.signature.is_some(),
47 }
48 }
49}
50
51pub fn snapshots_dir(project_root: &str) -> Result<PathBuf, String> {
53 let hash = crate::core::project_hash::hash_project_root(project_root);
54 Ok(crate::core::paths::data_dir()?.join("snapshots").join(hash))
55}
56
57fn is_safe_id(id: &str) -> bool {
60 !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_hexdigit())
61}
62
63fn payload_file(dir: &Path, snapshot_id: &str) -> Result<PathBuf, String> {
64 if !is_safe_id(snapshot_id) {
65 return Err(format!("invalid snapshot id: {snapshot_id}"));
66 }
67 Ok(dir.join(format!("{snapshot_id}.json")))
68}
69
70fn load_entries_in(dir: &Path) -> Vec<TimelineEntry> {
73 let Ok(content) = std::fs::read_to_string(dir.join(INDEX_FILENAME)) else {
74 return Vec::new();
75 };
76 content
77 .lines()
78 .filter(|l| !l.trim().is_empty())
79 .filter_map(|l| serde_json::from_str::<TimelineEntry>(l).ok())
80 .collect()
81}
82
83fn append_entry_in(dir: &Path, entry: &TimelineEntry) -> Result<(), String> {
84 std::fs::create_dir_all(dir).map_err(|e| format!("create snapshots dir: {e}"))?;
85 let line =
86 serde_json::to_string(entry).map_err(|e| format!("serialize timeline entry: {e}"))?;
87 use std::io::Write;
88 let mut f = std::fs::OpenOptions::new()
89 .create(true)
90 .append(true)
91 .open(dir.join(INDEX_FILENAME))
92 .map_err(|e| format!("open timeline index: {e}"))?;
93 writeln!(f, "{line}").map_err(|e| format!("append timeline entry: {e}"))
94}
95
96fn write_snapshot_in(dir: &Path, snapshot: &ContextSnapshotV1) -> Result<PathBuf, String> {
97 if snapshot.snapshot_id.is_empty() {
98 return Err("snapshot id is empty — finalize or sign before storing".into());
99 }
100 std::fs::create_dir_all(dir).map_err(|e| format!("create snapshots dir: {e}"))?;
101 let path = payload_file(dir, &snapshot.snapshot_id)?;
102 let json =
103 serde_json::to_string_pretty(snapshot).map_err(|e| format!("serialize snapshot: {e}"))?;
104 crate::config_io::write_atomic(&path, &json)?;
105 append_entry_in(dir, &TimelineEntry::from_snapshot(snapshot))?;
106 Ok(path)
107}
108
109fn read_snapshot_in(dir: &Path, snapshot_id: &str) -> Result<ContextSnapshotV1, String> {
110 let path = payload_file(dir, snapshot_id)?;
111 let content =
112 std::fs::read_to_string(&path).map_err(|e| format!("read snapshot {snapshot_id}: {e}"))?;
113 serde_json::from_str(&content).map_err(|e| format!("parse snapshot {snapshot_id}: {e}"))
114}
115
116pub fn load_entries(project_root: &str) -> Vec<TimelineEntry> {
120 snapshots_dir(project_root)
121 .map(|d| load_entries_in(&d))
122 .unwrap_or_default()
123}
124
125pub fn head_id(project_root: &str) -> Option<String> {
128 load_entries(project_root).pop().map(|e| e.snapshot_id)
129}
130
131pub fn write_snapshot(project_root: &str, snapshot: &ContextSnapshotV1) -> Result<PathBuf, String> {
134 write_snapshot_in(&snapshots_dir(project_root)?, snapshot)
135}
136
137pub fn read_snapshot(project_root: &str, snapshot_id: &str) -> Result<ContextSnapshotV1, String> {
139 read_snapshot_in(&snapshots_dir(project_root)?, snapshot_id)
140}
141
142pub fn resolve_id(project_root: &str, prefix: &str) -> Result<String, String> {
145 resolve_in(&load_entries(project_root), prefix)
146}
147
148fn resolve_in(entries: &[TimelineEntry], prefix: &str) -> Result<String, String> {
149 if prefix.is_empty() {
150 return Err("empty snapshot id".to_string());
151 }
152 let mut hits = entries.iter().filter(|e| e.snapshot_id.starts_with(prefix));
153 let first = hits
154 .next()
155 .ok_or_else(|| format!("no snapshot matches id '{prefix}'"))?;
156 if hits.next().is_some() {
157 return Err(format!(
158 "ambiguous snapshot id '{prefix}' — use more characters"
159 ));
160 }
161 Ok(first.snapshot_id.clone())
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::core::context_snapshot::digest::finalize_id;
168 use crate::core::context_snapshot::types::ContextSnapshotV1;
169
170 fn snap(parent: Option<String>, dirty: bool) -> ContextSnapshotV1 {
171 let mut s = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
172 s.parent_id = parent;
173 s.git.dirty = dirty;
174 finalize_id(&mut s).expect("finalize");
175 s
176 }
177
178 #[test]
179 fn append_then_load_preserves_order() {
180 let dir = tempfile::tempdir().unwrap();
181 let a = TimelineEntry::from_snapshot(&snap(None, false));
182 let b = TimelineEntry::from_snapshot(&snap(Some(a.snapshot_id.clone()), true));
183 append_entry_in(dir.path(), &a).unwrap();
184 append_entry_in(dir.path(), &b).unwrap();
185
186 let loaded = load_entries_in(dir.path());
187 assert_eq!(loaded.len(), 2);
188 assert_eq!(loaded[0].snapshot_id, a.snapshot_id);
189 assert_eq!(loaded[1].parent_id.as_deref(), Some(a.snapshot_id.as_str()));
190 }
191
192 #[test]
193 fn write_then_read_roundtrips_and_indexes() {
194 let dir = tempfile::tempdir().unwrap();
195 let s = snap(None, false);
196 write_snapshot_in(dir.path(), &s).unwrap();
197
198 let back = read_snapshot_in(dir.path(), &s.snapshot_id).unwrap();
199 assert_eq!(back, s);
200 assert_eq!(load_entries_in(dir.path()).len(), 1);
201 }
202
203 #[test]
204 fn load_is_empty_for_fresh_dir() {
205 let dir = tempfile::tempdir().unwrap();
206 assert!(load_entries_in(dir.path()).is_empty());
207 }
208
209 #[test]
210 fn malformed_index_lines_are_skipped() {
211 let dir = tempfile::tempdir().unwrap();
212 let good = TimelineEntry::from_snapshot(&snap(None, false));
213 append_entry_in(dir.path(), &good).unwrap();
214 use std::io::Write;
215 let mut f = std::fs::OpenOptions::new()
216 .append(true)
217 .open(dir.path().join(INDEX_FILENAME))
218 .unwrap();
219 writeln!(f, "{{not valid json").unwrap();
220 assert_eq!(load_entries_in(dir.path()).len(), 1);
221 }
222
223 #[test]
224 fn rejects_unsafe_snapshot_id() {
225 let dir = tempfile::tempdir().unwrap();
226 assert!(payload_file(dir.path(), "../escape").is_err());
227 assert!(payload_file(dir.path(), "not-hex!!").is_err());
228 assert!(payload_file(dir.path(), "").is_err());
229 assert!(payload_file(dir.path(), &"a".repeat(64)).is_ok());
230 }
231
232 #[test]
233 fn refuses_to_store_unfinalized_snapshot() {
234 let dir = tempfile::tempdir().unwrap();
235 let s = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
236 assert!(write_snapshot_in(dir.path(), &s).is_err());
237 }
238
239 #[test]
240 fn resolve_prefix_is_unique_or_errors() {
241 let a = TimelineEntry::from_snapshot(&snap(None, false));
242 let b = TimelineEntry::from_snapshot(&snap(Some(a.snapshot_id.clone()), true));
243 let entries = vec![a.clone(), b.clone()];
244
245 assert_eq!(resolve_in(&entries, &a.snapshot_id).unwrap(), a.snapshot_id);
247 assert_eq!(
248 resolve_in(&entries, &a.snapshot_id[..12]).unwrap(),
249 a.snapshot_id
250 );
251
252 assert!(resolve_in(&entries, "").is_err());
254 assert!(resolve_in(&entries, "ffffffffffff").is_err());
255
256 let common = common_prefix(&a.snapshot_id, &b.snapshot_id);
258 if !common.is_empty() {
259 assert!(resolve_in(&entries, &common).is_err());
260 }
261 }
262
263 fn common_prefix(a: &str, b: &str) -> String {
264 a.chars()
265 .zip(b.chars())
266 .take_while(|(x, y)| x == y)
267 .map(|(x, _)| x)
268 .collect()
269 }
270}