use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize, de::DeserializeOwned};
use std::path::{Path, PathBuf};
const DEFAULT_MAX_ARCHIVE_FILES: usize = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemoryStore {
Facts,
History,
Patterns,
}
pub const RETIRED_PROCEDURES_STORE_NOTICE: &str = "DEPRECATION: store=procedures is retired with the procedure memory; nothing was restored. Use: facts, history, patterns";
impl MemoryStore {
pub fn as_str(self) -> &'static str {
match self {
MemoryStore::Facts => "facts",
MemoryStore::History => "history",
MemoryStore::Patterns => "patterns",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"facts" | "fact" => Some(MemoryStore::Facts),
"history" | "insights" => Some(MemoryStore::History),
"patterns" | "pattern" => Some(MemoryStore::Patterns),
_ => None,
}
}
pub fn is_retired(s: &str) -> bool {
matches!(
s.trim().to_lowercase().as_str(),
"procedures" | "procedure" | "procs"
)
}
pub fn all() -> [MemoryStore; 3] {
[
MemoryStore::Facts,
MemoryStore::History,
MemoryStore::Patterns,
]
}
fn subdir(self) -> Option<&'static str> {
match self {
MemoryStore::Facts => None,
other => Some(other.as_str()),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ArchiveConfig {
pub max_files: usize,
pub rehydrate_reach: usize,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
max_files: DEFAULT_MAX_ARCHIVE_FILES,
rehydrate_reach: DEFAULT_MAX_ARCHIVE_FILES,
}
}
}
impl ArchiveConfig {
pub fn from_env() -> Self {
let mut cfg = Self::default();
if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_MAX_FILES")
&& let Ok(n) = v.parse::<usize>()
&& n > 0
{
cfg.max_files = n;
cfg.rehydrate_reach = n;
}
if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH")
&& let Ok(n) = v.parse::<usize>()
&& n > 0
{
cfg.rehydrate_reach = n;
}
cfg.rehydrate_reach = cfg.rehydrate_reach.min(cfg.max_files);
cfg
}
}
#[derive(Debug, Deserialize)]
pub struct ArchiveEnvelope<T> {
pub archived_at: DateTime<Utc>,
#[serde(default)]
pub store: String,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub project_root: Option<String>,
#[serde(rename = "items", alias = "facts")]
pub items: Vec<T>,
}
#[derive(Serialize)]
struct ArchiveEnvelopeRef<'a, T: Serialize> {
archived_at: DateTime<Utc>,
store: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
project_root: Option<&'a str>,
items: &'a [T],
}
fn archive_project() -> Option<PathBuf> {
match crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
Ok(project) => project,
Err(_) => std::env::current_dir().ok(),
}
}
fn effective_scope(store: MemoryStore, scope: Option<&str>) -> Result<Option<String>, String> {
let root = archive_project().ok_or("archive project unavailable")?;
let root = root.to_string_lossy();
let expected = crate::core::project_hash::hash_project_root(&root);
if crate::core::knowledge::protection::current(&root)?.is_some() {
if scope.is_some_and(|given| given != expected) {
return Err("archive scope does not match protected project".into());
}
Ok(Some(expected))
} else if store == MemoryStore::Facts {
if scope.is_some_and(|given| given != expected) {
return Err("facts archive scope does not match the request project".into());
}
Ok(Some(expected))
} else {
Ok(scope.map(str::to_owned))
}
}
fn archive_dir(store: MemoryStore, scope: Option<&str>) -> Result<PathBuf, String> {
let base = crate::core::data_dir::lean_ctx_data_dir()?
.join("memory")
.join("archive");
let scope = effective_scope(store, scope)?;
let dir = match (store.subdir(), scope.as_deref()) {
(None, Some(s)) => base.join("facts").join(sanitize_scope(s)),
(None, None) => return Err("facts archive requires a project partition".into()),
(Some(sub), None) => base.join(sub), (Some(sub), Some(s)) => base.join(sub).join(sanitize_scope(s)),
};
Ok(dir)
}
fn sanitize_scope(scope: &str) -> String {
scope
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub fn archive_items<T: Serialize>(
store: MemoryStore,
scope: Option<&str>,
items: &[T],
cfg: &ArchiveConfig,
) -> Result<Option<PathBuf>, String> {
archive_items_at(store, scope, items, cfg, Utc::now())
}
fn archive_items_at<T: Serialize>(
store: MemoryStore,
scope: Option<&str>,
items: &[T],
cfg: &ArchiveConfig,
now: DateTime<Utc>,
) -> Result<Option<PathBuf>, String> {
if knowledge_store(store) && !items.is_empty() {
let root = archive_project().ok_or("archive project unavailable")?;
return crate::core::policy::runtime::with_explicit_project_source_view(
&root,
|authority| {
let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
&root.to_string_lossy(),
)?;
archive_items_inner(
store,
scope,
items,
cfg,
Some((admission.gateway_snapshot(), authority)),
Some((&admission, authority)),
now,
)
},
)?;
}
archive_items_inner(store, scope, items, cfg, None, None, now)
}
fn knowledge_store(store: MemoryStore) -> bool {
matches!(
store,
MemoryStore::Facts | MemoryStore::History | MemoryStore::Patterns
)
}
fn admits_knowledge_archive_item(
store: MemoryStore,
admission: &crate::core::knowledge::protection::KnowledgeAdmission,
value: &serde_json::Value,
) -> bool {
match store {
MemoryStore::Facts => admission.admits_archive_fact_value(value),
MemoryStore::History => admission.admits_archive_history_value(value),
MemoryStore::Patterns => admission.admits_archive_pattern_value(value),
}
}
fn admits_typed_knowledge_archive_item(
store: MemoryStore,
admission: &crate::core::knowledge::protection::KnowledgeAdmission,
value: &serde_json::Value,
) -> bool {
match store {
MemoryStore::Facts => admission.admits_typed_archive_fact_value(value),
MemoryStore::History => admission.admits_typed_archive_history_value(value),
MemoryStore::Patterns => admission.admits_typed_archive_pattern_value(value),
}
}
fn archive_store(value: &serde_json::Value) -> Option<MemoryStore> {
match value.get("store") {
Some(serde_json::Value::String(store)) => MemoryStore::parse(store),
None if value.get("facts").is_some() => Some(MemoryStore::Facts),
Some(_) | None => None,
}
}
fn archive_items_field(value: &serde_json::Value, store: MemoryStore) -> Option<&'static str> {
if value.get("items").is_some() {
Some("items")
} else if store == MemoryStore::Facts && value.get("facts").is_some() {
Some("facts")
} else {
None
}
}
fn archive_items_inner<T: Serialize>(
store: MemoryStore,
scope: Option<&str>,
items: &[T],
cfg: &ArchiveConfig,
authorization: Option<(
&crate::core::context_admission::stores::StoreAdmission,
&crate::core::policy::runtime::PublicationAuthority,
)>,
knowledge_authorization: Option<(
&crate::core::knowledge::protection::KnowledgeAdmission,
&crate::core::policy::runtime::PublicationAuthority,
)>,
now: DateTime<Utc>,
) -> Result<Option<PathBuf>, String> {
if items.is_empty() {
return Ok(None);
}
if knowledge_store(store) && knowledge_authorization.is_none() {
return Err("knowledge archive requires an admitted publication scope".into());
}
let scope = effective_scope(store, scope)?;
let scope = scope.as_deref();
let dir = archive_dir(store, scope)?;
std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
if !ordered_archive_files(&dir).1 {
return Err("archive chronology unavailable".into());
}
let suffix = now.timestamp_subsec_nanos();
let filename = format!("archive-{}-{suffix:09}.json", now.format("%Y%m%d-%H%M%S"));
let path = dir.join(filename);
let project_root =
crate::core::policy::diagnostics::project().map(|root| root.to_string_lossy().into_owned());
let envelope = ArchiveEnvelopeRef {
archived_at: now,
store: store.as_str(),
scope,
project_root: project_root.as_deref(),
items,
};
let original = serde_json::to_value(&envelope).map_err(|_| "archive cannot be inspected")?;
if let Some((admission, _)) = knowledge_authorization {
let records = original["items"]
.as_array()
.ok_or("archive items are not inspectable")?;
if !records
.iter()
.all(|record| admits_knowledge_archive_item(store, admission, record))
{
return Err("knowledge archive withheld by current policy".into());
}
}
let typed_items = knowledge_authorization.map(|(admission, _)| (store, admission));
let safe = inspect_archive(&original, typed_items)?;
let json = serde_json::to_string_pretty(&safe).map_err(|_| "archive serialization failed")?;
if inspect_archive(&safe, typed_items)? != safe {
return Err("archive rules changed before publication".into());
}
use std::io::Write;
let mut file = tempfile::NamedTempFile::new_in(&dir).map_err(|_| "archive staging failed")?;
file.write_all(json.as_bytes())
.map_err(|_| "archive write failed")?;
if let Some((admission, authority)) = authorization {
admission.authorize_publication(authority)?;
}
if let Some((admission, authority)) = knowledge_authorization {
admission.authorize_publication(authority)?;
}
file.persist_noclobber(&path)
.map_err(|_| "archive publication failed")?;
let (archives, ordered) = ordered_archive_files(&dir);
if !ordered {
return Err("archive chronology changed before retention".into());
}
if archives.len() > cfg.max_files {
for old in &archives[..archives.len() - cfg.max_files] {
let _ = std::fs::remove_file(old);
}
}
Ok(Some(path))
}
pub fn legacy_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
let Ok(base) = crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive"))
else {
return Vec::new();
};
let Ok(entries) = std::fs::read_dir(&base) else {
return Vec::new();
};
let mut archives: Vec<_> = entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_file() && path.extension().is_some_and(|ext| ext == "json"))
.filter_map(|path| {
let text = crate::core::policy::diagnostics::Target::read_file(&path)?;
let value = serde_json::from_str::<serde_json::Value>(&text).ok()?;
Some((path, value))
})
.collect();
archives.sort_by(|a, b| a.0.cmp(&b.0));
archives
}
pub fn orphaned_facts_archives() -> Vec<(PathBuf, serde_json::Value)> {
let Ok(base) =
crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("memory/archive/facts"))
else {
return Vec::new();
};
let Ok(partitions) = std::fs::read_dir(&base) else {
return Vec::new();
};
let mut current_of: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut archives = Vec::new();
for partition in partitions.flatten() {
let name = partition.file_name().to_string_lossy().into_owned();
let Ok(files) = std::fs::read_dir(partition.path()) else {
continue;
};
for path in files.flatten().map(|file| file.path()) {
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let Some(value) = crate::core::policy::diagnostics::Target::read_file(&path)
.and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
else {
continue;
};
let Some(root) = value["project_root"].as_str() else {
continue;
};
let current = current_of
.entry(root.to_string())
.or_insert_with(|| crate::core::project_hash::hash_project_root(root));
if *current != name {
archives.push((path, value));
}
}
}
archives.sort_by(|a, b| a.0.cmp(&b.0));
archives
}
pub(crate) fn drifted_facts_archives(project_root: &Path) -> Vec<(PathBuf, serde_json::Value)> {
let canonical = |path: &Path| crate::core::pathutil::safe_canonicalize_bounded(path, 2000);
let wanted = canonical(project_root);
orphaned_facts_archives()
.into_iter()
.filter(|(_, value)| {
value["project_root"]
.as_str()
.is_some_and(|root| canonical(Path::new(root)) == wanted)
})
.collect()
}
pub fn adopt_legacy_facts(
project_root: &Path,
facts: &[serde_json::Value],
) -> Result<Vec<bool>, String> {
let root = project_root;
crate::core::policy::runtime::with_explicit_project_source_view(root, |authority| {
let admission = crate::core::knowledge::protection::KnowledgeAdmission::capture(
&root.to_string_lossy(),
)?;
let admitted: Vec<bool> = facts
.iter()
.map(|fact| admits_knowledge_archive_item(MemoryStore::Facts, &admission, fact))
.collect();
let archived = archived_fact_identities()?;
let kept: Vec<&serde_json::Value> = facts
.iter()
.zip(&admitted)
.filter_map(|(fact, ok)| {
(*ok && !archived.contains(&fact_identity(fact))).then_some(fact)
})
.collect();
archive_items_inner(
MemoryStore::Facts,
None,
&kept,
&ArchiveConfig::from_env(),
Some((admission.gateway_snapshot(), authority)),
Some((&admission, authority)),
Utc::now(),
)?;
Ok(admitted)
})?
}
fn fact_identity(fact: &serde_json::Value) -> [String; 3] {
["category", "key", "value"].map(|field| fact[field].as_str().unwrap_or_default().to_owned())
}
fn archived_fact_identities() -> Result<std::collections::HashSet<[String; 3]>, String> {
let dir = archive_dir(MemoryStore::Facts, None)?;
Ok(ordered_archive_files(&dir)
.0
.iter()
.filter_map(|path| crate::core::policy::diagnostics::Target::read_file(path))
.filter_map(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
.flat_map(|envelope| match envelope.get("items") {
Some(serde_json::Value::Array(items)) => items.clone(),
_ => Vec::new(),
})
.map(|fact| fact_identity(&fact))
.collect())
}
pub fn list_archives(store: MemoryStore, scope: Option<&str>) -> Vec<PathBuf> {
let Ok(dir) = archive_dir(store, scope) else {
return Vec::new();
};
ordered_archive_files(&dir).0
}
fn ordered_archive_files(dir: &Path) -> (Vec<PathBuf>, bool) {
#[derive(Deserialize)]
struct ArchiveTimestamp {
archived_at: DateTime<Utc>,
}
let Ok(entries) = std::fs::read_dir(dir) else {
return (Vec::new(), false);
};
let mut complete = true;
let mut archives = Vec::new();
for entry in entries {
let Ok(entry) = entry else {
complete = false;
continue;
};
let path = entry.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let Ok(kind) = entry.file_type() else {
complete = false;
continue;
};
if !kind.is_file() {
continue;
}
let timestamp = crate::core::policy::diagnostics::Target::read_file(&path)
.and_then(|text| serde_json::from_str::<ArchiveTimestamp>(&text).ok())
.map(|metadata| metadata.archived_at);
complete &= timestamp.is_some();
archives.push((timestamp, path));
}
archives.sort_unstable();
(
archives.into_iter().map(|(_, path)| path).collect(),
complete,
)
}
pub fn reachable_archives(
store: MemoryStore,
scope: Option<&str>,
cfg: &ArchiveConfig,
) -> Vec<PathBuf> {
let mut archives = list_archives(store, scope);
if archives.len() > cfg.rehydrate_reach {
archives = archives[archives.len() - cfg.rehydrate_reach..].to_vec();
}
archives
}
pub fn restore_items<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
restore_items_inner(path, None)
}
pub fn restore_knowledge_items<T: DeserializeOwned>(
path: &Path,
admission: &crate::core::knowledge::protection::KnowledgeAdmission,
) -> Result<Vec<T>, String> {
restore_items_inner(path, Some(admission))
}
fn restore_items_inner<T: DeserializeOwned>(
path: &Path,
supplied_admission: Option<&crate::core::knowledge::protection::KnowledgeAdmission>,
) -> Result<Vec<T>, String> {
let data = crate::core::policy::diagnostics::Target::read_file(path)
.ok_or("archive is unavailable or uninspectable")?;
let original: serde_json::Value = serde_json::from_str(&data).map_err(|_| "invalid archive")?;
let root = archive_project().ok_or("archive project unavailable")?;
let canonical = |p: &Path| crate::core::pathutil::safe_canonicalize_bounded(p, 2000);
let protected = crate::core::knowledge::protection::current(&root.to_string_lossy())?.is_some();
if !protected && archive_store(&original) == Some(MemoryStore::Facts) {
let expected = archive_dir(MemoryStore::Facts, None)?;
if path.parent().map(canonical) != Some(canonical(&expected)) {
return Err("archive belongs to a different storage scope".into());
}
} else if protected {
let kind = match original["store"].as_str() {
Some("facts") => MemoryStore::Facts,
Some("history") => MemoryStore::History,
Some("patterns") => MemoryStore::Patterns,
_ => return Err("archive store identity unavailable".into()),
};
let scope = original["scope"]
.as_str()
.ok_or("archive has no protected scope")?;
let expected = archive_dir(kind, Some(scope))?;
if path.parent().map(canonical) != Some(canonical(&expected)) {
return Err("archive belongs to a different storage scope".into());
}
}
let store = archive_store(&original);
let mut candidate = original.clone();
let captured_admission = if store.is_some_and(knowledge_store) && supplied_admission.is_none() {
Some(
crate::core::knowledge::protection::KnowledgeAdmission::capture(
&root.to_string_lossy(),
)?,
)
} else {
None
};
let mut typed_items = None;
if let Some(store) = store.filter(|store| knowledge_store(*store)) {
let admission = supplied_admission
.or(captured_admission.as_ref())
.ok_or("archive admission unavailable")?;
let collection =
archive_items_field(&candidate, store).ok_or("archive items are unavailable")?;
let records = candidate[collection]
.as_array_mut()
.ok_or("archive items are invalid")?;
records.retain(|record| admits_knowledge_archive_item(store, admission, record));
admission.authorize_gateway()?;
typed_items = Some((store, admission));
}
let safe = inspect_archive(&candidate, typed_items)?;
if let Some((_, admission)) = typed_items {
admission.authorize_gateway()?;
}
let envelope: ArchiveEnvelope<T> = serde_json::from_value(safe)
.map_err(|_| "archive policy result is structurally invalid")?;
Ok(envelope.items)
}
fn inspect_archive(
original: &serde_json::Value,
typed_items: Option<(
MemoryStore,
&crate::core::knowledge::protection::KnowledgeAdmission,
)>,
) -> Result<serde_json::Value, String> {
let root = archive_project().ok_or("archive project unavailable")?;
let active = crate::core::knowledge::protection::current(&root.to_string_lossy())?;
let mut inspection = original.clone();
let mut preserved_items = Vec::new();
if let Some((store, admission)) = typed_items {
if let Some(collection) = archive_items_field(&inspection, store)
&& let Some(items) = inspection
.get_mut(collection)
.and_then(serde_json::Value::as_array_mut)
{
for (index, item) in items.iter_mut().enumerate() {
if admits_typed_knowledge_archive_item(store, admission, item) {
preserved_items.push((index, item.clone()));
*item = serde_json::Value::Null;
}
}
}
}
let mut safe = crate::core::policy::diagnostics::inspect(&inspection, active.as_deref())
.ok_or("archive withheld by current policy")?;
if let Some((store, _)) = typed_items {
if let Some(collection) = archive_items_field(&safe, store)
&& let Some(items) = safe
.get_mut(collection)
.and_then(serde_json::Value::as_array_mut)
{
for (index, item) in preserved_items {
let slot = items
.get_mut(index)
.ok_or("archive item projection changed shape")?;
*slot = item;
}
}
}
for field in ["store", "scope", "archived_at"] {
if safe[field] != original[field] {
return Err("archive identity cannot be rewritten".into());
}
}
let before_items = original.get("items").or_else(|| original.get("facts"));
let after_items = safe.get("items").or_else(|| safe.get("facts"));
if let (Some(before), Some(after)) = (
before_items.and_then(serde_json::Value::as_array),
after_items.and_then(serde_json::Value::as_array),
) {
for (a, b) in before.iter().zip(after) {
for field in [
"id",
"key",
"category",
"source_session",
"supersedes",
"from_sessions",
"pattern_type",
] {
if a.get(field) != b.get(field) {
return Err("archived item identity cannot be rewritten".into());
}
}
}
}
Ok(safe)
}
#[cfg(test)]
pub mod tests {
use super::*;
fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
let _lock = crate::core::data_dir::test_env_lock();
let dir = std::env::temp_dir().join(format!(
"lctx-archive-{}-{}",
std::process::id(),
Utc::now().timestamp_nanos_opt().unwrap_or(0)
));
let _ = std::fs::create_dir_all(&dir);
crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
let out = f();
crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
let _ = std::fs::remove_dir_all(&dir);
out
}
#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
struct Item {
id: u32,
label: String,
}
fn items(n: u32) -> Vec<Item> {
(0..n)
.map(|i| Item {
id: i,
label: format!("item-{i}"),
})
.collect()
}
#[test]
fn round_trip_each_store() {
with_temp_data_dir(|| {
let cfg = ArchiveConfig::default();
for store in MemoryStore::all() {
let scope = if store == MemoryStore::Facts {
None
} else {
Some("projhash")
};
let path = archive_items(store, scope, &items(3), &cfg)
.unwrap()
.expect("wrote an archive");
let restored: Vec<Item> = restore_items(&path).unwrap();
assert_eq!(restored, items(3), "round-trip for {}", store.as_str());
}
});
}
#[test]
fn a_request_without_a_project_has_no_archive() {
with_temp_data_dir(|| {
let archived = crate::core::policy::runtime::REQUEST_PROJECT.sync_scope(
std::cell::RefCell::new(None),
|| {
archive_items(
MemoryStore::Facts,
None,
&items(1),
&ArchiveConfig::default(),
)
},
);
assert!(archived.is_err(), "{archived:?}");
});
}
#[test]
fn empty_archive_is_noop() {
with_temp_data_dir(|| {
let cfg = ArchiveConfig::default();
let res =
archive_items(MemoryStore::History, Some("p"), &Vec::<Item>::new(), &cfg).unwrap();
assert!(res.is_none());
{
let found = &list_archives(MemoryStore::History, Some("p"));
assert!(found.is_empty(), "{found:?}");
}
});
}
fn in_project<T>(root: &Path, f: impl FnOnce() -> T) -> T {
crate::core::policy::runtime::REQUEST_PROJECT
.sync_scope(std::cell::RefCell::new(Some(root.to_path_buf())), f)
}
#[test]
fn facts_archives_are_partitioned_by_request_project() {
with_temp_data_dir(|| {
let cfg = ArchiveConfig::default();
let a = tempfile::tempdir().expect("project a");
let b = tempfile::tempdir().expect("project b");
let archived = in_project(a.path(), || {
archive_items(MemoryStore::Facts, None, &items(2), &cfg)
.unwrap()
.expect("wrote an archive")
});
let base = crate::core::data_dir::lean_ctx_data_dir()
.unwrap()
.join("memory")
.join("archive");
let legacy = base.join("archive-20260101-000000-000000.json");
std::fs::write(
&legacy,
r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#,
)
.unwrap();
in_project(b.path(), || {
assert!(list_archives(MemoryStore::Facts, None).is_empty());
assert!(restore_items::<Item>(&archived).is_err());
assert!(restore_items::<Item>(&legacy).is_err());
});
in_project(a.path(), || {
assert_eq!(
list_archives(MemoryStore::Facts, None),
vec![archived.clone()]
);
assert_eq!(restore_items::<Item>(&archived).unwrap(), items(2));
assert!(restore_items::<Item>(&legacy).is_err());
});
});
}
#[test]
fn legacy_facts_field_alias_still_deserializes() {
with_temp_data_dir(|| {
let dir = archive_dir(MemoryStore::Facts, None).unwrap();
std::fs::create_dir_all(&dir).unwrap();
let legacy =
r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#;
let path = dir.join("archive-20260101-000000-000000.json");
std::fs::write(&path, legacy).unwrap();
let restored: Vec<Item> = restore_items(&path).unwrap();
assert_eq!(
restored,
vec![Item {
id: 7,
label: "old".into()
}]
);
});
}
#[test]
fn prune_keeps_newest_max_files() {
with_temp_data_dir(|| {
let cfg = ArchiveConfig {
max_files: 3,
rehydrate_reach: 3,
};
for (id, nanos) in [
1_100_000, 1_900_000, 2_100_000, 2_900_000, 3_100_000, 3_900_000,
]
.into_iter()
.enumerate()
{
let batch = [Item {
id: u32::try_from(id).unwrap(),
label: format!("batch-{id}"),
}];
archive_items_at(
MemoryStore::Patterns,
Some("p"),
&batch,
&cfg,
DateTime::from_timestamp(1_700_000_000, nanos).unwrap(),
)
.unwrap();
}
let archives = list_archives(MemoryStore::Patterns, Some("p"));
let retained: Vec<u32> = archives
.iter()
.map(|path| restore_items::<Item>(path).unwrap()[0].id)
.collect();
assert_eq!(retained, [3, 4, 5]);
});
}
#[test]
fn retention_orders_legacy_names_by_the_recorded_timestamp() {
with_temp_data_dir(|| {
let store = MemoryStore::Patterns;
let dir = archive_dir(store, Some("p")).unwrap();
std::fs::create_dir_all(&dir).unwrap();
let old_time = DateTime::from_timestamp(1_700_000_000, 1_900_000).unwrap();
let legacy = dir.join(format!(
"archive-{}-900000.json",
old_time.format("%Y%m%d-%H%M%S")
));
std::fs::write(
&legacy,
serde_json::to_vec(&ArchiveEnvelopeRef {
archived_at: old_time,
store: store.as_str(),
scope: Some("p"),
project_root: None,
items: &items(1),
})
.unwrap(),
)
.unwrap();
let cfg = ArchiveConfig {
max_files: 1,
rehydrate_reach: 1,
};
let newest = archive_items_at(
store,
Some("p"),
&items(2),
&cfg,
DateTime::from_timestamp(1_700_000_000, 2_100_000).unwrap(),
)
.unwrap()
.unwrap();
assert!(!legacy.exists());
assert_eq!(
list_archives(store, Some("p")).as_slice(),
std::slice::from_ref(&newest)
);
assert_eq!(restore_items::<Item>(&newest).unwrap(), items(2));
});
}
#[test]
fn unreadable_archive_chronology_blocks_writes_without_pruning() {
with_temp_data_dir(|| {
let store = MemoryStore::Patterns;
let dir = archive_dir(store, Some("p")).unwrap();
std::fs::create_dir_all(&dir).unwrap();
let legacy = dir.join("archive-20231114-221320-900000.json");
std::fs::write(&legacy, "not json").unwrap();
let cfg = ArchiveConfig {
max_files: 1,
rehydrate_reach: 1,
};
let error = archive_items(store, Some("p"), &items(1), &cfg).unwrap_err();
assert_eq!(error, "archive chronology unavailable");
assert_eq!(
list_archives(store, Some("p")).as_slice(),
std::slice::from_ref(&legacy)
);
assert_eq!(std::fs::read_to_string(legacy).unwrap(), "not json");
});
}
#[test]
fn reachable_is_bounded_and_aligns_with_retention_by_default() {
let cfg = ArchiveConfig::default();
assert_eq!(cfg.rehydrate_reach, cfg.max_files);
}
#[test]
fn from_env_reach_clamped_to_max() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_ARCHIVE_MAX_FILES", "5");
crate::test_env::set_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH", "99");
let cfg = ArchiveConfig::from_env();
assert_eq!(cfg.max_files, 5);
assert_eq!(cfg.rehydrate_reach, 5, "reach clamped to max_files");
crate::test_env::remove_var("LEAN_CTX_ARCHIVE_MAX_FILES");
crate::test_env::remove_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH");
}
#[test]
fn store_parse_round_trips() {
for store in MemoryStore::all() {
assert_eq!(MemoryStore::parse(store.as_str()), Some(store));
}
assert_eq!(MemoryStore::parse("nope"), None);
assert_eq!(MemoryStore::parse("procedures"), None);
assert!(MemoryStore::is_retired(" Procedures "));
assert!(!MemoryStore::is_retired("patterns"));
}
#[test]
fn unknown_explicit_store_does_not_fall_back_to_legacy_facts() {
let invalid = serde_json::json!({"store":"unknown","facts":[]});
assert_eq!(archive_store(&invalid), None);
let legacy = serde_json::json!({"facts":[]});
assert_eq!(archive_store(&legacy), Some(MemoryStore::Facts));
}
}