use crate::finding::{at, Finding, Severity};
use crate::instance::Outcome;
use crate::scope::{EdgeCheck, EdgeUnit, EdgeView};
use headwater_graph::declarations::Relation;
use headwater_graph::{Declarations, Target, Unbound};
pub const RULE: &str = "relation.target.unresolved";
const NO_HALF: &str = "the entry carries no declared half";
pub struct Targets<'a> {
declared: Vec<&'a Relation>,
}
impl<'a> Targets<'a> {
pub fn over(declarations: &'a Declarations) -> Self {
Targets {
declared: declarations.relations.iter().collect(),
}
}
}
impl EdgeCheck for Targets<'_> {
const RULE: &'static str = self::RULE;
const VERSION: u32 = 1;
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 Target::Unbound(unbound) = &edge.target else {
return Outcome::Passed;
};
let (line, column) = at(Some(edge.span));
Outcome::failed_with(Finding {
rule: self::RULE,
severity: Severity::Error,
obligation: None,
path: edge.source.path.clone(),
line,
column,
message: format!(
"`{}` declares `{}: {}`, and that target {unbound}",
edge.source.id, edge.name, edge.raw_target
),
remediation: remediation(unbound, &edge.name, &edge.raw_target),
patch: None,
})
}
}
fn remediation(unbound: &Unbound, name: &str, raw: &str) -> String {
match unbound {
Unbound::NoSuchTarget { .. } => format!(
"write the identifier of a document that exists under `{name}`, or mint `{raw}` on the document this entry means"
),
Unbound::NotTyped { path, .. } => format!(
"give {path} a kind, because both ends of a declared relation are kinds and only a typed document is a node"
),
Unbound::NoResolver {
anchor_kind,
resolver,
} => format!(
"supply the resolver `{resolver}` that `{anchor_kind}` names, or declare an anchor kind whose resolver this run has"
),
Unbound::AnchorUnresolved { anchor_kind, .. } => format!(
"correct `{raw}` so that the resolver for `{anchor_kind}` admits it"
),
Unbound::AmbiguousAnchor { anchor_kinds } => format!(
"narrow the anchor kinds that claim `{raw}`, because {} both admit it and the target has two identities",
anchor_kinds.join(" and ")
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn each_way_a_target_binds_to_nothing_sends_its_author_to_a_different_file() {
let cases = [
(
Unbound::NoSuchTarget {
also_tried: Vec::new(),
},
"mint `DR-FIX-0001`",
),
(
Unbound::NotTyped {
path: "docs/notes/loose.md".to_string(),
class: "untyped",
detail: "no front matter".to_string(),
},
"give docs/notes/loose.md a kind",
),
(
Unbound::NoResolver {
anchor_kind: "code_path".to_string(),
resolver: "source-tree".to_string(),
},
"supply the resolver `source-tree`",
),
(
Unbound::AnchorUnresolved {
anchor_kind: "code_path".to_string(),
why: "the path climbs above the repository".to_string(),
},
"correct `DR-FIX-0001`",
),
(
Unbound::AmbiguousAnchor {
anchor_kinds: vec!["code_path".to_string(), "work_item".to_string()],
},
"code_path and work_item",
),
];
let mut seen: Vec<String> = Vec::new();
for (unbound, expected) in cases {
let text = remediation(&unbound, "traces_to", "DR-FIX-0001");
assert!(text.contains(expected), "{text}");
assert!(
!seen.contains(&text),
"two variants share one remedy: {text}"
);
seen.push(text);
}
}
}