1use serde::{Deserialize, Serialize};
13use wm_core::{CoreError, Galaxy, Result};
14
15use crate::memory::Memory;
16use crate::search::{SearchEngine, sanitize_content_for_index};
17use crate::store::MemoryStore;
18
19pub const REINDEX_BATCH: usize = 2_000;
22
23#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GalaxyRebuildStats {
26 pub galaxy: String,
28 pub scanned: usize,
30 pub indexed: usize,
32 pub skipped: usize,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct IndexRebuildReport {
39 pub scanned: usize,
41 pub indexed: usize,
43 pub skipped: usize,
45 pub galaxies: Vec<GalaxyRebuildStats>,
47}
48
49#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
51pub struct GalaxyConsistency {
52 pub galaxy: String,
54 pub lmdb_count: usize,
56 pub tantivy_count: usize,
58 pub drift: bool,
60}
61
62#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConsistencyReport {
65 pub galaxies: Vec<GalaxyConsistency>,
67 pub total_lmdb: usize,
69 pub total_tantivy: usize,
71 pub has_drift: bool,
73}
74
75#[must_use]
87pub fn check_consistency(store: &MemoryStore, search: &SearchEngine) -> ConsistencyReport {
88 let mut report = ConsistencyReport::default();
89 for galaxy in Galaxy::memory_galaxies() {
90 let lmdb_count = store.count(galaxy).unwrap_or(0);
91 let tantivy_count = search.count_docs_in_galaxy(galaxy.db_name()).unwrap_or(0);
92 let drift = lmdb_count != tantivy_count;
93 report.total_lmdb += lmdb_count;
94 report.total_tantivy += tantivy_count;
95 if drift {
96 report.has_drift = true;
97 }
98 report.galaxies.push(GalaxyConsistency {
99 galaxy: galaxy.db_name().to_string(),
100 lmdb_count,
101 tantivy_count,
102 drift,
103 });
104 }
105 report
106}
107
108#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct GalaxyDriftClass {
119 pub galaxy: String,
121 pub lmdb_count: usize,
123 pub tantivy_count: usize,
125 pub skip_reserve: usize,
129 pub healable_gap: i64,
133}
134
135#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
137pub struct DriftClassification {
138 pub galaxies: Vec<GalaxyDriftClass>,
140 pub skip_reserve_total: usize,
142 pub healable_total: usize,
144}
145
146#[must_use]
154pub fn classify_drift(store: &MemoryStore, search: &SearchEngine) -> DriftClassification {
155 let mut out = DriftClassification::default();
156 for galaxy in Galaxy::memory_galaxies() {
157 let lmdb_count = store.count(galaxy).unwrap_or(0);
158 let tantivy_count = search.count_docs_in_galaxy(galaxy.db_name()).unwrap_or(0);
159 let mut skip_reserve = 0usize;
160 if lmdb_count > tantivy_count {
161 for mem in store.scan(galaxy, lmdb_count).unwrap_or_default() {
162 if sanitize_content_for_index(&mem.content).is_none() {
163 skip_reserve += 1;
164 }
165 }
166 }
167 let indexable = usize::try_into(lmdb_count - skip_reserve).unwrap_or(i64::MAX);
168 let indexed = usize::try_into(tantivy_count).unwrap_or(i64::MAX);
169 let healable_gap = indexable - indexed;
170 out.skip_reserve_total += skip_reserve;
171 out.healable_total += healable_gap.unsigned_abs() as usize;
172 out.galaxies.push(GalaxyDriftClass {
173 galaxy: galaxy.db_name().to_string(),
174 lmdb_count,
175 tantivy_count,
176 skip_reserve,
177 healable_gap,
178 });
179 }
180 out
181}
182
183pub fn rebuild_index(
194 store: &MemoryStore,
195 search: &SearchEngine,
196 galaxy_filter: &[String],
197) -> Result<IndexRebuildReport> {
198 let mut report = IndexRebuildReport::default();
199 {
200 let mut writer = search.writer()?;
201 if galaxy_filter.is_empty() {
202 writer
203 .as_mut()
204 .ok_or_else(|| {
205 CoreError::Memory("Tantivy writer unavailable: index opened read-only".into())
206 })?
207 .delete_all_documents()
208 .map_err(|e| CoreError::Memory(format!("Tantivy delete_all_documents: {e}")))?;
209 } else {
210 for galaxy in Galaxy::all() {
214 if galaxy_filter.iter().any(|g| g == galaxy.db_name()) {
215 search.delete_by_galaxy(&mut writer, galaxy.db_name())?;
216 }
217 }
218 }
219
220 for galaxy in Galaxy::all() {
221 if !galaxy_filter.is_empty() && !galaxy_filter.iter().any(|g| g == galaxy.db_name()) {
222 continue;
223 }
224 let memories = store.scan_all(galaxy)?;
225 let mut stats = GalaxyRebuildStats {
226 galaxy: galaxy.db_name().to_string(),
227 ..GalaxyRebuildStats::default()
228 };
229 for mem in &memories {
230 stats.scanned += 1;
231 if index_memory(search, &mut writer, galaxy, mem)?.is_some() {
232 stats.indexed += 1;
233 } else {
234 stats.skipped += 1;
235 }
236 }
237 report.scanned += stats.scanned;
238 report.indexed += stats.indexed;
239 report.skipped += stats.skipped;
240 report.galaxies.push(stats);
241 }
242
243 search.commit(&mut writer)?;
244 drop(writer);
245 }
246 Ok(report)
247}
248
249fn index_memory(
252 search: &SearchEngine,
253 writer: &mut Option<tantivy::IndexWriter>,
254 galaxy: Galaxy,
255 mem: &Memory,
256) -> Result<Option<()>> {
257 let Some(content) = sanitize_content_for_index(&mem.content) else {
258 return Ok(None);
259 };
260 let timestamp = mem.metadata.created_at.timestamp();
261 let id = mem.metadata.id.to_string();
262 search.add_document(
263 writer,
264 &id,
265 galaxy.db_name(),
266 &content,
267 &mem.metadata.tags,
268 timestamp,
269 )?;
270 Ok(Some(()))
271}
272
273pub fn heal_index_drift(
288 store: &MemoryStore,
289 search: &SearchEngine,
290) -> Result<Option<IndexRebuildReport>> {
291 let class = classify_drift(store, search);
296 let drifted: Vec<String> = class
297 .galaxies
298 .iter()
299 .filter(|g| g.healable_gap != 0)
300 .map(|g| g.galaxy.clone())
301 .collect();
302 if drifted.is_empty() {
303 return Ok(None);
304 }
305 rebuild_index(store, search, &drifted).map(Some)
306}
307
308#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
312pub struct GalaxyContentRepairStats {
313 pub galaxy: String,
315 pub scanned: usize,
317 pub repaired: usize,
319 pub unrepairable: usize,
322 pub already_clean: usize,
324}
325
326#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
328pub struct ContentRepairReport {
329 pub scanned: usize,
331 pub repaired: usize,
333 pub unrepairable: usize,
335 pub already_clean: usize,
337 pub galaxies: Vec<GalaxyContentRepairStats>,
339}
340
341fn clean_for_repair(content: &str) -> String {
345 content
346 .chars()
347 .map(|c| {
348 if c.is_control() && c != '\n' && c != '\t' && c != '\r' {
349 ' '
350 } else {
351 c
352 }
353 })
354 .collect()
355}
356
357pub fn repair_content(
381 store: &MemoryStore,
382 search: &SearchEngine,
383 galaxies: &[Galaxy],
384) -> Result<ContentRepairReport> {
385 let mut report = ContentRepairReport::default();
386 let mut writer = search.writer()?;
387 for galaxy in galaxies {
388 let mut stats = GalaxyContentRepairStats {
389 galaxy: galaxy.db_name().to_string(),
390 ..Default::default()
391 };
392 for mem in store.scan_all(*galaxy)? {
393 stats.scanned += 1;
394 if sanitize_content_for_index(&mem.content).is_some() {
395 stats.already_clean += 1;
396 continue;
397 }
398 let total = mem.content.chars().count();
401 let printable = mem.content.chars().filter(|c| !c.is_control()).count();
402 let cleaned = clean_for_repair(&mem.content);
403 if total == 0
404 || (printable as f32 / total as f32) < 0.5
405 || sanitize_content_for_index(&cleaned).is_none()
406 {
407 stats.unrepairable += 1;
408 continue;
409 }
410 let mut repaired_mem = mem;
411 let old_hash = repaired_mem.metadata.content_hash.clone();
412 repaired_mem.content = cleaned;
413 repaired_mem.metadata.content_hash = crate::content_hash(&repaired_mem.content);
414 repaired_mem.metadata.revision_count =
415 repaired_mem.metadata.revision_count.saturating_add(1);
416 store.put(*galaxy, &repaired_mem)?;
417 store.record_revision(
421 *galaxy,
422 repaired_mem.metadata.id,
423 &old_hash,
424 &repaired_mem.metadata.content_hash,
425 crate::revision::RevisionActor {
426 session: None,
427 user: Some("wm-repair-content".to_string()),
428 compartment: None,
429 },
430 )?;
431 let id_str = repaired_mem.metadata.id.to_string();
432 search.delete_document(&mut writer, &id_str)?;
435 search.add_document(
436 &mut writer,
437 &id_str,
438 galaxy.db_name(),
439 &repaired_mem.content,
440 &repaired_mem.metadata.tags,
441 repaired_mem.metadata.created_at.timestamp(),
442 )?;
443 stats.repaired += 1;
444 }
445 report.scanned += stats.scanned;
446 report.repaired += stats.repaired;
447 report.unrepairable += stats.unrepairable;
448 report.already_clean += stats.already_clean;
449 report.galaxies.push(stats);
450 }
451 search.commit(&mut writer)?;
452 Ok(report)
453}
454
455#[must_use]
458pub fn tantivy_path_for(store_path: &std::path::Path) -> std::path::PathBuf {
459 store_path.join("tantivy")
460}
461
462#[must_use]
464pub fn missing_index_error(store_path: &std::path::Path) -> CoreError {
465 CoreError::Memory(format!(
466 "Tantivy index not found at {} — run 'wm serve' once to create it",
467 tantivy_path_for(store_path).display()
468 ))
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::Memory;
475 use tempfile::tempdir;
476
477 fn setup() -> (tempfile::TempDir, MemoryStore, SearchEngine) {
478 let tmp = tempdir().unwrap();
479 let store = MemoryStore::open_default(tmp.path()).unwrap();
480 let tantivy_dir = tmp.path().join("tantivy");
481 std::fs::create_dir_all(&tantivy_dir).unwrap();
482 let search = SearchEngine::open(&tantivy_dir).unwrap();
483 (tmp, store, search)
484 }
485
486 fn put_and_index(store: &MemoryStore, search: &SearchEngine, galaxy: Galaxy, content: &str) {
487 let mem = Memory::new(galaxy, content.to_string());
488 let id = mem.metadata.id;
489 store.put(galaxy, &mem).unwrap();
490 let mut writer = search.writer().unwrap();
491 search
492 .add_document(
493 &mut writer,
494 &id.to_string(),
495 galaxy.db_name(),
496 content,
497 &mem.metadata.tags,
498 mem.metadata.created_at.timestamp(),
499 )
500 .unwrap();
501 search.commit(&mut writer).unwrap();
502 }
503
504 #[test]
505 fn rebuild_repopulates_index_from_lmdb() {
506 let (_tmp, store, search) = setup();
507 put_and_index(&store, &search, Galaxy::Codex, "rust memory one");
508 put_and_index(&store, &search, Galaxy::Codex, "python memory two");
509 put_and_index(&store, &search, Galaxy::Research, "research notes");
510
511 {
514 let mut writer = search.writer().unwrap();
515 search
516 .add_document(
517 &mut writer,
518 "99999999-9999-9999-9999-999999999999",
519 "codex",
520 "stale ghost document",
521 &[],
522 1000,
523 )
524 .unwrap();
525 search.commit(&mut writer).unwrap();
526 }
527 let ghost = search.search("ghost", 10).unwrap();
528 assert_eq!(ghost.len(), 1);
529
530 let report = rebuild_index(&store, &search, &[]).unwrap();
531 assert_eq!(report.indexed, 3);
532 assert_eq!(report.scanned, 3);
533 assert_eq!(report.galaxies.len(), Galaxy::COUNT);
534
535 let ghost = search.search("ghost", 10).unwrap();
536 assert!(ghost.is_empty(), "stale index entry must be purged");
537
538 let rust = search.search("rust memory one", 10).unwrap();
539 assert_eq!(rust.len(), 1);
540 assert_eq!(rust[0].content, "rust memory one");
541 }
542
543 #[test]
544 fn rebuild_skips_binary_garbage() {
545 let (_tmp, store, search) = setup();
546 put_and_index(&store, &search, Galaxy::Codex, "clean text entry");
547 let mem = Memory::new(Galaxy::Codex, "\u{00}\u{01}\u{02}raw bytes".to_string());
548 store.put(Galaxy::Codex, &mem).unwrap();
549
550 let report = rebuild_index(&store, &search, &[]).unwrap();
551 assert_eq!(report.indexed, 1, "garbage content must be skipped");
552 assert_eq!(report.skipped, 1);
553
554 let results = search.search("raw", 10).unwrap();
555 assert!(results.is_empty());
556 }
557
558 #[test]
559 fn rebuild_respects_galaxy_filter() {
560 let (_tmp, store, search) = setup();
561 put_and_index(&store, &search, Galaxy::Codex, "codex memory");
562 put_and_index(&store, &search, Galaxy::Research, "research memory");
563
564 let report = rebuild_index(&store, &search, &["codex".to_string()]).unwrap();
565 assert_eq!(report.indexed, 1);
566 assert_eq!(report.galaxies.len(), 1);
567 assert_eq!(report.galaxies[0].galaxy, "codex");
568
569 let codex = search
574 .search_in_galaxy("codex memory", Some(Galaxy::Codex), 10)
575 .unwrap();
576 assert_eq!(codex.len(), 1);
577 let research = search
578 .search_in_galaxy("research memory", Some(Galaxy::Research), 10)
579 .unwrap();
580 assert_eq!(
581 research.len(),
582 1,
583 "filtered rebuild must preserve documents in unselected galaxies"
584 );
585 }
586
587 #[test]
588 fn consistency_check_no_drift_when_indexed() {
589 let (_tmp, store, search) = setup();
590 put_and_index(&store, &search, Galaxy::Codex, "hello world");
591 put_and_index(&store, &search, Galaxy::Codex, "another memory");
592
593 let report = check_consistency(&store, &search);
594 assert!(!report.has_drift, "no drift expected when all indexed");
595 let codex = report
596 .galaxies
597 .iter()
598 .find(|g| g.galaxy == "codex")
599 .unwrap();
600 assert_eq!(codex.lmdb_count, 2);
601 assert_eq!(codex.tantivy_count, 2);
602 }
603
604 #[test]
605 fn consistency_check_detects_drift() {
606 let (_tmp, store, search) = setup();
607 let mem = Memory::new(Galaxy::Codex, "unindexed".to_string());
609 store.put(Galaxy::Codex, &mem).unwrap();
610
611 let report = check_consistency(&store, &search);
612 assert!(
613 report.has_drift,
614 "drift expected when LMDB has unindexed memory"
615 );
616 let codex = report
617 .galaxies
618 .iter()
619 .find(|g| g.galaxy == "codex")
620 .unwrap();
621 assert_eq!(codex.lmdb_count, 1);
622 assert_eq!(codex.tantivy_count, 0);
623 }
624
625 #[test]
626 fn heal_repairs_only_drifted_galaxies() {
627 let (_tmp, store, search) = setup();
628
629 store
631 .put(
632 Galaxy::Sessions,
633 &Memory::new(Galaxy::Sessions, "session needle".into()),
634 )
635 .unwrap();
636 store
637 .put(
638 Galaxy::Research,
639 &Memory::new(Galaxy::Research, "research needle".into()),
640 )
641 .unwrap();
642 put_and_index(&store, &search, Galaxy::Codex, "healthy codex entry");
644
645 let report = heal_index_drift(&store, &search)
646 .unwrap()
647 .expect("drift expected before heal");
648 let healed: Vec<_> = report.galaxies.iter().map(|g| g.galaxy.as_str()).collect();
649 assert!(healed.contains(&"sessions"));
650 assert!(healed.contains(&"research"));
651 assert!(
652 !healed.contains(&"codex"),
653 "healthy galaxy must be untouched"
654 );
655 assert_eq!(report.indexed, 2);
656
657 assert!(
658 heal_index_drift(&store, &search).unwrap().is_none(),
659 "second heal must be a no-op once consistent"
660 );
661 assert_eq!(
662 search
663 .search_in_galaxy("session needle", Some(Galaxy::Sessions), 10)
664 .unwrap()
665 .len(),
666 1
667 );
668 assert_eq!(
669 search
670 .search_in_galaxy("healthy codex", Some(Galaxy::Codex), 10)
671 .unwrap()
672 .len(),
673 1
674 );
675 }
676
677 #[test]
678 fn heal_noop_when_consistent() {
679 let (_tmp, store, search) = setup();
680 put_and_index(&store, &search, Galaxy::Codex, "indexed entry");
681 put_and_index(&store, &search, Galaxy::Dreams, "dream entry");
682
683 assert!(heal_index_drift(&store, &search).unwrap().is_none());
684 }
685
686 #[test]
687 fn index_health_tracks_successes() {
688 let (_tmp, store, search) = setup();
689 put_and_index(&store, &search, Galaxy::Codex, "test content");
690
691 let health = search.health().snapshot();
692 let successes = health
693 .get("successes")
694 .and_then(serde_json::Value::as_u64)
695 .unwrap_or(0);
696 assert!(successes > 0, "expected at least one success");
697 let failures = health
698 .get("failures")
699 .and_then(serde_json::Value::as_u64)
700 .unwrap_or(0);
701 assert_eq!(failures, 0);
702 assert_eq!(
703 health.get("degraded").and_then(serde_json::Value::as_bool),
704 Some(false)
705 );
706 }
707
708 #[test]
709 fn consistency_check_ignores_non_memory_galaxies() {
710 let (_tmp, store, search) = setup();
711 put_and_index(&store, &search, Galaxy::Codex, "indexed memory");
712
713 store.put_raw(Galaxy::Karma, b"key1", b"value1").unwrap();
716
717 let report = check_consistency(&store, &search);
718 assert!(
719 !report.has_drift,
720 "karma entries should not cause drift — non-memory galaxies are excluded"
721 );
722 let galaxy_names: Vec<_> = report.galaxies.iter().map(|g| g.galaxy.as_str()).collect();
724 assert!(
725 !galaxy_names.contains(&"karma"),
726 "karma should not appear in consistency report"
727 );
728 assert!(
729 !galaxy_names.contains(&"dharma"),
730 "dharma should not appear in consistency report"
731 );
732 }
733 #[test]
736 fn classify_separates_skip_reserve_from_healable_drift() {
737 let (tmp, store, search) = setup();
738 put_and_index(&store, &search, Galaxy::Codex, "clean doc one");
740 put_and_index(&store, &search, Galaxy::Codex, "clean doc two");
741 store
742 .put(
743 Galaxy::Codex,
744 &Memory::new(Galaxy::Codex, "bad \u{1}\u{2} doc".into()),
745 )
746 .unwrap();
747 search.commit(&mut search.writer().unwrap()).unwrap();
748
749 let class = classify_drift(&store, &search);
750 let codex = class.galaxies.iter().find(|g| g.galaxy == "codex").unwrap();
751 assert_eq!(codex.lmdb_count, 3);
752 assert_eq!(codex.tantivy_count, 2);
753 assert_eq!(codex.skip_reserve, 1);
754 assert_eq!(codex.healable_gap, 0, "skip reserve fully explains the gap");
755 assert_eq!(class.healable_total, 0);
756 drop(tmp);
757 }
758
759 #[test]
760 fn classify_flags_missing_indexable_docs_as_healable() {
761 let (tmp, store, search) = setup();
762 put_and_index(&store, &search, Galaxy::Codex, "indexed doc");
763 store
765 .put(
766 Galaxy::Codex,
767 &Memory::new(Galaxy::Codex, "unindexed clean doc".into()),
768 )
769 .unwrap();
770 search.commit(&mut search.writer().unwrap()).unwrap();
771
772 let class = classify_drift(&store, &search);
773 let codex = class.galaxies.iter().find(|g| g.galaxy == "codex").unwrap();
774 assert_eq!(codex.skip_reserve, 0);
775 assert_eq!(codex.healable_gap, 1);
776 assert_eq!(class.healable_total, 1);
777 drop(tmp);
778 }
779
780 #[test]
781 fn heal_ignores_pure_skip_reserve_and_heals_real_gaps() {
782 let (tmp, store, search) = setup();
783 put_and_index(&store, &search, Galaxy::Codex, "clean doc");
784 store
787 .put(
788 Galaxy::Codex,
789 &Memory::new(Galaxy::Codex, "gate\u{0} fails".into()),
790 )
791 .unwrap();
792 search.commit(&mut search.writer().unwrap()).unwrap();
793 let healed = heal_index_drift(&store, &search).unwrap();
794 assert!(
795 healed.is_none(),
796 "skip-reserve-only drift must not trigger a rebuild"
797 );
798
799 store
801 .put(
802 Galaxy::Codex,
803 &Memory::new(Galaxy::Codex, "genuinely missing doc".into()),
804 )
805 .unwrap();
806 let healed = heal_index_drift(&store, &search).unwrap();
807 assert!(healed.is_some(), "healable drift must trigger a rebuild");
808 assert_eq!(healed.unwrap().indexed, 2);
811 drop(tmp);
812 }
813
814 #[test]
815 fn repair_rewrites_in_place_and_indexes_clean_content() {
816 let (tmp, store, search) = setup();
817 let mut repairable = Memory::new(
820 Galaxy::Codex,
821 "kumquat\u{0} ratchet \u{1} repair end".into(),
822 );
823 let mut binary = Memory::new(Galaxy::Codex, "\u{1}\u{2}\u{3}\u{4}\u{5}\u{6}".into());
825 let mut clean = Memory::new(Galaxy::Codex, "perfectly fine prose".into());
827 let (id_r, id_b, id_c) = (
828 repairable.metadata.id,
829 binary.metadata.id,
830 clean.metadata.id,
831 );
832 for m in [&mut repairable, &mut binary, &mut clean] {
833 store.put(Galaxy::Codex, m).unwrap();
834 }
835
836 let report = repair_content(&store, &search, &[Galaxy::Codex]).unwrap();
837 assert_eq!(report.scanned, 3);
838 assert_eq!(report.repaired, 1, "{report:?}");
839 assert_eq!(report.unrepairable, 1, "{report:?}");
840 assert_eq!(report.already_clean, 1);
841
842 let row = store.get(Galaxy::Codex, id_r).unwrap().unwrap();
845 assert_eq!(row.content, "kumquat ratchet repair end");
846 assert_eq!(row.metadata.content_hash, crate::content_hash(&row.content));
847 assert!(sanitize_content_for_index(&row.content).is_some());
848 assert_eq!(row.metadata.revision_count, 1);
849
850 let chain = store.revisions(Galaxy::Codex, id_r).unwrap();
853 assert_eq!(chain.len(), 1);
854 assert_eq!(
855 chain[0].old_hash,
856 crate::content_hash("kumquat\u{0} ratchet \u{1} repair end")
857 );
858 assert_eq!(chain[0].new_hash, row.metadata.content_hash);
859 assert_eq!(chain[0].actor_user.as_deref(), Some("wm-repair-content"));
860 assert_eq!(chain[0].actor_session, None);
861 let verdict = store
862 .verify_revision_chain(Galaxy::Codex, id_r, &row.metadata.content_hash)
863 .unwrap();
864 assert!(verdict.valid, "{:?}", verdict.breaks);
865
866 assert!(store.revisions(Galaxy::Codex, id_b).unwrap().is_empty());
868 assert!(store.revisions(Galaxy::Codex, id_c).unwrap().is_empty());
869
870 let untouched = store.get(Galaxy::Codex, id_b).unwrap().unwrap();
872 assert_eq!(untouched.content, "\u{1}\u{2}\u{3}\u{4}\u{5}\u{6}");
873
874 let kept = store.get(Galaxy::Codex, id_c).unwrap().unwrap();
876 assert_eq!(kept.content, "perfectly fine prose");
877
878 let hits = search.search("kumquat ratchet repair", 10).unwrap();
880 assert!(
881 hits.iter().any(|h| h.memory_id == id_r.to_string()),
882 "repaired doc must be indexed: {hits:?}"
883 );
884
885 let again = repair_content(&store, &search, &[Galaxy::Codex]).unwrap();
887 assert_eq!(again.repaired, 0);
888 assert_eq!(again.already_clean, 2);
889 assert_eq!(
890 store.revisions(Galaxy::Codex, id_r).unwrap().len(),
891 1,
892 "idempotent re-run must not append"
893 );
894 drop(tmp);
895 }
896}