use std::collections::HashSet;
use crate::diagnostic::Finding;
use crate::model::{Graph, Node};
use crate::rules::{edge_provenance, provenance};
use crate::util::is_uri;
pub fn evaluate(graph: &Graph) -> Vec<Finding> {
let mut findings = Vec::new();
for edge in &graph.edges {
if is_uri(&edge.target) {
continue;
}
let resolved = graph.nodes.get(&edge.target).is_some_and(Node::is_resolved);
if !resolved {
let mut finding = Finding::warn(
"unresolved-edge",
&edge.source,
edge_provenance(edge),
"no defining node",
)
.with_target(&edge.target)
.with_lines(edge.lines());
if let Some(hint) = wrong_base_hint(graph, edge) {
finding = finding.with_hint(hint);
}
findings.push(finding);
}
}
let mut connected: HashSet<&str> = HashSet::new();
for edge in &graph.edges {
connected.insert(edge.source.as_str());
connected.insert(edge.target.as_str());
}
for (path, node) in &graph.nodes {
if node.fs_type() == Some("directory") {
continue;
}
if !connected.contains(path.as_str()) {
findings.push(Finding::warn(
"detached-node",
path,
provenance(&node.metadata),
"no connections",
));
}
}
findings
}
fn wrong_base_hint(graph: &Graph, edge: &crate::model::Edge) -> Option<String> {
let raw = edge.raw_links().into_iter().find(|raw| {
!(raw.starts_with("./") || raw.starts_with("../") || raw.starts_with('/'))
&& graph.nodes.get(*raw).is_some_and(Node::is_resolved)
})?;
let suggestion = crate::util::relative_from(&edge.source, raw);
Some(format!(
"`{raw}` resolves from the graph root, but paths resolve relative to the declaring file (did you mean `{suggestion}`?)"
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compose::compose;
use crate::model::{Edge, GraphSet, Metadata, Node};
use serde_json::json;
fn fs_node() -> Node {
Node::new(
json!({ "type": "file", "hash": "b3:x" })
.as_object()
.unwrap()
.clone(),
)
}
fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
findings
.iter()
.map(|f| (f.name.as_str(), f.subject.as_str()))
.collect()
}
#[test]
fn flags_unresolved_target() {
let mut fs = Graph::labeled("fs");
fs.set_node("index.md", fs_node());
fs.add_edge(Edge::new("index.md", "gone.md"));
let composed = compose(&GraphSet::new(vec![fs]));
let findings = evaluate(&composed);
assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
}
fn graph_with_raw_edge(files: &[&str], source: &str, target: &str, raw: &str) -> Graph {
let mut fs = Graph::labeled("fs");
for f in files {
fs.set_node(*f, fs_node());
}
let mut meta = Metadata::new();
meta.insert("raw".into(), json!(raw));
fs.add_edge(Edge::with_metadata(source, target, meta));
compose(&GraphSet::new(vec![fs]))
}
#[test]
fn wrong_base_hint_names_the_cause() {
let composed = graph_with_raw_edge(
&["docs/taxonomy.md", "predicated/artifact/src/lib.rs"],
"docs/taxonomy.md",
"docs/predicated/artifact/src/lib.rs",
"predicated/artifact/src/lib.rs",
);
let findings = evaluate(&composed);
let hint = findings
.iter()
.find(|f| f.name == "unresolved-edge")
.and_then(|f| f.hint.as_deref())
.expect("expected a hint");
assert!(hint.contains("resolves from the graph root"), "got: {hint}");
assert!(
hint.contains("../predicated/artifact/src/lib.rs"),
"suggestion missing: {hint}"
);
}
#[test]
fn no_hint_when_root_path_also_missing() {
let composed = graph_with_raw_edge(
&["docs/taxonomy.md"],
"docs/taxonomy.md",
"docs/typo.rs",
"typo.rs",
);
let findings = evaluate(&composed);
let f = findings
.iter()
.find(|f| f.name == "unresolved-edge")
.unwrap();
assert!(f.hint.is_none(), "got: {:?}", f.hint);
}
#[test]
fn no_hint_for_explicitly_relative_paths() {
let composed = graph_with_raw_edge(
&["docs/taxonomy.md", "x.md"],
"docs/taxonomy.md",
"docs/x.md",
"./x.md",
);
let findings = evaluate(&composed);
let f = findings
.iter()
.find(|f| f.name == "unresolved-edge")
.unwrap();
assert!(f.hint.is_none(), "got: {:?}", f.hint);
}
#[test]
fn unresolved_edge_carries_link_lines() {
let mut markdown = Graph::labeled("markdown");
let mut meta = Metadata::new();
meta.insert("lines".into(), json!([3]));
markdown.add_edge(Edge::with_metadata("index.md", "gone.md", meta));
let mut fs = Graph::labeled("fs");
fs.set_node("index.md", fs_node());
let composed = compose(&GraphSet::new(vec![fs, markdown]));
let findings = evaluate(&composed);
let f = findings
.iter()
.find(|f| f.name == "unresolved-edge")
.unwrap();
assert_eq!(f.lines, vec![3]);
assert!(
f.format_text().contains("index.md:3 → gone.md"),
"got: {}",
f.format_text()
);
}
#[test]
fn does_not_flag_uri_target() {
let mut markdown = Graph::labeled("markdown");
markdown.add_edge(Edge::new("index.md", "https://example.com"));
let mut fs = Graph::labeled("fs");
fs.set_node("index.md", fs_node());
let composed = compose(&GraphSet::new(vec![fs, markdown]));
assert!(
!names(&evaluate(&composed))
.iter()
.any(|(name, _)| *name == "unresolved-edge")
);
}
#[test]
fn flags_detached_node() {
let mut fs = Graph::labeled("fs");
fs.set_node("lonely.md", fs_node());
fs.set_node("a.md", fs_node());
fs.set_node("b.md", fs_node());
fs.add_edge(Edge::new("a.md", "b.md"));
let composed = compose(&GraphSet::new(vec![fs]));
let findings = evaluate(&composed);
let n = names(&findings);
assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
assert!(!n.contains(&("detached-node", "a.md")));
assert!(!n.contains(&("detached-node", "b.md")));
}
#[test]
fn directory_node_is_not_detached() {
let mut fs = Graph::labeled("fs");
fs.set_node(
"guides",
Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
);
fs.set_node("lonely.md", fs_node());
let composed = compose(&GraphSet::new(vec![fs]));
let findings = evaluate(&composed);
let n = names(&findings);
assert!(
!n.contains(&("detached-node", "guides")),
"directory should not be flagged detached, got {n:?}"
);
assert!(n.contains(&("detached-node", "lonely.md")));
}
}