use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::observation::{Observation, Observations, Problem, Recorded};
use crate::scope::{EdgeCheck, EdgeUnit, EdgeView};
use headwater_census::census::Census;
use headwater_graph::declarations::Relation;
use headwater_graph::Declarations;
use headwater_graph::{Direction, Graph, Target};
use std::collections::{BTreeMap, BTreeSet};
pub const RULE: &str = "relation.target.verification.suspect";
const TARGET_KIND: &str = "verification";
const NO_HALF: &str = "the entry carries no declared half";
const NO_PAIR: &str = "this edge has no second document to read";
pub struct Verified<'a> {
declared: Vec<&'a Relation>,
observations: &'a Observations,
}
impl<'a> Verified<'a> {
pub fn over(declarations: &'a Declarations, observations: &'a Observations) -> Self {
Verified {
declared: declarations
.relations
.iter()
.filter(|relation| relation.to.iter().any(|kind| kind == TARGET_KIND))
.collect(),
observations,
}
}
}
impl EdgeCheck for Verified<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 2;
const UNIT: EdgeUnit = EdgeUnit::Pair;
const NEEDS_OBSERVATIONS: bool = true;
fn instantiates(&self, relation: &str) -> bool {
self.declared.iter().any(|known| known.name == relation)
}
fn evaluate(&self, view: &EdgeView<'_>) -> Outcome {
let Some(half) = view.declared_half().or_else(|| view.inverse_half()) else {
return Outcome::Skipped(NO_HALF.to_string());
};
let Some((criterion, verification)) = view.ends() else {
return Outcome::Skipped(NO_PAIR.to_string());
};
let Some(current_digest) = criterion.digest() else {
return Outcome::Skipped(
"the census read no digest for the document at the criterion end of this edge, \
so there is nothing to compare a snapshot against"
.to_string(),
);
};
let commit = match compare(self.observations, verification.id, current_digest) {
Freshness::Declared | Freshness::Observed { .. } => return Outcome::Passed,
Freshness::Unknown { reason } => {
return Outcome::Skipped(format!(
"the observation snapshot did not read for this verification: {reason}"
))
}
Freshness::Suspect { commit } => commit,
};
let (line, column) = at(Some(half.span));
Outcome::failed_with(Finding {
rule: self::RULE,
severity: Severity::Warn,
obligation: None,
path: half.source.path.clone(),
line,
column,
message: message(criterion.id, verification.id, commit),
remediation: remediation(verification.id),
patch: None,
})
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Freshness<'a> {
Declared,
Observed { commit: &'a str },
Suspect { commit: &'a str },
Unknown { reason: &'a str },
}
pub fn compare<'a>(
observations: &'a Observations,
verification: &str,
current_digest: &str,
) -> Freshness<'a> {
match observations.recorded(verification) {
Recorded::Absent => Freshness::Declared,
Recorded::Unread(reason) => Freshness::Unknown { reason },
Recorded::Entry {
commit,
criterion_digest,
} if criterion_digest == current_digest => Freshness::Observed { commit },
Recorded::Entry { commit, .. } => Freshness::Suspect { commit },
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Line {
pub verification: String,
pub state: State,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum State {
Declared,
Observed {
commit: String,
},
Suspect {
commit: String,
criteria: Vec<String>,
},
Unknown {
reason: String,
},
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Block {
pub lines: Vec<Line>,
pub snapshot: Option<String>,
pub orphans: Vec<(String, Option<String>)>,
pub unread: bool,
}
impl Block {
pub fn of(
declarations: &Declarations,
census: &Census,
graph: &Graph,
observations: &Observations,
) -> Self {
let reaching: BTreeSet<&str> = declarations
.relations
.iter()
.filter(|relation| relation.to.iter().any(|kind| kind == TARGET_KIND))
.map(|relation| relation.name.as_str())
.collect();
let mut reached: BTreeMap<&str, BTreeSet<(&str, &str)>> = graph
.index
.typed
.iter()
.filter(|node| node.kind.as_deref() == Some(TARGET_KIND))
.map(|node| (node.id.as_str(), BTreeSet::new()))
.collect();
for edge in &graph.edges {
if !reaching.contains(edge.declared.as_str()) {
continue;
}
let Target::Document { id, path, .. } = &edge.target else {
continue;
};
let (criterion, verification) = match edge.direction {
Direction::AsDeclared => ((edge.source.id.as_str(), edge.source.path.as_str()), id),
Direction::Inverse => ((id.as_str(), path.as_str()), &edge.source.id),
};
if let Some(criteria) = reached.get_mut(verification.as_str()) {
criteria.insert(criterion);
}
}
let unread = observations.unread().is_some();
let lines: Vec<Line> = reached
.into_iter()
.map(|(verification, criteria)| {
let state = match observations.recorded(verification) {
Recorded::Absent => State::Declared,
Recorded::Unread(_) if unread => State::Unknown {
reason: "the snapshot did not read".to_string(),
},
Recorded::Unread(reason) => State::Unknown {
reason: format!("its snapshot entry did not read: {reason}"),
},
Recorded::Entry { commit, .. } => {
let mut moved = Vec::new();
let mut unknown = None;
for (criterion, path) in criteria {
let Some(digest) = digest_at(census, path) else {
unknown.get_or_insert_with(|| {
format!("the census read no bytes of {criterion}")
});
continue;
};
if let Freshness::Suspect { .. } =
compare(observations, verification, digest)
{
moved.push(criterion.to_string());
}
}
match (moved.is_empty(), unknown) {
(false, _) => State::Suspect {
commit: commit.to_string(),
criteria: moved,
},
(true, Some(reason)) => State::Unknown { reason },
(true, None) => State::Observed {
commit: commit.to_string(),
},
}
}
};
Line {
verification: verification.to_string(),
state,
}
})
.collect();
let named = |verification: &str| {
lines
.binary_search_by(|line| line.verification.as_str().cmp(verification))
.is_ok()
};
let mut orphans: BTreeMap<String, Option<String>> = BTreeMap::new();
for entry in observations.entries() {
if let Observation::Verification { verification, .. } = entry {
if !named(verification) {
orphans.insert(verification.clone(), None);
}
}
}
for problem in observations.problems() {
if let Problem::Entry {
id,
reason,
verification: true,
} = problem
{
if !named(id) {
orphans
.entry(id.clone())
.or_insert_with(|| Some(reason.clone()));
}
}
}
let orphans = orphans.into_iter().collect();
Block {
lines,
orphans,
snapshot: observations.read_set_digest().flatten().map(str::to_string),
unread,
}
}
pub fn counts(&self) -> (usize, usize, usize, usize) {
self.lines
.iter()
.fold((0, 0, 0, 0), |(d, o, s, u), line| match line.state {
State::Declared => (d + 1, o, s, u),
State::Observed { .. } => (d, o + 1, s, u),
State::Suspect { .. } => (d, o, s + 1, u),
State::Unknown { .. } => (d, o, s, u + 1),
})
}
pub fn render(&self) -> String {
use std::fmt::Write;
let mut out = String::new();
if self.lines.is_empty() && self.orphans.is_empty() {
return out;
}
let (declared, observed, suspect, unknown) = self.counts();
out.push_str("verifications\n");
let _ = writeln!(
out,
" {} verifications: {declared} declared, {observed} observed, {suspect} suspect, \
{unknown} unknown",
self.lines.len()
);
if self.unread {
let _ = writeln!(
out,
" {} did not read, so no verification has a known state",
crate::observation::PATH
);
} else {
let _ = writeln!(
out,
" each observed or suspect state is transcribed from {}{}",
crate::observation::PATH,
match &self.snapshot {
Some(digest) => format!(" at {digest}"),
None => String::new(),
}
);
}
for line in &self.lines {
let _ = match &line.state {
State::Declared => writeln!(out, " {} declared", line.verification),
State::Observed { commit } => {
writeln!(out, " {} observed at {commit}", line.verification)
}
State::Suspect { commit, criteria } => writeln!(
out,
" {} suspect since {commit}, {} changed",
line.verification,
criteria.join(", ")
),
State::Unknown { reason } => {
writeln!(out, " {} unknown, {reason}", line.verification)
}
};
}
for (orphan, unread) in &self.orphans {
let _ = match unread {
None => writeln!(
out,
" {orphan} is named in the snapshot and is no verification of this corpus"
),
Some(reason) => writeln!(
out,
" {orphan} is named in the snapshot and is no verification of this corpus, \
and its entry did not read: {reason}"
),
};
}
out
}
}
fn digest_at<'a>(census: &'a Census, path: &str) -> Option<&'a str> {
census
.rows
.binary_search_by(|row| row.path.as_str().cmp(path))
.ok()
.and_then(|index| census.rows[index].digest.as_deref())
}
fn message(criterion: &str, verification: &str, commit: &str) -> String {
format!(
"`{criterion}` names `proven_by: {verification}`, which a snapshot observed at commit \
`{commit}`; `{criterion}` has changed since that snapshot was written, so \
`{verification}` is suspect until a new snapshot observes it against the criterion as \
it now reads"
)
}
fn remediation(verification: &str) -> String {
format!(
"re-run the verification against the criterion as it now reads and commit a fresh entry \
for `{verification}` in `.headwater/observations.yml`, or confirm the change to the \
criterion did not affect what `{verification}` proves and record a new snapshot anyway"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_message_names_the_criterion_the_verification_and_the_commit() {
let text = message("FIX-AC-1", "FIX-VER-1", "788885a9");
assert!(text.contains("FIX-AC-1"), "{text}");
assert!(text.contains("proven_by: FIX-VER-1"), "{text}");
assert!(text.contains("commit `788885a9`"), "{text}");
assert!(text.contains("suspect"), "{text}");
}
#[test]
fn the_remediation_names_the_verification_and_the_snapshot_file() {
let text = remediation("FIX-VER-1");
assert!(text.contains("FIX-VER-1"), "{text}");
assert!(text.contains(".headwater/observations.yml"), "{text}");
assert!(!text.contains("git "), "{text}");
}
}