use std::collections::{BTreeSet, HashMap};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::graph::edges::{Edge, EdgeKind};
use crate::store::db::Store;
use crate::store::record::{
Category, GotchaRecord, Priority, Record, RecordLifecycle, RecordSource, RecordVersion,
StalenessScore,
};
#[allow(async_fn_in_trait)]
pub trait RepairReader {
async fn get(&self, key: &str) -> Result<Option<Record>>;
async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>>;
async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>>;
}
impl RepairReader for Store {
async fn get(&self, key: &str) -> Result<Option<Record>> {
Store::get(self, key).await
}
async fn scan_prefix(&self, prefix: &str) -> Result<Vec<Record>> {
Store::scan_prefix(self, prefix).await
}
async fn scan_keys(&self, prefix: &str) -> Result<Vec<String>> {
Store::scan_keys(self, prefix).await
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub const DIRTY_MARKER_KEY: &str = "analytics:integrity:gotcha_links";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepairReport {
pub scanned_gotchas: usize,
pub scanned_files: usize,
pub missing_file_links: Vec<DriftEntry>,
pub stale_file_links: Vec<DriftEntry>,
pub missing_edges: Vec<DriftEntry>,
pub stale_edges: Vec<DriftEntry>,
pub repaired_count: usize,
pub verification_passed: bool,
pub dirty_marker_cleared: bool,
}
impl RepairReport {
pub fn has_drift(&self) -> bool {
!self.missing_file_links.is_empty()
|| !self.stale_file_links.is_empty()
|| !self.missing_edges.is_empty()
|| !self.stale_edges.is_empty()
}
pub fn total_drift(&self) -> usize {
self.missing_file_links.len()
+ self.stale_file_links.len()
+ self.missing_edges.len()
+ self.stale_edges.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DriftEntry {
pub gotcha_key: String,
pub file_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirtyMarker {
pub dirty: bool,
pub dirty_since: u64,
pub cause: String,
pub affected_keys: Vec<String>,
pub last_checked_at: u64,
pub last_repaired_at: u64,
}
impl DirtyMarker {
pub fn clean() -> Self {
Self {
dirty: false,
dirty_since: 0,
cause: String::new(),
affected_keys: vec![],
last_checked_at: 0,
last_repaired_at: 0,
}
}
}
pub async fn mark_dirty(store: &Store, gotcha_key: &str, cause: &str) {
let now = now_secs();
let mut marker = read_dirty_marker(store)
.await
.unwrap_or_else(DirtyMarker::clean);
marker.dirty = true;
if marker.dirty_since == 0 {
marker.dirty_since = now;
}
marker.cause = cause.to_string();
if !marker.affected_keys.contains(&gotcha_key.to_string()) {
marker.affected_keys.push(gotcha_key.to_string());
}
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: cause.to_string(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
pub async fn read_dirty_marker<R: RepairReader>(reader: &R) -> Option<DirtyMarker> {
reader
.get(DIRTY_MARKER_KEY)
.await
.ok()
.flatten()
.and_then(|r| r.payload_as::<DirtyMarker>())
}
pub async fn is_dirty<R: RepairReader>(reader: &R) -> bool {
read_dirty_marker(reader)
.await
.map(|m| m.dirty)
.unwrap_or(false)
}
pub async fn check_gotcha_indexes<R: RepairReader>(reader: &R) -> Result<RepairReport> {
let (desired_file_links, desired_edges, scanned_gotchas) = derive_desired_state(reader).await?;
let (actual_file_links, scanned_files) = read_actual_file_links(reader).await?;
let actual_edges = read_actual_edges(reader).await?;
let (missing_file_links, stale_file_links) =
diff_file_links(&desired_file_links, &actual_file_links);
let (missing_edges, stale_edges) = diff_edges(&desired_edges, &actual_edges);
Ok(RepairReport {
scanned_gotchas,
scanned_files,
missing_file_links,
stale_file_links,
missing_edges,
stale_edges,
repaired_count: 0,
verification_passed: true, dirty_marker_cleared: false,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RepairMode {
Full,
Fast,
}
pub async fn repair_gotcha_indexes(store: &Store, mode: RepairMode) -> Result<RepairReport> {
let now = now_secs();
if mode == RepairMode::Fast {
return repair_fast(store, now).await;
}
let (desired_file_links, desired_edges, scanned_gotchas) = derive_desired_state(store).await?;
let (actual_file_links, scanned_files) = read_actual_file_links(store).await?;
let actual_edges = read_actual_edges(store).await?;
let (missing_file_links, stale_file_links) =
diff_file_links(&desired_file_links, &actual_file_links);
let (missing_edges, stale_edges) = diff_edges(&desired_edges, &actual_edges);
let total_drift =
missing_file_links.len() + stale_file_links.len() + missing_edges.len() + stale_edges.len();
if total_drift == 0 {
clear_dirty_marker(store, now).await;
return Ok(RepairReport {
scanned_gotchas,
scanned_files,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
repaired_count: 0,
verification_passed: true,
dirty_marker_cleared: true,
});
}
let mut repaired = 0usize;
for (file_path, desired_keys) in &desired_file_links {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
let current_keys = extract_gotcha_keys(&record);
let desired_sorted: Vec<&String> = desired_keys.iter().collect();
let current_sorted: Vec<&String> = current_keys.iter().collect();
if desired_sorted != current_sorted {
set_gotcha_keys(&mut record, desired_keys.iter().cloned().collect());
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
}
let (actual_file_links_2, _) = read_actual_file_links(store).await?;
for (file_path, actual_keys) in &actual_file_links_2 {
if !desired_file_links.contains_key(file_path.as_str()) && !actual_keys.is_empty() {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
set_gotcha_keys(&mut record, vec![]);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
}
let ts = now.to_le_bytes();
for entry in &missing_edges {
let file_key = format!("file:{}", entry.file_path);
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
if store.put_raw(&edge_key, &ts).await.is_ok() {
repaired += 1;
}
}
for entry in &stale_edges {
let file_key = format!("file:{}", entry.file_path);
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, &entry.gotcha_key).to_key();
if store.delete(&edge_key).await.is_ok() {
repaired += 1;
}
}
let verify = check_gotcha_indexes(store).await?;
let verification_passed = !verify.has_drift();
if verification_passed {
clear_dirty_marker(store, now).await;
}
Ok(RepairReport {
scanned_gotchas,
scanned_files,
missing_file_links,
stale_file_links,
missing_edges,
stale_edges,
repaired_count: repaired,
verification_passed,
dirty_marker_cleared: verification_passed,
})
}
async fn repair_fast(store: &Store, now: u64) -> Result<RepairReport> {
let marker = match read_dirty_marker(store).await {
Some(m) if m.dirty => m,
_ => {
return Ok(RepairReport {
scanned_gotchas: 0,
scanned_files: 0,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
repaired_count: 0,
verification_passed: true,
dirty_marker_cleared: false,
});
}
};
let mut repaired = 0usize;
let ts = now.to_le_bytes();
for gotcha_key in &marker.affected_keys {
let desired_files: Vec<String> = match store.get(gotcha_key).await? {
Some(record) if matches!(record.lifecycle, RecordLifecycle::Active) => record
.payload_as::<GotchaRecord>()
.map(|g| g.affected_files)
.unwrap_or_default(),
_ => vec![],
};
for file_path in &desired_files {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut record)) = store.get(&file_key).await {
let keys = extract_gotcha_keys(&record);
if !keys.contains(gotcha_key) {
let mut new_keys = keys;
new_keys.push(gotcha_key.clone());
set_gotcha_keys(&mut record, new_keys);
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
if store.put(&file_key, &record).await.is_ok() {
repaired += 1;
}
}
}
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
if store.put_raw(&edge_key, &ts).await.is_ok() {
repaired += 1;
}
}
{
let desired_set: std::collections::HashSet<&str> =
desired_files.iter().map(String::as_str).collect();
let files = store.scan_prefix("file:").await?;
for mut file_record in files {
let file_path = file_record
.key
.strip_prefix("file:")
.unwrap_or(&file_record.key);
if desired_set.contains(file_path) {
continue;
}
let keys = extract_gotcha_keys(&file_record);
if keys.contains(gotcha_key) {
let new_keys: Vec<String> =
keys.into_iter().filter(|k| k != gotcha_key).collect();
set_gotcha_keys(&mut file_record, new_keys);
file_record.updated_at = now;
file_record.version.logical_clock += 1;
file_record.version.wall_clock = now;
if store.put(&file_record.key, &file_record).await.is_ok() {
repaired += 1;
}
}
let edge_key =
Edge::new(&file_record.key, EdgeKind::HasGotcha, gotcha_key.as_str()).to_key();
let _ = store.delete(&edge_key).await;
}
}
}
if repaired > 0 {
clear_dirty_marker(store, now).await;
}
Ok(RepairReport {
scanned_gotchas: marker.affected_keys.len(),
scanned_files: 0,
missing_file_links: vec![],
stale_file_links: vec![],
missing_edges: vec![],
stale_edges: vec![],
repaired_count: repaired,
verification_passed: true,
dirty_marker_cleared: repaired > 0,
})
}
async fn derive_desired_state<R: RepairReader>(
reader: &R,
) -> Result<(
HashMap<String, BTreeSet<String>>,
BTreeSet<(String, String)>,
usize,
)> {
let gotchas = reader.scan_prefix("gotcha:").await?;
let scanned = gotchas.len();
let mut desired_file_links: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut desired_edges: BTreeSet<(String, String)> = BTreeSet::new();
for record in &gotchas {
if !matches!(record.lifecycle, RecordLifecycle::Active) {
continue;
}
let Some(gotcha) = record.payload_as::<GotchaRecord>() else {
continue;
};
for file_path in &gotcha.affected_files {
desired_file_links
.entry(file_path.clone())
.or_default()
.insert(record.key.clone());
desired_edges.insert((file_path.clone(), record.key.clone()));
}
}
Ok((desired_file_links, desired_edges, scanned))
}
async fn read_actual_file_links<R: RepairReader>(
reader: &R,
) -> Result<(HashMap<String, Vec<String>>, usize)> {
let files = reader.scan_prefix("file:").await?;
let count = files.len();
let mut actual: HashMap<String, Vec<String>> = HashMap::new();
for record in &files {
let path = record
.key
.strip_prefix("file:")
.unwrap_or(&record.key)
.to_string();
let keys = extract_gotcha_keys(record);
if !keys.is_empty() {
actual.insert(path, keys);
}
}
Ok((actual, count))
}
async fn read_actual_edges<R: RepairReader>(reader: &R) -> Result<BTreeSet<(String, String)>> {
let edge_keys = reader.scan_keys("graph:edge:").await?;
let mut actual = BTreeSet::new();
for key in &edge_keys {
if let Some(edge) = Edge::from_key(key) {
if edge.kind == EdgeKind::HasGotcha {
let file_path = edge
.from
.strip_prefix("file:")
.unwrap_or(&edge.from)
.to_string();
actual.insert((file_path, edge.to));
}
}
}
Ok(actual)
}
fn diff_file_links(
desired: &HashMap<String, BTreeSet<String>>,
actual: &HashMap<String, Vec<String>>,
) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
let mut missing = Vec::new();
let mut stale = Vec::new();
for (file_path, desired_keys) in desired {
let actual_keys: BTreeSet<String> = actual
.get(file_path)
.map(|v| v.iter().cloned().collect())
.unwrap_or_default();
for key in desired_keys {
if !actual_keys.contains(key) {
missing.push(DriftEntry {
gotcha_key: key.clone(),
file_path: file_path.clone(),
});
}
}
}
for (file_path, actual_keys) in actual {
let desired_keys = desired.get(file_path);
for key in actual_keys {
let is_desired = desired_keys.map(|d| d.contains(key)).unwrap_or(false);
if !is_desired {
stale.push(DriftEntry {
gotcha_key: key.clone(),
file_path: file_path.clone(),
});
}
}
}
(missing, stale)
}
fn diff_edges(
desired: &BTreeSet<(String, String)>,
actual: &BTreeSet<(String, String)>,
) -> (Vec<DriftEntry>, Vec<DriftEntry>) {
let missing: Vec<DriftEntry> = desired
.difference(actual)
.map(|(file_path, gotcha_key)| DriftEntry {
gotcha_key: gotcha_key.clone(),
file_path: file_path.clone(),
})
.collect();
let stale: Vec<DriftEntry> = actual
.difference(desired)
.map(|(file_path, gotcha_key)| DriftEntry {
gotcha_key: gotcha_key.clone(),
file_path: file_path.clone(),
})
.collect();
(missing, stale)
}
fn extract_gotcha_keys(record: &Record) -> Vec<String> {
record
.payload
.as_ref()
.and_then(|p| p.get("gotcha_keys"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn set_gotcha_keys(record: &mut Record, keys: Vec<String>) {
if let Some(payload) = record.payload.as_mut() {
if let Some(obj) = payload.as_object_mut() {
obj.insert(
"gotcha_keys".into(),
serde_json::Value::Array(keys.into_iter().map(serde_json::Value::String).collect()),
);
}
}
}
pub async fn clear_dirty_key_if_solo(store: &Store, gotcha_key: &str) {
let Some(mut marker) = read_dirty_marker(store).await else {
return;
};
if !marker.dirty {
return;
}
let only_ours = marker.affected_keys.len() == 1 && marker.affected_keys[0] == gotcha_key;
if !only_ours {
return;
}
let now = now_secs();
marker.dirty = false;
marker.affected_keys.clear();
marker.last_repaired_at = now;
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: String::new(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
async fn clear_dirty_marker(store: &Store, now: u64) {
if let Some(mut marker) = read_dirty_marker(store).await {
marker.dirty = false;
marker.affected_keys.clear();
marker.last_repaired_at = now;
let record = Record {
key: DIRTY_MARKER_KEY.to_string(),
value: String::new(),
payload: serde_json::to_value(&marker).ok(),
category: Category::Analytics,
priority: Priority::Normal,
tags: vec![],
created_at: now,
updated_at: now,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: crate::store::stable_device_id(),
logical_clock: 1,
wall_clock: now,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
};
let _ = store.put(DIRTY_MARKER_KEY, &record).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::record::FileRecord;
fn make_gotcha(key: &str, files: &[&str]) -> Record {
let gotcha = GotchaRecord {
rule: "test".into(),
reason: "test".into(),
severity: Priority::High,
affected_files: files.iter().map(|s| s.to_string()).collect(),
ref_url: None,
discovered_session: 1_000_000,
confirmed: true,
};
Record {
key: key.to_string(),
value: "test".into(),
payload: serde_json::to_value(&gotcha).ok(),
category: Category::Gotcha,
priority: Priority::High,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::DeveloperManual,
),
gap_analysis_score: 0.0,
}
}
fn make_file(path: &str, gotcha_keys: &[&str]) -> Record {
let file = FileRecord {
path: path.to_string(),
purpose: String::new(),
entry_points: vec![],
imports: vec![],
gotcha_keys: gotcha_keys.iter().map(|s| s.to_string()).collect(),
decision_keys: vec![],
todos: vec![],
unsafe_count: 0,
unwrap_count: 0,
change_frequency: 0,
last_author: None,
is_hotspot: false,
token_cost_estimate: 0,
last_modified_session: 0,
content_hash: None,
line_count: 0,
blast_radius: None,
propagated_staleness: None,
};
Record {
key: format!("file:{path}"),
value: String::new(),
payload: serde_json::to_value(&file).ok(),
category: Category::File,
priority: Priority::Normal,
tags: vec![],
created_at: 1_000_000,
updated_at: 1_000_000,
ref_url: None,
staleness: StalenessScore::fresh(),
lifecycle: RecordLifecycle::Active,
version: RecordVersion {
device_id: uuid::Uuid::new_v4(),
logical_clock: 1,
wall_clock: 1_000_000,
},
quality: crate::store::record::QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: crate::store::record::ConfidenceScore::for_new_record(
&RecordSource::StaticAnalysis,
),
gap_analysis_score: 0.0,
}
}
#[tokio::test]
async fn check_detects_no_drift_when_consistent() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:g1"]))
.await
.unwrap();
let edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1");
store
.put_raw(&edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
let report = check_gotcha_indexes(&store).await.unwrap();
assert!(!report.has_drift());
assert_eq!(report.scanned_gotchas, 1);
assert_eq!(report.scanned_files, 1);
store.close().await.unwrap();
}
#[tokio::test]
async fn check_detects_missing_file_link() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("gotcha:g1", &make_gotcha("gotcha:g1", &["src/a.rs"]))
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
let report = check_gotcha_indexes(&store).await.unwrap();
assert!(report.has_drift());
assert_eq!(report.missing_file_links.len(), 1);
assert_eq!(report.missing_file_links[0].gotcha_key, "gotcha:g1");
assert_eq!(report.missing_file_links[0].file_path, "src/a.rs");
store.close().await.unwrap();
}
#[tokio::test]
async fn check_detects_stale_file_link() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:deleted"]))
.await
.unwrap();
let report = check_gotcha_indexes(&store).await.unwrap();
assert!(report.has_drift());
assert_eq!(report.stale_file_links.len(), 1);
assert_eq!(report.stale_file_links[0].gotcha_key, "gotcha:deleted");
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_fixes_missing_links_and_verifies() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put(
"gotcha:g1",
&make_gotcha("gotcha:g1", &["src/a.rs", "src/b.rs"]),
)
.await
.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &[]))
.await
.unwrap();
let report = repair_gotcha_indexes(&store, RepairMode::Full)
.await
.unwrap();
assert!(report.verification_passed);
assert!(report.repaired_count > 0);
assert!(report.dirty_marker_cleared);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a).contains(&"gotcha:g1".to_string()));
assert!(extract_gotcha_keys(&b).contains(&"gotcha:g1".to_string()));
let edges = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:g1").to_key();
assert!(edges.contains(&edge_a));
assert!(edges.contains(&edge_b));
store.close().await.unwrap();
}
#[tokio::test]
async fn repair_removes_stale_links() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:ghost"]))
.await
.unwrap();
let report = repair_gotcha_indexes(&store, RepairMode::Full)
.await
.unwrap();
assert!(report.verification_passed);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a).is_empty());
store.close().await.unwrap();
}
#[tokio::test]
async fn dirty_marker_lifecycle() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
assert!(!is_dirty(&store).await);
mark_dirty(&store, "gotcha:test", "link sync failed").await;
assert!(is_dirty(&store).await);
let marker = read_dirty_marker(&store).await.unwrap();
assert!(marker.dirty);
assert_eq!(marker.affected_keys, vec!["gotcha:test"]);
clear_dirty_marker(&store, now_secs()).await;
assert!(!is_dirty(&store).await);
store.close().await.unwrap();
}
#[tokio::test]
async fn partial_failure_recovery_contract() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &[]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &[]))
.await
.unwrap();
let gotcha = make_gotcha("gotcha:partial", &["src/a.rs", "src/b.rs"]);
store.put("gotcha:partial", &gotcha).await.unwrap();
mark_dirty(&store, "gotcha:partial", "link sync failed").await;
let canonical = store.get("gotcha:partial").await.unwrap();
assert!(canonical.is_some(), "canonical gotcha record must persist");
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(
extract_gotcha_keys(&a).is_empty(),
"file link should be missing (secondary write failed)"
);
assert!(
extract_gotcha_keys(&b).is_empty(),
"file link should be missing (secondary write failed)"
);
assert!(is_dirty(&store).await, "dirty marker must be set");
let marker = read_dirty_marker(&store).await.unwrap();
assert!(marker.affected_keys.contains(&"gotcha:partial".to_string()));
let pre = check_gotcha_indexes(&store).await.unwrap();
assert!(pre.has_drift());
assert_eq!(pre.missing_file_links.len(), 2);
assert_eq!(pre.missing_edges.len(), 2);
let report = repair_gotcha_indexes(&store, RepairMode::Full)
.await
.unwrap();
assert!(report.repaired_count > 0, "repair should fix something");
assert!(
report.verification_passed,
"post-repair verification must pass"
);
assert!(
report.dirty_marker_cleared,
"dirty marker must be cleared after verified repair"
);
let a2 = store.get("file:src/a.rs").await.unwrap().unwrap();
let b2 = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&a2).contains(&"gotcha:partial".to_string()));
assert!(extract_gotcha_keys(&b2).contains(&"gotcha:partial".to_string()));
let edges = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:partial").to_key();
assert!(edges.contains(&edge_a));
assert!(edges.contains(&edge_b));
assert!(!is_dirty(&store).await);
let post = check_gotcha_indexes(&store).await.unwrap();
assert!(!post.has_drift());
store.close().await.unwrap();
}
#[tokio::test]
async fn fast_repair_removes_stale_links_on_move() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/c.rs", &make_file("src/c.rs", &[]))
.await
.unwrap();
store
.put(
"gotcha:moved",
&make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
)
.await
.unwrap();
let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
store
.put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
mark_dirty(&store, "gotcha:moved", "affected_files changed").await;
let report = repair_fast(&store, now_secs()).await.unwrap();
assert!(
report.repaired_count > 0,
"fast repair should fix something"
);
assert!(report.dirty_marker_cleared);
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(
!extract_gotcha_keys(&a).contains(&"gotcha:moved".to_string()),
"stale link on file A should be removed"
);
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&b).contains(&"gotcha:moved".to_string()));
let c = store.get("file:src/c.rs").await.unwrap().unwrap();
assert!(extract_gotcha_keys(&c).contains(&"gotcha:moved".to_string()));
let check = check_gotcha_indexes(&store).await.unwrap();
assert!(
!check.has_drift(),
"no drift should remain after fast repair: missing_file_links={}, stale_file_links={}, missing_edges={}, stale_edges={}",
check.missing_file_links.len(),
check.stale_file_links.len(),
check.missing_edges.len(),
check.stale_edges.len(),
);
store.close().await.unwrap();
}
#[tokio::test]
async fn auto_drain_on_reopen_clears_dirty_marker_and_drift() {
let dir = tempfile::TempDir::new().unwrap();
{
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file("src/a.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file("src/b.rs", &["gotcha:moved"]))
.await
.unwrap();
store
.put("file:src/c.rs", &make_file("src/c.rs", &[]))
.await
.unwrap();
store
.put(
"gotcha:moved",
&make_gotcha("gotcha:moved", &["src/b.rs", "src/c.rs"]),
)
.await
.unwrap();
let stale_edge = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:moved");
store
.put_raw(&stale_edge.to_key(), &now_secs().to_le_bytes())
.await
.unwrap();
mark_dirty(&store, "gotcha:moved", "simulated partial-write").await;
let pre = check_gotcha_indexes(&store).await.unwrap();
assert!(pre.has_drift(), "drift must exist before shutdown");
assert!(is_dirty(&store).await, "marker must be set before shutdown");
store.close().await.unwrap();
}
{
let store = Store::open(dir.path()).await.unwrap();
assert!(
is_dirty(&store).await,
"dirty marker should survive reopen across sessions"
);
let report = repair_gotcha_indexes(&store, RepairMode::Fast)
.await
.unwrap();
assert!(report.repaired_count > 0, "Fast drain must apply repairs");
assert!(
report.dirty_marker_cleared,
"Fast drain must clear the dirty marker on success"
);
assert!(
!is_dirty(&store).await,
"auto-drain should leave no dirty marker behind"
);
let post = check_gotcha_indexes(&store).await.unwrap();
assert!(
!post.has_drift(),
"no drift after auto-drain: missing_file={}, stale_file={}, missing_edge={}, stale_edge={}",
post.missing_file_links.len(),
post.stale_file_links.len(),
post.missing_edges.len(),
post.stale_edges.len(),
);
store.close().await.unwrap();
}
}
}