use crate::context::Date;
use crate::finding::{at, Finding};
use crate::instance::Outcome;
use crate::scope::{End, NeighbourhoodCheck, NeighbourhoodView};
use crate::shape::{Expectation, Shape};
use headwater_graph::{Declarations, Direction};
pub const RULE: &str = "relation.participation.overdue";
const NO_CLOCK: &str = "the run injected no clock";
const NO_RELATION: &str = "the expectation names a relation that no declaration holds";
const NO_SEVERITY: &str = "the expectation states a severity this engine does not know";
const NO_WINDOW: &str = "the expectation states no window this engine can read";
const NO_ORIGIN_FACET: &str = "the expectation measures from a role that no facet carries";
const NO_ORIGIN_DATE: &str = "the document declares no readable date under the origin facet";
struct Windowed {
kind: String,
expectation: Expectation,
relation: Option<String>,
end: Option<End>,
since_facet: Option<String>,
}
pub struct Participation<'a> {
windowed: Vec<Windowed>,
shape: &'a Shape,
}
impl<'a> Participation<'a> {
pub fn over(shape: &'a Shape, declarations: &Declarations) -> Self {
let mut windowed = Vec::new();
for kind in &shape.kinds {
for expectation in &kind.expectations {
let named = declarations.named(&expectation.relation);
windowed.push(Windowed {
kind: kind.name.clone(),
relation: named.map(|named| named.relation.name.clone()),
end: named.map(|named| match named.direction {
Direction::AsDeclared => End::Source,
Direction::Inverse => End::Target,
}),
since_facet: shape
.facet_in_role(&expectation.since_role)
.map(|facet| facet.name.clone()),
expectation: expectation.clone(),
});
}
}
Participation { windowed, shape }
}
fn applies(&self, expectation: &Expectation, view: &NeighbourhoodView<'_>) -> bool {
expectation.when.iter().all(|(facet, value)| {
view.facets()
.get(facet)
.and_then(|node| node.value.as_scalar())
.is_some_and(|scalar| &scalar.text == value)
})
}
fn satisfied(
&self,
expectation: &Expectation,
relation: &str,
end: End,
view: &NeighbourhoodView<'_>,
) -> bool {
view.neighbours().iter().any(|neighbour| {
neighbour.relation == relation
&& neighbour.end == end
&& match &expectation.to_kind {
None => true,
Some(kind) => self.shape.descends_from(neighbour.kind, kind),
}
})
}
}
impl NeighbourhoodCheck for Participation<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
const NEEDS_CLOCK: bool = true;
fn instantiates(&self, kind: &str) -> bool {
self.windowed.iter().any(|windowed| windowed.kind == kind)
}
fn evaluate(&self, view: &NeighbourhoodView<'_>) -> Outcome {
let Some(now) = view.now() else {
return Outcome::Skipped(NO_CLOCK.to_string());
};
let mut findings = Vec::new();
let mut unreadable: Option<&'static str> = None;
let mut note = |reason| {
unreadable.get_or_insert(reason);
};
for windowed in self
.windowed
.iter()
.filter(|windowed| windowed.kind == view.kind())
{
let expectation = &windowed.expectation;
if !self.applies(expectation, view) {
continue;
}
let (Some(relation), Some(end)) = (&windowed.relation, windowed.end) else {
note(NO_RELATION);
continue;
};
let Some(severity) = expectation.severity else {
note(NO_SEVERITY);
continue;
};
let Some(within) = expectation.within_days else {
note(NO_WINDOW);
continue;
};
let Some(since) = &windowed.since_facet else {
note(NO_ORIGIN_FACET);
continue;
};
if self.satisfied(expectation, relation, end, view) {
continue;
}
let entry = view.facets().entry(since);
let origin = entry
.and_then(|entry| entry.value.value.as_scalar())
.and_then(|scalar| Date::parse(&scalar.text));
let Some(origin) = origin else {
note(NO_ORIGIN_DATE);
continue;
};
let elapsed = now.days_since(origin);
if elapsed <= within {
continue;
}
let (line, column) = at(entry.map(|entry| entry.key.span));
let owed = match &expectation.to_kind {
Some(kind) => format!("`{relation}` to a `{kind}`"),
None => format!("`{relation}`"),
};
findings.push(Finding {
rule: self::RULE,
severity,
obligation: None,
path: view.path().to_string(),
line,
column,
message: format!(
"`{}`: {elapsed} days since `{since}`, and this `{}` reaches no {owed} \
inside the {within} days the taxonomy allows",
expectation.id,
view.kind()
),
remediation: match &expectation.rationale {
Some(rationale) => format!(
"declare {owed} under `relations:` in {}, or record why it is not owed: {rationale}",
view.path()
),
None => format!("declare {owed} under `relations:` in {}", view.path()),
},
patch: None,
});
}
match (findings.is_empty(), unreadable) {
(false, _) => Outcome::Failed(findings),
(true, Some(reason)) => Outcome::Skipped(reason.to_string()),
(true, None) => Outcome::Passed,
}
}
}