use std::collections::HashSet;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Result;
use crate::graph::edges::{Edge, EdgeKind};
use crate::store::db::Store;
use crate::store::enforcement::{
record_event, ControlChangeKind, EnforcementEventType, SubjectKind,
};
use crate::store::record::{FileRecord, Record, RecordLifecycle, TombstoneReason};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is before UNIX epoch — refusing to write a corrupt timestamp into the gotcha store")
.as_secs()
}
pub async fn ensure_gotcha_key_available(store: &Store, key: &str) -> Result<()> {
if store.get(key).await?.is_some() {
anyhow::bail!("gotcha key '{key}' already exists; edit the existing record instead");
}
Ok(())
}
pub async fn apply_gotcha_write(
store: &Store,
record: &Record,
old_files: &[String],
new_files: &[String],
is_new: bool,
) -> Result<()> {
let key = &record.key;
if is_new {
ensure_gotcha_key_available(store, key).await?;
}
store.put(key, record).await?;
crate::store::repair::mark_dirty(store, key, "gotcha_write: pre-arm cancellation guard").await;
let change_kind = if is_new {
ControlChangeKind::Created
} else {
ControlChangeKind::Updated
};
if let Err(e) = record_event(
store,
EnforcementEventType::ControlChanged { change_kind },
SubjectKind::Control,
key.to_string(),
"developer".to_string(),
None,
if is_new {
"control_created".to_string()
} else {
"control_updated".to_string()
},
None,
)
.await
{
tracing::warn!("gotcha_write: enforcement event recording failed for {key}: {e}");
}
if is_new {
let _ = crate::store::extraction::write_on_extraction(store, key, &record.tags, new_files)
.await;
}
let mut secondary_failed = false;
if let Err(e) = sync_gotcha_file_links(store, key, old_files, new_files).await {
tracing::warn!("gotcha_write: file link sync failed for {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(store, key, &format!("link sync failed: {e}")).await;
}
let old_set: HashSet<&str> = old_files.iter().map(String::as_str).collect();
let new_set: HashSet<&str> = new_files.iter().map(String::as_str).collect();
let ts = now_secs().to_le_bytes();
for file_path in &new_set {
if !old_set.contains(*file_path) {
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, key.as_str()).to_key();
if let Err(e) = store.put_raw(&edge_key, &ts).await {
tracing::warn!("gotcha_write: edge add failed for {file_key} → {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(store, key, &format!("edge add failed: {e}"))
.await;
}
}
}
for file_path in &old_set {
if !new_set.contains(*file_path) {
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, key.as_str()).to_key();
if let Err(e) = store.delete(&edge_key).await {
tracing::warn!("gotcha_write: edge remove failed for {file_key} → {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(store, key, &format!("edge remove failed: {e}"))
.await;
}
}
}
if !secondary_failed {
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
Ok(())
}
pub async fn apply_gotcha_tombstone(
store: &Store,
key: &str,
affected_files: &[String],
) -> Result<()> {
let mut exemplar_snapshot: Option<(String, String, crate::store::Priority)> = None;
match store.get(key).await? {
Some(mut record) => {
if let Some(ref payload) = record.payload {
if let Ok(gr) =
serde_json::from_value::<crate::store::GotchaRecord>(payload.clone())
{
exemplar_snapshot = Some((gr.rule, gr.reason, gr.severity));
}
}
let now = now_secs();
record.lifecycle = RecordLifecycle::Tombstoned {
reason: TombstoneReason::ManualDeletion,
at: now,
};
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
store.put(key, &record).await?;
}
None => anyhow::bail!("record not found: {key}"),
}
crate::store::repair::mark_dirty(store, key, "gotcha_tombstone: pre-arm cancellation guard")
.await;
let mut secondary_failed = false;
if let Err(e) = record_event(
store,
EnforcementEventType::ControlChanged {
change_kind: ControlChangeKind::Deleted,
},
SubjectKind::Control,
key.to_string(),
"developer".to_string(),
None,
"control_deleted".to_string(),
None,
)
.await
{
tracing::warn!("gotcha_tombstone: enforcement event recording failed for {key}: {e}");
}
if let Some((rule, reason, severity)) = exemplar_snapshot.as_ref() {
match crate::store::negative_exemplar::write_on_tombstone(
store,
key,
rule,
reason,
severity,
affected_files,
)
.await
{
Ok(n) => tracing::debug!(
"gotcha_tombstone: negative_exemplar archived for {key} across {n} dirname(s)"
),
Err(e) => {
tracing::warn!("gotcha_tombstone: negative_exemplar write failed for {key}: {e}")
}
}
} else {
tracing::debug!(
"gotcha_tombstone: no GotchaRecord payload on {key}; skipping negative_exemplar archive"
);
}
let _ = crate::store::extraction::mark_outcome(
store,
key,
crate::store::extraction::ExtractionOutcome::Tombstoned,
)
.await;
if let Err(e) = sync_gotcha_file_links(store, key, affected_files, &[]).await {
tracing::warn!("gotcha_tombstone: file link cleanup failed for {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(
store,
key,
&format!("tombstone link cleanup failed: {e}"),
)
.await;
}
for file_path in affected_files {
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, key).to_key();
if let Err(e) = store.delete(&edge_key).await {
tracing::warn!("gotcha_tombstone: edge remove failed for {file_key} → {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(
store,
key,
&format!("tombstone edge remove failed: {e}"),
)
.await;
}
}
if !secondary_failed {
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
Ok(())
}
pub async fn apply_gotcha_confirm(
store: &Store,
record: &Record,
affected_files: &[String],
) -> Result<()> {
let key = &record.key;
store.put(key, record).await?;
crate::store::repair::mark_dirty(store, key, "gotcha_confirm: pre-arm cancellation guard")
.await;
let mut secondary_failed = false;
if let Err(e) = record_event(
store,
EnforcementEventType::ControlChanged {
change_kind: ControlChangeKind::Confirmed,
},
SubjectKind::Control,
key.to_string(),
"developer".to_string(),
None,
"control_confirmed".to_string(),
None,
)
.await
{
tracing::warn!("gotcha_confirm: enforcement event recording failed for {key}: {e}");
}
let _ = crate::store::extraction::mark_outcome(
store,
key,
crate::store::extraction::ExtractionOutcome::Confirmed,
)
.await;
if let Err(e) = sync_gotcha_file_links(store, key, &[], affected_files).await {
tracing::warn!("gotcha_confirm: file link sync failed for {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(store, key, &format!("link sync failed: {e}")).await;
}
let ts = now_secs().to_le_bytes();
for file_path in affected_files {
let file_key = format!("file:{file_path}");
let edge_key = Edge::new(&file_key, EdgeKind::HasGotcha, key.as_str()).to_key();
if let Err(e) = store.put_raw(&edge_key, &ts).await {
tracing::warn!("gotcha_confirm: edge add failed for {file_key} → {key}: {e}");
secondary_failed = true;
crate::store::repair::mark_dirty(store, key, &format!("edge add failed: {e}")).await;
}
}
if !secondary_failed {
crate::store::repair::clear_dirty_key_if_solo(store, key).await;
}
Ok(())
}
pub async fn sync_gotcha_file_links(
store: &Store,
gotcha_key: &str,
old_files: &[String],
new_files: &[String],
) -> Result<()> {
let old_set: HashSet<&str> = old_files.iter().map(String::as_str).collect();
let new_set: HashSet<&str> = new_files.iter().map(String::as_str).collect();
for file_path in new_set.difference(&old_set) {
update_file_gotcha_key(store, file_path, gotcha_key, true).await?;
}
for file_path in old_set.difference(&new_set) {
update_file_gotcha_key(store, file_path, gotcha_key, false).await?;
}
Ok(())
}
async fn update_file_gotcha_key(
store: &Store,
file_path: &str,
gotcha_key: &str,
add: bool,
) -> Result<()> {
let file_key = format!("file:{file_path}");
const MAX_RETRIES: usize = 4;
for attempt in 0..MAX_RETRIES {
let Some(mut record) = store.get(&file_key).await? else {
if add {
let now = now_secs();
let mut stub = Record::layer0_file_stub(
file_key.clone(),
crate::store::stable_device_id(),
1,
now,
);
let mut fr = FileRecord::layer0_stub(
file_path,
vec![],
vec![],
vec![],
0,
0,
0,
None,
false,
0,
now,
);
fr.gotcha_keys = vec![gotcha_key.to_string()];
stub.payload = serde_json::to_value(&fr).ok();
store.put(&file_key, &stub).await?;
}
return Ok(());
};
let changed = if add {
add_gotcha_key(&mut record, gotcha_key)
} else {
remove_gotcha_key(&mut record, gotcha_key)
};
if !changed {
return Ok(());
}
let now = now_secs();
record.updated_at = now;
record.version.logical_clock += 1;
record.version.wall_clock = now;
match store.put(&file_key, &record).await {
Ok(()) => return Ok(()),
Err(e)
if attempt + 1 < MAX_RETRIES
&& e.to_string().to_lowercase().contains("write conflict") =>
{
tokio::time::sleep(std::time::Duration::from_millis(5u64 << attempt)).await;
continue;
}
Err(e) => return Err(e),
}
}
Ok(())
}
fn add_gotcha_key(record: &mut Record, gotcha_key: &str) -> bool {
let Some(payload) = record.payload.as_mut() else {
record.payload = Some(serde_json::json!({ "gotcha_keys": [gotcha_key] }));
return true;
};
if let Some(obj) = payload.as_object_mut() {
match obj.get_mut("gotcha_keys") {
Some(existing) => {
if let Some(arr) = existing.as_array_mut() {
if arr.iter().any(|v| v.as_str() == Some(gotcha_key)) {
false
} else {
arr.push(serde_json::Value::String(gotcha_key.to_string()));
true
}
} else {
*existing = serde_json::json!([gotcha_key]);
true
}
}
None => {
obj.insert("gotcha_keys".into(), serde_json::json!([gotcha_key]));
true
}
}
} else {
record.payload = Some(serde_json::json!({ "gotcha_keys": [gotcha_key] }));
true
}
}
fn remove_gotcha_key(record: &mut Record, gotcha_key: &str) -> bool {
let Some(payload) = record.payload.as_mut() else {
return false;
};
let Some(obj) = payload.as_object_mut() else {
return false;
};
let Some(existing) = obj.get_mut("gotcha_keys") else {
return false;
};
let Some(arr) = existing.as_array_mut() else {
return false;
};
let before = arr.len();
arr.retain(|v| v.as_str() != Some(gotcha_key));
arr.len() != before
}
pub async fn propagate_confirmation_to_files(store: &Store, affected_files: &[String]) {
for file_path in affected_files {
let file_key = format!("file:{file_path}");
if let Ok(Some(mut file_record)) = store.get(&file_key).await {
file_record.confidence.confirmation_count += 1;
let now = now_secs();
file_record.updated_at = now;
file_record.version.logical_clock += 1;
file_record.version.wall_clock = now;
if let Err(e) = store.put(&file_key, &file_record).await {
tracing::warn!("propagate_confirmation: failed to update {file_key}: {e}");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::record::{
Category, ConfidenceScore, GotchaRecord, Priority, QualityScore, RecordSource,
RecordVersion, StalenessScore,
};
fn make_gotcha_record(key: &str, files: &[&str]) -> Record {
let gotcha = GotchaRecord {
rule: "test rule".into(),
reason: "test reason".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 rule because test reason".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: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::DeveloperManual,
confidence: ConfidenceScore::for_new_record(&RecordSource::DeveloperManual),
gap_analysis_score: 0.0,
}
}
fn make_file_record(path: &str) -> Record {
Record {
key: format!("file:{path}"),
value: String::new(),
payload: Some(serde_json::json!({
"path": path,
"purpose": "",
"entry_points": [],
"imports": [],
"gotcha_keys": [],
"decision_keys": [],
"todos": [],
"unsafe_count": 0,
"unwrap_count": 0,
"change_frequency": 0,
"is_hotspot": false,
"token_cost_estimate": 0,
"last_modified_session": 0,
"line_count": 0
})),
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: QualityScore::layer0_default(),
access_count: 0,
last_accessed: 0,
source: RecordSource::StaticAnalysis,
confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
gap_analysis_score: 0.0,
}
}
fn file_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()
}
#[tokio::test]
async fn ensure_key_available_rejects_existing() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let record = make_gotcha_record("gotcha:exists", &["src/a.rs"]);
store.put("gotcha:exists", &record).await.unwrap();
let err = ensure_gotcha_key_available(&store, "gotcha:exists")
.await
.unwrap_err();
assert!(err.to_string().contains("already exists"));
store.close().await.unwrap();
}
#[tokio::test]
async fn ensure_key_available_passes_for_missing() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
ensure_gotcha_key_available(&store, "gotcha:new")
.await
.unwrap();
store.close().await.unwrap();
}
#[tokio::test]
async fn apply_write_adds_file_links_and_edges() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let record = make_gotcha_record("gotcha:test", &["src/a.rs", "src/b.rs"]);
let files = vec!["src/a.rs".into(), "src/b.rs".into()];
apply_gotcha_write(&store, &record, &[], &files, true)
.await
.unwrap();
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(file_gotcha_keys(&a).contains(&"gotcha:test".to_string()));
assert!(file_gotcha_keys(&b).contains(&"gotcha:test".to_string()));
let edge_keys = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:test").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:test").to_key();
assert!(edge_keys.contains(&edge_a));
assert!(edge_keys.contains(&edge_b));
store.close().await.unwrap();
}
#[tokio::test]
async fn apply_write_rejects_collision_when_is_new() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let record = make_gotcha_record("gotcha:dup", &["src/a.rs"]);
store.put("gotcha:dup", &record).await.unwrap();
let record2 = make_gotcha_record("gotcha:dup", &["src/b.rs"]);
let err = apply_gotcha_write(&store, &record2, &[], &["src/b.rs".into()], true)
.await
.unwrap_err();
assert!(err.to_string().contains("already exists"));
store.close().await.unwrap();
}
#[tokio::test]
async fn apply_write_edit_moves_links_between_files() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let record = make_gotcha_record("gotcha:move", &["src/a.rs"]);
apply_gotcha_write(&store, &record, &[], &["src/a.rs".into()], true)
.await
.unwrap();
let record2 = make_gotcha_record("gotcha:move", &["src/b.rs"]);
apply_gotcha_write(
&store,
&record2,
&["src/a.rs".into()],
&["src/b.rs".into()],
false,
)
.await
.unwrap();
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(!file_gotcha_keys(&a).contains(&"gotcha:move".to_string()));
assert!(file_gotcha_keys(&b).contains(&"gotcha:move".to_string()));
let edge_keys = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:move").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:move").to_key();
assert!(!edge_keys.contains(&edge_a));
assert!(edge_keys.contains(&edge_b));
store.close().await.unwrap();
}
#[tokio::test]
async fn apply_tombstone_cleans_links_and_edges() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
store
.put("file:src/b.rs", &make_file_record("src/b.rs"))
.await
.unwrap();
let record = make_gotcha_record("gotcha:del", &["src/a.rs", "src/b.rs"]);
let files = vec!["src/a.rs".into(), "src/b.rs".into()];
apply_gotcha_write(&store, &record, &[], &files, true)
.await
.unwrap();
apply_gotcha_tombstone(&store, "gotcha:del", &files)
.await
.unwrap();
let rec = store.get("gotcha:del").await.unwrap().unwrap();
assert!(matches!(rec.lifecycle, RecordLifecycle::Tombstoned { .. }));
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
let b = store.get("file:src/b.rs").await.unwrap().unwrap();
assert!(file_gotcha_keys(&a).is_empty());
assert!(file_gotcha_keys(&b).is_empty());
let edge_keys = store.scan_keys("graph:edge:").await.unwrap();
let edge_a = Edge::new("file:src/a.rs", EdgeKind::HasGotcha, "gotcha:del").to_key();
let edge_b = Edge::new("file:src/b.rs", EdgeKind::HasGotcha, "gotcha:del").to_key();
assert!(!edge_keys.contains(&edge_a));
assert!(!edge_keys.contains(&edge_b));
store.close().await.unwrap();
}
#[tokio::test]
async fn apply_tombstone_errors_on_missing_key() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let err = apply_gotcha_tombstone(&store, "gotcha:ghost", &[])
.await
.unwrap_err();
assert!(err.to_string().contains("not found"));
store.close().await.unwrap();
}
#[tokio::test]
async fn sync_file_links_backfills_after_direct_write() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
store
.put("file:src/a.rs", &make_file_record("src/a.rs"))
.await
.unwrap();
let record = make_gotcha_record("gotcha:mcp-created", &["src/a.rs"]);
store.put("gotcha:mcp-created", &record).await.unwrap();
let a = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(!file_gotcha_keys(&a).contains(&"gotcha:mcp-created".to_string()));
sync_gotcha_file_links(&store, "gotcha:mcp-created", &[], &["src/a.rs".into()])
.await
.unwrap();
let a2 = store.get("file:src/a.rs").await.unwrap().unwrap();
assert!(file_gotcha_keys(&a2).contains(&"gotcha:mcp-created".to_string()));
store.close().await.unwrap();
}
#[test]
fn now_secs_panics_on_pre_epoch_clock_with_unix_epoch_in_message() {
use std::panic;
use std::time::{Duration, UNIX_EPOCH};
let pre_epoch = UNIX_EPOCH - Duration::from_secs(1);
let result = panic::catch_unwind(|| {
let _ = pre_epoch
.duration_since(UNIX_EPOCH)
.expect("system clock is before UNIX epoch — refusing to write a corrupt timestamp into the gotcha store")
.as_secs();
});
let payload = result.expect_err("pre-epoch SystemTime must panic, not silently return 0");
let msg = if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
panic!("panic payload was neither &str nor String");
};
assert!(
msg.contains("UNIX epoch"),
"panic message should mention 'UNIX epoch' so operators can diagnose clock-backward; got: {msg}"
);
assert!(
msg.contains("refusing to write"),
"panic message should indicate the write was refused (not silently zeroed); got: {msg}"
);
}
#[test]
fn now_secs_returns_recent_post_epoch_seconds() {
let s = now_secs();
assert!(
s > 1_704_067_200,
"now_secs() returned {s}; expected a post-2024 timestamp"
);
}
#[tokio::test]
async fn enriched_gotcha_lifecycle_flips_extraction_outcome() {
use crate::store::extraction::{key_for, ExtractionOutcome, ExtractionRecord};
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let mut record = make_gotcha_record("gotcha:enriched-rule", &["src/cli/repair.rs"]);
record.tags = vec!["enriched".into(), "depth:deep".into()];
apply_gotcha_write(&store, &record, &[], &["src/cli/repair.rs".into()], true)
.await
.unwrap();
let rec = store
.get(&key_for("gotcha:enriched-rule"))
.await
.unwrap()
.expect("extraction record must exist for enriched gotcha");
let extraction: ExtractionRecord =
serde_json::from_value(rec.payload.expect("payload")).unwrap();
assert_eq!(extraction.outcome, ExtractionOutcome::Pending);
assert_eq!(
extraction.depth,
Some(crate::health::enrichment::EnrichmentDepth::Deep)
);
assert_eq!(extraction.file_path, "src/cli/repair.rs");
assert!(extraction.outcome_at.is_none());
apply_gotcha_confirm(&store, &record, &["src/cli/repair.rs".into()])
.await
.unwrap();
let rec = store
.get(&key_for("gotcha:enriched-rule"))
.await
.unwrap()
.unwrap();
let extraction: ExtractionRecord = serde_json::from_value(rec.payload.unwrap()).unwrap();
assert_eq!(extraction.outcome, ExtractionOutcome::Confirmed);
assert!(extraction.outcome_at.is_some());
let mut t_record = make_gotcha_record("gotcha:tombstone-me", &["src/cli/init.rs"]);
t_record.tags = vec!["enriched".into(), "depth:fast".into()];
apply_gotcha_write(&store, &t_record, &[], &["src/cli/init.rs".into()], true)
.await
.unwrap();
apply_gotcha_tombstone(&store, "gotcha:tombstone-me", &["src/cli/init.rs".into()])
.await
.unwrap();
let rec = store
.get(&key_for("gotcha:tombstone-me"))
.await
.unwrap()
.unwrap();
let extraction: ExtractionRecord = serde_json::from_value(rec.payload.unwrap()).unwrap();
assert_eq!(extraction.outcome, ExtractionOutcome::Tombstoned);
assert_eq!(
extraction.depth,
Some(crate::health::enrichment::EnrichmentDepth::Fast)
);
let untagged = make_gotcha_record("gotcha:manual-add", &["src/foo.rs"]);
apply_gotcha_write(&store, &untagged, &[], &["src/foo.rs".into()], true)
.await
.unwrap();
assert!(store
.get(&key_for("gotcha:manual-add"))
.await
.unwrap()
.is_none());
store.close().await.unwrap();
}
#[tokio::test]
async fn tombstone_writes_negative_exemplar_per_unique_dirname() {
let dir = tempfile::TempDir::new().unwrap();
let store = Store::open(dir.path()).await.unwrap();
let record = make_gotcha_record(
"gotcha:vague-rule",
&["src/cli/repair.rs", "src/cli/init.rs", "src/store/db.rs"],
);
store.put("gotcha:vague-rule", &record).await.unwrap();
apply_gotcha_tombstone(
&store,
"gotcha:vague-rule",
&[
"src/cli/repair.rs".into(),
"src/cli/init.rs".into(),
"src/store/db.rs".into(),
],
)
.await
.unwrap();
let cli_exemplar = store
.get("analytics:negative_exemplar:src/cli:vague-rule")
.await
.unwrap()
.expect("src/cli exemplar must exist");
let store_exemplar = store
.get("analytics:negative_exemplar:src/store:vague-rule")
.await
.unwrap()
.expect("src/store exemplar must exist");
for rec in [&cli_exemplar, &store_exemplar] {
let payload = rec.payload.clone().expect("payload present");
let exemplar: crate::store::negative_exemplar::NegativeExemplar =
serde_json::from_value(payload).unwrap();
assert_eq!(exemplar.gotcha_key, "gotcha:vague-rule");
assert_eq!(exemplar.rule, "test rule");
assert_eq!(exemplar.reason, "test reason");
assert_eq!(exemplar.severity, Priority::High);
assert!(exemplar.tombstoned_at > 0);
}
store.close().await.unwrap();
}
}