use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{EdgeCheck, EdgeUnit, EdgeView};
use crate::shape::Shape;
use crate::{Date, Patch};
use headwater_graph::declarations::Relation;
use headwater_graph::edges::VERIFIED_REVISION;
use headwater_graph::{Declarations, Direction, Edge, Target};
pub const RULE: &str = "relation.target.suspect";
const NO_HALF: &str = "the entry carries no declared half";
const DOCUMENT_TARGET: &str =
"the target is a document, which holds no revision for an edge to be verified against";
const SOURCE_TREE: &str = "source-tree";
pub struct Suspect<'a> {
declared: Vec<&'a Relation>,
freshness: Option<&'a str>,
}
impl<'a> Suspect<'a> {
pub fn over(declarations: &'a Declarations, shape: &'a Shape) -> Self {
Suspect {
declared: declarations
.relations
.iter()
.filter(|relation| {
relation.created_by.as_deref() == Some("import")
|| relation
.to
.iter()
.any(|kind| declarations.anchor(kind).is_some())
})
.collect(),
freshness: shape
.facet_in_role("freshness")
.map(|facet| facet.name.as_str()),
}
}
fn verified_today(&self, view: &EdgeView<'_>) -> bool {
let (Some(facet), Some(now), Some(facets)) =
(self.freshness, view.now(), view.declarer_facets())
else {
return false;
};
facets
.get(facet)
.and_then(|node| node.value.as_scalar())
.and_then(|scalar| Date::parse(&scalar.text))
.is_some_and(|verified| verified >= now)
}
}
impl EdgeCheck for Suspect<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 4;
const NEEDS_CLOCK: bool = true;
const UNIT: EdgeUnit = EdgeUnit::Entry;
fn instantiates(&self, relation: &str) -> bool {
self.declared.iter().any(|known| known.name == relation)
}
fn evaluate(&self, view: &EdgeView<'_>) -> Outcome {
let Some(edge) = view.declared_half().or_else(|| view.inverse_half()) else {
return Outcome::Skipped(NO_HALF.to_string());
};
let (revision, resolver, patterns) = match &edge.target {
Target::Anchor {
revision,
resolver,
patterns,
..
} => (revision, resolver, patterns),
Target::Document { .. } => return Outcome::Skipped(DOCUMENT_TARGET.to_string()),
_ => return Outcome::Passed,
};
let today = self.verified_today(view)
&& self
.declared
.iter()
.find(|known| known.name == view.relation())
.is_some_and(|known| known.attributes.iter().any(|a| a == VERIFIED_REVISION));
let (line, column) = at(Some(edge.span));
let finding = |severity, message, remediation, patch| Finding {
rule: self::RULE,
severity,
obligation: None,
path: edge.source.path.clone(),
line,
column,
message,
remediation,
patch,
};
let Some(current) = revision.as_deref() else {
let members = directory_members(resolver, patterns);
return match (patterns.as_slice(), members.as_slice()) {
(_, []) => Outcome::Passed,
([_], [literal]) => Outcome::failed_with(finding(
Severity::Info,
directory(&edge.source.id, &edge.name, literal),
format!(
"write `{literal}/**` to govern the entries under it, which carries a \
digest this rule compares, or name the one file the document governs"
),
None,
)),
(_, members) => Outcome::failed_with(finding(
Severity::Info,
directories(&edge.source.id, &edge.name, &edge.raw_target, members),
format!(
"write {} in the list to govern the entries under each directory, which \
carries a digest this rule compares, or name the files the document \
governs",
spoken(members.iter().map(|member| format!("`{member}/**`")))
),
None,
)),
};
};
if edge.verified_revision().is_none() {
if !today || resolver != SOURCE_TREE {
return Outcome::Passed;
}
let Some(patch) = recording(edge, current) else {
return Outcome::Passed;
};
return Outcome::failed_with(finding(
Severity::Info,
unrecorded(&edge.source.id, &edge.name, &edge.raw_target, current),
format!(
"run `headwater check --fix` to record `{VERIFIED_REVISION}: {current}` on \
this entry, so that a later change to what it reaches is reported"
),
Some(patch),
));
}
let Some((verified, current)) = edge.suspect_revisions() else {
return Outcome::Passed;
};
let reached: usize = {
let mut union: Vec<&String> = patterns.iter().flat_map(|p| p.matched.iter()).collect();
union.sort();
union.dedup();
union.len()
};
let literal = patterns.len() == 1
&& patterns[0].matched.len() == 1
&& patterns[0].matched[0] == patterns[0].pattern;
let (message, remediation) = match resolver.as_str() {
SOURCE_TREE => (
moved(
&edge.source.id,
&edge.name,
&edge.raw_target,
verified,
current,
match literal {
true => Reach::One,
false => Reach::Set(reached),
},
),
reread(current),
),
_ => (
message(
&edge.source.id,
&edge.name,
&edge.raw_target,
verified,
current,
),
remediation(current),
),
};
let patch = match resolver == SOURCE_TREE && today {
true => recording(edge, current),
false => None,
};
Outcome::failed_with(finding(Severity::Warn, message, remediation, patch))
}
}
fn directory_members<'e>(
resolver: &str,
patterns: &'e [headwater_graph::edges::PatternMember],
) -> Vec<&'e str> {
if resolver != SOURCE_TREE {
return Vec::new();
}
patterns
.iter()
.filter(|member| {
member.revision.is_none()
&& member.matched.len() == 1
&& member.matched[0] == member.pattern
})
.map(|member| member.pattern.as_str())
.collect()
}
fn directory(id: &str, name: &str, literal: &str) -> String {
format!(
"`{id}` declares `{name}: {literal}`, and `{literal}` names a directory, which the source \
tree takes no digest of, so this edge never goes suspect when what it governs changes"
)
}
fn directories(id: &str, name: &str, raw: &str, members: &[&str]) -> String {
let verb = match members {
[_] => "names",
_ => "each name",
};
format!(
"`{id}` declares `{name}: {raw}`, and {} {verb} a directory, which the source tree takes \
no digest of, so this edge never goes suspect when what it governs changes",
spoken(members.iter().map(|member| format!("`{member}`")))
)
}
fn spoken(items: impl Iterator<Item = String>) -> String {
let items: Vec<String> = items.collect();
match items.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
fn recording(edge: &Edge, current: &str) -> Option<Patch> {
if edge.direction != Direction::AsDeclared {
return None;
}
let Target::Anchor { patterns, .. } = &edge.target else {
return None;
};
if patterns.len() != 1 {
return None;
}
let mut attributes = Vec::with_capacity(edge.attributes.len() + 1);
for entry in &edge.attributes {
if entry.key.value == VERIFIED_REVISION {
continue;
}
let scalar = entry.value.value.as_scalar()?;
attributes.push((entry.key.value.clone(), scalar.text.clone()));
}
attributes.push((VERIFIED_REVISION.to_string(), current.to_string()));
Some(Patch::Half {
path: edge.source.path.clone(),
relation: edge.name.clone(),
id: edge.raw_target.clone(),
attributes,
})
}
#[derive(Clone, Copy)]
enum Reach {
One,
Set(usize),
}
fn moved(id: &str, name: &str, raw: &str, verified: &str, current: &str, reach: Reach) -> String {
match reach {
Reach::One => format!(
"`{id}` declares `{name}: {raw}`, which was verified against content `{verified}`, \
and the 1 entry it reaches has changed since: it now reads `{current}`"
),
Reach::Set(count) => format!(
"`{id}` declares `{name}: {raw}`, which was verified against content `{verified}`, \
and the {count} {entries} it matches now read `{current}`; the digest covers the \
set, so how many of them changed is not recorded",
entries = match count {
1 => "entry",
_ => "entries",
}
),
}
}
fn reread(current: &str) -> String {
format!(
"re-read what the entry reaches and correct this document where it no longer holds; then \
set its `last_verified` to today and run `headwater check --fix`, which records \
`{VERIFIED_REVISION}: {current}` on this entry"
)
}
fn unrecorded(id: &str, name: &str, raw: &str, current: &str) -> String {
format!(
"`{id}` declares `{name}: {raw}` with no `{VERIFIED_REVISION}`, and the document was \
verified today, so the content it reaches now, `{current}`, can be recorded"
)
}
fn message(id: &str, name: &str, raw: &str, verified: &str, current: &str) -> String {
format!(
"`{id}` declares `{name}: {raw}`, which was verified at revision `{verified}` and the \
snapshot now pins revision `{current}`"
)
}
fn remediation(current: &str) -> String {
format!(
"re-read the upstream item, and where the edge still holds set \
`{VERIFIED_REVISION}: {current}` on this entry"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_finding_names_both_revisions_and_the_remedy_names_the_attribute() {
let text = message("SPEC-FIX-one", "audited_by", "12345", "7", "8");
assert!(text.contains("SPEC-FIX-one"), "{text}");
assert!(text.contains("audited_by: 12345"), "{text}");
assert!(text.contains("verified at revision `7`"), "{text}");
assert!(text.contains("pins revision `8`"), "{text}");
let remedy = remediation("8");
assert!(remedy.contains("verified_revision: 8"), "{remedy}");
assert!(!remedy.contains("headwater import"), "{remedy}");
}
#[test]
fn the_recorded_revision_and_the_pinned_one_are_never_the_same_side() {
let text = message("SPEC-FIX-one", "audited_by", "12345", "7", "8");
let verified = text.find("verified at revision").expect("it is there");
let pinned = text.find("now pins revision").expect("it is there");
assert!(verified < pinned, "{text}");
}
}