68 lines
2.2 KiB
Rust
68 lines
2.2 KiB
Rust
//! 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<String> = 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);
|
|
}
|
|
}
|