Skip to main content

lean_ctx/core/context_snapshot/
restore.rs

1//! Restore / resume from a Context Snapshot (#1026).
2//!
3//! Phase 3 of the Context Time Machine: take a stored snapshot and bring the
4//! live working state back to it. Two halves, mirroring the vision verbs:
5//!
6//! - **continue** — merge the snapshot's distilled session slice (task,
7//!   progress, decisions, touched files) into the project's live session so the
8//!   next agent picks up exactly where that snapshot left off.
9//! - **reproduce** — optionally check out the snapshot's git anchor so the code
10//!   matches what the model saw (guarded: never discards a dirty tree).
11//!
12//! `merge_session` is a pure function over an in-memory [`SessionState`]
13//! (unit-tested); loading/saving and git are the impure shell around it.
14
15use std::path::Path;
16use std::time::Duration;
17
18use crate::core::session::SessionState;
19
20use super::types::{ContextSnapshotV1, SnapshotSessionV1};
21
22/// What [`restore`] should do beyond the always-on session resume.
23pub struct RestoreOptions {
24    /// Project the snapshot belongs to (selects the live session + git repo).
25    pub project_root: String,
26    /// Check out the snapshot's git commit (refused if the tree is dirty).
27    pub checkout_git: bool,
28}
29
30/// Git side-effect outcome of a restore.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum GitRestore {
33    /// Caller didn't ask to touch git.
34    Skipped,
35    /// Snapshot has no commit anchor (or git is unavailable) to check out.
36    NoAnchor,
37    /// Refused: the working tree had uncommitted changes.
38    DirtyTree,
39    /// Checked out the given commit.
40    CheckedOut(String),
41    /// `git checkout` ran but failed (carries the trimmed git error).
42    Failed(String),
43}
44
45/// What a session merge changed — pure, drives tests and CLI output.
46#[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
54/// Full outcome of a [`restore`].
55pub struct RestoreOutcome {
56    pub session: SessionMerge,
57    pub git: GitRestore,
58    /// Whether the snapshot actually carried a session slice to resume from.
59    pub had_session_slice: bool,
60}
61
62/// Restore a snapshot into the live project state: resume its session, and —
63/// when `opts.checkout_git` — check out its git anchor (guarded).
64pub 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
97/// Pure merge of a snapshot session slice into a live session. Task + progress
98/// are overwritten (the snapshot is the source of truth being restored);
99/// decisions and touched files are appended, de-duplicated against what the
100/// live session already holds.
101fn 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
139/// Check out the snapshot's git anchor, refusing to clobber a dirty tree.
140fn 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        // Only the genuinely new, non-empty decision is added.
206        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}