//! Reference-integrity checks: `replaced_by` existence + cycle detection. use std::collections::HashSet; use crate::Slug; use crate::error::LintError; use crate::linter::ExistingRecords; use crate::linter::LintReport; use crate::workspace::RecordKind; /// Validate a Decision's `replaced_by` against the existing record set. /// /// `self_slug` is the slug of the record currently being written (None /// only when the path was malformed and we shouldn't even reach here). pub fn check_replaced_by( self_slug: Option<&Slug>, target: &Slug, existing: &ExistingRecords, report: &mut LintReport, ) { // Existence: target must already be a Decision on disk. if !existing.contains(RecordKind::Decision, target) { report.push_error(LintError::UnknownReference { field: "replaced_by", kind: "decision", slug: target.to_string(), }); return; } // Cycle: walk the chain target → target.replaced_by → ... and // ensure we never revisit `self_slug` or any node twice. let mut visited = HashSet::new(); if let Some(s) = self_slug { visited.insert(s.clone()); } let mut cursor = Some(target.clone()); let mut chain: Vec = Vec::new(); while let Some(node) = cursor { if !visited.insert(node.clone()) { chain.push(node.to_string()); report.push_error(LintError::ReplacedByCycle { chain: chain.join(" -> "), }); return; } chain.push(node.to_string()); cursor = existing.decision(&node).and_then(|m| m.replaced_by.clone()); } } #[cfg(test)] mod tests { use super::*; // Smoke test: cycle detection terminates on a 2-node loop where the // existing tree already contains A↔B and the new write would close // the loop. A direct unit test against `check_replaced_by` is // exercised by linter::tests; here we just guard the loop bound. #[test] fn empty_chain_terminates() { let mut report = LintReport::default(); let existing = ExistingRecords::default(); let target = Slug::parse("foo").unwrap(); check_replaced_by(None, &target, &existing, &mut report); assert_eq!(report.errors.len(), 1); } }