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 !crate::core::git::git_available() {
145 return GitRestore::NoAnchor;
146 }
147 let root = Path::new(project_root);
148 let dirty = crate::core::git::run_git(
149 &["status", "--porcelain"],
150 root,
151 Duration::from_secs(5),
152 &[],
153 )
154 .is_ok_and(|o| o.success && !o.stdout.trim().is_empty());
155 if dirty {
156 return GitRestore::DirtyTree;
157 }
158 match crate::core::git::run_git(&["checkout", commit], root, Duration::from_secs(20), &[]) {
159 Ok(o) if o.success => GitRestore::CheckedOut(commit.to_string()),
160 Ok(o) => GitRestore::Failed(o.stderr.trim().to_string()),
161 Err(e) => GitRestore::Failed(e),
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn slice(
170 task: Option<&str>,
171 progress: Option<u8>,
172 decisions: &[&str],
173 files: &[&str],
174 ) -> SnapshotSessionV1 {
175 SnapshotSessionV1 {
176 session_id: None,
177 task: task.map(str::to_string),
178 decisions: decisions.iter().map(|d| (*d).to_string()).collect(),
179 files_touched: files.iter().map(|f| (*f).to_string()).collect(),
180 progress_pct: progress,
181 }
182 }
183
184 #[test]
185 fn merge_sets_task_and_progress() {
186 let mut session = SessionState::new();
187 let s = slice(Some("Resume the timeline"), Some(60), &[], &[]);
188 let report = merge_session(&mut session, &s);
189 assert_eq!(report.task.as_deref(), Some("Resume the timeline"));
190 assert_eq!(report.progress_pct, Some(60));
191 assert_eq!(session.task.as_ref().and_then(|t| t.progress_pct), Some(60));
192 }
193
194 #[test]
195 fn merge_appends_and_dedups_decisions() {
196 let mut session = SessionState::new();
197 session.add_decision("Use JSONL index", None);
198 let s = slice(
199 None,
200 None,
201 &["Use JSONL index", "Sign with ed25519", " "],
202 &[],
203 );
204 let report = merge_session(&mut session, &s);
205 assert_eq!(report.decisions_added, 1);
207 assert!(
208 session
209 .decisions
210 .iter()
211 .any(|d| d.summary == "Sign with ed25519")
212 );
213 assert_eq!(
214 session
215 .decisions
216 .iter()
217 .filter(|d| d.summary == "Use JSONL index")
218 .count(),
219 1
220 );
221 }
222
223 #[test]
224 fn merge_appends_and_dedups_files() {
225 let mut session = SessionState::new();
226 session.touch_file("src/a.rs", None, "full", 100);
227 let s = slice(None, None, &[], &["src/a.rs", "src/b.rs", ""]);
228 let report = merge_session(&mut session, &s);
229 assert_eq!(report.files_added, 1);
230 assert!(session.files_touched.iter().any(|f| f.path == "src/b.rs"));
231 }
232
233 #[test]
234 fn merge_of_empty_slice_changes_nothing() {
235 let mut session = SessionState::new();
236 let report = merge_session(&mut session, &slice(None, None, &[], &[]));
237 assert_eq!(report, SessionMerge::default());
238 assert!(session.task.is_none());
239 }
240}