use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::patch::Patch;
use crate::scope::{CorpusCheck, CorpusView};
use crate::shape::{IdentifierScheme, Shape};
use headwater_census::shelves::{Shelf, Taxonomy};
use headwater_graph::index::{Defect, Index};
use std::path::Path;
pub const STORE: &str = ".headwater/ids";
pub const RECONCILE_FIRST: &str = "reconcile-first";
pub fn takes_a_claim(shelf: &Shelf, scheme: &IdentifierScheme) -> bool {
shelf.layout.is_some() || scheme.allocation.as_deref() == Some(RECONCILE_FIRST)
}
pub const MISSING: &str = "identifier.claim.missing";
pub const STALE: &str = "identifier.claim.stale";
const NO_REPORT: &str = "the view carries no phase-A report for this corpus";
const NO_STORE: &str = "the view carries no claim store for this corpus";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Claim {
pub scheme: String,
pub id: String,
pub claimant: String,
}
#[derive(Clone, Debug, Default)]
pub struct Claims {
entries: Vec<Claim>,
}
impl Claims {
pub fn empty() -> Self {
Claims::default()
}
pub fn at(root: &Path) -> Self {
let mut entries = Vec::new();
let Ok(schemes) = std::fs::read_dir(root.join(STORE)) else {
return Claims { entries };
};
for scheme in schemes.flatten() {
if !scheme.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
let Some(scheme_name) = scheme.file_name().to_str().map(str::to_string) else {
continue;
};
let Ok(claims) = std::fs::read_dir(scheme.path()) else {
continue;
};
for claim in claims.flatten() {
if claim.file_type().is_ok_and(|kind| kind.is_dir()) {
continue;
}
let Some(id) = claim.file_name().to_str().map(str::to_string) else {
continue;
};
let claimant = std::fs::read_to_string(claim.path())
.unwrap_or_default()
.lines()
.next()
.unwrap_or_default()
.trim()
.to_string();
entries.push(Claim {
scheme: scheme_name.clone(),
id,
claimant,
});
}
}
entries.sort_by(|a, b| (&a.scheme, &a.id).cmp(&(&b.scheme, &b.id)));
Claims { entries }
}
pub fn of(mut entries: Vec<Claim>) -> Self {
entries.sort_by(|a, b| (&a.scheme, &a.id).cmp(&(&b.scheme, &b.id)));
Claims { entries }
}
pub fn entries(&self) -> &[Claim] {
&self.entries
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn claimant(&self, scheme: &str, id: &str) -> Option<&str> {
self.entries
.binary_search_by(|claim| (claim.scheme.as_str(), claim.id.as_str()).cmp(&(scheme, id)))
.ok()
.map(|index| self.entries[index].claimant.as_str())
}
pub fn ids_of<'a>(&'a self, scheme: &'a str) -> impl Iterator<Item = &'a str> {
self.entries
.iter()
.filter(move |claim| claim.scheme == scheme)
.map(|claim| claim.id.as_str())
}
pub fn render(&self) -> String {
let mut out = String::new();
for claim in &self.entries {
out.push_str(&format!(
"{}/{} {} {}\n",
claim.scheme,
claim.id,
claim.claimant.len(),
claim.claimant
));
}
out
}
pub fn digest(&self) -> String {
headwater_hash::digest(self.render().as_bytes())
}
}
pub fn path_of(scheme: &str, id: &str) -> String {
format!("{STORE}/{scheme}/{id}")
}
pub fn contents_for(claimant: &str) -> String {
format!("{claimant}\n")
}
fn contended(identity: &[headwater_graph::index::Reported]) -> Vec<&str> {
let mut out: Vec<&str> = identity
.iter()
.filter_map(|reported| match &reported.defect {
Defect::Duplicate { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
out.sort_unstable();
out.dedup();
out
}
pub struct Missing<'a> {
index: &'a Index,
schemes: Vec<(String, String)>,
}
impl<'a> Missing<'a> {
pub fn over(shape: &Shape, taxonomy: &Taxonomy, index: &'a Index) -> Self {
let schemes = claiming(shape, taxonomy);
Missing { index, schemes }
}
fn scheme_of(&self, kind: &str) -> Option<&str> {
self.schemes
.iter()
.find(|(named, _)| named == kind)
.map(|(_, scheme)| scheme.as_str())
}
}
fn claiming(shape: &Shape, taxonomy: &Taxonomy) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = shape
.kinds
.iter()
.filter_map(|kind| {
let scheme = shape.identifier_scheme_of(&kind.name)?;
let shelves: Vec<&Shelf> = taxonomy
.shelves
.iter()
.filter(|shelf| shelf.carries(&kind.name))
.collect();
let claims = match shelves.is_empty() {
true => scheme.allocation.as_deref() == Some(RECONCILE_FIRST),
false => shelves.iter().any(|shelf| takes_a_claim(shelf, scheme)),
};
claims.then(|| (kind.name.clone(), scheme.name.clone()))
})
.collect();
out.sort();
out
}
impl CorpusCheck for Missing<'_> {
const RULE: &'static str = self::MISSING;
const VERSION: u32 = 2;
const NEEDS_PHASE_A: bool = true;
const NEEDS_CLAIMS: bool = true;
fn evaluate(&self, view: &CorpusView<'_>) -> Outcome {
let Some(identity) = view.identity() else {
return Outcome::Skipped(NO_REPORT.to_string());
};
let Some(claims) = view.claims() else {
return Outcome::Skipped(NO_STORE.to_string());
};
let contended = contended(identity);
let mut findings = Vec::new();
for node in &self.index.typed {
let Some(kind) = node.kind.as_deref() else {
continue;
};
let Some(scheme) = self.scheme_of(kind) else {
continue;
};
if contended.binary_search(&node.id.as_str()).is_ok() {
continue;
}
if claims.claimant(scheme, &node.id).is_some() {
continue;
}
let claim = path_of(scheme, &node.id);
let (line, column) = at(node.id_span);
findings.push(Finding {
rule: self::MISSING,
severity: Severity::Error,
obligation: None,
path: node.path.clone(),
line,
column,
message: format!(
"`{}` is spent by {} and no file of `{STORE}` claims it, so this identifier \
is invisible to the allocator of every other branch and a second document \
can be minted onto it",
node.id, node.path
),
remediation: format!(
"run `headwater check --fix`, which writes `{claim}` holding `{}`",
node.path
),
patch: Some(Patch::Create {
path: claim,
contents: contents_for(&node.path),
}),
});
}
Outcome::failed(findings)
}
}
pub struct Stale<'a> {
index: &'a Index,
facet: String,
schemes: Vec<(String, String)>,
}
impl<'a> Stale<'a> {
pub fn over(facet: &str, shape: &Shape, index: &'a Index) -> Self {
let schemes = shape
.kinds
.iter()
.filter_map(|kind| {
let scheme = shape.identifier_scheme_of(&kind.name)?;
Some((kind.name.clone(), scheme.name.clone()))
})
.collect();
Stale::with_schemes(facet, schemes, index)
}
fn with_schemes(facet: &str, mut schemes: Vec<(String, String)>, index: &'a Index) -> Self {
schemes.sort();
Stale {
index,
facet: facet.to_string(),
schemes,
}
}
fn mints_under(&self, kind: &str, scheme: &str) -> bool {
self.schemes
.iter()
.any(|(named, minted)| named == kind && minted == scheme)
}
}
impl CorpusCheck for Stale<'_> {
const RULE: &'static str = self::STALE;
const VERSION: u32 = 2;
const NEEDS_CLAIMS: bool = true;
fn evaluate(&self, view: &CorpusView<'_>) -> Outcome {
let Some(claims) = view.claims() else {
return Outcome::Skipped(NO_STORE.to_string());
};
let mut findings = Vec::new();
for claim in claims.entries() {
let at_path = path_of(&claim.scheme, &claim.id);
if claim.claimant.is_empty() {
findings.push(Finding {
rule: self::STALE,
severity: Severity::Warn,
obligation: None,
path: at_path.clone(),
line: 0,
column: 0,
message: format!(
"`{at_path}` names no document, so `{}` is claimed by nobody and two \
branches adding this claim would merge without a word",
claim.id
),
remediation: format!(
"write the path of the document whose `{}` is `{}` into `{at_path}`, or \
delete the file if the identifier was never spent",
self.facet, claim.id
),
patch: None,
});
continue;
}
let Some(entry) = self.index.by_path(&claim.claimant) else {
let mut holders = self.index.paths.iter().filter(|entry| {
entry.id.as_deref() == Some(claim.id.as_str())
&& entry
.kind
.as_deref()
.is_some_and(|kind| self.mints_under(kind, &claim.scheme))
});
let (Some(current), None) = (holders.next(), holders.next()) else {
continue;
};
findings.push(Finding {
rule: self::STALE,
severity: Severity::Warn,
obligation: None,
path: current.path.clone(),
line: 0,
column: 0,
message: format!(
"`{at_path}` names {}, and no document stands at that path. The one \
document holding `{}` is {}, so the claim names a path that is not its \
holder's, which follows a rename or a claim edited by hand",
claim.claimant, claim.id, current.path
),
remediation: format!(
"write {} into `{at_path}` in place of {}",
current.path, claim.claimant
),
patch: None,
});
continue;
};
let held = entry.id.as_deref().unwrap_or_default();
if held == claim.id {
continue;
}
findings.push(Finding {
rule: self::STALE,
severity: Severity::Warn,
obligation: None,
path: claim.claimant.clone(),
line: 0,
column: 0,
message: match held.is_empty() {
true => format!(
"`{at_path}` names {} and that document declares no identifier, so the \
claim records a mint that no document carries",
claim.claimant
),
false => format!(
"`{at_path}` names {} and that document declares `{held}`, so one of the \
two records a mint that did not happen",
claim.claimant
),
},
remediation: format!(
"decide which of the two moved. Repoint `{at_path}` at the document whose \
`{}` is `{}`, or correct the document",
self.facet, claim.id
),
patch: None,
});
}
Outcome::failed(findings)
}
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_graph::index::PathEntry;
fn entry(path: &str, id: &str) -> PathEntry {
of_kind(path, id, Some("decision"))
}
fn of_kind(path: &str, id: &str, kind: Option<&str>) -> PathEntry {
PathEntry {
path: path.to_string(),
class: match kind {
Some(_) => "typed",
None => "untyped",
},
id: Some(id.to_string()),
kind: kind.map(str::to_string),
}
}
fn claim(id: &str, claimant: &str) -> Claim {
Claim {
scheme: "decision_id".to_string(),
id: id.to_string(),
claimant: claimant.to_string(),
}
}
fn findings(index: &Index, claims: &Claims) -> Vec<Finding> {
let view = CorpusView::only_claims(Some(claims));
let schemes = vec![
("decision".to_string(), "decision_id".to_string()),
("requirement".to_string(), "requirement_id".to_string()),
];
match Stale::with_schemes("id", schemes, index).evaluate(&view) {
Outcome::Passed => Vec::new(),
Outcome::Failed(found) => found,
other => panic!("the rule did not run: {other:?}"),
}
}
#[test]
fn a_renamed_claimant_is_stale_and_a_deleted_one_is_not() {
let index = Index {
paths: vec![
entry("docs/decisions/0002-new-name.md", "DR-0002"),
entry("docs/decisions/0003-one.md", "DR-0003"),
entry("docs/decisions/0003-two.md", "DR-0003"),
of_kind("docs/w3id/stray.md", "DR-0004", None),
of_kind(
"docs/requirements/0005-other.md",
"DR-0005",
Some("requirement"),
),
entry("docs/decisions/0006-new-name.md", "DR-0006"),
of_kind("docs/w3id/stray-0006.md", "DR-0006", None),
],
..Index::default()
};
let claims = Claims::of(vec![
claim("DR-0001", "docs/decisions/0001-deleted.md"),
claim("DR-0002", "docs/decisions/0002-old-name.md"),
claim("DR-0003", "docs/decisions/0003-gone.md"),
claim("DR-0004", "docs/decisions/0004-gone.md"),
claim("DR-0005", "docs/decisions/0005-gone.md"),
claim("DR-0006", "docs/decisions/0006-old-name.md"),
]);
let found = findings(&index, &claims);
let paths: Vec<&str> = found.iter().map(|f| f.path.as_str()).collect();
assert_eq!(
paths,
vec![
"docs/decisions/0002-new-name.md",
"docs/decisions/0006-new-name.md"
],
"{found:#?}"
);
let finding = &found[0];
assert_eq!(finding.rule, STALE);
assert_eq!(finding.severity, Severity::Warn);
assert!(finding.patch.is_none(), "{finding:#?}");
assert_eq!(finding.path, "docs/decisions/0002-new-name.md");
assert!(
finding
.message
.contains(".headwater/ids/decision_id/DR-0002"),
"{finding:#?}"
);
assert!(
finding.message.contains("docs/decisions/0002-old-name.md"),
"{finding:#?}"
);
assert!(
finding.message.contains("docs/decisions/0002-new-name.md"),
"{finding:#?}"
);
assert!(
finding
.remediation
.contains("docs/decisions/0002-new-name.md"),
"{finding:#?}"
);
assert!(!finding.message.contains("DR-0001"), "{finding:#?}");
assert!(
finding
.message
.contains("which follows a rename or a claim edited by hand"),
"{finding:#?}"
);
assert!(!finding.message.contains("renamed"), "{finding:#?}");
}
#[test]
fn a_claim_edited_by_hand_to_a_wrong_path_is_stale_and_not_called_a_rename() {
let index = Index {
paths: vec![entry("docs/decisions/0007-the-title.md", "DR-0007")],
..Index::default()
};
let claims = Claims::of(vec![claim("DR-0007", "docs/decisions/0007-the-titel.md")]);
let found = findings(&index, &claims);
assert_eq!(found.len(), 1, "{found:#?}");
let finding = &found[0];
assert_eq!(finding.path, "docs/decisions/0007-the-title.md");
assert!(
finding.message.contains("docs/decisions/0007-the-titel.md"),
"{finding:#?}"
);
assert!(!finding.message.contains("renamed"), "{finding:#?}");
assert!(!finding.message.contains("no longer"), "{finding:#?}");
assert!(
finding
.message
.contains("which follows a rename or a claim edited by hand"),
"{finding:#?}"
);
}
}