use std::path::Path;
use std::time::Duration;
use crate::core::session::SessionState;
use super::types::{ContextSnapshotV1, SnapshotSessionV1};
pub(crate) struct RestoreOptions {
pub project_root: String,
pub checkout_git: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GitRestore {
Skipped,
NoAnchor,
DirtyTree,
CheckedOut(String),
Failed(String),
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SessionMerge {
pub task: Option<String>,
pub progress_pct: Option<u8>,
pub decisions_added: usize,
pub files_added: usize,
}
pub(crate) struct RestoreOutcome {
pub session: SessionMerge,
pub git: GitRestore,
pub had_session_slice: bool,
}
pub(crate) 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,
})
}
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
}
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;
};
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;
}
match crate::core::git::run_git(&["checkout", 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),
}
}
#[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);
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 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());
}
}