1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5use super::data_dir::lean_ctx_data_dir;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct ArchiveEntry {
9 pub id: String,
10 pub tool: String,
11 pub command: String,
12 pub size_chars: usize,
13 pub size_tokens: usize,
14 pub created_at: DateTime<Utc>,
15 pub session_id: Option<String>,
16}
17
18fn archive_base_dir() -> PathBuf {
19 lean_ctx_data_dir()
20 .unwrap_or_else(|_| PathBuf::from(".lean-ctx"))
21 .join("archives")
22}
23
24fn entry_dir(id: &str) -> PathBuf {
25 let prefix = if id.len() >= 2 { &id[..2] } else { id };
26 archive_base_dir().join(prefix)
27}
28
29fn content_path(id: &str) -> PathBuf {
30 entry_dir(id).join(format!("{id}.txt"))
31}
32
33fn meta_path(id: &str) -> PathBuf {
34 entry_dir(id).join(format!("{id}.meta.json"))
35}
36
37#[cfg(unix)]
38fn set_private_file_perms(path: &PathBuf) {
39 use std::os::unix::fs::PermissionsExt;
40 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
41}
42
43fn compute_id(content: &str) -> String {
44 use std::collections::hash_map::DefaultHasher;
45 use std::hash::{Hash, Hasher};
46 let mut hasher = DefaultHasher::new();
47 content.hash(&mut hasher);
48 let hash = hasher.finish();
49 format!("{hash:016x}")
50}
51
52pub fn is_enabled() -> bool {
53 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE") {
54 return !matches!(v.as_str(), "0" | "false" | "off");
55 }
56 super::config::Config::load().archive.enabled
57}
58
59fn threshold_chars() -> usize {
60 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_THRESHOLD")
61 && let Ok(n) = v.parse::<usize>()
62 {
63 return n;
64 }
65 super::config::Config::load().archive.threshold_chars
66}
67
68fn max_age_hours() -> u64 {
69 if let Ok(v) = std::env::var("LEAN_CTX_ARCHIVE_TTL")
70 && let Ok(n) = v.parse::<u64>()
71 {
72 return n;
73 }
74 super::config::Config::load().archive_max_age_hours_effective()
75}
76
77fn max_disk_bytes() -> u64 {
82 super::config::Config::load()
83 .archive_max_disk_mb_effective()
84 .saturating_mul(1024 * 1024)
85}
86
87pub fn should_archive(content: &str) -> bool {
88 is_enabled() && content.len() >= threshold_chars()
89}
90
91const MAX_ARCHIVE_SIZE: usize = 10 * 1024 * 1024; pub fn store(tool: &str, command: &str, content: &str, session_id: Option<&str>) -> Option<String> {
94 if !is_enabled() || content.is_empty() {
95 return None;
96 }
97
98 let content = if content.len() > MAX_ARCHIVE_SIZE {
99 &content[..content.floor_char_boundary(MAX_ARCHIVE_SIZE)]
100 } else {
101 content
102 };
103
104 let id = compute_id(content);
105 let c_path = content_path(&id);
106
107 if c_path.exists() {
109 return Some(id);
110 }
111
112 let dir = entry_dir(&id);
113 if std::fs::create_dir_all(&dir).is_err() {
114 return None;
115 }
116
117 let pid = std::process::id();
123 let tmp_path = c_path.with_extension(format!("tmp.{pid}"));
124 if std::fs::write(&tmp_path, content).is_err() {
125 return None;
126 }
127 if std::fs::rename(&tmp_path, &c_path).is_err() {
128 let _ = std::fs::remove_file(&tmp_path);
129 if c_path.exists() {
131 return Some(id);
132 }
133 return None;
134 }
135 #[cfg(unix)]
136 set_private_file_perms(&c_path);
137
138 let tokens = super::tokens::count_tokens(content);
139 let entry = ArchiveEntry {
140 id: id.clone(),
141 tool: tool.to_string(),
142 command: command.to_string(),
143 size_chars: content.len(),
144 size_tokens: tokens,
145 created_at: Utc::now(),
146 session_id: session_id.map(std::string::ToString::to_string),
147 };
148
149 if let Ok(json) = serde_json::to_string_pretty(&entry) {
150 let meta_tmp = meta_path(&id).with_extension(format!("tmp.{pid}"));
151 if std::fs::write(&meta_tmp, &json).is_ok() {
152 let meta_final = meta_path(&id);
153 let _ = std::fs::rename(&meta_tmp, &meta_final);
154 #[cfg(unix)]
155 set_private_file_perms(&meta_final);
156 }
157 }
158
159 super::archive_fts::index_entry(&id, tool, command, content);
160
161 Some(id)
162}
163
164pub fn retrieve(id: &str) -> Option<String> {
165 let path = content_path(id);
166 std::fs::read_to_string(path).ok()
167}
168
169pub(crate) fn format_range(content: &str, start: usize, end: usize) -> String {
172 let lines: Vec<&str> = content.lines().collect();
173 let start = start.saturating_sub(1).min(lines.len());
174 let end = end.min(lines.len());
175 if start >= end {
176 return String::new();
177 }
178 lines[start..end]
179 .iter()
180 .enumerate()
181 .map(|(i, line)| format!("{:>6}|{line}", start + i + 1))
182 .collect::<Vec<_>>()
183 .join("\n")
184}
185
186pub(crate) fn format_search(content: &str, pattern: &str, label: &str) -> String {
190 let pattern_lower = pattern.to_lowercase();
191 let matches: Vec<String> = content
192 .lines()
193 .enumerate()
194 .filter(|(_, line)| line.to_lowercase().contains(&pattern_lower))
195 .map(|(i, line)| format!("{:>6}|{line}", i + 1))
196 .collect();
197 if matches.is_empty() {
198 format!("No matches for \"{pattern}\" in {label}")
199 } else {
200 format!(
201 "{} match(es) for \"{}\":\n{}",
202 matches.len(),
203 pattern,
204 matches.join("\n")
205 )
206 }
207}
208
209pub(crate) fn format_json_keys(content: &str, path: Option<&str>, label: &str) -> Option<String> {
213 let root: serde_json::Value = serde_json::from_str(content.trim()).ok()?;
214 let mut cur = &root;
215 let mut walked = String::from("$");
216 if let Some(p) = path {
217 for seg in p.split(['.', '/']).filter(|s| !s.is_empty()) {
218 let next = if let Ok(idx) = seg.parse::<usize>() {
219 cur.get(idx)
220 } else {
221 cur.get(seg)
222 };
223 match next {
224 Some(v) => {
225 cur = v;
226 walked.push('.');
227 walked.push_str(seg);
228 }
229 None => {
230 return Some(format!("Path '{p}' not found at '{walked}' in {label}"));
231 }
232 }
233 }
234 }
235 Some(format!("{walked} => {}", describe_json(cur)))
236}
237
238pub fn retrieve_with_range(id: &str, start: usize, end: usize) -> Option<String> {
239 let content = retrieve(id)?;
240 Some(format_range(&content, start, end))
241}
242
243pub fn retrieve_with_search(id: &str, pattern: &str) -> Option<String> {
244 let content = retrieve(id)?;
245 Some(format_search(&content, pattern, &format!("archive {id}")))
246}
247
248pub fn retrieve_head(id: &str, n: usize) -> Option<String> {
250 retrieve_with_range(id, 1, n)
251}
252
253pub fn retrieve_tail(id: &str, n: usize) -> Option<String> {
255 let content = retrieve(id)?;
256 let total = content.lines().count();
257 let start = if total > n { total - n + 1 } else { 1 };
258 retrieve_with_range(id, start, total)
259}
260
261pub fn retrieve_json_keys(id: &str, path: Option<&str>) -> Option<String> {
263 let content = retrieve(id)?;
264 format_json_keys(&content, path, &format!("archive {id}"))
265}
266
267pub(crate) fn json_type_hint(v: &serde_json::Value) -> String {
268 use serde_json::Value;
269 match v {
270 Value::Object(m) => format!("object({})", m.len()),
271 Value::Array(a) => format!("array({})", a.len()),
272 Value::String(s) => {
273 let preview: String = s.chars().take(40).collect();
274 if s.chars().count() > 40 {
275 format!("string \"{preview}…\"")
276 } else {
277 format!("string \"{preview}\"")
278 }
279 }
280 Value::Number(n) => format!("number {n}"),
281 Value::Bool(b) => format!("bool {b}"),
282 Value::Null => "null".to_string(),
283 }
284}
285
286pub(crate) fn describe_json(v: &serde_json::Value) -> String {
287 use serde_json::Value;
288 match v {
289 Value::Object(map) => {
290 let mut keys: Vec<&String> = map.keys().collect();
291 keys.sort();
292 let rendered: Vec<String> = keys
293 .iter()
294 .map(|k| format!(" {k}: {}", json_type_hint(&map[*k])))
295 .collect();
296 format!("object ({} keys)\n{}", map.len(), rendered.join("\n"))
297 }
298 Value::Array(arr) => {
299 let elem = arr.first().map_or("empty", |e| match e {
300 Value::Object(_) => "object",
301 Value::Array(_) => "array",
302 Value::String(_) => "string",
303 Value::Number(_) => "number",
304 Value::Bool(_) => "bool",
305 Value::Null => "null",
306 });
307 let mut out = format!("array ({} items of {elem})", arr.len());
308 if let Some(Value::Object(map)) = arr.first() {
309 let mut keys: Vec<&String> = map.keys().collect();
310 keys.sort();
311 out.push_str(&format!(
312 "\n [0] keys: {}",
313 keys.iter()
314 .map(|s| s.as_str())
315 .collect::<Vec<_>>()
316 .join(", ")
317 ));
318 }
319 out
320 }
321 Value::String(s) => format!("string ({} chars)", s.len()),
322 Value::Number(n) => format!("number ({n})"),
323 Value::Bool(b) => format!("bool ({b})"),
324 Value::Null => "null".to_string(),
325 }
326}
327
328pub fn list_entries(session_id: Option<&str>) -> Vec<ArchiveEntry> {
329 let base = archive_base_dir();
330 if !base.exists() {
331 return Vec::new();
332 }
333 let mut entries = Vec::new();
334 if let Ok(dirs) = std::fs::read_dir(&base) {
335 for dir_entry in dirs.flatten() {
336 if !dir_entry.path().is_dir() {
337 continue;
338 }
339 if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
340 for file in files.flatten() {
341 let path = file.path();
342 if path.extension().and_then(|e| e.to_str()) != Some("json") {
343 continue;
344 }
345 if let Ok(data) = std::fs::read_to_string(&path)
346 && let Ok(entry) = serde_json::from_str::<ArchiveEntry>(&data)
347 {
348 if let Some(sid) = session_id
349 && entry.session_id.as_deref() != Some(sid)
350 {
351 continue;
352 }
353 entries.push(entry);
354 }
355 }
356 }
357 }
358 }
359 entries.sort_by_key(|e| std::cmp::Reverse(e.created_at));
360 entries
361}
362
363pub fn remove_files(id: &str) {
367 let _ = std::fs::remove_file(content_path(id));
368 let _ = std::fs::remove_file(meta_path(id));
369}
370
371pub fn cleanup() -> u32 {
380 let cutoff = Utc::now() - chrono::Duration::hours(max_age_hours() as i64);
381 cleanup_with(cutoff, max_disk_bytes())
382}
383
384fn cleanup_with(cutoff: DateTime<Utc>, budget_bytes: u64) -> u32 {
388 let base = archive_base_dir();
389 if !base.exists() {
390 return 0;
391 }
392
393 struct Scanned {
394 id: String,
395 created_at: DateTime<Utc>,
396 bytes: u64,
397 }
398
399 let mut entries: Vec<Scanned> = Vec::new();
400 if let Ok(dirs) = std::fs::read_dir(&base) {
401 for dir_entry in dirs.flatten() {
402 if !dir_entry.path().is_dir() {
403 continue;
404 }
405 if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
406 for file in files.flatten() {
407 let path = file.path();
408 if path.extension().and_then(|e| e.to_str()) != Some("json") {
409 continue;
410 }
411 let Ok(data) = std::fs::read_to_string(&path) else {
412 continue;
413 };
414 let Ok(entry) = serde_json::from_str::<ArchiveEntry>(&data) else {
415 continue;
416 };
417 let content_bytes =
418 std::fs::metadata(content_path(&entry.id)).map_or(0, |m| m.len());
419 let meta_bytes = file.metadata().map_or(0, |m| m.len());
420 entries.push(Scanned {
421 id: entry.id,
422 created_at: entry.created_at,
423 bytes: content_bytes + meta_bytes,
424 });
425 }
426 }
427 }
428 }
429
430 entries.sort_by_key(|e| e.created_at);
433 let mut live_bytes: u64 = entries.iter().map(|e| e.bytes).sum();
434
435 let mut removed = 0u32;
436 for e in &entries {
437 let expired = e.created_at < cutoff;
438 let over_budget = budget_bytes > 0 && live_bytes > budget_bytes;
439 if !expired && !over_budget {
440 break;
441 }
442 remove_files(&e.id);
443 super::archive_fts::remove_entry(&e.id);
444 live_bytes = live_bytes.saturating_sub(e.bytes);
445 removed += 1;
446 }
447 removed
448}
449
450pub fn disk_usage_bytes() -> u64 {
451 let base = archive_base_dir();
452 if !base.exists() {
453 return 0;
454 }
455 let mut total = 0u64;
456 if let Ok(dirs) = std::fs::read_dir(&base) {
457 for dir_entry in dirs.flatten() {
458 if let Ok(files) = std::fs::read_dir(dir_entry.path()) {
459 for file in files.flatten() {
460 total += file.metadata().map_or(0, |m| m.len());
461 }
462 }
463 }
464 }
465 total
466}
467
468pub fn content_path_str(id: &str) -> String {
472 content_path(id).to_string_lossy().into_owned()
473}
474
475pub fn format_hint(id: &str, size_chars: usize, size_tokens: usize) -> String {
476 let clause = crate::core::recovery::handle_clause(id, Some(&content_path_str(id)));
480 format!("[Archived: {size_chars} chars ({size_tokens} tok). {clause}]")
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486
487 #[test]
488 fn compute_id_deterministic() {
489 let id1 = compute_id("test content");
490 let id2 = compute_id("test content");
491 assert_eq!(id1, id2);
492 let id3 = compute_id("different content");
493 assert_ne!(id1, id3);
494 }
495
496 #[test]
497 fn nonexistent_id_returns_none() {
498 assert!(retrieve("nonexistent_archive_id_xyz").is_none());
499 }
500
501 #[test]
502 fn format_hint_readable() {
503 let hint = format_hint("abc123", 5000, 1200);
504 assert!(hint.contains("5000 chars"));
505 assert!(hint.contains("1200 tok"));
506 assert!(hint.contains("ctx_expand"));
507 assert!(hint.contains("abc123"));
508 }
509
510 fn write_test_entry(id: &str, created_at: DateTime<Utc>, content_bytes: usize) {
511 std::fs::create_dir_all(entry_dir(id)).unwrap();
512 std::fs::write(content_path(id), "x".repeat(content_bytes)).unwrap();
513 let entry = ArchiveEntry {
514 id: id.to_string(),
515 tool: "ctx_shell".to_string(),
516 command: "test".to_string(),
517 size_chars: content_bytes,
518 size_tokens: content_bytes / 4,
519 created_at,
520 session_id: None,
521 };
522 std::fs::write(meta_path(id), serde_json::to_string(&entry).unwrap()).unwrap();
523 }
524
525 #[test]
526 fn cleanup_removes_expired_keeps_fresh() {
527 let _lock = crate::core::data_dir::test_env_lock();
528 let tmp = tempfile::tempdir().unwrap();
529 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
530
531 let now = Utc::now();
532 write_test_entry("aa_old", now - chrono::Duration::hours(100), 100);
533 write_test_entry("bb_new", now - chrono::Duration::hours(1), 100);
534
535 let removed = cleanup_with(now - chrono::Duration::hours(48), u64::MAX);
537 assert_eq!(removed, 1);
538 assert!(!content_path("aa_old").exists());
539 assert!(!meta_path("aa_old").exists());
540 assert!(content_path("bb_new").exists());
541
542 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
543 }
544
545 #[test]
546 fn cleanup_enforces_disk_budget_oldest_first() {
547 let _lock = crate::core::data_dir::test_env_lock();
548 let tmp = tempfile::tempdir().unwrap();
549 crate::test_env::set_var("LEAN_CTX_DATA_DIR", tmp.path());
550
551 let now = Utc::now();
552 write_test_entry("c1_oldest", now - chrono::Duration::minutes(30), 10_000);
553 write_test_entry("c2_middle", now - chrono::Duration::minutes(20), 10_000);
554 write_test_entry("c3_newest", now - chrono::Duration::minutes(10), 10_000);
555
556 let removed = cleanup_with(now - chrono::Duration::days(365), 25_000);
559 assert_eq!(removed, 1, "only the oldest over-budget entry is evicted");
560 assert!(!content_path("c1_oldest").exists());
561 assert!(content_path("c2_middle").exists());
562 assert!(content_path("c3_newest").exists());
563
564 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
565 }
566}