1use std::collections::{BTreeSet, HashMap, HashSet};
77use std::path::{Component, Path};
78use std::time::{SystemTime, UNIX_EPOCH};
79
80use anyhow::Result;
81use serde::{Deserialize, Serialize};
82
83use crate::graph::edges::{Edge, EdgeKind};
84use crate::store::db::Store;
85use crate::store::record::{
86 Category, GotchaRecord, Priority, Record, RecordLifecycle, RecordSource, RecordVersion,
87 StalenessScore, TombstoneReason,
88};
89
90#[allow(async_fn_in_trait)]
95pub trait RepairReader {
96 async fn get(&self, key: &str) -> Result<Option<Record>>;
97 async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>>;
98 async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>>;
99}
100
101impl RepairReader for Store {
102 async fn get(&self, key: &str) -> Result<Option<Record>> {
103 Store::get(self, key).await
104 }
105 async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>> {
106 Store::scan_prefix(self, prefix).await
107 }
108 async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>> {
109 Store::scan_keys(self, prefix).await
110 }
111}
112
113fn now_secs() -> u64 {
114 SystemTime::now()
115 .duration_since(UNIX_EPOCH)
116 .unwrap_or_default()
117 .as_secs()
118}
119
120pub const DIRTY_MARKER_KEY: &str = "analytics:integrity:gotcha_links";
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct RepairReport {
128 pub scanned_gotchas: usize,
129 pub scanned_files: usize,
130 pub missing_file_links: Vec<DriftEntry>,
131 pub stale_file_links: Vec<DriftEntry>,
132 pub missing_edges: Vec<DriftEntry>,
133 pub stale_edges: Vec<DriftEntry>,
134 #[serde(default)]
139 pub unnormalized_paths: Vec<DriftEntry>,
140 #[serde(default)]
146 pub orphaned_files: Vec<OrphanEntry>,
147 pub repaired_count: usize,
148 pub verification_passed: bool,
149 pub dirty_marker_cleared: bool,
150}
151
152impl RepairReport {
153 pub fn has_drift(&self) -> bool {
154 !self.missing_file_links.is_empty()
155 || !self.stale_file_links.is_empty()
156 || !self.missing_edges.is_empty()
157 || !self.stale_edges.is_empty()
158 || !self.unnormalized_paths.is_empty()
159 }
160
161 pub fn total_drift(&self) -> usize {
162 self.missing_file_links.len()
163 + self.stale_file_links.len()
164 + self.missing_edges.len()
165 + self.stale_edges.len()
166 + self.unnormalized_paths.len()
167 }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct DriftEntry {
173 pub gotcha_key: String,
174 pub file_path: String,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum OrphanKind {
182 OutsideRepo,
185 DeletedInRepo,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct OrphanEntry {
193 pub key: String,
194 pub kind: OrphanKind,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct DirtyMarker {
200 pub dirty: bool,
201 pub dirty_since: u64,
202 pub cause: String,
203 pub affected_keys: Vec<String>,
204 pub last_checked_at: u64,
205 pub last_repaired_at: u64,
206}
207
208impl DirtyMarker {
209 pub fn clean() -> Self {
210 Self {
211 dirty: false,
212 dirty_since: 0,
213 cause: String::new(),
214 affected_keys: vec![],
215 last_checked_at: 0,
216 last_repaired_at: 0,
217 }
218 }
219}
220
221pub async fn mark_dirty(store: &Store, gotcha_key: &str, cause: &str) {
225 let now = now_secs();
226
227 let mut marker = read_dirty_marker(store)
229 .await
230 .unwrap_or_else(DirtyMarker::clean);
231 marker.dirty = true;
232 if marker.dirty_since == 0 {
233 marker.dirty_since = now;
234 }
235 marker.cause = cause.to_string();
236 if !marker.affected_keys.contains(&gotcha_key.to_string()) {
237 marker.affected_keys.push(gotcha_key.to_string());
238 }
239
240 let record = Record {
241 key: DIRTY_MARKER_KEY.to_string(),
242 value: cause.to_string(),
243 payload: serde_json::to_value(&marker).ok(),
244 category: Category::Analytics,
245 priority: Priority::Normal,
246 tags: vec![],
247 created_at: now,
248 updated_at: now,
249 ref_url: None,
250 staleness: StalenessScore::fresh(),
251 lifecycle: RecordLifecycle::Active,
252 version: RecordVersion {
253 device_id: crate::store::stable_device_id(),
254 logical_clock: 1,
255 wall_clock: now,
256 },
257 quality: crate::store::record::QualityScore::layer0_default(),
258 access_count: 0,
259 last_accessed: 0,
260 source: RecordSource::StaticAnalysis,
261 confidence: crate::store::record::ConfidenceScore::for_new_record(
262 &RecordSource::StaticAnalysis,
263 ),
264 gap_analysis_score: 0.0,
265 };
266
267 let _ = store.put(DIRTY_MARKER_KEY, &record).await;
269}
270
271pub async fn read_dirty_marker<R: RepairReader>(reader: &R) -> Option<DirtyMarker> {
273 reader
274 .get(DIRTY_MARKER_KEY)
275 .await
276 .ok()
277 .flatten()
278 .and_then(|r| r.payload_as::<DirtyMarker>())
279}
280
281pub async fn is_dirty<R: RepairReader>(reader: &R) -> bool {
283 read_dirty_marker(reader)
284 .await
285 .map(|m| m.dirty)
286 .unwrap_or(false)
287}
288
289pub async fn check_gotcha_indexes<R: RepairReader>(
298 reader: &R,
299 repo_root: &Path,
300) -> Result<RepairReport> {
301 let mut desired = derive_desired_state(reader, repo_root).await?;
303
304 let actual = read_actual_file_links(reader).await?;
306 let actual_edges = read_actual_edges(reader).await?;
307 desired.drop_tombstoned(&actual.tombstoned);
308
309 let (missing_file_links, stale_file_links) =
310 diff_file_links(&desired.file_links, &actual.links);
311 let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
312
313 Ok(RepairReport {
314 scanned_gotchas: desired.scanned,
315 scanned_files: actual.scanned,
316 missing_file_links,
317 stale_file_links,
318 missing_edges,
319 stale_edges,
320 unnormalized_paths: desired.unnormalized,
321 orphaned_files: vec![],
324 repaired_count: 0,
325 verification_passed: true, dirty_marker_cleared: false,
327 })
328}
329
330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
334pub enum RepairMode {
335 Full,
337 Fast,
339}
340
341pub async fn repair_gotcha_indexes(
347 store: &Store,
348 repo_root: &Path,
349 mode: RepairMode,
350) -> Result<RepairReport> {
351 let now = now_secs();
352
353 if mode == RepairMode::Fast {
355 return repair_fast(store, now).await;
356 }
357
358 let (unnormalized_paths, path_repairs) =
363 repair_unnormalized_paths(store, repo_root, now).await?;
364
365 let mut desired = derive_desired_state(store, repo_root).await?;
367 let scanned_gotchas = desired.scanned;
368
369 let actual = read_actual_file_links(store).await?;
371 let actual_edges = read_actual_edges(store).await?;
372 desired.drop_tombstoned(&actual.tombstoned);
373 let scanned_files = actual.scanned;
374
375 let (missing_file_links, stale_file_links) =
376 diff_file_links(&desired.file_links, &actual.links);
377 let (missing_edges, stale_edges) = diff_edges(&desired.edges, &actual_edges);
378
379 let total_drift =
380 missing_file_links.len() + stale_file_links.len() + missing_edges.len() + stale_edges.len();
381
382 if total_drift == 0 {
383 clear_dirty_marker(store, now).await;
385 return Ok(RepairReport {
386 scanned_gotchas,
387 scanned_files,
388 missing_file_links: vec![],
389 stale_file_links: vec![],
390 missing_edges: vec![],
391 stale_edges: vec![],
392 unnormalized_paths,
393 orphaned_files: vec![],
394 repaired_count: path_repairs,
395 verification_passed: true,
396 dirty_marker_cleared: true,
397 });
398 }
399
400 let mut repaired = path_repairs;
404 for (file_path, desired_keys) in &desired.file_links {
405 let file_key = format!("file:{file_path}");
406 if let Ok(Some(mut record)) = store.get(&file_key).await {
407 let current_keys = extract_gotcha_keys(&record);
408 let desired_sorted: Vec<&String> = desired_keys.iter().collect();
409 let current_sorted: Vec<&String> = current_keys.iter().collect();
410
411 if desired_sorted != current_sorted {
412 set_gotcha_keys(&mut record, desired_keys.iter().cloned().collect());
413 record.updated_at = now;
414 record.version.logical_clock += 1;
415 record.version.wall_clock = now;
416 if store.put(&file_key, &record).await.is_ok() {
417 repaired += 1;
418 }
419 }
420 }
421 }
422
423 let actual_file_links_2 = read_actual_file_links(store).await?.links;
425 for (file_path, actual_keys) in &actual_file_links_2 {
426 if !desired.file_links.contains_key(file_path.as_str()) && !actual_keys.is_empty() {
427 let file_key = format!("file:{file_path}");
428 if let Ok(Some(mut record)) = store.get(&file_key).await {
429 set_gotcha_keys(&mut record, vec![]);
430 record.updated_at = now;
431 record.version.logical_clock += 1;
432 record.version.wall_clock = now;
433 if store.put(&file_key, &record).await.is_ok() {
434 repaired += 1;
435 }
436 }
437 }
438 }
439
440 let ts = now.to_le_bytes();
442 for entry in &missing_edges {
443 let file_key = format!("file:{}", entry.file_path);
444 let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
445 if store.put_raw(&edge_key, &ts).await.is_ok() {
446 repaired += 1;
447 }
448 }
449 for entry in &stale_edges {
450 let file_key = format!("file:{}", entry.file_path);
451 let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
452 if store.delete(&edge_key).await.is_ok() {
453 repaired += 1;
454 }
455 }
456
457 let verify = check_gotcha_indexes(store, repo_root).await?;
459 let verification_passed = !verify.has_drift();
460
461 if verification_passed {
462 clear_dirty_marker(store, now).await;
463 }
464
465 Ok(RepairReport {
466 scanned_gotchas,
467 scanned_files,
468 missing_file_links,
469 stale_file_links,
470 missing_edges,
471 stale_edges,
472 unnormalized_paths,
473 orphaned_files: vec![],
474 repaired_count: repaired,
475 verification_passed,
476 dirty_marker_cleared: verification_passed,
477 })
478}
479
480async fn repair_unnormalized_paths(
496 store: &Store,
497 repo_root: &Path,
498 now: u64,
499) -> Result<(Vec<DriftEntry>, usize)> {
500 let mut drift: Vec<DriftEntry> = Vec::new();
501 let mut repaired = 0usize;
502
503 for mut record in store.scan_prefix("gotcha:").await? {
504 if !matches!(record.lifecycle, RecordLifecycle::Active) {
505 continue;
506 }
507 let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
508 continue;
509 };
510
511 let normalized =
512 crate::store::gotcha_ops::normalize_affected_files(&gotcha.affected_files, repo_root);
513 if normalized == gotcha.affected_files {
514 continue;
515 }
516 drift.extend(unnormalized_entries(
517 &record.key,
518 &gotcha.affected_files,
519 repo_root,
520 ));
521
522 let Some(obj) = record.payload.as_mut().and_then(|p| p.as_object_mut()) else {
526 continue;
527 };
528 obj.insert(
529 "affected_files".into(),
530 serde_json::Value::Array(
531 normalized
532 .iter()
533 .cloned()
534 .map(serde_json::Value::String)
535 .collect(),
536 ),
537 );
538 record.updated_at = now;
539 record.version.logical_clock += 1;
540 record.version.wall_clock = now;
541 store.put(&record.key, &record).await?;
542 repaired += 1;
543
544 if let Err(e) = crate::store::gotcha_ops::sync_gotcha_file_links(
545 store,
546 &record.key,
547 &gotcha.affected_files,
548 &normalized,
549 )
550 .await
551 {
552 tracing::warn!("repair: file link re-point failed for {}: {e}", record.key);
553 }
554 }
555
556 Ok((drift, repaired))
557}
558
559pub const ORPHAN_PURGE_MAX_SHARE: f64 = 0.9;
568
569pub struct OrphanScan {
571 pub orphans: Vec<OrphanEntry>,
572 pub active_files: usize,
574}
575
576#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578pub enum PurgeOutcome {
579 Purged {
580 tombstoned: usize,
581 unlinked: usize,
582 },
583 Refused {
585 orphans: usize,
586 active_files: usize,
587 },
588}
589
590pub async fn find_orphaned_files<R: RepairReader>(reader: &R, root: &Path) -> Result<OrphanScan> {
597 let mut orphans = Vec::new();
598 let mut active_files = 0usize;
599
600 for record in reader.scan_prefix("file:").await? {
601 if !matches!(record.lifecycle, RecordLifecycle::Active) {
602 continue;
603 }
604 active_files += 1;
605
606 let path = record.key.strip_prefix("file:").unwrap_or_default();
607 if path.is_empty() || root.join(path).exists() {
608 continue;
609 }
610 orphans.push(OrphanEntry {
611 key: record.key.clone(),
612 kind: classify_orphan(root, path),
613 });
614 }
615
616 Ok(OrphanScan {
617 orphans,
618 active_files,
619 })
620}
621
622fn classify_orphan(root: &Path, path: &str) -> OrphanKind {
628 let path = Path::new(path);
629 if path.is_absolute() {
630 return OrphanKind::OutsideRepo;
631 }
632 let mut components = path.components();
633 match (components.next(), components.next()) {
634 (Some(Component::ParentDir), _) => OrphanKind::OutsideRepo,
635 (Some(Component::Normal(top)), Some(_)) if !root.join(top).is_dir() => {
636 OrphanKind::OutsideRepo
637 }
638 _ => OrphanKind::DeletedInRepo,
639 }
640}
641
642pub async fn purge_orphaned_files(store: &Store, scan: &OrphanScan) -> Result<PurgeOutcome> {
649 if scan.orphans.len() as f64 > scan.active_files as f64 * ORPHAN_PURGE_MAX_SHARE {
650 return Ok(PurgeOutcome::Refused {
651 orphans: scan.orphans.len(),
652 active_files: scan.active_files,
653 });
654 }
655
656 let now = now_secs();
657 let mut tombstoned = 0usize;
658 let mut unlinked = 0usize;
659
660 for orphan in &scan.orphans {
661 let Some(mut record) = store.get(&orphan.key).await? else {
662 continue;
663 };
664 for gotcha_key in extract_gotcha_keys(&record) {
665 let edge = Edge::new(&record.key, EdgeKind::HasGotcha, &gotcha_key).to_key();
666 let _ = store.delete(&edge).await;
667 unlinked += 1;
668 }
669 set_gotcha_keys(&mut record, vec![]);
670 let dead =
671 crate::store::gotcha_ops::tombstoned_copy(&record, TombstoneReason::FileDeleted, now);
672 store.put(&dead.key, &dead).await?;
673 tombstoned += 1;
674 }
675
676 Ok(PurgeOutcome::Purged {
677 tombstoned,
678 unlinked,
679 })
680}
681
682async fn repair_fast(store: &Store, now: u64) -> Result<RepairReport> {
685 let marker = match read_dirty_marker(store).await {
686 Some(m) if m.dirty => m,
687 _ => {
688 return Ok(RepairReport {
689 scanned_gotchas: 0,
690 scanned_files: 0,
691 missing_file_links: vec![],
692 stale_file_links: vec![],
693 missing_edges: vec![],
694 stale_edges: vec![],
695 unnormalized_paths: vec![],
696 orphaned_files: vec![],
697 repaired_count: 0,
698 verification_passed: true,
699 dirty_marker_cleared: false,
700 });
701 }
702 };
703
704 let mut repaired = 0usize;
705 let ts = now.to_le_bytes();
706
707 for gotcha_key in &marker.affected_keys {
708 let desired_files: Vec<String> = match store.get(gotcha_key).await? {
710 Some(record) if matches!(record.lifecycle, RecordLifecycle::Active) => record
711 .payload_as::<GotchaRecord>()
712 .map(|g| g.affected_files)
713 .unwrap_or_default(),
714 _ => vec![],
716 };
717
718 for file_path in &desired_files {
720 let file_key = format!("file:{file_path}");
721 if let Ok(Some(mut record)) = store.get(&file_key).await {
722 let keys = extract_gotcha_keys(&record);
723 if !keys.contains(gotcha_key) {
724 let mut new_keys = keys;
725 new_keys.push(gotcha_key.clone());
726 set_gotcha_keys(&mut record, new_keys);
727 record.updated_at = now;
728 record.version.logical_clock += 1;
729 record.version.wall_clock = now;
730 if store.put(&file_key, &record).await.is_ok() {
731 repaired += 1;
732 }
733 }
734 }
735
736 let file_key = format!("file:{file_path}");
738 let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
739 if store.put_raw(&edge_key, &ts).await.is_ok() {
740 repaired += 1;
741 }
742 }
743
744 {
752 let desired_set: std::collections::HashSet<&str> =
753 desired_files.iter().map(String::as_str).collect();
754 let files = store.scan_prefix("file:").await?;
755 for mut file_record in files {
756 let file_path = file_record
757 .key
758 .strip_prefix("file:")
759 .unwrap_or(&file_record.key);
760 if desired_set.contains(file_path) {
762 continue;
763 }
764 let keys = extract_gotcha_keys(&file_record);
765 if keys.contains(gotcha_key) {
766 let new_keys: Vec<String> =
767 keys.into_iter().filter(|k| k != gotcha_key).collect();
768 set_gotcha_keys(&mut file_record, new_keys);
769 file_record.updated_at = now;
770 file_record.version.logical_clock += 1;
771 file_record.version.wall_clock = now;
772 if store.put(&file_record.key, &file_record).await.is_ok() {
773 repaired += 1;
774 }
775 }
776 let edge_key =
778 Edge::new(&file_record.key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
779 let _ = store.delete(&edge_key).await;
780 }
781 }
782 }
783
784 if repaired > 0 {
785 clear_dirty_marker(store, now).await;
786 }
787
788 Ok(RepairReport {
789 scanned_gotchas: marker.affected_keys.len(),
790 scanned_files: 0,
791 missing_file_links: vec![],
792 stale_file_links: vec![],
793 missing_edges: vec![],
794 stale_edges: vec![],
795 unnormalized_paths: vec![],
798 orphaned_files: vec![],
799 repaired_count: repaired,
800 verification_passed: true,
801 dirty_marker_cleared: repaired > 0,
802 })
803}
804
805struct DesiredState {
810 file_links: HashMap<String, BTreeSet<String>>,
811 edges: BTreeSet<(String, String)>,
812 scanned: usize,
813 unnormalized: Vec<DriftEntry>,
817}
818
819impl DesiredState {
820 fn drop_tombstoned(&mut self, tombstoned: &HashSet<String>) {
824 self.file_links.retain(|path, _| !tombstoned.contains(path));
825 self.edges.retain(|(path, _)| !tombstoned.contains(path));
826 }
827}
828
829async fn derive_desired_state<R: RepairReader>(
831 reader: &R,
832 repo_root: &Path,
833) -> Result<DesiredState> {
834 let gotchas = reader.scan_prefix("gotcha:").await?;
835 let scanned = gotchas.len();
836
837 let mut file_links: HashMap<String, BTreeSet<String>> = HashMap::new();
838 let mut edges: BTreeSet<(String, String)> = BTreeSet::new();
839 let mut unnormalized: Vec<DriftEntry> = Vec::new();
840
841 for record in &gotchas {
842 if !matches!(record.lifecycle, RecordLifecycle::Active) {
843 continue;
844 }
845 let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
846 continue;
847 };
848
849 unnormalized.extend(unnormalized_entries(
850 &record.key,
851 &gotcha.affected_files,
852 repo_root,
853 ));
854
855 for file_path in &gotcha.affected_files {
856 file_links
857 .entry(file_path.clone())
858 .or_default()
859 .insert(record.key.clone());
860 edges.insert((file_path.clone(), record.key.clone()));
861 }
862 }
863
864 Ok(DesiredState {
865 file_links,
866 edges,
867 scanned,
868 unnormalized,
869 })
870}
871
872fn unnormalized_entries(
879 gotcha_key: &str,
880 affected_files: &[String],
881 repo_root: &Path,
882) -> Vec<DriftEntry> {
883 let normalized = crate::store::gotcha_ops::normalize_affected_files(affected_files, repo_root);
884 if normalized == affected_files {
885 return Vec::new();
886 }
887 affected_files
888 .iter()
889 .filter(|path| !normalized.contains(*path))
890 .map(|path| DriftEntry {
891 gotcha_key: gotcha_key.to_string(),
892 file_path: path.clone(),
893 })
894 .collect()
895}
896
897struct ActualFileLinks {
901 links: HashMap<String, Vec<String>>,
902 scanned: usize,
903 tombstoned: HashSet<String>,
904}
905
906async fn read_actual_file_links<R: RepairReader>(reader: &R) -> Result<ActualFileLinks> {
908 let files = reader.scan_prefix("file:").await?;
909 let scanned = files.len();
910 let mut links: HashMap<String, Vec<String>> = HashMap::new();
911 let mut tombstoned: HashSet<String> = HashSet::new();
912
913 for record in &files {
914 let path = record
915 .key
916 .strip_prefix("file:")
917 .unwrap_or(&record.key)
918 .to_string();
919 if !matches!(record.lifecycle, RecordLifecycle::Active) {
920 tombstoned.insert(path.clone());
921 }
922 let keys = extract_gotcha_keys(record);
923 if !keys.is_empty() {
924 links.insert(path, keys);
925 }
926 }
927
928 Ok(ActualFileLinks {
929 links,
930 scanned,
931 tombstoned,
932 })
933}
934
935async fn read_actual_edges<R: RepairReader>(reader: &R) -> Result<BTreeSet<(String, String)>> {
937 let edge_keys = reader.scan_keys("graph:edge:").await?;
938 let mut actual = BTreeSet::new();
939
940 for key in &edge_keys {
941 if let Some(edge) = Edge::from_key(key) {
942 if edge.kind == EdgeKind::HasGotcha {
943 let file_path = edge
944 .from
945 .strip_prefix("file:")
946 .unwrap_or(&edge.from)
947 .to_string();
948 actual.insert((file_path, edge.to));
949 }
950 }
951 }
952
953 Ok(actual)
954}
955
956fn diff_file_links(
958 desired: &HashMap<String, BTreeSet<String>>,
959 actual: &HashMap<String, Vec<String>>,
960) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
961 let mut missing = Vec::new();
962 let mut stale = Vec::new();
963
964 for (file_path, desired_keys) in desired {
966 let actual_keys: BTreeSet<String> = actual
967 .get(file_path)
968 .map(|v| v.iter().cloned().collect())
969 .unwrap_or_default();
970
971 for key in desired_keys {
972 if !actual_keys.contains(key) {
973 missing.push(DriftEntry {
974 gotcha_key: key.clone(),
975 file_path: file_path.clone(),
976 });
977 }
978 }
979 }
980
981 for (file_path, actual_keys) in actual {
983 let desired_keys = desired.get(file_path);
984 for key in actual_keys {
985 let is_desired = desired_keys.map(|d| d.contains(key)).unwrap_or(false);
986 if !is_desired {
987 stale.push(DriftEntry {
988 gotcha_key: key.clone(),
989 file_path: file_path.clone(),
990 });
991 }
992 }
993 }
994
995 (missing, stale)
996}
997
998fn diff_edges(
1000 desired: &BTreeSet<(String, String)>,
1001 actual: &BTreeSet<(String, String)>,
1002) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
1003 let missing: Vec<DriftEntry> = desired
1004 .difference(actual)
1005 .map(|(file_path, gotcha_key)| DriftEntry {
1006 gotcha_key: gotcha_key.clone(),
1007 file_path: file_path.clone(),
1008 })
1009 .collect();
1010
1011 let stale: Vec<DriftEntry> = actual
1012 .difference(desired)
1013 .map(|(file_path, gotcha_key)| DriftEntry {
1014 gotcha_key: gotcha_key.clone(),
1015 file_path: file_path.clone(),
1016 })
1017 .collect();
1018
1019 (missing, stale)
1020}
1021
1022fn extract_gotcha_keys(record: &Record) -> Vec<String> {
1023 record
1024 .payload
1025 .as_ref()
1026 .and_then(|p| p.get("gotcha_keys"))
1027 .and_then(|v| v.as_array())
1028 .map(|arr| {
1029 arr.iter()
1030 .filter_map(|v| v.as_str().map(String::from))
1031 .collect()
1032 })
1033 .unwrap_or_default()
1034}
1035
1036fn set_gotcha_keys(record: &mut Record, keys: Vec<String>) {
1037 if let Some(payload) = record.payload.as_mut() {
1038 if let Some(obj) = payload.as_object_mut() {
1039 obj.insert(
1040 "gotcha_keys".into(),
1041 serde_json::Value::Array(keys.into_iter().map(serde_json::Value::String).collect()),
1042 );
1043 }
1044 }
1045}
1046
1047pub async fn clear_dirty_key_if_solo(store: &Store, gotcha_key: &str) {
1057 let Some(mut marker) = read_dirty_marker(store).await else {
1058 return;
1059 };
1060 if !marker.dirty {
1061 return;
1062 }
1063 let only_ours = marker.affected_keys.len() == 1 && marker.affected_keys[0] == gotcha_key;
1067 if !only_ours {
1068 return;
1069 }
1070
1071 let now = now_secs();
1072 marker.dirty = false;
1073 marker.affected_keys.clear();
1074 marker.last_repaired_at = now;
1075
1076 let record = Record {
1077 key: DIRTY_MARKER_KEY.to_string(),
1078 value: String::new(),
1079 payload: serde_json::to_value(&marker).ok(),
1080 category: Category::Analytics,
1081 priority: Priority::Normal,
1082 tags: vec![],
1083 created_at: now,
1084 updated_at: now,
1085 ref_url: None,
1086 staleness: StalenessScore::fresh(),
1087 lifecycle: RecordLifecycle::Active,
1088 version: RecordVersion {
1089 device_id: crate::store::stable_device_id(),
1090 logical_clock: 1,
1091 wall_clock: now,
1092 },
1093 quality: crate::store::record::QualityScore::layer0_default(),
1094 access_count: 0,
1095 last_accessed: 0,
1096 source: RecordSource::StaticAnalysis,
1097 confidence: crate::store::record::ConfidenceScore::for_new_record(
1098 &RecordSource::StaticAnalysis,
1099 ),
1100 gap_analysis_score: 0.0,
1101 };
1102 let _ = store.put(DIRTY_MARKER_KEY, &record).await;
1103}
1104
1105async fn clear_dirty_marker(store: &Store, now: u64) {
1106 if let Some(mut marker) = read_dirty_marker(store).await {
1107 marker.dirty = false;
1108 marker.affected_keys.clear();
1109 marker.last_repaired_at = now;
1110
1111 let record = Record {
1112 key: DIRTY_MARKER_KEY.to_string(),
1113 value: String::new(),
1114 payload: serde_json::to_value(&marker).ok(),
1115 category: Category::Analytics,
1116 priority: Priority::Normal,
1117 tags: vec![],
1118 created_at: now,
1119 updated_at: now,
1120 ref_url: None,
1121 staleness: StalenessScore::fresh(),
1122 lifecycle: RecordLifecycle::Active,
1123 version: RecordVersion {
1124 device_id: crate::store::stable_device_id(),
1125 logical_clock: 1,
1126 wall_clock: now,
1127 },
1128 quality: crate::store::record::QualityScore::layer0_default(),
1129 access_count: 0,
1130 last_accessed: 0,
1131 source: RecordSource::StaticAnalysis,
1132 confidence: crate::store::record::ConfidenceScore::for_new_record(
1133 &RecordSource::StaticAnalysis,
1134 ),
1135 gap_analysis_score: 0.0,
1136 };
1137 let _ = store.put(DIRTY_MARKER_KEY, &record).await;
1138 }
1139}
1140
1141#[cfg(test)]
1144mod tests {
1145 use super::*;
1146 use crate::store::record::FileRecord;
1147
1148 fn make_gotcha(key: &str, files: &[&str]) -> Record {
1149 let gotcha = GotchaRecord {
1150 rule: "test".into(),
1151 reason: "test".into(),
1152 severity: Priority::High,
1153 affected_files: files.iter().map(|s| s.to_string()).collect(),
1154 ref_url: None,
1155 discovered_session: 1_000_000,
1156 confirmed: true,
1157 confirmed_content: Default::default(),
1158 };
1159 Record {
1160 key: key.to_string(),
1161 value: "test".into(),
1162 payload: serde_json::to_value(&gotcha).ok(),
1163 category: Category::Gotcha,
1164 priority: Priority::High,
1165 tags: vec![],
1166 created_at: 1_000_000,
1167 updated_at: 1_000_000,
1168 ref_url: None,
1169 staleness: StalenessScore::fresh(),
1170 lifecycle: RecordLifecycle::Active,
1171 version: RecordVersion {
1172 device_id: uuid::Uuid::new_v4(),
1173 logical_clock: 1,
1174 wall_clock: 1_000_000,
1175 },
1176 quality: crate::store::record::QualityScore::layer0_default(),
1177 access_count: 0,
1178 last_accessed: 0,
1179 source: RecordSource::DeveloperManual,
1180 confidence: crate::store::record::ConfidenceScore::for_new_record(
1181 &RecordSource::DeveloperManual,
1182 ),
1183 gap_analysis_score: 0.0,
1184 }
1185 }
1186
1187 fn make_file(path: &str, gotcha_keys: &[&str]) -> Record {
1188 let file = FileRecord {
1189 path: path.to_string(),
1190 purpose: String::new(),
1191 entry_points: vec![],
1192 imports: vec![],
1193 gotcha_keys: gotcha_keys.iter().map(|s| s.to_string()).collect(),
1194 decision_keys: vec![],
1195 todos: vec![],
1196 unsafe_count: 0,
1197 unwrap_count: 0,
1198 change_frequency: 0,
1199 last_author: None,
1200 is_hotspot: false,
1201 token_cost_estimate: 0,
1202 last_modified_session: 0,
1203 content_hash: None,
1204 line_count: 0,
1205 blast_radius: None,
1206 propagated_staleness: None,
1207 };
1208 Record {
1209 key: format!("file:{path}"),
1210 value: String::new(),
1211 payload: serde_json::to_value(&file).ok(),
1212 category: Category::File,
1213 priority: Priority::Normal,
1214 tags: vec![],
1215 created_at: 1_000_000,
1216 updated_at: 1_000_000,
1217 ref_url: None,
1218 staleness: StalenessScore::fresh(),
1219 lifecycle: RecordLifecycle::Active,
1220 version: RecordVersion {
1221 device_id: uuid::Uuid::new_v4(),
1222 logical_clock: 1,
1223 wall_clock: 1_000_000,
1224 },
1225 quality: crate::store::record::QualityScore::layer0_default(),
1226 access_count: 0,
1227 last_accessed: 0,
1228 source: RecordSource::StaticAnalysis,
1229 confidence: crate::store::record::ConfidenceScore::for_new_record(
1230 &RecordSource::StaticAnalysis,
1231 ),
1232 gap_analysis_score: 0.0,
1233 }
1234 }
1235
1236 #[tokio::test]
1237 async fn check_detects_no_drift_when_consistent() {
1238 let dir = tempfile::TempDir::new().unwrap();
1239 let store = Store::open(dir.path()).await.unwrap();
1240
1241 store
1242 .put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
1243 .await
1244 .unwrap();
1245 store
1246 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1247 .await
1248 .unwrap();
1249
1250 let edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1");
1251 store
1252 .put_raw(&edge.to_key(), &now_secs().to_le_bytes())
1253 .await
1254 .unwrap();
1255
1256 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1257 assert!(!report.has_drift());
1258 assert_eq!(report.scanned_gotchas, 1);
1259 assert_eq!(report.scanned_files, 1);
1260
1261 store.close().await.unwrap();
1262 }
1263
1264 #[tokio::test]
1265 async fn codeowners_candidate_write_has_no_index_drift() {
1266 let dir = tempfile::TempDir::new().unwrap();
1267 let store = Store::open(dir.path()).await.unwrap();
1268 for path in ["src/payments/card.rs", "src/payments/wallet.rs"] {
1269 store
1270 .put(&format!("file:{path}"), &make_file(path, &[]))
1271 .await
1272 .unwrap();
1273 }
1274
1275 let rules = crate::analysis::onboarding::parse_codeowners("src/payments/** @team\n");
1276 let repo_files = vec![
1277 "src/payments/card.rs".to_string(),
1278 "src/payments/wallet.rs".to_string(),
1279 ];
1280 let candidate = crate::analysis::onboarding::codeowners_candidates(
1281 &rules,
1282 &repo_files,
1283 uuid::Uuid::nil(),
1284 1,
1285 1_000_000,
1286 )
1287 .pop()
1288 .unwrap();
1289 let gotcha = candidate
1290 .payload_as::<GotchaRecord>()
1291 .expect("candidate must carry a GotchaRecord");
1292
1293 crate::store::gotcha_ops::apply_gotcha_write(
1294 &store,
1295 dir.path(),
1296 &candidate,
1297 &[],
1298 &gotcha.affected_files,
1299 true,
1300 )
1301 .await
1302 .unwrap();
1303
1304 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1305 assert!(!report.has_drift());
1306 store.close().await.unwrap();
1307 }
1308
1309 #[tokio::test]
1310 async fn check_detects_missing_file_link() {
1311 let dir = tempfile::TempDir::new().unwrap();
1312 let store = Store::open(dir.path()).await.unwrap();
1313
1314 store
1315 .put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
1316 .await
1317 .unwrap();
1318 store
1320 .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1321 .await
1322 .unwrap();
1323
1324 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1325 assert!(report.has_drift());
1326 assert_eq!(report.missing_file_links.len(), 1);
1327 assert_eq!(report.missing_file_links[0].gotcha_key, "gotcha:g1");
1328 assert_eq!(report.missing_file_links[0].file_path, "src/a.rs");
1329
1330 store.close().await.unwrap();
1331 }
1332
1333 #[tokio::test]
1334 async fn check_detects_stale_file_link() {
1335 let dir = tempfile::TempDir::new().unwrap();
1336 let store = Store::open(dir.path()).await.unwrap();
1337
1338 store
1340 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:deleted"]))
1341 .await
1342 .unwrap();
1343
1344 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1345 assert!(report.has_drift());
1346 assert_eq!(report.stale_file_links.len(), 1);
1347 assert_eq!(report.stale_file_links[0].gotcha_key, "gotcha:deleted");
1348
1349 store.close().await.unwrap();
1350 }
1351
1352 #[tokio::test]
1353 async fn repair_fixes_missing_links_and_verifies() {
1354 let dir = tempfile::TempDir::new().unwrap();
1355 let store = Store::open(dir.path()).await.unwrap();
1356
1357 store
1358 .put(
1359 "gotcha:g1",
1360 &make_gotcha("gotcha:g1", &["src/a.rs", "src/b.rs"]),
1361 )
1362 .await
1363 .unwrap();
1364 store
1365 .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1366 .await
1367 .unwrap();
1368 store
1369 .put("file:src/b.rs", &make_file("src/b.rs", &[]))
1370 .await
1371 .unwrap();
1372
1373 let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1374 .await
1375 .unwrap();
1376 assert!(report.verification_passed);
1377 assert!(report.repaired_count > 0);
1378 assert!(report.dirty_marker_cleared);
1379
1380 let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1382 let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1383 assert!(extract_gotcha_keys(&a).contains(&"gotcha:g1".to_string()));
1384 assert!(extract_gotcha_keys(&b).contains(&"gotcha:g1".to_string()));
1385
1386 let edges = store.scan_keys("graph:edge:").await.unwrap();
1388 let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1389 let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1390 assert!(edges.contains(&edge_a));
1391 assert!(edges.contains(&edge_b));
1392
1393 store.close().await.unwrap();
1394 }
1395
1396 #[tokio::test]
1397 async fn repair_removes_stale_links() {
1398 let dir = tempfile::TempDir::new().unwrap();
1399 let store = Store::open(dir.path()).await.unwrap();
1400
1401 store
1403 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:ghost"]))
1404 .await
1405 .unwrap();
1406
1407 let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1408 .await
1409 .unwrap();
1410 assert!(report.verification_passed);
1411
1412 let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1413 assert!(extract_gotcha_keys(&a).is_empty());
1414
1415 store.close().await.unwrap();
1416 }
1417
1418 #[tokio::test]
1426 async fn check_detects_unnormalized_affected_files() {
1427 let dir = tempfile::TempDir::new().unwrap();
1428 let store = Store::open(dir.path()).await.unwrap();
1429
1430 store
1431 .put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
1432 .await
1433 .unwrap();
1434
1435 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1436 assert!(report.has_drift(), "--check must exit non-zero on this");
1437 assert_eq!(report.unnormalized_paths.len(), 1);
1438 assert_eq!(report.unnormalized_paths[0].gotcha_key, "gotcha:g1");
1439 assert_eq!(report.unnormalized_paths[0].file_path, "./src/a.rs");
1440 let stored = store.get("gotcha:g1").await.unwrap().unwrap();
1442 let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
1443 assert_eq!(gotcha.affected_files, vec!["./src/a.rs".to_string()]);
1444
1445 store.close().await.unwrap();
1446 }
1447
1448 #[tokio::test]
1449 async fn check_reports_no_unnormalized_drift_for_clean_paths_and_globs() {
1450 let dir = tempfile::TempDir::new().unwrap();
1451 let store = Store::open(dir.path()).await.unwrap();
1452
1453 store
1454 .put(
1455 "gotcha:g1",
1456 &make_gotcha("gotcha:g1", &["src/a.rs", "src/payments/**"]),
1457 )
1458 .await
1459 .unwrap();
1460 store
1461 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1462 .await
1463 .unwrap();
1464
1465 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1466 assert!(report.unnormalized_paths.is_empty());
1467
1468 store.close().await.unwrap();
1469 }
1470
1471 #[tokio::test]
1475 async fn repair_rewrites_unnormalized_paths_and_repoints_links() {
1476 let dir = tempfile::TempDir::new().unwrap();
1477 let store = Store::open(dir.path()).await.unwrap();
1478
1479 store
1480 .put("gotcha:g1", &make_gotcha("gotcha:g1", &["./src/a.rs"]))
1481 .await
1482 .unwrap();
1483 store
1486 .put("file:./src/a.rs", &make_file("./src/a.rs", &["gotcha:g1"]))
1487 .await
1488 .unwrap();
1489
1490 let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1491 .await
1492 .unwrap();
1493 assert_eq!(report.unnormalized_paths.len(), 1);
1494 assert!(report.repaired_count > 0);
1495 assert!(report.verification_passed);
1496
1497 let stored = store.get("gotcha:g1").await.unwrap().unwrap();
1498 let gotcha = stored.payload_as::<GotchaRecord>().unwrap();
1499 assert_eq!(gotcha.affected_files, vec!["src/a.rs".to_string()]);
1500
1501 let good = store
1502 .get("file:src/a.rs")
1503 .await
1504 .unwrap()
1505 .expect("corrected path must be linked, stubbed if it had no record");
1506 assert_eq!(extract_gotcha_keys(&good), vec!["gotcha:g1".to_string()]);
1507
1508 let raw = store.get("file:./src/a.rs").await.unwrap().unwrap();
1509 assert!(
1510 extract_gotcha_keys(&raw).is_empty(),
1511 "link at the raw spelling must be dropped"
1512 );
1513
1514 let again = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1516 assert!(!again.has_drift());
1517
1518 store.close().await.unwrap();
1519 }
1520
1521 #[tokio::test]
1524 async fn repair_skips_unnormalized_paths_on_tombstoned_gotchas() {
1525 let dir = tempfile::TempDir::new().unwrap();
1526 let store = Store::open(dir.path()).await.unwrap();
1527
1528 let mut record = make_gotcha("gotcha:dead", &["./src/a.rs"]);
1529 record.lifecycle = RecordLifecycle::Tombstoned {
1530 reason: crate::store::record::TombstoneReason::ManualDeletion,
1531 at: 1_000_000,
1532 };
1533 store.put("gotcha:dead", &record).await.unwrap();
1534
1535 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1536 assert!(report.unnormalized_paths.is_empty());
1537
1538 store.close().await.unwrap();
1539 }
1540
1541 fn seed_repo(dir: &Path) {
1546 std::fs::create_dir_all(dir.join("src")).unwrap();
1547 std::fs::write(dir.join("src/a.rs"), "fn main() {}").unwrap();
1548 }
1549
1550 #[tokio::test]
1551 async fn orphan_scan_separates_outside_repo_from_deleted_in_repo() {
1552 let dir = tempfile::TempDir::new().unwrap();
1553 seed_repo(dir.path());
1554 let store = Store::open(dir.path()).await.unwrap();
1555
1556 for path in [
1557 "src/a.rs",
1558 "src/gone.rs",
1559 "hclsyntax/parser.go",
1560 "/tmp/scratchpad/notes.md",
1561 ] {
1562 store
1563 .put(&format!("file:{path}"), &make_file(path, &[]))
1564 .await
1565 .unwrap();
1566 }
1567
1568 let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1569 assert_eq!(scan.active_files, 4);
1570
1571 let kind = |key: &str| scan.orphans.iter().find(|o| o.key == key).map(|o| o.kind);
1572 assert_eq!(kind("file:src/a.rs"), None, "the file is right there");
1573 assert_eq!(kind("file:src/gone.rs"), Some(OrphanKind::DeletedInRepo));
1574 assert_eq!(
1575 kind("file:hclsyntax/parser.go"),
1576 Some(OrphanKind::OutsideRepo),
1577 "a top-level directory this repo does not have"
1578 );
1579 assert_eq!(
1580 kind("file:/tmp/scratchpad/notes.md"),
1581 Some(OrphanKind::OutsideRepo)
1582 );
1583
1584 let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
1586 assert!(matches!(gone.lifecycle, RecordLifecycle::Active));
1587
1588 store.close().await.unwrap();
1589 }
1590
1591 #[tokio::test]
1592 async fn purge_tombstones_orphans_and_unlinks_their_gotchas() {
1593 let dir = tempfile::TempDir::new().unwrap();
1594 seed_repo(dir.path());
1595 let store = Store::open(dir.path()).await.unwrap();
1596
1597 store
1598 .put(
1599 "gotcha:g1",
1600 &make_gotcha("gotcha:g1", &["src/a.rs", "src/gone.rs"]),
1601 )
1602 .await
1603 .unwrap();
1604 store
1605 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
1606 .await
1607 .unwrap();
1608 store
1609 .put(
1610 "file:src/gone.rs",
1611 &make_file("src/gone.rs", &["gotcha:g1"]),
1612 )
1613 .await
1614 .unwrap();
1615 for path in ["file:src/a.rs", "file:src/gone.rs"] {
1616 let edge = Edge::new(path, EdgeKind::HasGotcha, "gotcha:g1");
1617 store
1618 .put_raw(&edge.to_key(), &now_secs().to_le_bytes())
1619 .await
1620 .unwrap();
1621 }
1622
1623 let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1624 let outcome = purge_orphaned_files(&store, &scan).await.unwrap();
1625 assert_eq!(
1626 outcome,
1627 PurgeOutcome::Purged {
1628 tombstoned: 1,
1629 unlinked: 1
1630 }
1631 );
1632
1633 let gone = store.get("file:src/gone.rs").await.unwrap().unwrap();
1634 assert!(matches!(gone.lifecycle, RecordLifecycle::Tombstoned { .. }));
1635 assert!(extract_gotcha_keys(&gone).is_empty());
1636
1637 let edges = store.scan_keys("graph:edge:").await.unwrap();
1638 let dead_edge = Edge::new("file:src/gone.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
1639 assert!(!edges.contains(&dead_edge));
1640
1641 let live = store.get("file:src/a.rs").await.unwrap().unwrap();
1644 assert_eq!(extract_gotcha_keys(&live), vec!["gotcha:g1".to_string()]);
1645 let report = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1646 assert!(
1647 !report.has_drift(),
1648 "purged orphans must not read as missing links: missing_file={:?}, missing_edges={:?}",
1649 report.missing_file_links.len(),
1650 report.missing_edges.len(),
1651 );
1652
1653 store.close().await.unwrap();
1654 }
1655
1656 #[tokio::test]
1657 async fn purge_refuses_when_orphans_are_most_of_the_store() {
1658 let dir = tempfile::TempDir::new().unwrap();
1659 seed_repo(dir.path());
1660 let store = Store::open(dir.path()).await.unwrap();
1661
1662 store
1664 .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1665 .await
1666 .unwrap();
1667 for i in 0..19 {
1668 let path = format!("src/gone{i}.rs");
1669 store
1670 .put(&format!("file:{path}"), &make_file(&path, &[]))
1671 .await
1672 .unwrap();
1673 }
1674
1675 let scan = find_orphaned_files(&store, dir.path()).await.unwrap();
1676 assert_eq!(
1677 purge_orphaned_files(&store, &scan).await.unwrap(),
1678 PurgeOutcome::Refused {
1679 orphans: 19,
1680 active_files: 20
1681 }
1682 );
1683
1684 let untouched = store.get("file:src/gone0.rs").await.unwrap().unwrap();
1685 assert!(matches!(untouched.lifecycle, RecordLifecycle::Active));
1686
1687 store.close().await.unwrap();
1688 }
1689
1690 #[tokio::test]
1691 async fn dirty_marker_lifecycle() {
1692 let dir = tempfile::TempDir::new().unwrap();
1693 let store = Store::open(dir.path()).await.unwrap();
1694
1695 assert!(!is_dirty(&store).await);
1696
1697 mark_dirty(&store, "gotcha:test", "link sync failed").await;
1698 assert!(is_dirty(&store).await);
1699
1700 let marker = read_dirty_marker(&store).await.unwrap();
1701 assert!(marker.dirty);
1702 assert_eq!(marker.affected_keys, vec!["gotcha:test"]);
1703
1704 clear_dirty_marker(&store, now_secs()).await;
1705 assert!(!is_dirty(&store).await);
1706
1707 store.close().await.unwrap();
1708 }
1709
1710 #[tokio::test]
1717 async fn partial_failure_recovery_contract() {
1718 let dir = tempfile::TempDir::new().unwrap();
1719 let store = Store::open(dir.path()).await.unwrap();
1720
1721 store
1723 .put("file:src/a.rs", &make_file("src/a.rs", &[]))
1724 .await
1725 .unwrap();
1726 store
1727 .put("file:src/b.rs", &make_file("src/b.rs", &[]))
1728 .await
1729 .unwrap();
1730
1731 let gotcha = make_gotcha("gotcha:partial", &["src/a.rs", "src/b.rs"]);
1733 store.put("gotcha:partial", &gotcha).await.unwrap();
1734
1735 mark_dirty(&store, "gotcha:partial", "link sync failed").await;
1740
1741 let canonical = store.get("gotcha:partial").await.unwrap();
1745 assert!(canonical.is_some(), "canonical gotcha record must persist");
1746
1747 let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1749 let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1750 assert!(
1751 extract_gotcha_keys(&a).is_empty(),
1752 "file link should be missing (secondary write failed)"
1753 );
1754 assert!(
1755 extract_gotcha_keys(&b).is_empty(),
1756 "file link should be missing (secondary write failed)"
1757 );
1758
1759 assert!(is_dirty(&store).await, "dirty marker must be set");
1761 let marker = read_dirty_marker(&store).await.unwrap();
1762 assert!(marker.affected_keys.contains(&"gotcha:partial".to_string()));
1763
1764 let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1766 assert!(pre.has_drift());
1767 assert_eq!(pre.missing_file_links.len(), 2);
1768 assert_eq!(pre.missing_edges.len(), 2);
1769
1770 let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Full)
1773 .await
1774 .unwrap();
1775 assert!(report.repaired_count > 0, "repair should fix something");
1776 assert!(
1777 report.verification_passed,
1778 "post-repair verification must pass"
1779 );
1780 assert!(
1781 report.dirty_marker_cleared,
1782 "dirty marker must be cleared after verified repair"
1783 );
1784
1785 let a2 = store.get("file:src/a.rs").await.unwrap().unwrap();
1787 let b2 = store.get("file:src/b.rs").await.unwrap().unwrap();
1788 assert!(extract_gotcha_keys(&a2).contains(&"gotcha:partial".to_string()));
1789 assert!(extract_gotcha_keys(&b2).contains(&"gotcha:partial".to_string()));
1790
1791 let edges = store.scan_keys("graph:edge:").await.unwrap();
1793 let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
1794 let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
1795 assert!(edges.contains(&edge_a));
1796 assert!(edges.contains(&edge_b));
1797
1798 assert!(!is_dirty(&store).await);
1800
1801 let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1803 assert!(!post.has_drift());
1804
1805 store.close().await.unwrap();
1806 }
1807
1808 #[tokio::test]
1813 async fn fast_repair_removes_stale_links_on_move() {
1814 let dir = tempfile::TempDir::new().unwrap();
1815 let store = Store::open(dir.path()).await.unwrap();
1816
1817 store
1819 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
1820 .await
1821 .unwrap();
1822 store
1823 .put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
1824 .await
1825 .unwrap();
1826 store
1827 .put("file:src/c.rs", &make_file("src/c.rs", &[]))
1828 .await
1829 .unwrap();
1830
1831 store
1833 .put(
1834 "gotcha:moved",
1835 &make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
1836 )
1837 .await
1838 .unwrap();
1839
1840 let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
1842 store
1843 .put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
1844 .await
1845 .unwrap();
1846
1847 mark_dirty(&store, "gotcha:moved", "affected_files changed").await;
1849
1850 let report = repair_fast(&store, now_secs()).await.unwrap();
1852 assert!(
1853 report.repaired_count > 0,
1854 "fast repair should fix something"
1855 );
1856 assert!(report.dirty_marker_cleared);
1857
1858 let a = store.get("file:src/a.rs").await.unwrap().unwrap();
1860 assert!(
1861 !extract_gotcha_keys(&a).contains(&"gotcha:moved".to_string()),
1862 "stale link on file A should be removed"
1863 );
1864
1865 let b = store.get("file:src/b.rs").await.unwrap().unwrap();
1867 assert!(extract_gotcha_keys(&b).contains(&"gotcha:moved".to_string()));
1868
1869 let c = store.get("file:src/c.rs").await.unwrap().unwrap();
1871 assert!(extract_gotcha_keys(&c).contains(&"gotcha:moved".to_string()));
1872
1873 let check = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1875 assert!(
1876 !check.has_drift(),
1877 "no drift should remain after fast repair: missing_file_links={}, stale_file_links={}, missing_edges={}, stale_edges={}",
1878 check.missing_file_links.len(),
1879 check.stale_file_links.len(),
1880 check.missing_edges.len(),
1881 check.stale_edges.len(),
1882 );
1883
1884 store.close().await.unwrap();
1885 }
1886
1887 #[tokio::test]
1895 async fn auto_drain_on_reopen_clears_dirty_marker_and_drift() {
1896 let dir = tempfile::TempDir::new().unwrap();
1897
1898 {
1903 let store = Store::open(dir.path()).await.unwrap();
1904 store
1905 .put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
1906 .await
1907 .unwrap();
1908 store
1909 .put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
1910 .await
1911 .unwrap();
1912 store
1913 .put("file:src/c.rs", &make_file("src/c.rs", &[]))
1914 .await
1915 .unwrap();
1916 store
1917 .put(
1918 "gotcha:moved",
1919 &make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
1920 )
1921 .await
1922 .unwrap();
1923 let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
1924 store
1925 .put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
1926 .await
1927 .unwrap();
1928 mark_dirty(&store, "gotcha:moved", "simulated partial-write").await;
1929
1930 let pre = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1932 assert!(pre.has_drift(), "drift must exist before shutdown");
1933 assert!(is_dirty(&store).await, "marker must be set before shutdown");
1934
1935 store.close().await.unwrap();
1936 }
1937
1938 {
1943 let store = Store::open(dir.path()).await.unwrap();
1944 assert!(
1945 is_dirty(&store).await,
1946 "dirty marker should survive reopen across sessions"
1947 );
1948
1949 let report = repair_gotcha_indexes(&store, dir.path(), RepairMode::Fast)
1950 .await
1951 .unwrap();
1952 assert!(report.repaired_count > 0, "Fast drain must apply repairs");
1953 assert!(
1954 report.dirty_marker_cleared,
1955 "Fast drain must clear the dirty marker on success"
1956 );
1957
1958 assert!(
1959 !is_dirty(&store).await,
1960 "auto-drain should leave no dirty marker behind"
1961 );
1962
1963 let post = check_gotcha_indexes(&store, dir.path()).await.unwrap();
1964 assert!(
1965 !post.has_drift(),
1966 "no drift after auto-drain: missing_file={}, stale_file={}, missing_edge={}, stale_edge={}",
1967 post.missing_file_links.len(),
1968 post.stale_file_links.len(),
1969 post.missing_edges.len(),
1970 post.stale_edges.len(),
1971 );
1972
1973 store.close().await.unwrap();
1974 }
1975 }
1976}