1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "snake_case")]
32pub enum RecoveryStrategy {
33 #[default]
35 None,
36 WarnOnly,
38 AutoRepair,
40 AutoRepairAndGrow,
42}
43
44#[allow(clippy::derivable_impls, clippy::should_implement_trait)]
45impl RecoveryStrategy {
46 #[must_use]
48 pub const fn repairs(self) -> bool {
49 matches!(self, Self::AutoRepair | Self::AutoRepairAndGrow)
50 }
51
52 #[must_use]
54 pub const fn grows_map(self) -> bool {
55 matches!(self, Self::AutoRepairAndGrow)
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct GalaxyIntegrity {
64 pub galaxy: String,
66 pub total: usize,
68 pub valid: usize,
70 pub corrupted: usize,
72 pub corrupted_keys: Vec<String>,
74}
75
76impl GalaxyIntegrity {
77 #[must_use]
79 pub const fn is_clean(&self) -> bool {
80 self.corrupted == 0
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct IntegrityReport {
87 pub galaxies: Vec<GalaxyIntegrity>,
89 pub total_entries: usize,
91 pub total_valid: usize,
93 pub total_corrupted: usize,
95 pub is_clean: bool,
97}
98
99impl IntegrityReport {
100 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct RepairReport {
123 pub integrity: IntegrityReport,
125 pub quarantined: usize,
127 pub indexes_rebuilt: usize,
129 pub quarantine_path: Option<String>,
131 pub backup_path: Option<String>,
133 pub new_map_size: Option<usize>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct QuarantineEntry {
142 pub galaxy: String,
144 pub key_hex: String,
146 pub value_base64: String,
148 pub error: String,
150}
151
152#[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#[allow(dead_code)]
164const DEFAULT_MAP_SIZE: usize = 1024 * 1024 * 1024; pub 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
195fn 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
236pub 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 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 for key_hex in &corrupted {
263 let key = hex_decode(key_hex);
264 let _ = store.delete_raw(galaxy, &key);
265 }
266
267 indexes_rebuilt += rebuild_galaxy_indexes(store, galaxy)?;
269 }
270
271 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 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
293fn 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
335fn 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 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 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 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
384fn 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
401fn 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
415pub 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 match MemoryStore::open(&path, map_size) {
429 Ok(store) => {
430 let report = check_integrity(&store)?;
432 if report.is_clean {
433 return Ok(store);
434 }
435
436 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 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
482pub 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 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
511fn 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
522fn 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
530fn 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#[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 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 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 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 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 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 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 let report = repair(&mut store, tmp.path()).unwrap();
634 assert_eq!(report.quarantined, 2);
635 assert!(report.integrity.is_clean);
636
637 let quarantine_path = tmp.path().join("quarantine.jsonl");
639 assert!(quarantine_path.exists());
640
641 assert!(report.backup_path.is_some());
643
644 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 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 store
665 .put_raw(Galaxy::Codex, b"bad_key", b"corrupted")
666 .unwrap();
667
668 let _ = repair(&mut store, tmp.path()).unwrap();
670
671 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 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 let found = store
694 .find_by_content_hash(Galaxy::Codex, &content_hash)
695 .unwrap();
696 assert!(found.is_some());
697
698 store
700 .put_raw(Galaxy::Codex, b"bad_key", b"corrupted")
701 .unwrap();
702
703 let report = repair(&mut store, tmp.path()).unwrap();
705 assert!(report.indexes_rebuilt > 0);
706
707 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 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 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 let store = open_with_recovery(tmp.path(), DEFAULT_MAP_SIZE, RecoveryStrategy::AutoRepair);
742 assert!(store.is_ok());
743 let store = store.unwrap();
744
745 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 store
758 .put_raw(Galaxy::Codex, b"bad_key", b"corrupted data")
759 .unwrap();
760 drop(store);
761
762 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 store
774 .put_raw(Galaxy::Codex, b"bad_key", b"corrupted data")
775 .unwrap();
776 drop(store);
777
778 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 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 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 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}