1use chrono::{DateTime, Utc};
25use serde::{Deserialize, Serialize, de::DeserializeOwned};
26use std::path::{Path, PathBuf};
27
28const DEFAULT_MAX_ARCHIVE_FILES: usize = 16;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum MemoryStore {
38 Facts,
39 History,
40 Patterns,
41}
42
43pub const RETIRED_PROCEDURES_STORE_NOTICE: &str = "DEPRECATION: store=procedures is retired with the procedure memory; nothing was restored. Use: facts, history, patterns";
48
49impl MemoryStore {
50 pub fn as_str(self) -> &'static str {
51 match self {
52 MemoryStore::Facts => "facts",
53 MemoryStore::History => "history",
54 MemoryStore::Patterns => "patterns",
55 }
56 }
57
58 pub fn parse(s: &str) -> Option<Self> {
59 match s.trim().to_lowercase().as_str() {
60 "facts" | "fact" => Some(MemoryStore::Facts),
61 "history" | "insights" => Some(MemoryStore::History),
62 "patterns" | "pattern" => Some(MemoryStore::Patterns),
63 _ => None,
64 }
65 }
66
67 pub fn is_retired(s: &str) -> bool {
70 matches!(
71 s.trim().to_lowercase().as_str(),
72 "procedures" | "procedure" | "procs"
73 )
74 }
75
76 pub fn all() -> [MemoryStore; 3] {
78 [
79 MemoryStore::Facts,
80 MemoryStore::History,
81 MemoryStore::Patterns,
82 ]
83 }
84
85 fn subdir(self) -> Option<&'static str> {
87 match self {
88 MemoryStore::Facts => None,
89 other => Some(other.as_str()),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy)]
99pub struct ArchiveConfig {
100 pub max_files: usize,
101 pub rehydrate_reach: usize,
102}
103
104impl Default for ArchiveConfig {
105 fn default() -> Self {
106 Self {
107 max_files: DEFAULT_MAX_ARCHIVE_FILES,
108 rehydrate_reach: DEFAULT_MAX_ARCHIVE_FILES,
109 }
110 }
111}
112
113impl ArchiveConfig {
114 pub fn from_env() -> Self {
117 let mut cfg = Self::default();
118 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_MAX_FILES")
119 && let Ok(n) = v.parse::<usize>()
120 && n > 0
121 {
122 cfg.max_files = n;
123 cfg.rehydrate_reach = n;
124 }
125 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH")
126 && let Ok(n) = v.parse::<usize>()
127 && n > 0
128 {
129 cfg.rehydrate_reach = n;
130 }
131 cfg.rehydrate_reach = cfg.rehydrate_reach.min(cfg.max_files);
132 cfg
133 }
134}
135
136#[derive(Debug, Deserialize)]
140pub struct ArchiveEnvelope<T> {
141 pub archived_at: DateTime<Utc>,
142 #[serde(default)]
143 pub store: String,
144 #[serde(default)]
145 pub scope: Option<String>,
146 #[serde(default)]
150 pub project_root: Option<String>,
151 #[serde(rename = "items", alias = "facts")]
152 pub items: Vec<T>,
153}
154
155#[derive(Serialize)]
158struct ArchiveEnvelopeRef<'a, T: Serialize> {
159 archived_at: DateTime<Utc>,
160 store: &'a str,
161 #[serde(skip_serializing_if = "Option::is_none")]
162 scope: Option<&'a str>,
163 #[serde(skip_serializing_if = "Option::is_none")]
164 project_root: Option<&'a str>,
165 items: &'a [T],
166}
167
168fn archive_project() -> Option<PathBuf> {
173 match crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
174 Ok(project) => project,
175 Err(_) => std::env::current_dir().ok(),
176 }
177}
178
179fn effective_scope(store: MemoryStore, scope: Option<&str>) -> Result<Option<String>, String> {
183 let root = archive_project().ok_or("archive project unavailable")?;
184 let root = root.to_string_lossy();
185 let expected = crate::core::project_hash::hash_project_root(&root);
186 if crate::core::knowledge::protection::current(&root)?.is_some() {
187 if scope.is_some_and(|given| given != expected) {
188 return Err("archive scope does not match protected project".into());
189 }
190 Ok(Some(expected))
191 } else if store == MemoryStore::Facts {
192 if scope.is_some_and(|given| given != expected) {
193 return Err("facts archive scope does not match the request project".into());
194 }
195 Ok(Some(expected))
196 } else {
197 Ok(scope.map(str::to_owned))
198 }
199}
200
201fn archive_dir(store: MemoryStore, scope: Option<&str>) -> Result<PathBuf, String> {
202 let base = crate::core::data_dir::lean_ctx_data_dir()?
203 .join("memory")
204 .join("archive");
205 let scope = effective_scope(store, scope)?;
206 let dir = match (store.subdir(), scope.as_deref()) {
207 (None, Some(s)) => base.join("facts").join(sanitize_scope(s)),
208 (None, None) => return Err("facts archive requires a project partition".into()),
209 (Some(sub), None) => base.join(sub), (Some(sub), Some(s)) => base.join(sub).join(sanitize_scope(s)),
211 };
212 Ok(dir)
213}
214
215fn sanitize_scope(scope: &str) -> String {
218 scope
219 .chars()
220 .map(|c| {
221 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
222 c
223 } else {
224 '_'
225 }
226 })
227 .collect()
228}
229
230pub fn archive_items<T: Serialize>(
235 store: MemoryStore,
236 scope: Option<&str>,
237 items: &[T],
238 cfg: &ArchiveConfig,
239) -> Result<Option<PathBuf>, String> {
240 archive_items_at(store, scope, items, cfg, Utc::now())
241}
242
243fn archive_items_at<T: Serialize>(
244 store: MemoryStore,
245 scope: Option<&str>,
246 items: &[T],
247 cfg: &ArchiveConfig,
248 now: DateTime<Utc>,
249) -> Result<Option<PathBuf>, String> {
250 if knowledge_store(store) && !items.is_empty() {
251 let root = archive_project().ok_or("archive project unavailable")?;
252 return crate::core::policy::runtime::with_explicit_project_source_view(
253 &root,
254 |authority| {
255 let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
256 &root.to_string_lossy(),
257 )?;
258 archive_items_inner(
259 store,
260 scope,
261 items,
262 cfg,
263 Some((admission.gateway_snapshot(), authority)),
264 Some((&admission, authority)),
265 now,
266 )
267 },
268 )?;
269 }
270 archive_items_inner(store, scope, items, cfg, None, None, now)
271}
272
273fn knowledge_store(store: MemoryStore) -> bool {
274 matches!(
275 store,
276 MemoryStore::Facts | MemoryStore::History | MemoryStore::Patterns
277 )
278}
279
280fn admits_knowledge_archive_item(
281 store: MemoryStore,
282 admission: &crate::core::knowledge::protection::KnowledgeAdmission,
283 value: &serde_json::Value,
284) -> bool {
285 match store {
286 MemoryStore::Facts => admission.admits_archive_fact_value(value),
287 MemoryStore::History => admission.admits_archive_history_value(value),
288 MemoryStore::Patterns => admission.admits_archive_pattern_value(value),
289 }
290}
291
292fn admits_typed_knowledge_archive_item(
293 store: MemoryStore,
294 admission: &crate::core::knowledge::protection::KnowledgeAdmission,
295 value: &serde_json::Value,
296) -> bool {
297 match store {
298 MemoryStore::Facts => admission.admits_typed_archive_fact_value(value),
299 MemoryStore::History => admission.admits_typed_archive_history_value(value),
300 MemoryStore::Patterns => admission.admits_typed_archive_pattern_value(value),
301 }
302}
303
304fn archive_store(value: &serde_json::Value) -> Option<MemoryStore> {
305 match value.get("store") {
306 Some(serde_json::Value::String(store)) => MemoryStore::parse(store),
307 None if value.get("facts").is_some() => Some(MemoryStore::Facts),
308 Some(_) | None => None,
309 }
310}
311
312fn archive_items_field(value: &serde_json::Value, store: MemoryStore) -> Option<&'static str> {
313 if value.get("items").is_some() {
314 Some("items")
315 } else if store == MemoryStore::Facts && value.get("facts").is_some() {
316 Some("facts")
317 } else {
318 None
319 }
320}
321
322fn archive_items_inner<T: Serialize>(
323 store: MemoryStore,
324 scope: Option<&str>,
325 items: &[T],
326 cfg: &ArchiveConfig,
327 authorization: Option<(
328 &crate::core::context_admission::stores::StoreAdmission,
329 &crate::core::policy::runtime::PublicationAuthority,
330 )>,
331 knowledge_authorization: Option<(
332 &crate::core::knowledge::protection::KnowledgeAdmission,
333 &crate::core::policy::runtime::PublicationAuthority,
334 )>,
335 now: DateTime<Utc>,
336) -> Result<Option<PathBuf>, String> {
337 if items.is_empty() {
338 return Ok(None);
339 }
340 if knowledge_store(store) && knowledge_authorization.is_none() {
341 return Err("knowledge archive requires an admitted publication scope".into());
342 }
343 let scope = effective_scope(store, scope)?;
344 let scope = scope.as_deref();
345 let dir = archive_dir(store, scope)?;
346 std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
347 if !ordered_archive_files(&dir).1 {
348 return Err("archive chronology unavailable".into());
349 }
350
351 let suffix = now.timestamp_subsec_nanos();
354 let filename = format!("archive-{}-{suffix:09}.json", now.format("%Y%m%d-%H%M%S"));
355 let path = dir.join(filename);
356
357 let project_root =
358 crate::core::policy::diagnostics::project().map(|root| root.to_string_lossy().into_owned());
359 let envelope = ArchiveEnvelopeRef {
360 archived_at: now,
361 store: store.as_str(),
362 scope,
363 project_root: project_root.as_deref(),
364 items,
365 };
366 let original = serde_json::to_value(&envelope).map_err(|_| "archive cannot be inspected")?;
367 if let Some((admission, _)) = knowledge_authorization {
368 let records = original["items"]
369 .as_array()
370 .ok_or("archive items are not inspectable")?;
371 if !records
372 .iter()
373 .all(|record| admits_knowledge_archive_item(store, admission, record))
374 {
375 return Err("knowledge archive withheld by current policy".into());
376 }
377 }
378 let typed_items = knowledge_authorization.map(|(admission, _)| (store, admission));
379 let safe = inspect_archive(&original, typed_items)?;
380 let json = serde_json::to_string_pretty(&safe).map_err(|_| "archive serialization failed")?;
381 if inspect_archive(&safe, typed_items)? != safe {
382 return Err("archive rules changed before publication".into());
383 }
384 use std::io::Write;
385 let mut file = tempfile::NamedTempFile::new_in(&dir).map_err(|_| "archive staging failed")?;
386 file.write_all(json.as_bytes())
387 .map_err(|_| "archive write failed")?;
388 if let Some((admission, authority)) = authorization {
389 admission.authorize_publication(authority)?;
390 }
391 if let Some((admission, authority)) = knowledge_authorization {
392 admission.authorize_publication(authority)?;
393 }
394 file.persist_noclobber(&path)
396 .map_err(|_| "archive publication failed")?;
397
398 let (archives, ordered) = ordered_archive_files(&dir);
399 if !ordered {
400 return Err("archive chronology changed before retention".into());
401 }
402 if archives.len() > cfg.max_files {
403 for old in &archives[..archives.len() - cfg.max_files] {
404 let _ = std::fs::remove_file(old);
405 }
406 }
407 Ok(Some(path))
408}
409
410pub fn legacy_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
414 let Ok(base) = crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive"))
415 else {
416 return Vec::new();
417 };
418 let Ok(entries) = std::fs::read_dir(&base) else {
419 return Vec::new();
420 };
421 let mut archives: Vec<_> = entries
422 .flatten()
423 .map(|entry| entry.path())
424 .filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "json"))
425 .filter_map(|path| {
426 let text = crate::core::policy::diagnostics::Target::read_file(&path)?;
427 let value = serde_json::from_str::<serde_json::Value>(&text).ok()?;
428 Some((path, value))
429 })
430 .collect();
431 archives.sort_by(|a, b| a.0.cmp(&b.0));
432 archives
433}
434
435pub fn orphaned_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
439 let Ok(base) =
440 crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive/facts"))
441 else {
442 return Vec::new();
443 };
444 let Ok(partitions) = std::fs::read_dir(&base) else {
445 return Vec::new();
446 };
447 let mut current_of: std::collections::HashMap<String, String> =
448 std::collections::HashMap::new();
449 let mut archives = Vec::new();
450 for partition in partitions.flatten() {
451 let name = partition.file_name().to_string_lossy().into_owned();
452 let Ok(files) = std::fs::read_dir(partition.path()) else {
453 continue;
454 };
455 for path in files.flatten().map(|file| file.path()) {
456 if path.extension().is_none_or(|ext| ext != "json") {
457 continue;
458 }
459 let Some(value) = crate::core::policy::diagnostics::Target::read_file(&path)
460 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
461 else {
462 continue;
463 };
464 let Some(root) = value["project_root"].as_str() else {
465 continue;
466 };
467 let current = current_of
468 .entry(root.to_string())
469 .or_insert_with(|| crate::core::project_hash::hash_project_root(root));
470 if *current != name {
471 archives.push((path, value));
472 }
473 }
474 }
475 archives.sort_by(|a, b| a.0.cmp(&b.0));
476 archives
477}
478
479pub(crate) fn drifted_facts_archives(project_root: &Path) -> Vec<(PathBuf, serde_json::Value)> {
481 let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
482 let wanted = canonical(project_root);
483 orphaned_facts_archives()
484 .into_iter()
485 .filter(|(_, value)| {
486 value["project_root"]
487 .as_str()
488 .is_some_and(|root| canonical(Path::new(root)) == wanted)
489 })
490 .collect()
491}
492
493pub fn adopt_legacy_facts(
500 project_root: &Path,
501 facts: &[serde_json::Value],
502) -> Result<Vec<bool>, String> {
503 let root = project_root;
504 crate::core::policy::runtime::with_explicit_project_source_view(root, |authority| {
505 let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
506 &root.to_string_lossy(),
507 )?;
508 let admitted: Vec<bool> = facts
509 .iter()
510 .map(|fact| admits_knowledge_archive_item(MemoryStore::Facts, &admission, fact))
511 .collect();
512 let archived = archived_fact_identities()?;
513 let kept: Vec<&serde_json::Value> = facts
514 .iter()
515 .zip(&admitted)
516 .filter_map(|(fact, ok)| {
517 (*ok && !archived.contains(&fact_identity(fact))).then_some(fact)
518 })
519 .collect();
520 archive_items_inner(
521 MemoryStore::Facts,
522 None,
523 &kept,
524 &ArchiveConfig::from_env(),
525 Some((admission.gateway_snapshot(), authority)),
526 Some((&admission, authority)),
527 Utc::now(),
528 )?;
529 Ok(admitted)
530 })?
531}
532
533fn fact_identity(fact: &serde_json::Value) -> [String; 3] {
537 ["category", "key", "value"].map(|field| fact[field].as_str().unwrap_or_default().to_owned())
538}
539
540fn archived_fact_identities() -> Result<std::collections::HashSet<[String; 3]>, String> {
542 let dir = archive_dir(MemoryStore::Facts, None)?;
543 Ok(ordered_archive_files(&dir)
544 .0
545 .iter()
546 .filter_map(|path| crate::core::policy::diagnostics::Target::read_file(path))
547 .filter_map(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
548 .flat_map(|envelope| match envelope.get("items") {
549 Some(serde_json::Value::Array(items)) => items.clone(),
550 _ => Vec::new(),
551 })
552 .map(|fact| fact_identity(&fact))
553 .collect())
554}
555
556pub fn list_archives(store: MemoryStore, scope: Option<&str>) -> Vec<PathBuf> {
559 let Ok(dir) = archive_dir(store, scope) else {
560 return Vec::new();
561 };
562 ordered_archive_files(&dir).0
563}
564
565fn ordered_archive_files(dir: &Path) -> (Vec<PathBuf>, bool) {
566 #[derive(Deserialize)]
567 struct ArchiveTimestamp {
568 archived_at: DateTime<Utc>,
569 }
570
571 let Ok(entries) = std::fs::read_dir(dir) else {
572 return (Vec::new(), false);
573 };
574 let mut complete = true;
575 let mut archives = Vec::new();
576 for entry in entries {
577 let Ok(entry) = entry else {
578 complete = false;
579 continue;
580 };
581 let path = entry.path();
582 if path.extension().is_none_or(|ext| ext != "json") {
583 continue;
584 }
585 let Ok(kind) = entry.file_type() else {
586 complete = false;
587 continue;
588 };
589 if !kind.is_file() {
590 continue;
591 }
592 let timestamp = crate::core::policy::diagnostics::Target::read_file(&path)
596 .and_then(|text| serde_json::from_str::<ArchiveTimestamp>(&text).ok())
597 .map(|metadata| metadata.archived_at);
598 complete &= timestamp.is_some();
599 archives.push((timestamp, path));
600 }
601 archives.sort_unstable();
604 (
605 archives.into_iter().map(|(_, path)| path).collect(),
606 complete,
607 )
608}
609
610pub fn reachable_archives(
614 store: MemoryStore,
615 scope: Option<&str>,
616 cfg: &ArchiveConfig,
617) -> Vec<PathBuf> {
618 let mut archives = list_archives(store, scope);
619 if archives.len() > cfg.rehydrate_reach {
620 archives = archives[archives.len() - cfg.rehydrate_reach..].to_vec();
621 }
622 archives
623}
624
625pub fn restore_items<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
627 restore_items_inner(path, None)
628}
629
630pub fn restore_knowledge_items<T: DeserializeOwned>(
631 path: &Path,
632 admission: &crate::core::knowledge::protection::KnowledgeAdmission,
633) -> Result<Vec<T>, String> {
634 restore_items_inner(path, Some(admission))
635}
636
637fn restore_items_inner<T: DeserializeOwned>(
638 path: &Path,
639 supplied_admission: Option<&crate::core::knowledge::protection::KnowledgeAdmission>,
640) -> Result<Vec<T>, String> {
641 let data = crate::core::policy::diagnostics::Target::read_file(path)
642 .ok_or("archive is unavailable or uninspectable")?;
643 let original: serde_json::Value = serde_json::from_str(&data).map_err(|_| "invalid archive")?;
644 let root = archive_project().ok_or("archive project unavailable")?;
645 let canonical = |p: &Path| crate::core::pathutil::safe_canonicalize_bounded(p, 2000);
646 let protected = crate::core::knowledge::protection::current(&root.to_string_lossy())?.is_some();
647 if !protected && archive_store(&original) == Some(MemoryStore::Facts) {
648 let expected = archive_dir(MemoryStore::Facts, None)?;
651 if path.parent().map(canonical) != Some(canonical(&expected)) {
652 return Err("archive belongs to a different storage scope".into());
653 }
654 } else if protected {
655 let kind = match original["store"].as_str() {
656 Some("facts") => MemoryStore::Facts,
657 Some("history") => MemoryStore::History,
658 Some("patterns") => MemoryStore::Patterns,
659 _ => return Err("archive store identity unavailable".into()),
660 };
661 let scope = original["scope"]
662 .as_str()
663 .ok_or("archive has no protected scope")?;
664 let expected = archive_dir(kind, Some(scope))?;
665 if path.parent().map(canonical) != Some(canonical(&expected)) {
666 return Err("archive belongs to a different storage scope".into());
667 }
668 }
669 let store = archive_store(&original);
670 let mut candidate = original.clone();
671 let captured_admission = if store.is_some_and(knowledge_store) && supplied_admission.is_none() {
672 Some(
673 crate::core::knowledge::protection::KnowledgeAdmission::capture(
674 &root.to_string_lossy(),
675 )?,
676 )
677 } else {
678 None
679 };
680 let mut typed_items = None;
681 if let Some(store) = store.filter(|store| knowledge_store(*store)) {
682 let admission = supplied_admission
683 .or(captured_admission.as_ref())
684 .ok_or("archive admission unavailable")?;
685 let collection =
686 archive_items_field(&candidate, store).ok_or("archive items are unavailable")?;
687 let records = candidate[collection]
688 .as_array_mut()
689 .ok_or("archive items are invalid")?;
690 records.retain(|record| admits_knowledge_archive_item(store, admission, record));
691 admission.authorize_gateway()?;
692 typed_items = Some((store, admission));
693 }
694 let safe = inspect_archive(&candidate, typed_items)?;
695 if let Some((_, admission)) = typed_items {
696 admission.authorize_gateway()?;
697 }
698 let envelope: ArchiveEnvelope<T> = serde_json::from_value(safe)
699 .map_err(|_| "archive policy result is structurally invalid")?;
700 Ok(envelope.items)
701}
702
703fn inspect_archive(
704 original: &serde_json::Value,
705 typed_items: Option<(
706 MemoryStore,
707 &crate::core::knowledge::protection::KnowledgeAdmission,
708 )>,
709) -> Result<serde_json::Value, String> {
710 let root = archive_project().ok_or("archive project unavailable")?;
711 let active = crate::core::knowledge::protection::current(&root.to_string_lossy())?;
712 let mut inspection = original.clone();
713 let mut preserved_items = Vec::new();
714 if let Some((store, admission)) = typed_items {
715 if let Some(collection) = archive_items_field(&inspection, store)
716 && let Some(items) = inspection
717 .get_mut(collection)
718 .and_then(serde_json::Value::as_array_mut)
719 {
720 for (index, item) in items.iter_mut().enumerate() {
721 if admits_typed_knowledge_archive_item(store, admission, item) {
722 preserved_items.push((index, item.clone()));
723 *item = serde_json::Value::Null;
724 }
725 }
726 }
727 }
728 let mut safe = crate::core::policy::diagnostics::inspect(&inspection, active.as_deref())
729 .ok_or("archive withheld by current policy")?;
730 if let Some((store, _)) = typed_items {
731 if let Some(collection) = archive_items_field(&safe, store)
732 && let Some(items) = safe
733 .get_mut(collection)
734 .and_then(serde_json::Value::as_array_mut)
735 {
736 for (index, item) in preserved_items {
737 let slot = items
738 .get_mut(index)
739 .ok_or("archive item projection changed shape")?;
740 *slot = item;
741 }
742 }
743 }
744 for field in ["store", "scope", "archived_at"] {
745 if safe[field] != original[field] {
746 return Err("archive identity cannot be rewritten".into());
747 }
748 }
749 let before_items = original.get("items").or_else(|| original.get("facts"));
750 let after_items = safe.get("items").or_else(|| safe.get("facts"));
751 if let (Some(before), Some(after)) = (
752 before_items.and_then(serde_json::Value::as_array),
753 after_items.and_then(serde_json::Value::as_array),
754 ) {
755 for (a, b) in before.iter().zip(after) {
756 for field in [
757 "id",
758 "key",
759 "category",
760 "source_session",
761 "supersedes",
762 "from_sessions",
763 "pattern_type",
764 ] {
765 if a.get(field) != b.get(field) {
766 return Err("archived item identity cannot be rewritten".into());
767 }
768 }
769 }
770 }
771 Ok(safe)
772}
773
774#[cfg(test)]
775pub mod tests {
776 use super::*;
777
778 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
779 let _lock = crate::core::data_dir::test_env_lock();
780 let dir = std::env::temp_dir().join(format!(
781 "lctx-archive-{}-{}",
782 std::process::id(),
783 Utc::now().timestamp_nanos_opt().unwrap_or(0)
784 ));
785 let _ = std::fs::create_dir_all(&dir);
786 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
787 let out = f();
788 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
789 let _ = std::fs::remove_dir_all(&dir);
790 out
791 }
792
793 #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
794 struct Item {
795 id: u32,
796 label: String,
797 }
798
799 fn items(n: u32) -> Vec<Item> {
800 (0..n)
801 .map(|i| Item {
802 id: i,
803 label: format!("item-{i}"),
804 })
805 .collect()
806 }
807
808 #[test]
809 fn round_trip_each_store() {
810 with_temp_data_dir(|| {
811 let cfg = ArchiveConfig::default();
812 for store in MemoryStore::all() {
813 let scope = if store == MemoryStore::Facts {
814 None
815 } else {
816 Some("projhash")
817 };
818 let path = archive_items(store, scope, &items(3), &cfg)
819 .unwrap()
820 .expect("wrote an archive");
821 let restored: Vec<Item> = restore_items(&path).unwrap();
822 assert_eq!(restored, items(3), "round-trip for {}", store.as_str());
823 }
824 });
825 }
826
827 #[test]
830 fn a_request_without_a_project_has_no_archive() {
831 with_temp_data_dir(|| {
832 let archived = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
833 std::cell::RefCell::new(None),
834 || {
835 archive_items(
836 MemoryStore::Facts,
837 None,
838 &items(1),
839 &ArchiveConfig::default(),
840 )
841 },
842 );
843 assert!(archived.is_err(), "{archived:?}");
844 });
845 }
846
847 #[test]
848 fn empty_archive_is_noop() {
849 with_temp_data_dir(|| {
850 let cfg = ArchiveConfig::default();
851 let res =
852 archive_items(MemoryStore::History, Some("p"), &Vec::<Item>::new(), &cfg).unwrap();
853 assert!(res.is_none());
854 {
855 let found = &list_archives(MemoryStore::History, Some("p"));
856 assert!(found.is_empty(), "{found:?}");
857 }
858 });
859 }
860
861 fn in_project<T>(root: &Path, f: impl FnOnce() -> T) -> T {
862 crate::core::policy::runtime::REQUEST_PROJECT
863 .sync_scope(std::cell::RefCell::new(Some(root.to_path_buf())), f)
864 }
865
866 #[test]
870 fn facts_archives_are_partitioned_by_request_project() {
871 with_temp_data_dir(|| {
872 let cfg = ArchiveConfig::default();
873 let a = tempfile::tempdir().expect("project a");
874 let b = tempfile::tempdir().expect("project b");
875 let archived = in_project(a.path(), || {
876 archive_items(MemoryStore::Facts, None, &items(2), &cfg)
877 .unwrap()
878 .expect("wrote an archive")
879 });
880 let base = crate::core::data_dir::lean_ctx_data_dir()
881 .unwrap()
882 .join("memory")
883 .join("archive");
884 let legacy = base.join("archive-20260101-000000-000000.json");
885 std::fs::write(
886 &legacy,
887 r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#,
888 )
889 .unwrap();
890 in_project(b.path(), || {
891 assert!(list_archives(MemoryStore::Facts, None).is_empty());
892 assert!(restore_items::<Item>(&archived).is_err());
893 assert!(restore_items::<Item>(&legacy).is_err());
894 });
895 in_project(a.path(), || {
896 assert_eq!(
897 list_archives(MemoryStore::Facts, None),
898 vec![archived.clone()]
899 );
900 assert_eq!(restore_items::<Item>(&archived).unwrap(), items(2));
901 assert!(restore_items::<Item>(&legacy).is_err());
902 });
903 });
904 }
905
906 #[test]
907 fn legacy_facts_field_alias_still_deserializes() {
908 with_temp_data_dir(|| {
909 let dir = archive_dir(MemoryStore::Facts, None).unwrap();
910 std::fs::create_dir_all(&dir).unwrap();
911 let legacy =
913 r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#;
914 let path = dir.join("archive-20260101-000000-000000.json");
915 std::fs::write(&path, legacy).unwrap();
916 let restored: Vec<Item> = restore_items(&path).unwrap();
917 assert_eq!(
918 restored,
919 vec![Item {
920 id: 7,
921 label: "old".into()
922 }]
923 );
924 });
925 }
926
927 #[test]
928 fn prune_keeps_newest_max_files() {
929 with_temp_data_dir(|| {
930 let cfg = ArchiveConfig {
931 max_files: 3,
932 rehydrate_reach: 3,
933 };
934 for (id, nanos) in [
937 1_100_000, 1_900_000, 2_100_000, 2_900_000, 3_100_000, 3_900_000,
938 ]
939 .into_iter()
940 .enumerate()
941 {
942 let batch = [Item {
943 id: u32::try_from(id).unwrap(),
944 label: format!("batch-{id}"),
945 }];
946 archive_items_at(
947 MemoryStore::Patterns,
948 Some("p"),
949 &batch,
950 &cfg,
951 DateTime::from_timestamp(1_700_000_000, nanos).unwrap(),
952 )
953 .unwrap();
954 }
955 let archives = list_archives(MemoryStore::Patterns, Some("p"));
956 let retained: Vec<u32> = archives
957 .iter()
958 .map(|path| restore_items::<Item>(path).unwrap()[0].id)
959 .collect();
960 assert_eq!(retained, [3, 4, 5]);
961 });
962 }
963
964 #[test]
965 fn retention_orders_legacy_names_by_the_recorded_timestamp() {
966 with_temp_data_dir(|| {
967 let store = MemoryStore::Patterns;
968 let dir = archive_dir(store, Some("p")).unwrap();
969 std::fs::create_dir_all(&dir).unwrap();
970 let old_time = DateTime::from_timestamp(1_700_000_000, 1_900_000).unwrap();
971 let legacy = dir.join(format!(
972 "archive-{}-900000.json",
973 old_time.format("%Y%m%d-%H%M%S")
974 ));
975 std::fs::write(
976 &legacy,
977 serde_json::to_vec(&ArchiveEnvelopeRef {
978 archived_at: old_time,
979 store: store.as_str(),
980 scope: Some("p"),
981 project_root: None,
982 items: &items(1),
983 })
984 .unwrap(),
985 )
986 .unwrap();
987 let cfg = ArchiveConfig {
988 max_files: 1,
989 rehydrate_reach: 1,
990 };
991 let newest = archive_items_at(
992 store,
993 Some("p"),
994 &items(2),
995 &cfg,
996 DateTime::from_timestamp(1_700_000_000, 2_100_000).unwrap(),
997 )
998 .unwrap()
999 .unwrap();
1000 assert!(!legacy.exists());
1001 assert_eq!(
1002 list_archives(store, Some("p")).as_slice(),
1003 std::slice::from_ref(&newest)
1004 );
1005 assert_eq!(restore_items::<Item>(&newest).unwrap(), items(2));
1006 });
1007 }
1008
1009 #[test]
1010 fn unreadable_archive_chronology_blocks_writes_without_pruning() {
1011 with_temp_data_dir(|| {
1012 let store = MemoryStore::Patterns;
1013 let dir = archive_dir(store, Some("p")).unwrap();
1014 std::fs::create_dir_all(&dir).unwrap();
1015 let legacy = dir.join("archive-20231114-221320-900000.json");
1016 std::fs::write(&legacy, "not json").unwrap();
1017 let cfg = ArchiveConfig {
1018 max_files: 1,
1019 rehydrate_reach: 1,
1020 };
1021 let error = archive_items(store, Some("p"), &items(1), &cfg).unwrap_err();
1022 assert_eq!(error, "archive chronology unavailable");
1023 assert_eq!(
1024 list_archives(store, Some("p")).as_slice(),
1025 std::slice::from_ref(&legacy)
1026 );
1027 assert_eq!(std::fs::read_to_string(legacy).unwrap(), "not json");
1028 });
1029 }
1030
1031 #[test]
1032 fn reachable_is_bounded_and_aligns_with_retention_by_default() {
1033 let cfg = ArchiveConfig::default();
1034 assert_eq!(cfg.rehydrate_reach, cfg.max_files);
1035 }
1036
1037 #[test]
1038 fn from_env_reach_clamped_to_max() {
1039 let _lock = crate::core::data_dir::test_env_lock();
1040 crate::test_env::set_var("LEAN_CTX_ARCHIVE_MAX_FILES", "5");
1041 crate::test_env::set_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH", "99");
1042 let cfg = ArchiveConfig::from_env();
1043 assert_eq!(cfg.max_files, 5);
1044 assert_eq!(cfg.rehydrate_reach, 5, "reach clamped to max_files");
1045 crate::test_env::remove_var("LEAN_CTX_ARCHIVE_MAX_FILES");
1046 crate::test_env::remove_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH");
1047 }
1048
1049 #[test]
1050 fn store_parse_round_trips() {
1051 for store in MemoryStore::all() {
1052 assert_eq!(MemoryStore::parse(store.as_str()), Some(store));
1053 }
1054 assert_eq!(MemoryStore::parse("nope"), None);
1055 assert_eq!(MemoryStore::parse("procedures"), None);
1057 assert!(MemoryStore::is_retired(" Procedures "));
1058 assert!(!MemoryStore::is_retired("patterns"));
1059 }
1060
1061 #[test]
1062 fn unknown_explicit_store_does_not_fall_back_to_legacy_facts() {
1063 let invalid = serde_json::json!({"store":"unknown","facts":[]});
1064 assert_eq!(archive_store(&invalid), None);
1065
1066 let legacy = serde_json::json!({"facts":[]});
1067 assert_eq!(archive_store(&legacy), Some(MemoryStore::Facts));
1068 }
1069}