use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{DocumentCheck, DocumentView};
use crate::shape::Shape;
use headwater_graph::declarations::Relation;
use headwater_graph::edges::{Problem, Reported};
use headwater_graph::Declarations;
pub const RULE: &str = "relation.declaration.unusable";
const NO_REPORT: &str = "the view carries no phase-A report for this document";
pub struct EndpointKinds<'a> {
declared: Vec<&'a Relation>,
shape: &'a Shape,
}
impl<'a> EndpointKinds<'a> {
pub fn of(declarations: &'a Declarations, shape: &'a Shape) -> Self {
EndpointKinds {
declared: declarations.relations.iter().collect(),
shape,
}
}
pub fn admit(&self, kind: &str) -> bool {
self.declared.iter().any(|relation| {
relation
.from
.iter()
.chain(relation.to.iter())
.any(|allowed| self.shape.descends_from(kind, allowed))
})
}
}
pub struct Unusable<'a> {
kinds: EndpointKinds<'a>,
}
impl<'a> Unusable<'a> {
pub fn over(declarations: &'a Declarations, shape: &'a Shape) -> Self {
Unusable {
kinds: EndpointKinds::of(declarations, shape),
}
}
}
impl DocumentCheck for Unusable<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
const NEEDS_PHASE_A: bool = true;
fn instantiates(&self, kind: &str) -> bool {
self.kinds.admit(kind)
}
fn evaluate(&self, view: &DocumentView<'_>) -> Outcome {
let Some(trouble) = view.phase_a() else {
return Outcome::Skipped(NO_REPORT.to_string());
};
let findings = trouble
.relations
.iter()
.filter_map(|reported| finding(reported))
.collect();
Outcome::failed(findings)
}
}
fn finding(reported: &Reported) -> Option<Finding> {
let remediation = match &reported.problem {
Problem::BlockNotAMapping(_) => {
"write `relations:` as a mapping whose keys are relation names, because Q4 puts every \
relation instance under one key and the relation is that key"
.to_string()
}
Problem::UnknownRelation { name } => format!(
"write a relation this taxonomy declares, or the inverse of one, in place of `{name}`, \
or declare `{name}` in the taxonomy"
),
Problem::EntryNotUsable { relation, .. } => format!(
"write each entry of `{relation}` as a target reference, or as a mapping with `to:` \
and the instance attributes beside it"
),
Problem::NoTarget { relation } => format!(
"give this entry of `{relation}` a `to:`, or delete it, because an entry that names no \
target declares no edge and the attributes on it reach nothing"
),
Problem::RepeatedTriple { relation, target } => format!(
"delete one of the two entries of `{relation}` that name {target}. Q4 identifies an \
edge by its source, its relation and its normalized target, so the second declares no \
second edge, and the instance attributes on it are lost with it"
),
Problem::SourceHasNoIdentifier => return None,
};
let (line, column) = at(reported.span);
Some(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: reported.path.clone(),
line,
column,
message: reported.problem.to_string(),
remediation,
patch: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use headwater_yaml::{Position, Span};
fn span() -> Option<Span> {
Some(Span {
start: Position {
line: 7,
col: 3,
offset: 0,
},
end: Position {
line: 7,
col: 9,
offset: 6,
},
})
}
fn reported(problem: Problem) -> Reported {
Reported {
path: "docs/spec/00-first.md".to_string(),
span: span(),
problem,
}
}
#[test]
fn each_way_a_relations_block_fails_sends_its_author_to_a_different_line() {
let cases = [
(
Problem::BlockNotAMapping("a sequence"),
"write `relations:` as a mapping",
),
(
Problem::UnknownRelation {
name: "invented_relation".to_string(),
},
"in place of `invented_relation`",
),
(
Problem::EntryNotUsable {
relation: "traces_to".to_string(),
found: "a sequence",
},
"write each entry of `traces_to`",
),
(
Problem::NoTarget {
relation: "cites_evidence".to_string(),
},
"give this entry of `cites_evidence` a `to:`",
),
(
Problem::RepeatedTriple {
relation: "governs".to_string(),
target: "docs/spec/01-second.md".to_string(),
},
"delete one of the two entries of `governs`",
),
];
let mut seen: Vec<String> = Vec::new();
for (problem, expected) in cases {
let text = reported(problem);
let found = finding(&text).expect("a finding");
assert!(found.remediation.contains(expected), "{found:#?}");
assert_eq!(found.severity, Severity::Error, "{found:#?}");
assert!(!found.fixable(), "{found:#?}");
assert_eq!(found.line, 7, "{found:#?}");
assert!(
!seen.contains(&found.remediation),
"two variants share one repair: {}",
found.remediation
);
seen.push(found.remediation);
}
assert!(finding(&reported(Problem::SourceHasNoIdentifier)).is_none());
}
#[test]
fn the_message_is_the_sentence_the_graph_build_already_wrote() {
let problem = Problem::UnknownRelation {
name: "invented_relation".to_string(),
};
let text = problem.to_string();
let found = finding(&reported(problem)).expect("a finding");
assert_eq!(found.message, text);
}
}