Skip to main content

wm_memory/
recovery.rs

1//! LMDB corruption recovery — integrity checks, repair, and map-size growth.
2//!
3//! Provides defense-in-depth against LMDB data store corruption:
4//! - **Integrity check**: Read-only scan of all galaxy DBs, verifying
5//!   deserialization of every entry.
6//! - **Auto-repair**: Quarantine corrupted entries, rebuild secondary indexes
7//!   from valid memories.
8//! - **Map-size growth**: Detect `MDB_MAP_FULL` and reopen with a larger
9//!   virtual address space.
10//!
11//! Recovery strategies control how aggressively the store attempts to recover:
12//! - `None`: No recovery — fail on corruption (current behavior).
13//! - `WarnOnly`: Log warnings but don't modify data.
14//! - `AutoRepair`: Quarantine corrupted entries and rebuild indexes.
15//! - `AutoRepairAndGrow`: AutoRepair + grow map size on `MDB_MAP_FULL`.
16
17use std::fmt::Write;
18use std::path::{Path, PathBuf};
19
20use lmdb::{Cursor, Environment, Transaction};
21use serde::{Deserialize, Serialize};
22use wm_core::{CoreError, Galaxy, Result};
23
24use crate::memory::Memory;
25use crate::store::MemoryStore;
26
27// ── Recovery Strategy ─────────────────────────────────────────────────
28
29/// Strategy for handling LMDB corruption on open.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum RecoveryStrategy {
33    /// No recovery — fail on corruption.
34    #[default]
35    None,
36    /// Log warnings but don't modify data.
37    WarnOnly,
38    /// Quarantine corrupted entries and rebuild indexes.
39    AutoRepair,
40    /// AutoRepair + grow map size on MDB_MAP_FULL.
41    AutoRepairAndGrow,
42}
43
44#[allow(clippy::derivable_impls, clippy::should_implement_trait)]
45impl RecoveryStrategy {
46    /// Whether this strategy performs repairs.
47    #[must_use]
48    pub const fn repairs(self) -> bool {
49        matches!(self, Self::AutoRepair | Self::AutoRepairAndGrow)
50    }
51
52    /// Whether this strategy can grow map size.
53    #[must_use]
54    pub const fn grows_map(self) -> bool {
55        matches!(self, Self::AutoRepairAndGrow)
56    }
57}
58
59// ── Integrity Report ──────────────────────────────────────────────────
60
61/// Per-galaxy integrity check result.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct GalaxyIntegrity {
64    /// Galaxy name.
65    pub galaxy: String,
66    /// Total entries scanned.
67    pub total: usize,
68    /// Entries that deserialized successfully.
69    pub valid: usize,
70    /// Entries that failed deserialization.
71    pub corrupted: usize,
72    /// Corrupted keys (hex-encoded).
73    pub corrupted_keys: Vec<String>,
74}
75
76impl GalaxyIntegrity {
77    /// Whether this galaxy is clean.
78    #[must_use]
79    pub const fn is_clean(&self) -> bool {
80        self.corrupted == 0
81    }
82}
83
84/// Full integrity report across all galaxies.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct IntegrityReport {
87    /// Per-galaxy results.
88    pub galaxies: Vec<GalaxyIntegrity>,
89    /// Total entries across all galaxies.
90    pub total_entries: usize,
91    /// Total valid entries.
92    pub total_valid: usize,
93    /// Total corrupted entries.
94    pub total_corrupted: usize,
95    /// Whether the store is clean.
96    pub is_clean: bool,
97}
98
99impl IntegrityReport {
100    /// Get a human-readable summary.
101    #[must_use]
102    pub fn summary(&self) -> String {
103        if self.is_clean {
104            format!(
105                "Store is clean: {} entries across {} galaxies, 0 corrupted",
106                self.total_entries,
107                self.galaxies.len()
108            )
109        } else {
110            format!(
111                "Store has corruption: {} valid, {} corrupted out of {} total entries",
112                self.total_valid, self.total_corrupted, self.total_entries
113            )
114        }
115    }
116}
117
118// ── Repair Report ─────────────────────────────────────────────────────
119
120/// Result of a repair operation.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct RepairReport {
123    /// Integrity report after repair.
124    pub integrity: IntegrityReport,
125    /// Number of entries quarantined.
126    pub quarantined: usize,
127    /// Number of index entries rebuilt.
128    pub indexes_rebuilt: usize,
129    /// Path to the quarantine file (if any).
130    pub quarantine_path: Option<String>,
131    /// Path to the backup of the original data file (if any).
132    pub backup_path: Option<String>,
133    /// New map size (if grown).
134    pub new_map_size: Option<usize>,
135}
136
137// ── Quarantine Entry ──────────────────────────────────────────────────
138
139/// A quarantined entry that failed deserialization.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct QuarantineEntry {
142    /// Galaxy name.
143    pub galaxy: String,
144    /// Key (hex-encoded).
145    pub key_hex: String,
146    /// Raw value (base64-encoded).
147    pub value_base64: String,
148    /// Error message from deserialization attempt.
149    pub error: String,
150}
151
152// ── Recovery Functions ────────────────────────────────────────────────
153
154/// Maximum map size for auto-growth (4 GB).
155// Windows NTFS materializes the LMDB map file at full size on open, so the
156// auto-grow ceiling is smaller there (see MemoryStore::open_default).
157#[cfg(windows)]
158const MAX_MAP_SIZE: usize = 256 * 1024 * 1024;
159#[cfg(not(windows))]
160const MAX_MAP_SIZE: usize = 4 * 1024 * 1024 * 1024;
161
162/// Initial map size if none specified.
163#[allow(dead_code)]
164const DEFAULT_MAP_SIZE: usize = 1024 * 1024 * 1024; // 1 GB
165
166/// Check the integrity of all memory galaxies in the store.
167///
168/// This is a read-only operation — it does not modify any data.
169/// Scans all 10 memory galaxies (excluding Karma, Dharma, Associations,
170/// and Embeddings which store non-Memory data).
171pub fn check_integrity(store: &MemoryStore) -> Result<IntegrityReport> {
172    let mut galaxies = Vec::new();
173    let mut total_entries = 0;
174    let mut total_valid = 0;
175    let mut total_corrupted = 0;
176
177    for galaxy in Galaxy::memory_galaxies() {
178        let gi = check_galaxy_integrity(store, galaxy)?;
179        total_entries += gi.total;
180        total_valid += gi.valid;
181        total_corrupted += gi.corrupted;
182        galaxies.push(gi);
183    }
184
185    let is_clean = total_corrupted == 0;
186    Ok(IntegrityReport {
187        galaxies,
188        total_entries,
189        total_valid,
190        total_corrupted,
191        is_clean,
192    })
193}
194
195/// Check integrity of a single galaxy.
196fn check_galaxy_integrity(store: &MemoryStore, galaxy: Galaxy) -> Result<GalaxyIntegrity> {
197    let db = store.galaxy_db(galaxy)?;
198    let env = store.env();
199
200    let tx = env
201        .begin_ro_txn()
202        .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
203
204    let mut cursor = tx
205        .open_ro_cursor(db)
206        .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
207
208    let mut total = 0;
209    let mut valid = 0;
210    let mut corrupted_keys = Vec::new();
211
212    for (key, val) in cursor.iter() {
213        total += 1;
214        match crate::codec::decode(val) {
215            Ok(_) => valid += 1,
216            Err(_) => {
217                corrupted_keys.push(hex_encode(key));
218            }
219        }
220    }
221
222    drop(cursor);
223    tx.commit()
224        .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
225
226    let corrupted = total - valid;
227    Ok(GalaxyIntegrity {
228        galaxy: galaxy.db_name().to_string(),
229        total,
230        valid,
231        corrupted,
232        corrupted_keys,
233    })
234}
235
236/// Repair a corrupted LMDB store.
237///
238/// This will:
239/// 1. Back up the original data file.
240/// 2. Scan all memory galaxies for corrupted entries.
241/// 3. Quarantine corrupted entries to a sidecar JSONL file.
242/// 4. Delete corrupted entries from the store.
243/// 5. Rebuild secondary indexes from valid memories.
244///
245/// Returns a `RepairReport` with details of what was done.
246pub fn repair(store: &mut MemoryStore, store_path: &Path) -> Result<RepairReport> {
247    let quarantine_path = store_path.join("quarantine.jsonl");
248    let backup_path = backup_data_file(store_path)?;
249
250    // Collect corrupted entries and quarantine them
251    let mut quarantined_entries: Vec<QuarantineEntry> = Vec::new();
252    let mut indexes_rebuilt = 0;
253
254    for galaxy in Galaxy::memory_galaxies() {
255        let corrupted = collect_and_quarantine(store, galaxy, &mut quarantined_entries)?;
256
257        if corrupted.is_empty() {
258            continue;
259        }
260
261        // Delete corrupted entries from the store
262        for key_hex in &corrupted {
263            let key = hex_decode(key_hex);
264            let _ = store.delete_raw(galaxy, &key);
265        }
266
267        // Rebuild indexes for this galaxy
268        indexes_rebuilt += rebuild_galaxy_indexes(store, galaxy)?;
269    }
270
271    // Write quarantine file
272    let quarantine_str = if quarantined_entries.is_empty() {
273        None
274    } else {
275        write_quarantine_file(&quarantine_path, &quarantined_entries)?;
276        Some(quarantine_path.to_string_lossy().to_string())
277    };
278
279    // Verify integrity after repair
280    let integrity = check_integrity(store)?;
281
282    let quarantined = quarantined_entries.len();
283    Ok(RepairReport {
284        integrity,
285        quarantined,
286        indexes_rebuilt,
287        quarantine_path: quarantine_str,
288        backup_path: Some(backup_path.to_string_lossy().to_string()),
289        new_map_size: None,
290    })
291}
292
293/// Collect corrupted entries from a galaxy and add them to the quarantine list.
294/// Returns the hex-encoded keys of corrupted entries.
295fn collect_and_quarantine(
296    store: &MemoryStore,
297    galaxy: Galaxy,
298    quarantine: &mut Vec<QuarantineEntry>,
299) -> Result<Vec<String>> {
300    let db = store.galaxy_db(galaxy)?;
301    let env = store.env();
302
303    let tx = env
304        .begin_ro_txn()
305        .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
306
307    let mut cursor = tx
308        .open_ro_cursor(db)
309        .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
310
311    let mut corrupted_keys = Vec::new();
312
313    for (key, val) in cursor.iter() {
314        match crate::codec::decode(val) {
315            Ok(_) => {}
316            Err(e) => {
317                corrupted_keys.push(hex_encode(key));
318                quarantine.push(QuarantineEntry {
319                    galaxy: galaxy.db_name().to_string(),
320                    key_hex: hex_encode(key),
321                    value_base64: base64_encode(val),
322                    error: e.to_string(),
323                });
324            }
325        }
326    }
327
328    drop(cursor);
329    tx.commit()
330        .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
331
332    Ok(corrupted_keys)
333}
334
335/// Rebuild secondary indexes for a galaxy by re-adding all valid memories.
336fn rebuild_galaxy_indexes(store: &MemoryStore, galaxy: Galaxy) -> Result<usize> {
337    let db = store.galaxy_db(galaxy)?;
338    let env = store.env();
339    let index_dbs = store.index_dbs();
340
341    // First pass: collect all valid memories
342    let memories: Vec<Memory> = {
343        let tx = env
344            .begin_ro_txn()
345            .map_err(|e| CoreError::Memory(format!("LMDB ro_txn failed: {e}")))?;
346        let mut cursor = tx
347            .open_ro_cursor(db)
348            .map_err(|e| CoreError::Memory(format!("LMDB cursor failed: {e}")))?;
349        let mut mems = Vec::new();
350        for (_key, val) in cursor.iter() {
351            if let Ok(mem) = crate::codec::decode(val) {
352                mems.push(mem);
353            }
354        }
355        drop(cursor);
356        tx.commit()
357            .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
358        mems
359    };
360
361    // Second pass: remove old index entries and re-add
362    let mut count = 0;
363    let mut tx = env
364        .begin_rw_txn()
365        .map_err(|e| CoreError::Memory(format!("LMDB rw_txn failed: {e}")))?;
366
367    // Remove all existing index entries for this galaxy
368    // (We do this by removing each memory's indexes, then re-adding)
369    for mem in &memories {
370        let _ = index_dbs.remove(&mut tx, galaxy, mem);
371    }
372
373    for mem in &memories {
374        index_dbs.add(&mut tx, galaxy, mem)?;
375        count += 1;
376    }
377
378    tx.commit()
379        .map_err(|e| CoreError::Memory(format!("LMDB commit failed: {e}")))?;
380
381    Ok(count)
382}
383
384/// Back up the LMDB data file before repair.
385fn backup_data_file(store_path: &Path) -> Result<PathBuf> {
386    let data_file = store_path.join("data.mdb");
387    if !data_file.exists() {
388        return Err(CoreError::Memory(format!(
389            "LMDB data file not found: {}",
390            data_file.display()
391        )));
392    }
393
394    let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
395    let backup = store_path.join(format!("data.mdb.corrupt-{timestamp}"));
396    std::fs::copy(&data_file, &backup)
397        .map_err(|e| CoreError::Memory(format!("backup failed: {e}")))?;
398    Ok(backup)
399}
400
401/// Write quarantine entries to a JSONL file.
402fn write_quarantine_file(path: &Path, entries: &[QuarantineEntry]) -> Result<()> {
403    use std::io::Write;
404    let mut file = std::fs::File::create(path)
405        .map_err(|e| CoreError::Memory(format!("quarantine file create: {e}")))?;
406    for entry in entries {
407        let line = serde_json::to_string(entry)
408            .map_err(|e| CoreError::Memory(format!("quarantine serialize: {e}")))?;
409        writeln!(file, "{line}")
410            .map_err(|e| CoreError::Memory(format!("quarantine write: {e}")))?;
411    }
412    Ok(())
413}
414
415/// Open a MemoryStore with recovery support.
416///
417/// If `strategy` is `None`, behaves like `MemoryStore::open()`.
418/// Otherwise, attempts to open the store, and if corruption is detected,
419/// performs the appropriate recovery actions.
420pub fn open_with_recovery(
421    path: impl AsRef<Path>,
422    map_size: usize,
423    strategy: RecoveryStrategy,
424) -> Result<MemoryStore> {
425    let path = path.as_ref().to_path_buf();
426
427    // First, try normal open
428    match MemoryStore::open(&path, map_size) {
429        Ok(store) => {
430            // Check integrity
431            let report = check_integrity(&store)?;
432            if report.is_clean {
433                return Ok(store);
434            }
435
436            // Corruption detected
437            match strategy {
438                RecoveryStrategy::None => Err(CoreError::Memory(format!(
439                    "LMDB corruption detected: {} corrupted entries. Use recovery strategy to repair.",
440                    report.total_corrupted
441                ))),
442                RecoveryStrategy::WarnOnly => {
443                    tracing::warn!(
444                        "LMDB corruption detected: {} corrupted entries. WarnOnly strategy — no repair performed.",
445                        report.total_corrupted
446                    );
447                    Ok(store)
448                }
449                RecoveryStrategy::AutoRepair | RecoveryStrategy::AutoRepairAndGrow => {
450                    tracing::warn!(
451                        "LMDB corruption detected: {} corrupted entries. Attempting auto-repair.",
452                        report.total_corrupted
453                    );
454                    let mut store = store;
455                    let repair_report = repair(&mut store, &path)?;
456                    tracing::info!(
457                        "LMDB repair complete: {} quarantined, {} indexes rebuilt. Backup: {:?}",
458                        repair_report.quarantined,
459                        repair_report.indexes_rebuilt,
460                        repair_report.backup_path
461                    );
462                    Ok(store)
463                }
464            }
465        }
466        Err(e) => {
467            // Open failed — could be MAP_FULL or corruption
468            if strategy.grows_map() && map_size < MAX_MAP_SIZE {
469                let new_map_size = (map_size * 2).min(MAX_MAP_SIZE);
470                tracing::warn!(
471                    "LMDB open failed ({e}). Retrying with larger map size: {} -> {}",
472                    map_size,
473                    new_map_size
474                );
475                return open_with_recovery(&path, new_map_size, strategy);
476            }
477            Err(e)
478        }
479    }
480}
481
482/// Attempt to grow the map size of an existing store.
483///
484/// This closes the current environment and reopens with a larger map size.
485/// Returns the new map size on success.
486///
487/// Note: This function is not needed in normal operation since
488/// `open_with_recovery` handles map size growth on open. It is provided
489/// for cases where the store needs to grow while running.
490pub fn grow_map_size(path: impl AsRef<Path>, current_size: usize) -> Result<usize> {
491    let path = path.as_ref();
492    let new_size = (current_size * 2).min(MAX_MAP_SIZE);
493    if new_size == current_size {
494        return Err(CoreError::Memory(format!(
495            "Map size already at maximum ({current_size} bytes)"
496        )));
497    }
498
499    // Verify we can open with the new size
500    let env = Environment::new()
501        .set_map_size(new_size)
502        .set_max_dbs(32)
503        .open(path)
504        .map_err(|e| CoreError::Memory(format!("LMDB grow_map_size open: {e}")))?;
505    drop(env);
506
507    tracing::info!("Map size grown: {current_size} -> {new_size}");
508    Ok(new_size)
509}
510
511// ── Encoding Helpers ──────────────────────────────────────────────────
512
513/// Encode bytes as hex string.
514fn hex_encode(bytes: &[u8]) -> String {
515    let mut s = String::with_capacity(bytes.len() * 2);
516    for b in bytes {
517        let _ = write!(s, "{b:02x}");
518    }
519    s
520}
521
522/// Decode hex string to bytes.
523fn hex_decode(hex: &str) -> Vec<u8> {
524    (0..hex.len())
525        .step_by(2)
526        .filter_map(|i| u8::from_str_radix(&hex[i..i.saturating_add(2).min(hex.len())], 16).ok())
527        .collect()
528}
529
530/// Encode bytes as base64 string.
531fn base64_encode(bytes: &[u8]) -> String {
532    use std::fmt::Write;
533    const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
534    let mut result = String::new();
535    for chunk in bytes.chunks(3) {
536        let b0 = chunk[0];
537        let b1 = *chunk.get(1).unwrap_or(&0);
538        let b2 = *chunk.get(2).unwrap_or(&0);
539        let _ = result.write_char(CHARS[(b0 >> 2) as usize & 0x3F] as char);
540        let _ = result.write_char(CHARS[((b0 << 4) | (b1 >> 4)) as usize & 0x3F] as char);
541        if chunk.len() > 1 {
542            let _ = result.write_char(CHARS[((b1 << 2) | (b2 >> 6)) as usize & 0x3F] as char);
543        } else {
544            let _ = result.write_char('=');
545        }
546        if chunk.len() > 2 {
547            let _ = result.write_char(CHARS[b2 as usize & 0x3F] as char);
548        } else {
549            let _ = result.write_char('=');
550        }
551    }
552    result
553}
554
555// ── Tests ─────────────────────────────────────────────────────────────
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::memory::Memory;
561
562    #[test]
563    fn integrity_check_clean_store() {
564        let tmp = tempfile::tempdir().unwrap();
565        let store = MemoryStore::open_default(tmp.path()).unwrap();
566
567        // Add some memories
568        for i in 0..5 {
569            let mem = Memory::new(Galaxy::Codex, format!("memory-{i}"));
570            store.put(Galaxy::Codex, &mem).unwrap();
571        }
572
573        let report = check_integrity(&store).unwrap();
574        assert!(report.is_clean);
575        assert_eq!(report.total_valid, 5);
576        assert_eq!(report.total_corrupted, 0);
577    }
578
579    #[test]
580    fn integrity_check_detects_corruption() {
581        let tmp = tempfile::tempdir().unwrap();
582        let store = MemoryStore::open_default(tmp.path()).unwrap();
583
584        // Add valid memories
585        for i in 0..3 {
586            let mem = Memory::new(Galaxy::Codex, format!("valid-{i}"));
587            store.put(Galaxy::Codex, &mem).unwrap();
588        }
589
590        // Inject corrupted data
591        store
592            .put_raw(Galaxy::Codex, b"corrupted_key_1", b"not valid msgpack")
593            .unwrap();
594        store
595            .put_raw(Galaxy::Codex, b"corrupted_key_2", b"also not valid")
596            .unwrap();
597
598        let report = check_integrity(&store).unwrap();
599        assert!(!report.is_clean);
600        assert_eq!(report.total_corrupted, 2);
601        assert_eq!(report.total_valid, 3);
602
603        // Check the galaxy report
604        let codex = report
605            .galaxies
606            .iter()
607            .find(|g| g.galaxy == "codex")
608            .unwrap();
609        assert_eq!(codex.corrupted, 2);
610        assert_eq!(codex.corrupted_keys.len(), 2);
611    }
612
613    #[test]
614    fn repair_quarantines_corrupted_entries() {
615        let tmp = tempfile::tempdir().unwrap();
616        let mut store = MemoryStore::open_default(tmp.path()).unwrap();
617
618        // Add valid memories
619        for i in 0..3 {
620            let mem = Memory::new(Galaxy::Codex, format!("valid-{i}"));
621            store.put(Galaxy::Codex, &mem).unwrap();
622        }
623
624        // Inject corrupted data
625        store
626            .put_raw(Galaxy::Codex, b"corrupt_key_1", b"invalid data 1")
627            .unwrap();
628        store
629            .put_raw(Galaxy::Codex, b"corrupt_key_2", b"invalid data 2")
630            .unwrap();
631
632        // Repair
633        let report = repair(&mut store, tmp.path()).unwrap();
634        assert_eq!(report.quarantined, 2);
635        assert!(report.integrity.is_clean);
636
637        // Quarantine file should exist
638        let quarantine_path = tmp.path().join("quarantine.jsonl");
639        assert!(quarantine_path.exists());
640
641        // Backup should exist
642        assert!(report.backup_path.is_some());
643
644        // Store should be clean after repair
645        let integrity = check_integrity(&store).unwrap();
646        assert!(integrity.is_clean);
647        assert_eq!(integrity.total_valid, 3);
648    }
649
650    #[test]
651    fn repair_preserves_valid_memories() {
652        let tmp = tempfile::tempdir().unwrap();
653        let mut store = MemoryStore::open_default(tmp.path()).unwrap();
654
655        // Add valid memories across galaxies
656        let mem1 = Memory::new(Galaxy::Codex, "important data".to_string());
657        let mem2 = Memory::new(Galaxy::Research, "research note".to_string());
658        let id1 = mem1.metadata.id;
659        let id2 = mem2.metadata.id;
660        store.put(Galaxy::Codex, &mem1).unwrap();
661        store.put(Galaxy::Research, &mem2).unwrap();
662
663        // Inject corruption
664        store
665            .put_raw(Galaxy::Codex, b"bad_key", b"corrupted")
666            .unwrap();
667
668        // Repair
669        let _ = repair(&mut store, tmp.path()).unwrap();
670
671        // Valid memories should still be accessible
672        let retrieved1 = store.get(Galaxy::Codex, id1).unwrap();
673        assert!(retrieved1.is_some());
674        assert_eq!(retrieved1.unwrap().content, "important data");
675
676        let retrieved2 = store.get(Galaxy::Research, id2).unwrap();
677        assert!(retrieved2.is_some());
678        assert_eq!(retrieved2.unwrap().content, "research note");
679    }
680
681    #[test]
682    fn repair_rebuilds_indexes() {
683        let tmp = tempfile::tempdir().unwrap();
684        let mut store = MemoryStore::open_default(tmp.path()).unwrap();
685
686        // Add a memory with tags
687        let mut mem = Memory::new(Galaxy::Codex, "tagged memory".to_string());
688        mem.metadata.tags = vec!["important".to_string(), "test".to_string()];
689        let content_hash = mem.metadata.content_hash.clone();
690        store.put(Galaxy::Codex, &mem).unwrap();
691
692        // Verify index works before repair
693        let found = store
694            .find_by_content_hash(Galaxy::Codex, &content_hash)
695            .unwrap();
696        assert!(found.is_some());
697
698        // Inject corruption
699        store
700            .put_raw(Galaxy::Codex, b"bad_key", b"corrupted")
701            .unwrap();
702
703        // Repair
704        let report = repair(&mut store, tmp.path()).unwrap();
705        assert!(report.indexes_rebuilt > 0);
706
707        // Index should still work after repair
708        let found = store
709            .find_by_content_hash(Galaxy::Codex, &content_hash)
710            .unwrap();
711        assert!(found.is_some());
712    }
713
714    #[test]
715    fn open_with_recovery_clean_store() {
716        let tmp = tempfile::tempdir().unwrap();
717        let store = MemoryStore::open_default(tmp.path()).unwrap();
718        let mem = Memory::new(Galaxy::Codex, "test".to_string());
719        store.put(Galaxy::Codex, &mem).unwrap();
720        drop(store);
721
722        // Reopen with recovery — should work fine
723        let store = open_with_recovery(tmp.path(), DEFAULT_MAP_SIZE, RecoveryStrategy::AutoRepair);
724        assert!(store.is_ok());
725    }
726
727    #[test]
728    fn open_with_recovery_auto_repairs() {
729        let tmp = tempfile::tempdir().unwrap();
730        let store = MemoryStore::open_default(tmp.path()).unwrap();
731
732        // Add valid + corrupted data
733        let mem = Memory::new(Galaxy::Codex, "valid".to_string());
734        store.put(Galaxy::Codex, &mem).unwrap();
735        store
736            .put_raw(Galaxy::Codex, b"bad_key", b"corrupted data")
737            .unwrap();
738        drop(store);
739
740        // Reopen with AutoRepair — should repair and succeed
741        let store = open_with_recovery(tmp.path(), DEFAULT_MAP_SIZE, RecoveryStrategy::AutoRepair);
742        assert!(store.is_ok());
743        let store = store.unwrap();
744
745        // Verify clean
746        let report = check_integrity(&store).unwrap();
747        assert!(report.is_clean);
748        assert_eq!(report.total_valid, 1);
749    }
750
751    #[test]
752    fn open_with_recovery_none_fails_on_corruption() {
753        let tmp = tempfile::tempdir().unwrap();
754        let store = MemoryStore::open_default(tmp.path()).unwrap();
755
756        // Add corrupted data
757        store
758            .put_raw(Galaxy::Codex, b"bad_key", b"corrupted data")
759            .unwrap();
760        drop(store);
761
762        // Reopen with None — should fail
763        let result = open_with_recovery(tmp.path(), DEFAULT_MAP_SIZE, RecoveryStrategy::None);
764        assert!(result.is_err());
765    }
766
767    #[test]
768    fn open_with_recovery_warn_only_succeeds() {
769        let tmp = tempfile::tempdir().unwrap();
770        let store = MemoryStore::open_default(tmp.path()).unwrap();
771
772        // Add corrupted data
773        store
774            .put_raw(Galaxy::Codex, b"bad_key", b"corrupted data")
775            .unwrap();
776        drop(store);
777
778        // Reopen with WarnOnly — should succeed
779        let store = open_with_recovery(tmp.path(), DEFAULT_MAP_SIZE, RecoveryStrategy::WarnOnly);
780        assert!(store.is_ok());
781    }
782
783    #[test]
784    fn grow_map_size_doubles() {
785        let tmp = tempfile::tempdir().unwrap();
786        let store = MemoryStore::open(tmp.path(), 1024 * 1024).unwrap();
787        drop(store);
788
789        let new_size = grow_map_size(tmp.path(), 1024 * 1024).unwrap();
790        assert_eq!(new_size, 2 * 1024 * 1024);
791    }
792
793    #[test]
794    fn grow_map_size_capped_at_max() {
795        let tmp = tempfile::tempdir().unwrap();
796        let store = MemoryStore::open(tmp.path(), MAX_MAP_SIZE).unwrap();
797        drop(store);
798
799        // Already at max — should fail
800        let result = grow_map_size(tmp.path(), MAX_MAP_SIZE);
801        assert!(result.is_err());
802    }
803
804    #[test]
805    fn hex_encode_decode_roundtrip() {
806        let original = b"hello world";
807        let encoded = hex_encode(original);
808        let decoded = hex_decode(&encoded);
809        assert_eq!(decoded, original);
810    }
811
812    #[test]
813    fn base64_encode_known_values() {
814        assert_eq!(base64_encode(b""), "");
815        assert_eq!(base64_encode(b"f"), "Zg==");
816        assert_eq!(base64_encode(b"fo"), "Zm8=");
817        assert_eq!(base64_encode(b"foo"), "Zm9v");
818        assert_eq!(base64_encode(b"foob"), "Zm9vYg==");
819        assert_eq!(base64_encode(b"fooba"), "Zm9vYmE=");
820        assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");
821    }
822
823    #[test]
824    fn integrity_report_summary_clean() {
825        let report = IntegrityReport {
826            galaxies: vec![GalaxyIntegrity {
827                galaxy: "codex".to_string(),
828                total: 5,
829                valid: 5,
830                corrupted: 0,
831                corrupted_keys: vec![],
832            }],
833            total_entries: 5,
834            total_valid: 5,
835            total_corrupted: 0,
836            is_clean: true,
837        };
838        let summary = report.summary();
839        assert!(summary.contains("clean"));
840    }
841
842    #[test]
843    fn integrity_report_summary_corrupted() {
844        let report = IntegrityReport {
845            galaxies: vec![GalaxyIntegrity {
846                galaxy: "codex".to_string(),
847                total: 5,
848                valid: 3,
849                corrupted: 2,
850                corrupted_keys: vec!["ab".to_string(), "cd".to_string()],
851            }],
852            total_entries: 5,
853            total_valid: 3,
854            total_corrupted: 2,
855            is_clean: false,
856        };
857        let summary = report.summary();
858        assert!(summary.contains("corruption"));
859        assert!(summary.contains("2 corrupted"));
860    }
861
862    #[test]
863    fn recovery_strategy_flags() {
864        assert!(!RecoveryStrategy::None.repairs());
865        assert!(!RecoveryStrategy::WarnOnly.repairs());
866        assert!(RecoveryStrategy::AutoRepair.repairs());
867        assert!(RecoveryStrategy::AutoRepairAndGrow.repairs());
868
869        assert!(!RecoveryStrategy::None.grows_map());
870        assert!(!RecoveryStrategy::WarnOnly.grows_map());
871        assert!(!RecoveryStrategy::AutoRepair.grows_map());
872        assert!(RecoveryStrategy::AutoRepairAndGrow.grows_map());
873    }
874
875    #[test]
876    fn repair_on_clean_store_is_noop() {
877        let tmp = tempfile::tempdir().unwrap();
878        let mut store = MemoryStore::open_default(tmp.path()).unwrap();
879
880        let mem = Memory::new(Galaxy::Codex, "clean".to_string());
881        store.put(Galaxy::Codex, &mem).unwrap();
882
883        let report = repair(&mut store, tmp.path()).unwrap();
884        assert_eq!(report.quarantined, 0);
885        assert!(report.integrity.is_clean);
886    }
887
888    #[test]
889    fn repair_across_multiple_galaxies() {
890        let tmp = tempfile::tempdir().unwrap();
891        let mut store = MemoryStore::open_default(tmp.path()).unwrap();
892
893        // Valid memories in different galaxies
894        store
895            .put(
896                Galaxy::Codex,
897                &Memory::new(Galaxy::Codex, "codex".to_string()),
898            )
899            .unwrap();
900        store
901            .put(
902                Galaxy::Research,
903                &Memory::new(Galaxy::Research, "research".to_string()),
904            )
905            .unwrap();
906        store
907            .put(Galaxy::Aria, &Memory::new(Galaxy::Aria, "aria".to_string()))
908            .unwrap();
909
910        // Corrupted entries in different galaxies
911        store.put_raw(Galaxy::Codex, b"bad1", b"x").unwrap();
912        store.put_raw(Galaxy::Research, b"bad2", b"y").unwrap();
913
914        let report = repair(&mut store, tmp.path()).unwrap();
915        assert_eq!(report.quarantined, 2);
916        assert!(report.integrity.is_clean);
917        assert_eq!(report.integrity.total_valid, 3);
918    }
919}