lean_ctx/core/context_package/
import.rs1use std::path::Path;
4
5use super::bundle::ContextPackage;
6use crate::core::session::SessionState;
7
8pub 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 if session.task.is_none() {
28 session.task.clone_from(&pkg.session.task);
29 report.task_restored = session.task.is_some();
30 }
31
32 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 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 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 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 if session.test_results.is_none() {
75 session.test_results.clone_from(&pkg.session.test_results);
76 }
77
78 if !pkg.knowledge.is_empty() {
80 report.knowledge_merged = replay_knowledge(&pkg)?;
81 }
82
83 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#[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 assert_eq!(second.decisions_merged, 0);
275 assert_eq!(second.findings_merged, 0);
276 assert_eq!(second.files_merged, 0);
277 }
278}