1use lean_ctx::core::context_admission::stores::StoreAdmission;
15use lean_ctx::core::knowledge::ProjectKnowledge;
16use lean_ctx::core::memory_archive::{ArchiveConfig, MemoryStore, reachable_archives};
17
18pub const DEFAULT_RESTORE_LIMIT: usize = 50;
22
23pub struct RestoreOptions {
25 pub stores: Vec<MemoryStore>,
27 pub query: Option<String>,
29 pub limit: usize,
31}
32
33impl RestoreOptions {
34 pub fn new(store: Option<MemoryStore>, query: Option<String>, limit: usize) -> Self {
36 Self {
37 stores: store.map_or_else(|| MemoryStore::all().to_vec(), |s| vec![s]),
38 query: query.filter(|q| !q.trim().is_empty()),
39 limit: limit.max(1),
40 }
41 }
42}
43
44pub struct StoreRestore {
46 pub store: MemoryStore,
47 pub matched: usize,
49 pub restored: usize,
51}
52
53pub struct RestoreReport {
55 pub per_store: Vec<StoreRestore>,
56 pub query: Option<String>,
57}
58
59impl RestoreReport {
60 pub(crate) fn total_restored(&self) -> usize {
61 self.per_store.iter().map(|s| s.restored).sum()
62 }
63}
64
65fn collect<T, S>(
68 store: MemoryStore,
69 scope: Option<&str>,
70 cfg: &ArchiveConfig,
71 query: Option<&str>,
72 admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
73 searchable: S,
74) -> Vec<T>
75where
76 T: serde::de::DeserializeOwned,
77 S: Fn(&T) -> String,
78{
79 let needle = query.map(str::to_lowercase);
80 let mut out = Vec::new();
81 for path in reachable_archives(store, scope, cfg).into_iter().rev() {
83 let Ok(items) =
84 lean_ctx::core::memory_archive::restore_knowledge_items::<T>(&path, admission)
85 else {
86 continue;
87 };
88 for it in items {
89 if needle
90 .as_deref()
91 .is_none_or(|n| searchable(&it).to_lowercase().contains(n))
92 {
93 out.push(it);
94 }
95 }
96 }
97 out
98}
99
100fn restore_facts(
103 knowledge: &mut ProjectKnowledge,
104 cfg: &ArchiveConfig,
105 query: Option<&str>,
106 remaining: &mut usize,
107 admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
108) -> StoreRestore {
109 let archived = collect::<lean_ctx::core::knowledge::KnowledgeFact, _>(
110 MemoryStore::Facts,
111 None,
112 cfg,
113 None,
114 admission,
115 |f| format!("{} {} {}", f.category, f.key, f.value),
116 );
117 let mut candidate = ProjectKnowledge::new(&knowledge.project_root);
120 candidate.facts = archived;
121 let needle = query.map(str::to_lowercase);
122 let cands: Vec<_> = candidate
123 .admit_sources()
124 .facts
125 .into_iter()
126 .filter(|f| {
127 needle.as_ref().is_none_or(|needle| {
128 format!("{} {} {}", f.category, f.key, f.value)
129 .to_lowercase()
130 .contains(needle)
131 })
132 })
133 .collect();
134 let matched = cands.len();
135 let mut restored = 0;
136 for f in cands {
137 if *remaining == 0 {
138 break;
139 }
140 let already = knowledge
141 .facts
142 .iter()
143 .chain(knowledge.withheld.iter().filter_map(|item| match item {
144 lean_ctx::core::knowledge::WithheldKnowledge::Fact(fact) => Some(fact),
145 _ => None,
146 }))
147 .any(|e| lean_ctx::core::knowledge::source_view::same_record(e, &f));
148 if already {
149 continue;
150 }
151 let key_owned = knowledge
152 .facts
153 .iter()
154 .chain(knowledge.withheld.iter().filter_map(|item| match item {
155 lean_ctx::core::knowledge::WithheldKnowledge::Fact(fact) => Some(fact),
156 _ => None,
157 }))
158 .any(|e| e.category == f.category && e.key == f.key && e.is_current());
159 if key_owned {
160 continue;
161 }
162 knowledge.facts.push(f);
163 restored += 1;
164 *remaining -= 1;
165 }
166 StoreRestore {
167 store: MemoryStore::Facts,
168 matched,
169 restored,
170 }
171}
172
173fn restore_history(
175 knowledge: &mut ProjectKnowledge,
176 hash: &str,
177 cfg: &ArchiveConfig,
178 query: Option<&str>,
179 remaining: &mut usize,
180 admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
181) -> StoreRestore {
182 let cands = collect::<lean_ctx::core::knowledge::ConsolidatedInsight, _>(
183 MemoryStore::History,
184 Some(hash),
185 cfg,
186 query,
187 admission,
188 |h| h.summary.clone(),
189 );
190 let matched = cands.len();
191 let mut restored = 0;
192 for h in cands {
193 if *remaining == 0 {
194 break;
195 }
196 let already = knowledge.history.iter().any(|e| {
197 e.summary == h.summary
198 && e.from_sessions == h.from_sessions
199 && e.timestamp == h.timestamp
200 });
201 if already {
202 continue;
203 }
204 knowledge.history.push(h);
205 restored += 1;
206 *remaining -= 1;
207 }
208 StoreRestore {
209 store: MemoryStore::History,
210 matched,
211 restored,
212 }
213}
214
215fn restore_patterns(
217 knowledge: &mut ProjectKnowledge,
218 hash: &str,
219 cfg: &ArchiveConfig,
220 query: Option<&str>,
221 remaining: &mut usize,
222 admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
223) -> StoreRestore {
224 let cands = collect::<lean_ctx::core::knowledge::ProjectPattern, _>(
225 MemoryStore::Patterns,
226 Some(hash),
227 cfg,
228 query,
229 admission,
230 |p| format!("{} {}", p.pattern_type, p.description),
231 );
232 let matched = cands.len();
233 let mut restored = 0;
234 for p in cands {
235 if *remaining == 0 {
236 break;
237 }
238 let already = knowledge.patterns.iter().any(|e| {
239 e.pattern_type == p.pattern_type
240 && e.description == p.description
241 && e.source_session == p.source_session
242 && e.created_at == p.created_at
243 });
244 if already {
245 continue;
246 }
247 knowledge.patterns.push(p);
248 restored += 1;
249 *remaining -= 1;
250 }
251 StoreRestore {
252 store: MemoryStore::Patterns,
253 matched,
254 restored,
255 }
256}
257
258pub fn run_restore(project_root: &str, opts: &RestoreOptions) -> Result<RestoreReport, String> {
262 lean_ctx::core::policy::runtime::with_explicit_project_source_view(
263 std::path::Path::new(project_root),
264 |_| run_restore_in_view(project_root, opts),
265 )?
266}
267
268fn run_restore_in_view(project_root: &str, opts: &RestoreOptions) -> Result<RestoreReport, String> {
269 let admission = StoreAdmission::current();
270 let knowledge_admission =
271 lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
272 let cfg = ArchiveConfig::from_env();
273 let query = opts.query.clone();
274 let want = |s: MemoryStore| opts.stores.contains(&s);
275
276 let mut per_store: Vec<StoreRestore> = Vec::new();
277 let remaining = opts.limit;
278
279 let knowledge_wanted =
280 want(MemoryStore::Facts) || want(MemoryStore::History) || want(MemoryStore::Patterns);
281
282 if want(MemoryStore::Facts) {
283 lean_ctx::core::legacy_adoption::auto_adopt_facts(project_root);
285 }
286 if knowledge_wanted {
287 let (_k, results) = ProjectKnowledge::mutate_locked_with_admission(
288 project_root,
289 &knowledge_admission,
290 |knowledge| {
291 let hash = knowledge.project_hash.clone();
292 let mut local = Vec::new();
293 let mut rem = remaining;
294 if want(MemoryStore::Facts) {
295 local.push(restore_facts(
296 knowledge,
297 &cfg,
298 query.as_deref(),
299 &mut rem,
300 &knowledge_admission,
301 ));
302 }
303 if want(MemoryStore::History) {
304 local.push(restore_history(
305 knowledge,
306 &hash,
307 &cfg,
308 query.as_deref(),
309 &mut rem,
310 &knowledge_admission,
311 ));
312 }
313 if want(MemoryStore::Patterns) {
314 local.push(restore_patterns(
315 knowledge,
316 &hash,
317 &cfg,
318 query.as_deref(),
319 &mut rem,
320 &knowledge_admission,
321 ));
322 }
323 local
324 },
325 )?;
326 per_store.extend(results);
327 }
328
329 if !admission.is_current() {
330 return Err("session admission policy changed; retry".into());
331 }
332 knowledge_admission.authorize_gateway()?;
333 Ok(RestoreReport {
334 per_store,
335 query: opts.query.clone(),
336 })
337}
338
339pub fn format_restore_report(report: &RestoreReport) -> String {
341 let total = report.total_restored();
342 let scope = match &report.query {
343 Some(q) => format!(" matching \"{q}\""),
344 None => String::new(),
345 };
346
347 if total == 0 {
348 let matched: usize = report.per_store.iter().map(|s| s.matched).sum();
349 if matched == 0 {
350 return format!("No archived items{scope} to restore.");
351 }
352 return format!(
353 "Nothing restored{scope}: all {matched} matching archived item(s) are already live."
354 );
355 }
356
357 let mut out = format!("Restored {total} item(s){scope} from archive:");
358 for s in &report.per_store {
359 if s.restored > 0 {
360 out.push_str(&format!(
361 "\n {}: {} restored ({} matched)",
362 s.store.as_str(),
363 s.restored,
364 s.matched
365 ));
366 }
367 }
368 out
369}
370
371#[cfg(test)]
372mod tests {
373 use super::*;
374 use lean_ctx::core::knowledge::ProjectKnowledge;
375 use lean_ctx::core::memory_archive::archive_items;
376 use lean_ctx::core::memory_policy::MemoryPolicy;
377
378 fn with_sandbox<T>(f: impl FnOnce(String) -> T) -> T {
380 let isolated = lean_ctx::core::data_dir::isolated_data_dir();
381 let root = isolated.path().join("proj").to_string_lossy().to_string();
385 std::fs::create_dir_all(&root).unwrap();
386 f(root)
387 }
388
389 fn archive_facts_of(root: &str, facts: &[lean_ctx::core::knowledge::KnowledgeFact]) {
392 let project = std::path::Path::new(root).canonicalize().unwrap();
393 lean_ctx::core::policy::runtime::REQUEST_PROJECT
394 .sync_scope(std::cell::RefCell::new(Some(project)), || {
395 archive_items(MemoryStore::Facts, None, facts, &ArchiveConfig::default())
396 })
397 .unwrap();
398 }
399
400 fn archive_then_drop_facts(root: &str) {
403 let mut k = ProjectKnowledge::load(root).expect("knowledge");
404 archive_facts_of(root, &k.facts);
405 k.facts.clear();
406 k.save().unwrap();
407 }
408
409 #[test]
410 fn restore_facts_round_trip_is_idempotent() {
411 with_sandbox(|root| {
412 let policy = MemoryPolicy::default();
413 let mut k = ProjectKnowledge::new(&root);
414 k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
415 k.remember("db", "engine", "Postgres", "s1", 0.8, &policy);
416 k.save().unwrap();
417 archive_then_drop_facts(&root);
418
419 let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
420 let report = run_restore(&root, &opts).unwrap();
421 assert_eq!(report.total_restored(), 2);
422
423 let reloaded = ProjectKnowledge::load(&root).unwrap();
424 assert_eq!(reloaded.facts.iter().filter(|f| f.is_current()).count(), 2);
425
426 let again = run_restore(&root, &opts).unwrap();
428 assert_eq!(again.total_restored(), 0);
429 });
430 }
431
432 #[test]
433 fn restore_facts_honors_query_filter() {
434 with_sandbox(|root| {
435 let policy = MemoryPolicy::default();
436 let mut k = ProjectKnowledge::new(&root);
437 k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
438 k.remember("db", "engine", "Postgres", "s1", 0.8, &policy);
439 k.save().unwrap();
440 archive_then_drop_facts(&root);
441
442 let opts = RestoreOptions::new(Some(MemoryStore::Facts), Some("postgres".into()), 50);
443 let report = run_restore(&root, &opts).unwrap();
444 assert_eq!(report.total_restored(), 1);
445
446 let reloaded = ProjectKnowledge::load(&root).unwrap();
447 assert!(
448 reloaded
449 .facts
450 .iter()
451 .any(|f| f.key == "engine" && f.is_current())
452 );
453 assert!(
454 !reloaded
455 .facts
456 .iter()
457 .any(|f| f.key == "token" && f.is_current())
458 );
459 });
460 }
461
462 #[test]
463 fn restore_never_resurrects_a_superseded_live_key() {
464 with_sandbox(|root| {
465 let policy = MemoryPolicy::default();
466 let mut k = ProjectKnowledge::new(&root);
468 k.remember("auth", "token", "opaque session token", "s0", 0.7, &policy);
469 archive_facts_of(&root, &k.facts);
470 k.facts.clear();
472 k.remember("auth", "token", "JWT RS256", "s1", 0.9, &policy);
473 k.save().unwrap();
474
475 let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
476 let report = run_restore(&root, &opts).unwrap();
477 assert_eq!(
478 report.total_restored(),
479 0,
480 "a live key must not be shadowed by an archived older value"
481 );
482 });
483 }
484
485 #[test]
486 fn restore_patterns_round_trip_is_scoped() {
487 with_sandbox(|root| {
488 let policy = MemoryPolicy::default();
489 let mut k = ProjectKnowledge::new(&root);
490 k.add_pattern(
491 "error-handling",
492 "wrap IO in Result",
493 Vec::new(),
494 "s1",
495 &policy,
496 );
497 k.add_pattern("naming", "snake_case modules", Vec::new(), "s1", &policy);
498 archive_items(
499 MemoryStore::Patterns,
500 Some(&k.project_hash),
501 &k.patterns,
502 &ArchiveConfig::default(),
503 )
504 .unwrap();
505 k.patterns.clear();
506 k.save().unwrap();
507
508 let opts = RestoreOptions::new(Some(MemoryStore::Patterns), None, 50);
509 let report = run_restore(&root, &opts).unwrap();
510 assert_eq!(report.total_restored(), 2);
511
512 let reloaded = ProjectKnowledge::load(&root).unwrap();
513 assert_eq!(reloaded.patterns.len(), 2);
514 });
515 }
516
517 #[test]
518 fn restore_report_is_deterministic() {
519 with_sandbox(|root| {
522 let policy = MemoryPolicy::default();
523 let mut k = ProjectKnowledge::new(&root);
524 k.remember("auth", "token", "JWT", "s1", 0.9, &policy);
525 k.save().unwrap();
526 archive_then_drop_facts(&root);
527
528 let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 50);
529 let report = run_restore(&root, &opts).unwrap();
530 assert_eq!(
531 format_restore_report(&report),
532 format_restore_report(&report)
533 );
534 });
535 }
536
537 #[test]
538 fn restore_limit_caps_total_recovered() {
539 with_sandbox(|root| {
540 let policy = MemoryPolicy::default();
541 let mut k = ProjectKnowledge::new(&root);
542 for i in 0..5 {
543 k.remember(
544 "finding",
545 &format!("k{i}"),
546 &format!("value {i}"),
547 "s1",
548 0.8,
549 &policy,
550 );
551 }
552 k.save().unwrap();
553 archive_then_drop_facts(&root);
554
555 let opts = RestoreOptions::new(Some(MemoryStore::Facts), None, 2);
556 let report = run_restore(&root, &opts).unwrap();
557 assert_eq!(report.total_restored(), 2, "limit bounds the recovery");
558 });
559 }
560
561 #[test]
562 fn restore_reports_nothing_when_archive_empty() {
563 with_sandbox(|root| {
564 let opts = RestoreOptions::new(None, None, 50);
565 let report = run_restore(&root, &opts).unwrap();
566 assert_eq!(report.total_restored(), 0);
567 assert!(format_restore_report(&report).contains("No archived items"));
568 });
569 }
570}