lean-ctx-engine 3.12.3

LeanCTX | OSS library: the open-source runtime for the Context Gateway for AI agents behind the lean-ctx CLI, MCP server and proxy integrations.
Documentation
//! Restore / resume from a Context Snapshot (#1026).
//!
//! Phase 3 of the Context Time Machine: take a stored snapshot and bring the
//! live working state back to it. Two halves, mirroring the vision verbs:
//!
//! - **continue** — merge the snapshot's distilled session slice (task,
//!   progress, decisions, touched files) into the project's live session so the
//!   next agent picks up exactly where that snapshot left off.
//! - **reproduce** — optionally check out the snapshot's git anchor so the code
//!   matches what the model saw (guarded: never discards a dirty tree).
//!
//! `merge_session` is a pure function over an in-memory [`SessionState`]
//! (unit-tested); loading/saving and git are the impure shell around it.

use std::path::Path;
use std::time::Duration;

use crate::core::session::SessionState;

use super::types::{ContextSnapshotV1, SnapshotSessionV1};

/// What [`restore`] should do beyond the always-on session resume.
pub struct RestoreOptions {
    /// Project the snapshot belongs to (selects the live session + git repo).
    pub project_root: String,
    /// Check out the snapshot's git commit (refused if the tree is dirty).
    pub checkout_git: bool,
}

/// Git side-effect outcome of a restore.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GitRestore {
    /// Caller didn't ask to touch git.
    Skipped,
    /// Snapshot has no commit anchor (or git is unavailable) to check out.
    NoAnchor,
    /// Refused: the working tree had uncommitted changes.
    DirtyTree,
    /// Checked out the given commit.
    CheckedOut(String),
    /// `git checkout` ran but failed (carries the trimmed git error).
    Failed(String),
}

/// What a session merge changed — pure, drives tests and CLI output.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct SessionMerge {
    pub task: Option<String>,
    pub progress_pct: Option<u8>,
    pub decisions_added: usize,
    pub files_added: usize,
}

/// Full outcome of a [`restore`].
pub struct RestoreOutcome {
    pub session: SessionMerge,
    pub git: GitRestore,
    /// Whether the snapshot actually carried a session slice to resume from.
    pub had_session_slice: bool,
}

/// Restore a snapshot into the live project state: resume its session, and —
/// when `opts.checkout_git` — check out its git anchor (guarded).
pub fn restore(
    snapshot: &ContextSnapshotV1,
    opts: &RestoreOptions,
) -> Result<RestoreOutcome, String> {
    let mut session =
        SessionState::load_latest_for_project_root(&opts.project_root).unwrap_or_default();
    if session.project_root.is_none() {
        session.project_root = Some(opts.project_root.clone());
    }

    let had_session_slice = snapshot.session.is_some();
    let merge = match snapshot.session.as_ref() {
        Some(slice) => {
            let m = merge_session(&mut session, slice);
            session.save()?;
            m
        }
        None => SessionMerge::default(),
    };

    let git = if opts.checkout_git {
        checkout_anchor(snapshot, &opts.project_root)
    } else {
        GitRestore::Skipped
    };

    Ok(RestoreOutcome {
        session: merge,
        git,
        had_session_slice,
    })
}

/// Pure merge of a snapshot session slice into a live session. Task + progress
/// are overwritten (the snapshot is the source of truth being restored);
/// decisions and touched files are appended, de-duplicated against what the
/// live session already holds.
fn merge_session(session: &mut SessionState, slice: &SnapshotSessionV1) -> SessionMerge {
    let mut report = SessionMerge::default();

    if let Some(task) = slice
        .task
        .as_deref()
        .map(str::trim)
        .filter(|t| !t.is_empty())
    {
        session.set_task(task, None);
        if let Some(t) = session.task.as_mut() {
            t.progress_pct = slice.progress_pct;
        }
        report.task = Some(task.to_string());
        report.progress_pct = slice.progress_pct;
    }

    for decision in &slice.decisions {
        let summary = decision.trim();
        if summary.is_empty() || session.decisions.iter().any(|e| e.summary == summary) {
            continue;
        }
        session.add_decision(summary, None);
        report.decisions_added += 1;
    }

    for path in &slice.files_touched {
        let p = path.trim();
        if p.is_empty() || session.files_touched.iter().any(|f| f.path == p) {
            continue;
        }
        session.touch_file(p, None, "full", 0);
        report.files_added += 1;
    }

    report
}

/// Check out the snapshot's git anchor, refusing to clobber a dirty tree.
fn checkout_anchor(snapshot: &ContextSnapshotV1, project_root: &str) -> GitRestore {
    let Some(commit) = snapshot.git.commit.as_deref().filter(|c| !c.is_empty()) else {
        return GitRestore::NoAnchor;
    };
    // An imported snapshot is untrusted input: only a full object id may
    // reach git, never an option (`--orphan=…`), a ref or a refspec.
    if !is_full_object_id(commit) {
        return GitRestore::Failed(
            "snapshot git anchor is not a full hex commit id — refusing to check it out".into(),
        );
    }
    if !crate::core::git::git_available() {
        return GitRestore::NoAnchor;
    }
    let root = Path::new(project_root);
    let dirty = crate::core::git::run_git(
        &["status", "--porcelain"],
        root,
        Duration::from_secs(5),
        &[],
    )
    .is_ok_and(|o| o.success && !o.stdout.trim().is_empty());
    if dirty {
        return GitRestore::DirtyTree;
    }
    // `--` ends the revision: git never reads the id as a pathspec.
    match crate::core::git::run_git(
        &["checkout", "--detach", commit, "--"],
        root,
        Duration::from_secs(20),
        &[],
    ) {
        Ok(o) if o.success => GitRestore::CheckedOut(commit.to_string()),
        Ok(o) => GitRestore::Failed(o.stderr.trim().to_string()),
        Err(e) => GitRestore::Failed(e),
    }
}

/// A full SHA-1 (40) or SHA-256 (64) object id in lowercase hex, as
/// `git rev-parse HEAD` writes it into the anchor.
fn is_full_object_id(commit: &str) -> bool {
    matches!(commit.len(), 40 | 64)
        && commit
            .bytes()
            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}

#[cfg(test)]
mod tests {
    use super::*;

    fn slice(
        task: Option<&str>,
        progress: Option<u8>,
        decisions: &[&str],
        files: &[&str],
    ) -> SnapshotSessionV1 {
        SnapshotSessionV1 {
            session_id: None,
            task: task.map(str::to_string),
            decisions: decisions.iter().map(|d| (*d).to_string()).collect(),
            files_touched: files.iter().map(|f| (*f).to_string()).collect(),
            progress_pct: progress,
        }
    }

    #[test]
    fn merge_sets_task_and_progress() {
        let mut session = SessionState::new();
        let s = slice(Some("Resume the timeline"), Some(60), &[], &[]);
        let report = merge_session(&mut session, &s);
        assert_eq!(report.task.as_deref(), Some("Resume the timeline"));
        assert_eq!(report.progress_pct, Some(60));
        assert_eq!(session.task.as_ref().and_then(|t| t.progress_pct), Some(60));
    }

    #[test]
    fn merge_appends_and_dedups_decisions() {
        let mut session = SessionState::new();
        session.add_decision("Use JSONL index", None);
        let s = slice(
            None,
            None,
            &["Use JSONL index", "Sign with ed25519", "  "],
            &[],
        );
        let report = merge_session(&mut session, &s);
        // Only the genuinely new, non-empty decision is added.
        assert_eq!(report.decisions_added, 1);
        assert!(
            session
                .decisions
                .iter()
                .any(|d| d.summary == "Sign with ed25519")
        );
        assert_eq!(
            session
                .decisions
                .iter()
                .filter(|d| d.summary == "Use JSONL index")
                .count(),
            1
        );
    }

    #[test]
    fn merge_appends_and_dedups_files() {
        let mut session = SessionState::new();
        session.touch_file("src/a.rs", None, "full", 100);
        let s = slice(None, None, &[], &["src/a.rs", "src/b.rs", ""]);
        let report = merge_session(&mut session, &s);
        assert_eq!(report.files_added, 1);
        assert!(session.files_touched.iter().any(|f| f.path == "src/b.rs"));
    }

    #[test]
    fn only_full_hex_object_ids_reach_git_checkout() {
        assert!(is_full_object_id(&"a1".repeat(20)));
        assert!(is_full_object_id(&"0f".repeat(32)));
        for hostile in [
            "--orphan=x",
            "-b",
            "abc1234",
            "main",
            "HEAD~1",
            "refs/heads/main:refs/heads/x",
            &"A1".repeat(20),
            &format!("-{}", "a".repeat(39)),
        ] {
            assert!(!is_full_object_id(hostile), "{hostile}");
        }

        let mut snapshot = ContextSnapshotV1::new("2026-06-28T00:00:00Z".into(), "9.9.9".into());
        snapshot.git.commit = Some("--orphan=pwned".into());
        let outcome = checkout_anchor(&snapshot, "/nonexistent-project-root");
        assert!(
            matches!(&outcome, GitRestore::Failed(e) if e.contains("full hex commit id")),
            "{outcome:?}"
        );
    }

    #[test]
    fn merge_of_empty_slice_changes_nothing() {
        let mut session = SessionState::new();
        let report = merge_session(&mut session, &slice(None, None, &[], &[]));
        assert_eq!(report, SessionMerge::default());
        assert!(session.task.is_none());
    }
}