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,
}
impl<'a> Stale<'a> {
pub fn over(facet: &str, index: &'a Index) -> Self {
Stale {
index,
facet: facet.to_string(),
}
}
}
impl CorpusCheck for Stale<'_> {
const RULE: &'static str = self::STALE;
const VERSION: u32 = 1;
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 {
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)
}
}