Skip to main content

lean_ctx/core/context_package/
import.rs

1//! Import (resume) a context package into the current session (#293).
2
3use std::path::Path;
4
5use super::bundle::ContextPackage;
6use crate::core::session::SessionState;
7
8/// Resume a session from a previously saved context package.
9///
10/// Merges the package's session slice, summaries, and knowledge facts into
11/// the provided live session. Does **not** overwrite; it augments.
12pub fn resume_package(session: &mut SessionState, path: &Path) -> Result<ResumeReport, String> {
13    let json = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?;
14    let pkg: ContextPackage = serde_json::from_str(&json).map_err(|e| format!("parse: {e}"))?;
15
16    if !pkg.is_compatible() {
17        return Err(format!(
18            "package format_version {} is newer than supported ({})",
19            pkg.format_version,
20            super::bundle::FORMAT_VERSION
21        ));
22    }
23
24    let mut report = ResumeReport::default();
25
26    // Restore task if not already set.
27    if session.task.is_none() {
28        session.task.clone_from(&pkg.session.task);
29        report.task_restored = session.task.is_some();
30    }
31
32    // Merge decisions (deduplicate by summary text).
33    let existing_decisions: std::collections::HashSet<String> = session
34        .decisions
35        .iter()
36        .map(|d| d.summary.clone())
37        .collect();
38    for d in &pkg.session.decisions {
39        if !existing_decisions.contains(&d.summary) {
40            session.decisions.push(d.clone());
41            report.decisions_merged += 1;
42        }
43    }
44
45    // Merge findings.
46    let existing_findings: std::collections::HashSet<String> =
47        session.findings.iter().map(|f| f.summary.clone()).collect();
48    for f in &pkg.session.findings {
49        if !existing_findings.contains(&f.summary) {
50            session.findings.push(f.clone());
51            report.findings_merged += 1;
52        }
53    }
54
55    // Merge files (update or insert).
56    for pf in &pkg.session.files {
57        if !session.files_touched.iter().any(|f| f.path == pf.path) {
58            session.files_touched.push(pf.clone());
59            report.files_merged += 1;
60        }
61    }
62
63    // Merge next_steps.
64    let existing_next: std::collections::HashSet<String> =
65        session.next_steps.iter().cloned().collect();
66    for ns in &pkg.session.next_steps {
67        if !existing_next.contains(ns) {
68            session.next_steps.push(ns.clone());
69            report.next_steps_merged += 1;
70        }
71    }
72
73    // Restore test snapshot if none active.
74    if session.test_results.is_none() {
75        session.test_results.clone_from(&pkg.session.test_results);
76    }
77
78    // Replay knowledge facts into the project knowledge store.
79    if !pkg.knowledge.is_empty() {
80        report.knowledge_merged = replay_knowledge(&pkg)?;
81    }
82
83    // Replay summaries into the summary store.
84    if !pkg.summaries.is_empty() {
85        report.summaries_merged = replay_summaries(&pkg)?;
86    }
87
88    report.source_session_id = pkg.session_id;
89    Ok(report)
90}
91
92/// Report of what was merged.
93#[derive(Debug, Clone, Default)]
94pub struct ResumeReport {
95    pub source_session_id: String,
96    pub task_restored: bool,
97    pub decisions_merged: usize,
98    pub findings_merged: usize,
99    pub files_merged: usize,
100    pub next_steps_merged: usize,
101    pub knowledge_merged: usize,
102    pub summaries_merged: usize,
103}
104
105impl ResumeReport {
106    pub fn format(&self) -> String {
107        let short = self
108            .source_session_id
109            .split('-')
110            .next()
111            .unwrap_or(&self.source_session_id);
112        let mut lines = vec![format!("resumed from package [{}]", short)];
113        if self.task_restored {
114            lines.push("  + task restored".to_string());
115        }
116        if self.decisions_merged > 0 {
117            lines.push(format!("  + {} decisions", self.decisions_merged));
118        }
119        if self.findings_merged > 0 {
120            lines.push(format!("  + {} findings", self.findings_merged));
121        }
122        if self.files_merged > 0 {
123            lines.push(format!("  + {} files", self.files_merged));
124        }
125        if self.next_steps_merged > 0 {
126            lines.push(format!("  + {} next steps", self.next_steps_merged));
127        }
128        if self.knowledge_merged > 0 {
129            lines.push(format!("  + {} knowledge facts", self.knowledge_merged));
130        }
131        if self.summaries_merged > 0 {
132            lines.push(format!("  + {} summaries", self.summaries_merged));
133        }
134        lines.join("\n")
135    }
136}
137
138fn replay_knowledge(pkg: &ContextPackage) -> Result<usize, String> {
139    let mut pk = crate::core::knowledge::ProjectKnowledge::load(&pkg.project_root)
140        .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(&pkg.project_root));
141    let policy = crate::core::memory_policy::MemoryPolicy::default();
142    let before = pk.facts.len();
143    for fact in &pkg.knowledge {
144        pk.remember(
145            &fact.category,
146            &fact.key,
147            &fact.value,
148            &pkg.session_id,
149            fact.confidence,
150            &policy,
151        );
152    }
153    pk.save().map_err(|e| format!("save knowledge: {e}"))?;
154    Ok(pk.facts.len().saturating_sub(before))
155}
156
157fn replay_summaries(pkg: &ContextPackage) -> Result<usize, String> {
158    let mut store =
159        crate::core::session_summary::store::SummaryStore::load_or_create(&pkg.project_root);
160    let before = store.summaries.len();
161    let existing_ids: std::collections::HashSet<String> =
162        store.summaries.iter().map(|s| s.id.clone()).collect();
163    let cfg = crate::core::config::Config::load();
164    for s in &pkg.summaries {
165        if !existing_ids.contains(&s.id) {
166            store.push(s.clone(), cfg.summaries.max_kept as usize);
167        }
168    }
169    store.save().map_err(|e| format!("save summaries: {e}"))?;
170    Ok(store.summaries.len().saturating_sub(before))
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::core::context_package::save_package;
177    use crate::core::session::{Decision, FileTouched, Finding, TaskInfo};
178    use chrono::Utc;
179
180    fn sample_session() -> SessionState {
181        let mut s = SessionState::new();
182        s.task = Some(TaskInfo {
183            description: "implement feature X".to_string(),
184            intent: None,
185            progress_pct: Some(50),
186        });
187        s.findings.push(Finding {
188            file: Some("src/main.rs".to_string()),
189            line: Some(10),
190            summary: "entry point uses tokio".to_string(),
191            timestamp: Utc::now(),
192        });
193        s.decisions.push(Decision {
194            summary: "use bounded_lock for cache".to_string(),
195            rationale: Some("avoid nested block_in_place".to_string()),
196            timestamp: Utc::now(),
197        });
198        s.files_touched.push(FileTouched {
199            path: "src/lib.rs".to_string(),
200            file_ref: Some("F1".to_string()),
201            read_count: 2,
202            modified: true,
203            last_mode: "full".to_string(),
204            tokens: 1234,
205            stale: false,
206            context_item_id: None,
207            summary: None,
208        });
209        s.next_steps.push("write tests".to_string());
210        s
211    }
212
213    #[test]
214    fn save_then_resume_round_trips_session_slice() {
215        let dir = tempfile::tempdir().expect("tempdir");
216        let pkg_path = dir.path().join("snapshot.ctx.json");
217        let project_root = dir.path().to_string_lossy().to_string();
218
219        let source = sample_session();
220        let saved = save_package(
221            &source,
222            &project_root,
223            Some("agent-1"),
224            Some("test snapshot"),
225            Some(&pkg_path),
226        )
227        .expect("save");
228        assert!(saved.exists(), "package file should be written");
229
230        let mut target = SessionState::new();
231        let report = resume_package(&mut target, &saved).expect("resume");
232
233        assert!(report.task_restored, "task should be restored");
234        assert_eq!(report.decisions_merged, 1);
235        assert_eq!(report.findings_merged, 1);
236        assert_eq!(report.files_merged, 1);
237        assert_eq!(report.next_steps_merged, 1);
238        assert_eq!(
239            target.task.as_ref().map(|t| t.description.as_str()),
240            Some("implement feature X")
241        );
242        assert!(target
243            .decisions
244            .iter()
245            .any(|d| d.summary == "use bounded_lock for cache"));
246    }
247
248    #[test]
249    fn resume_missing_file_errors() {
250        let mut target = SessionState::new();
251        let result = resume_package(&mut target, Path::new("/nonexistent/pkg.ctx.json"));
252        assert!(result.is_err(), "missing package must error, not panic");
253    }
254
255    #[test]
256    fn resume_twice_does_not_duplicate() {
257        let dir = tempfile::tempdir().expect("tempdir");
258        let pkg_path = dir.path().join("snap.ctx.json");
259        let project_root = dir.path().to_string_lossy().to_string();
260        let saved = save_package(
261            &sample_session(),
262            &project_root,
263            None,
264            None,
265            Some(&pkg_path),
266        )
267        .expect("save");
268
269        let mut target = SessionState::new();
270        resume_package(&mut target, &saved).expect("resume 1");
271        let second = resume_package(&mut target, &saved).expect("resume 2");
272
273        // Dedup by summary/path means a second resume merges nothing new.
274        assert_eq!(second.decisions_merged, 0);
275        assert_eq!(second.findings_merged, 0);
276        assert_eq!(second.files_merged, 0);
277    }
278}