1use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize, de::DeserializeOwned};
23use std::path::{Path, PathBuf};
24
25const DEFAULT_MAX_ARCHIVE_FILES: usize = 16;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum MemoryStore {
35 Facts,
36 History,
37 Procedures,
38 Patterns,
39}
40
41impl MemoryStore {
42 pub fn as_str(self) -> &'static str {
43 match self {
44 MemoryStore::Facts => "facts",
45 MemoryStore::History => "history",
46 MemoryStore::Procedures => "procedures",
47 MemoryStore::Patterns => "patterns",
48 }
49 }
50
51 pub fn parse(s: &str) -> Option<Self> {
52 match s.trim().to_lowercase().as_str() {
53 "facts" | "fact" => Some(MemoryStore::Facts),
54 "history" | "insights" => Some(MemoryStore::History),
55 "procedures" | "procedure" | "procs" => Some(MemoryStore::Procedures),
56 "patterns" | "pattern" => Some(MemoryStore::Patterns),
57 _ => None,
58 }
59 }
60
61 pub fn all() -> [MemoryStore; 4] {
63 [
64 MemoryStore::Facts,
65 MemoryStore::History,
66 MemoryStore::Procedures,
67 MemoryStore::Patterns,
68 ]
69 }
70
71 fn subdir(self) -> Option<&'static str> {
73 match self {
74 MemoryStore::Facts => None,
75 other => Some(other.as_str()),
76 }
77 }
78}
79
80#[derive(Debug, Clone, Copy)]
85pub struct ArchiveConfig {
86 pub max_files: usize,
87 pub rehydrate_reach: usize,
88}
89
90impl Default for ArchiveConfig {
91 fn default() -> Self {
92 Self {
93 max_files: DEFAULT_MAX_ARCHIVE_FILES,
94 rehydrate_reach: DEFAULT_MAX_ARCHIVE_FILES,
95 }
96 }
97}
98
99impl ArchiveConfig {
100 pub fn from_env() -> Self {
103 let mut cfg = Self::default();
104 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_MAX_FILES")
105 && let Ok(n) = v.parse::<usize>()
106 && n > 0
107 {
108 cfg.max_files = n;
109 cfg.rehydrate_reach = n;
110 }
111 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH")
112 && let Ok(n) = v.parse::<usize>()
113 && n > 0
114 {
115 cfg.rehydrate_reach = n;
116 }
117 cfg.rehydrate_reach = cfg.rehydrate_reach.min(cfg.max_files);
118 cfg
119 }
120}
121
122#[derive(Debug, Deserialize)]
126pub struct ArchiveEnvelope<T> {
127 pub archived_at: DateTime<Utc>,
128 #[serde(default)]
129 pub store: String,
130 #[serde(default)]
131 pub scope: Option<String>,
132 #[serde(rename = "items", alias = "facts")]
133 pub items: Vec<T>,
134}
135
136#[derive(Serialize)]
139struct ArchiveEnvelopeRef<'a, T: Serialize> {
140 archived_at: DateTime<Utc>,
141 store: &'a str,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 scope: Option<&'a str>,
144 items: &'a [T],
145}
146
147fn archive_dir(store: MemoryStore, scope: Option<&str>) -> Result<PathBuf, String> {
148 let base = crate::core::data_dir::lean_ctx_data_dir()?
149 .join("memory")
150 .join("archive");
151 let dir = match (store.subdir(), scope) {
152 (None, _) => base, (Some(sub), None) => base.join(sub), (Some(sub), Some(s)) => base.join(sub).join(sanitize_scope(s)),
155 };
156 Ok(dir)
157}
158
159fn sanitize_scope(scope: &str) -> String {
162 scope
163 .chars()
164 .map(|c| {
165 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
166 c
167 } else {
168 '_'
169 }
170 })
171 .collect()
172}
173
174pub fn archive_items<T: Serialize>(
178 store: MemoryStore,
179 scope: Option<&str>,
180 items: &[T],
181 cfg: &ArchiveConfig,
182) -> Result<Option<PathBuf>, String> {
183 if items.is_empty() {
184 return Ok(None);
185 }
186 let dir = archive_dir(store, scope)?;
187 std::fs::create_dir_all(&dir).map_err(|e| format!("{e}"))?;
188
189 let now = Utc::now();
192 let suffix = now.timestamp_subsec_nanos() % 1_000_000;
193 let filename = format!("archive-{}-{suffix:06}.json", now.format("%Y%m%d-%H%M%S"));
194 let path = dir.join(filename);
195
196 let envelope = ArchiveEnvelopeRef {
197 archived_at: now,
198 store: store.as_str(),
199 scope,
200 items,
201 };
202 let json = serde_json::to_string_pretty(&envelope).map_err(|e| format!("{e}"))?;
203 std::fs::write(&path, json).map_err(|e| format!("{e}"))?;
204
205 let archives = list_archives(store, scope);
206 if archives.len() > cfg.max_files {
207 for old in &archives[..archives.len() - cfg.max_files] {
208 let _ = std::fs::remove_file(old);
209 }
210 }
211 Ok(Some(path))
212}
213
214pub fn list_archives(store: MemoryStore, scope: Option<&str>) -> Vec<PathBuf> {
217 let Ok(dir) = archive_dir(store, scope) else {
218 return Vec::new();
219 };
220 if !dir.exists() {
221 return Vec::new();
222 }
223 let mut archives: Vec<PathBuf> = std::fs::read_dir(&dir)
224 .into_iter()
225 .flatten()
226 .flatten()
227 .map(|e| e.path())
228 .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "json"))
229 .collect();
230 archives.sort();
231 archives
232}
233
234pub fn reachable_archives(
238 store: MemoryStore,
239 scope: Option<&str>,
240 cfg: &ArchiveConfig,
241) -> Vec<PathBuf> {
242 let mut archives = list_archives(store, scope);
243 if archives.len() > cfg.rehydrate_reach {
244 archives = archives[archives.len() - cfg.rehydrate_reach..].to_vec();
245 }
246 archives
247}
248
249pub fn restore_items<T: DeserializeOwned>(path: &Path) -> Result<Vec<T>, String> {
251 let data = std::fs::read_to_string(path).map_err(|e| format!("{e}"))?;
252 let envelope: ArchiveEnvelope<T> = serde_json::from_str(&data).map_err(|e| format!("{e}"))?;
253 Ok(envelope.items)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn with_temp_data_dir<T>(f: impl FnOnce() -> T) -> T {
261 let _lock = crate::core::data_dir::test_env_lock();
262 let dir = std::env::temp_dir().join(format!(
263 "lctx-archive-{}-{}",
264 std::process::id(),
265 Utc::now().timestamp_nanos_opt().unwrap_or(0)
266 ));
267 let _ = std::fs::create_dir_all(&dir);
268 crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
269 let out = f();
270 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
271 let _ = std::fs::remove_dir_all(&dir);
272 out
273 }
274
275 #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)]
276 struct Item {
277 id: u32,
278 label: String,
279 }
280
281 fn items(n: u32) -> Vec<Item> {
282 (0..n)
283 .map(|i| Item {
284 id: i,
285 label: format!("item-{i}"),
286 })
287 .collect()
288 }
289
290 #[test]
291 fn round_trip_each_store() {
292 with_temp_data_dir(|| {
293 let cfg = ArchiveConfig::default();
294 for store in MemoryStore::all() {
295 let scope = if store == MemoryStore::Facts {
296 None
297 } else {
298 Some("projhash")
299 };
300 let path = archive_items(store, scope, &items(3), &cfg)
301 .unwrap()
302 .expect("wrote an archive");
303 let restored: Vec<Item> = restore_items(&path).unwrap();
304 assert_eq!(restored, items(3), "round-trip for {}", store.as_str());
305 }
306 });
307 }
308
309 #[test]
310 fn empty_archive_is_noop() {
311 with_temp_data_dir(|| {
312 let cfg = ArchiveConfig::default();
313 let res =
314 archive_items(MemoryStore::History, Some("p"), &Vec::<Item>::new(), &cfg).unwrap();
315 assert!(res.is_none());
316 assert!(list_archives(MemoryStore::History, Some("p")).is_empty());
317 });
318 }
319
320 #[test]
321 fn facts_use_legacy_root_other_stores_are_scoped() {
322 with_temp_data_dir(|| {
323 let base = crate::core::data_dir::lean_ctx_data_dir()
324 .unwrap()
325 .join("memory")
326 .join("archive");
327 assert_eq!(archive_dir(MemoryStore::Facts, None).unwrap(), base);
328 assert_eq!(
329 archive_dir(MemoryStore::History, Some("h")).unwrap(),
330 base.join("history").join("h")
331 );
332 });
333 }
334
335 #[test]
336 fn legacy_facts_field_alias_still_deserializes() {
337 with_temp_data_dir(|| {
338 let dir = archive_dir(MemoryStore::Facts, None).unwrap();
339 std::fs::create_dir_all(&dir).unwrap();
340 let legacy =
342 r#"{"archived_at":"2026-01-01T00:00:00Z","facts":[{"id":7,"label":"old"}]}"#;
343 let path = dir.join("archive-20260101-000000-000000.json");
344 std::fs::write(&path, legacy).unwrap();
345 let restored: Vec<Item> = restore_items(&path).unwrap();
346 assert_eq!(
347 restored,
348 vec![Item {
349 id: 7,
350 label: "old".into()
351 }]
352 );
353 });
354 }
355
356 #[test]
357 fn prune_keeps_newest_max_files() {
358 with_temp_data_dir(|| {
359 let cfg = ArchiveConfig {
360 max_files: 3,
361 rehydrate_reach: 3,
362 };
363 for _ in 0..6 {
364 archive_items(MemoryStore::Patterns, Some("p"), &items(1), &cfg).unwrap();
367 std::thread::sleep(std::time::Duration::from_millis(2));
368 }
369 let archives = list_archives(MemoryStore::Patterns, Some("p"));
370 assert!(
371 archives.len() <= 3,
372 "prune should bound to max_files, got {}",
373 archives.len()
374 );
375 });
376 }
377
378 #[test]
379 fn reachable_is_bounded_and_aligns_with_retention_by_default() {
380 let cfg = ArchiveConfig::default();
381 assert_eq!(cfg.rehydrate_reach, cfg.max_files);
382 }
383
384 #[test]
385 fn from_env_reach_clamped_to_max() {
386 let _lock = crate::core::data_dir::test_env_lock();
387 crate::test_env::set_var("LEAN_CTX_ARCHIVE_MAX_FILES", "5");
388 crate::test_env::set_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH", "99");
389 let cfg = ArchiveConfig::from_env();
390 assert_eq!(cfg.max_files, 5);
391 assert_eq!(cfg.rehydrate_reach, 5, "reach clamped to max_files");
392 crate::test_env::remove_var("LEAN_CTX_ARCHIVE_MAX_FILES");
393 crate::test_env::remove_var("LEAN_CTX_ARCHIVE_REHYDRATE_REACH");
394 }
395
396 #[test]
397 fn store_parse_round_trips() {
398 for store in MemoryStore::all() {
399 assert_eq!(MemoryStore::parse(store.as_str()), Some(store));
400 }
401 assert_eq!(MemoryStore::parse("nope"), None);
402 }
403}