lean_ctx/core/context_snapshot/
restore.rs1use std::path::Path;
16use std::time::Duration;
17
18use crate::core::session::SessionState;
19
20use super::types::{ContextSnapshotV1, SnapshotSessionV1};
21
22pub struct RestoreOptions {
24 pub project_root: String,
26 pub checkout_git: bool,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GitRestore {
33 Skipped,
35 NoAnchor,
37 DirtyTree,
39 CheckedOut(String),
41 Failed(String),
43}
44
45#[derive(Debug, Default, PartialEq, Eq)]
47pub struct SessionMerge {
48 pub task: Option<String>,
49 pub progress_pct: Option<u8>,
50 pub decisions_added: usize,
51 pub files_added: usize,
52}
53
54pub struct RestoreOutcome {
56 pub session: SessionMerge,
57 pub git: GitRestore,
58 pub had_session_slice: bool,
60}
61
62pub fn restore(
65 snapshot: &ContextSnapshotV1,
66 opts: &RestoreOptions,
67) -> Result<RestoreOutcome, String> {
68 let mut session =
69 SessionState::load_latest_for_project_root(&opts.project_root).unwrap_or_default();
70 if session.project_root.is_none() {
71 session.project_root = Some(opts.project_root.clone());
72 }
73
74 let had_session_slice = snapshot.session.is_some();
75 let merge = match snapshot.session.as_ref() {
76 Some(slice) => {
77 let m = merge_session(&mut session, slice);
78 session.save()?;
79 m
80 }
81 None => SessionMerge::default(),
82 };
83
84 let git = if opts.checkout_git {
85 checkout_anchor(snapshot, &opts.project_root)
86 } else {
87 GitRestore::Skipped
88 };
89
90 Ok(RestoreOutcome {
91 session: merge,
92 git,
93 had_session_slice,
94 })
95}
96
97fn merge_session(session: &mut SessionState, slice: &SnapshotSessionV1) -> SessionMerge {
102 let mut report = SessionMerge::default();
103
104 if let Some(task) = slice
105 .task
106 .as_deref()
107 .map(str::trim)
108 .filter(|t| !t.is_empty())
109 {
110 session.set_task(task, None);
111 if let Some(t) = session.task.as_mut() {
112 t.progress_pct = slice.progress_pct;
113 }
114 report.task = Some(task.to_string());
115 report.progress_pct = slice.progress_pct;
116 }
117
118 for decision in &slice.decisions {
119 let summary = decision.trim();
120 if summary.is_empty() || session.decisions.iter().any(|e| e.summary == summary) {
121 continue;
122 }
123 session.add_decision(summary, None);
124 report.decisions_added += 1;
125 }
126
127 for path in &slice.files_touched {
128 let p = path.trim();
129 if p.is_empty() || session.files_touched.iter().any(|f| f.path == p) {
130 continue;
131 }
132 session.touch_file(p, None, "full", 0);
133 report.files_added += 1;
134 }
135
136 report
137}
138
139fn checkout_anchor(snapshot: &ContextSnapshotV1, project_root: &str) -> GitRestore {
141 let Some(commit) = snapshot.git.commit.as_deref().filter(|c| !c.is_empty()) else {
142 return GitRestore::NoAnchor;
143 };
144 if !is_full_object_id(commit) {
147 return GitRestore::Failed(
148 "snapshot git anchor is not a full hex commit id — refusing to check it out".into(),
149 );
150 }
151 if !crate::core::git::git_available() {
152 return GitRestore::NoAnchor;
153 }
154 let root = Path::new(project_root);
155 let dirty = crate::core::git::run_git(
156 &["status", "--porcelain"],
157 root,
158 Duration::from_secs(5),
159 &[],
160 )
161 .is_ok_and(|o| o.success && !o.stdout.trim().is_empty());
162 if dirty {
163 return GitRestore::DirtyTree;
164 }
165 match crate::core::git::run_git(
167 &["checkout", "--detach", commit, "--"],
168 root,
169 Duration::from_secs(20),
170 &[],
171 ) {
172 Ok(o) if o.success => GitRestore::CheckedOut(commit.to_string()),
173 Ok(o) => GitRestore::Failed(o.stderr.trim().to_string()),
174 Err(e) => GitRestore::Failed(e),
175 }
176}
177
178fn is_full_object_id(commit: &str) -> bool {
181 matches!(commit.len(), 40 | 64)
182 && commit
183 .bytes()
184 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn slice(
192 task: Option<&str>,
193 progress: Option<u8>,
194 decisions: &[&str],
195 files: &[&str],
196 ) -> SnapshotSessionV1 {
197 SnapshotSessionV1 {
198 session_id: None,
199 task: task.map(str::to_string),
200 decisions: decisions.iter().map(|d| (*d).to_string()).collect(),
201 files_touched: files.iter().map(|f| (*f).to_string()).collect(),
202 progress_pct: progress,
203 }
204 }
205
206 #[test]
207 fn merge_sets_task_and_progress() {
208 let mut session = SessionState::new();
209 let s = slice(Some("Resume the timeline"), Some(60), &[], &[]);
210 let report = merge_session(&mut session, &s);
211 assert_eq!(report.task.as_deref(), Some("Resume the timeline"));
212 assert_eq!(report.progress_pct, Some(60));
213 assert_eq!(session.task.as_ref().and_then(|t| t.progress_pct), Some(60));
214 }
215
216 #[test]
217 fn merge_appends_and_dedups_decisions() {
218 let mut session = SessionState::new();
219 session.add_decision("Use JSONL index", None);
220 let s = slice(
221 None,
222 None,
223 &["Use JSONL index", "Sign with ed25519", " "],
224 &[],
225 );
226 let report = merge_session(&mut session, &s);
227 assert_eq!(report.decisions_added, 1);
229 assert!(
230 session
231 .decisions
232 .iter()
233 .any(|d| d.summary == "Sign with ed25519")
234 );
235 assert_eq!(
236 session
237 .decisions
238 .iter()
239 .filter(|d| d.summary == "Use JSONL index")
240 .count(),
241 1
242 );
243 }
244
245 #[test]
246 fn merge_appends_and_dedups_files() {
247 let mut session = SessionState::new();
248 session.touch_file("src/a.rs", None, "full", 100);
249 let s = slice(None, None, &[], &["src/a.rs", "src/b.rs", ""]);
250 let report = merge_session(&mut session, &s);
251 assert_eq!(report.files_added, 1);
252 assert!(session.files_touched.iter().any(|f| f.path == "src/b.rs"));
253 }
254
255 #[test]
256 fn only_full_hex_object_ids_reach_git_checkout() {
257 assert!(is_full_object_id(&"a1".repeat(20)));
258 assert!(is_full_object_id(&"0f".repeat(32)));
259 for hostile in [
260 "--orphan=x",
261 "-b",
262 "abc1234",
263 "main",
264 "HEAD~1",
265 "refs/heads/main:refs/heads/x",
266 &"A1".repeat(20),
267 &format!("-{}", "a".repeat(39)),
268 ] {
269 assert!(!is_full_object_id(hostile), "{hostile}");
270 }
271
272 let mut snapshot = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
273 snapshot.git.commit = Some("--orphan=pwned".into());
274 let outcome = checkout_anchor(&snapshot, "/nonexistent-project-root");
275 assert!(
276 matches!(&outcome, GitRestore::Failed(e) if e.contains("full hex commit id")),
277 "{outcome:?}"
278 );
279 }
280
281 #[test]
282 fn merge_of_empty_slice_changes_nothing() {
283 let mut session = SessionState::new();
284 let report = merge_session(&mut session, &slice(None, None, &[], &[]));
285 assert_eq!(report, SessionMerge::default());
286 assert!(session.task.is_none());
287 }
288}