use std::fs;
use std::path::{Path, PathBuf};
use rac_engine::derived::{build_derived_index, SCHEMA_VERSION};
use rac_engine::freshness::{FreshnessTracker, TrackerModel};
use rac_engine::index_store::{store_dir, write_store};
fn scratch(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("rac-tracker-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("scratch");
dir
}
const DOC: &str = "# ADR-1: Widget Base\n\n## Context\n\nBase.\n\n## Decision\n\nKeep.\n\n## Consequences\n\nNone.\n\n## Status\n\nAccepted\n";
#[test]
fn base_delta_compaction_lifecycle() {
let corpus = scratch("corpus");
let cache = scratch("cache");
fs::write(corpus.join("adr-1-base.md"), DOC).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut tracker = FreshnessTracker::new_snapshot(cache.clone(), &root, Some(2));
#[cfg(target_os = "linux")]
assert_eq!(tracker.mode(), "inotify");
#[cfg(not(target_os = "linux"))]
assert_eq!(tracker.mode(), "stat");
assert!(matches!(tracker.read_model(false), TrackerModel::View(_)));
assert!(tracker.last_detect_scanned());
assert_eq!(tracker.base_generation(), 1);
assert_eq!(tracker.serving_generation(), 1);
assert_eq!(tracker.delta_size(), 0);
let cold_hash = tracker.corpus_hash().unwrap().to_string();
assert!(matches!(tracker.read_model(false), TrackerModel::View(_)));
#[cfg(target_os = "linux")]
assert!(!tracker.last_detect_scanned());
#[cfg(not(target_os = "linux"))]
assert!(tracker.last_detect_scanned());
assert_eq!(tracker.base_generation(), 1);
assert_eq!(tracker.serving_generation(), 1);
fs::write(corpus.join("adr-2-two.md"), DOC.replace("ADR-1", "ADR-2")).unwrap();
assert!(matches!(
tracker.read_model(false),
TrackerModel::Snapshot(_)
));
assert!(tracker.last_detect_scanned());
assert_eq!(tracker.base_generation(), 1);
assert_eq!(tracker.serving_generation(), 2);
assert_eq!(tracker.delta_size(), 1);
assert_ne!(tracker.corpus_hash().unwrap(), cold_hash);
fs::write(corpus.join("adr-3-three.md"), DOC.replace("ADR-1", "ADR-3")).unwrap();
assert!(matches!(tracker.read_model(false), TrackerModel::View(_)));
assert_eq!(tracker.base_generation(), 2);
assert_eq!(tracker.serving_generation(), 3);
assert_eq!(tracker.delta_size(), 0);
fs::remove_file(corpus.join("adr-2-two.md")).unwrap();
match tracker.read_model(false) {
TrackerModel::Snapshot(derived) => {
assert_eq!(derived.index_entries.len(), 2);
}
TrackerModel::View(_) => panic!("one change below threshold must serve the snapshot"),
TrackerModel::Delta(_) => panic!("snapshot fallback must not serve delta generations"),
}
assert_eq!(tracker.serving_generation(), 4);
let _ = fs::remove_dir_all(&corpus);
let _ = fs::remove_dir_all(&cache);
}
#[test]
fn stat_fallback_and_verify_never_trust_clean_events() {
let corpus = scratch("stat-corpus");
let cache = scratch("stat-cache");
fs::write(corpus.join("adr-1-base.md"), DOC).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut tracker = FreshnessTracker::new_stat(cache.clone(), &root, None);
assert_eq!(tracker.mode(), "stat");
tracker.read_model(false);
assert!(tracker.last_detect_scanned());
tracker.read_model(false);
assert!(tracker.last_detect_scanned());
let mut watcher = FreshnessTracker::new(cache.clone(), &root, None);
watcher.read_model(false);
watcher.read_model(true);
assert!(watcher.last_detect_scanned());
let _ = fs::remove_dir_all(&corpus);
let _ = fs::remove_dir_all(&cache);
}
#[test]
fn detection_barrier_catches_immediate_nested_mutation() {
let corpus = scratch("event-corpus");
let cache = scratch("event-cache");
fs::write(corpus.join("adr-1-base.md"), DOC).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut tracker = FreshnessTracker::new(cache.clone(), &root, Some(10));
tracker.read_model(false);
let generation = tracker.serving_generation();
let nested = corpus.join("new").join("nested");
fs::create_dir_all(&nested).unwrap();
fs::write(nested.join("adr-2-two.md"), DOC.replace("ADR-1", "ADR-2")).unwrap();
match tracker.read_model(false) {
TrackerModel::Delta(delta) => {
assert_eq!(delta.materialize_derived(&root, true).index_entries.len(), 2)
}
TrackerModel::View(_) => panic!("nested mutation must open the delta window"),
TrackerModel::Snapshot(_) => panic!("default tracker must serve the delta generation"),
}
assert!(tracker.last_detect_scanned());
assert_eq!(tracker.serving_generation(), generation + 1);
tracker.read_model(false);
#[cfg(target_os = "linux")]
assert!(!tracker.last_detect_scanned());
#[cfg(not(target_os = "linux"))]
assert!(tracker.last_detect_scanned());
let _ = fs::remove_dir_all(&corpus);
let _ = fs::remove_dir_all(&cache);
}
#[test]
fn watcher_setup_and_runtime_failure_degrade_to_stat() {
let missing = scratch("missing-root");
let _ = fs::remove_dir_all(&missing);
let cache = scratch("missing-cache");
let missing_root = missing.to_string_lossy().into_owned();
let missing_tracker = FreshnessTracker::new(cache.clone(), &missing_root, None);
assert_eq!(missing_tracker.mode(), "stat");
let corpus = scratch("removed-corpus");
fs::write(corpus.join("adr-1-base.md"), DOC).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut tracker = FreshnessTracker::new(cache.clone(), &root, Some(10));
tracker.read_model(false);
fs::remove_dir_all(&corpus).unwrap();
match tracker.read_model(false) {
TrackerModel::Delta(delta) => {
assert!(delta.materialize_derived(&root, true).index_entries.is_empty())
}
TrackerModel::View(_) => panic!("root removal must open an empty delta generation"),
TrackerModel::Snapshot(_) => panic!("default tracker must serve the delta generation"),
}
assert_eq!(tracker.mode(), "stat");
assert!(tracker.last_detect_scanned());
let _ = fs::remove_dir_all(&cache);
}
fn store_hashes(cache_dir: &Path, corpus_hash: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = fs::read_dir(store_dir(cache_dir, corpus_hash))
.unwrap()
.map(|entry| {
let entry = entry.unwrap();
let bytes = fs::read(entry.path()).unwrap();
(
entry.file_name().to_string_lossy().into_owned(),
rac_engine::sha256::hexdigest(&bytes),
)
})
.collect();
out.sort();
out
}
fn assert_delta_matches_fresh(model: &TrackerModel, root: &str, tag: &str) {
let TrackerModel::Delta(generation) = model else {
panic!("expected a delta generation");
};
let candidate_cache = scratch(&format!("{tag}-candidate"));
let fresh_cache = scratch(&format!("{tag}-fresh"));
let key = "referee";
let materialized = generation.materialize_derived(root, true);
assert!(write_store(
&candidate_cache,
key,
SCHEMA_VERSION,
&materialized,
));
assert!(write_store(
&fresh_cache,
key,
SCHEMA_VERSION,
&build_derived_index(root, true),
));
assert_eq!(
store_hashes(&candidate_cache, key),
store_hashes(&fresh_cache, key),
"delta generation must be byte-identical to a fresh derivation"
);
let fresh_items = rac_engine::relationships::corpus_items(root, true);
assert_eq!(
generation.summary.value(root, true),
rac_engine::output::portfolio_summary_value(
&rac_engine::portfolio::portfolio_from_corpus(root, &fresh_items, true)
),
"summary generation must equal a fresh portfolio reduction"
);
let scope_signature = |rows: Vec<rac_engine::retrieve::ScopeRow>| {
rows.into_iter()
.map(|row| (row.id, row.title, row.status, row.path, row.scope_entries))
.collect::<Vec<_>>()
};
assert_eq!(
scope_signature(generation.scope.rows()),
scope_signature(rac_engine::retrieve::scope_rows_from_items(&fresh_items)),
"scope generation must equal fresh scope rows"
);
let fresh_live: Vec<String> = build_derived_index(root, true)
.live_decision_paths
.into_iter()
.collect();
assert_eq!(generation.scope.live_paths(), fresh_live);
let relationship_signature = |edges: Vec<rac_engine::relationships::Relationship>| {
edges
.into_iter()
.map(|edge| {
(
edge.source_path,
edge.relationship,
edge.target,
edge.resolved_path,
edge.issue,
)
})
.collect::<Vec<_>>()
};
assert_eq!(
relationship_signature(generation.graph.relationships()),
relationship_signature(rac_engine::relationships::relationships_from_corpus(
&fresh_items,
)),
"graph generation must equal fresh relationship resolution"
);
let fresh_index = rac_engine::resolve::index_from_items(&fresh_items);
for entry in &fresh_index {
for alias in &entry.aliases {
let expected = rac_engine::resolve::resolve_in_index(&fresh_index, alias);
let actual = generation.identity.resolve(alias);
assert_eq!(
actual.outcome, expected.outcome,
"identity outcome for {alias}"
);
assert_eq!(actual.duplicate_paths, expected.duplicate_paths);
assert_eq!(
actual.artifact.as_ref().map(|artifact| (
artifact.id.as_str(),
artifact.artifact_type.as_str(),
artifact.title.as_deref(),
artifact.path.as_str(),
)),
expected.artifact.as_ref().map(|artifact| (
artifact.id.as_str(),
artifact.artifact_type.as_str(),
artifact.title.as_deref(),
artifact.path.as_str(),
)),
"identity row for {alias}"
);
}
}
let root_path = Path::new(root);
for item in &fresh_items {
let relative = Path::new(&item.path)
.strip_prefix(root_path)
.unwrap()
.to_string_lossy()
.into_owned();
let expected_status = rac_engine::resolve::artifact_status(&item.artifact);
assert_eq!(
generation.identity.status_for_path(&relative),
Some(expected_status.as_str()),
"status row for {relative}"
);
}
for (query, artifact_type, live_only) in [
("widget", None, false),
("widget", None, true),
("adr widget", Some("decision"), false),
("widget widget", None, false),
("accepted", None, true),
("zzzz-no-match", None, false),
] {
let expected = rac_engine::resolve::search_index_filtered(
&fresh_index,
query,
artifact_type,
&[],
live_only,
);
let actual = generation.search.search(
query,
artifact_type,
&[],
live_only,
&generation.graph,
);
assert_eq!(
rac_engine::output::search_result_value(&actual, true),
rac_engine::output::search_result_value(&expected, true),
"search generation for {query}"
);
}
let _ = fs::remove_dir_all(candidate_cache);
let _ = fs::remove_dir_all(fresh_cache);
}
#[test]
fn default_delta_stages_mutations_compacts_and_keeps_parsed_base() {
let corpus = scratch("p6-corpus");
let cache = scratch("p6-cache");
fs::write(corpus.join("adr-1-base.md"), DOC).unwrap();
fs::write(corpus.join("adr-2-two.md"), DOC.replace("ADR-1", "ADR-2")).unwrap();
let root = corpus.to_string_lossy().into_owned();
let mut tracker = FreshnessTracker::new(cache.clone(), &root, Some(3));
assert!(tracker.delta_enabled());
assert!(matches!(tracker.read_model(false), TrackerModel::View(_)));
assert_eq!(tracker.base_generation(), 1);
assert_eq!(tracker.delta_base_documents(), 2);
assert_eq!(tracker.delta_size(), 0);
assert_eq!(tracker.last_parse_files(), 2);
fs::write(
corpus.join("adr-1-base.md"),
DOC.replace("Keep.", "Keep the edited base.")
.replace("Accepted", "Superseded"),
)
.unwrap();
let serving = tracker.serving_generation() + 1;
let base = tracker.base_generation();
let model = tracker.read_model(false);
let TrackerModel::Delta(generation) = model else {
panic!("one edit must open the delta generation");
};
assert_eq!(generation.base_generation, base);
assert_eq!(generation.serving_generation, serving);
assert_eq!(generation.changed_paths, vec!["adr-1-base.md"]);
assert_delta_matches_fresh(model, &root, "p6-edit");
assert_eq!(tracker.last_parse_files(), 1);
assert_eq!(tracker.delta_upserts(), 1);
assert_eq!(tracker.delta_tombstones(), 0);
fs::remove_file(corpus.join("adr-2-two.md")).unwrap();
let model = tracker.read_model(false);
assert_delta_matches_fresh(model, &root, "p6-delete");
assert_eq!(tracker.last_parse_files(), 0);
assert_eq!(tracker.delta_upserts(), 1);
assert_eq!(tracker.delta_tombstones(), 1);
fs::rename(
corpus.join("adr-1-base.md"),
corpus.join("adr-3-renamed.md"),
)
.unwrap();
assert!(matches!(tracker.read_model(false), TrackerModel::View(_)));
assert_eq!(tracker.base_generation(), 2);
assert_eq!(tracker.delta_base_documents(), 1);
assert_eq!(tracker.delta_size(), 0);
fs::write(
corpus.join("adr-3-renamed.md"),
DOC.replace("Keep.", "Keep after compaction."),
)
.unwrap();
let model = tracker.read_model(false);
assert_delta_matches_fresh(model, &root, "p6-post-compact");
assert_eq!(tracker.last_parse_files(), 1);
assert_eq!(tracker.delta_upserts(), 1);
assert_eq!(tracker.delta_tombstones(), 0);
let _ = fs::remove_dir_all(&corpus);
let _ = fs::remove_dir_all(&cache);
}