1use std::collections::HashSet;
8
9use crate::diagnostic::Finding;
10use crate::model::{Graph, Node};
11use crate::rules::{edge_provenance, provenance};
12use crate::util::is_uri;
13
14pub fn evaluate(graph: &Graph) -> Vec<Finding> {
16 let mut findings = Vec::new();
17
18 for edge in &graph.edges {
20 if is_uri(&edge.target) {
21 continue;
22 }
23 let resolved = graph.nodes.get(&edge.target).is_some_and(Node::is_resolved);
24 if !resolved {
25 findings.push(
26 Finding::warn(
27 "unresolved-edge",
28 &edge.source,
29 edge_provenance(edge),
30 "no defining node",
31 )
32 .with_target(&edge.target),
33 );
34 }
35 }
36
37 let mut connected: HashSet<&str> = HashSet::new();
39 for edge in &graph.edges {
40 connected.insert(edge.source.as_str());
41 connected.insert(edge.target.as_str());
42 }
43 for (path, node) in &graph.nodes {
44 if !connected.contains(path.as_str()) {
45 findings.push(Finding::warn(
46 "detached-node",
47 path,
48 provenance(&node.metadata),
49 "no connections",
50 ));
51 }
52 }
53
54 findings
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60 use crate::compose::compose;
61 use crate::model::{Edge, GraphSet, Node};
62 use serde_json::json;
63
64 fn fs_node() -> Node {
65 Node::new(
66 json!({ "type": "file", "hash": "b3:x" })
67 .as_object()
68 .unwrap()
69 .clone(),
70 )
71 }
72
73 fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
74 findings
75 .iter()
76 .map(|f| (f.name.as_str(), f.subject.as_str()))
77 .collect()
78 }
79
80 #[test]
81 fn flags_unresolved_target() {
82 let mut fs = Graph::labeled("fs");
83 fs.set_node("index.md", fs_node());
84 fs.add_edge(Edge::new("index.md", "gone.md"));
85 let composed = compose(&GraphSet::new(vec![fs]));
86
87 let findings = evaluate(&composed);
88 assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
89 }
90
91 #[test]
92 fn does_not_flag_uri_target() {
93 let mut markdown = Graph::labeled("markdown");
94 markdown.add_edge(Edge::new("index.md", "https://example.com"));
95 let mut fs = Graph::labeled("fs");
96 fs.set_node("index.md", fs_node());
97 let composed = compose(&GraphSet::new(vec![fs, markdown]));
98
99 assert!(
100 !names(&evaluate(&composed))
101 .iter()
102 .any(|(name, _)| *name == "unresolved-edge")
103 );
104 }
105
106 #[test]
107 fn flags_detached_node() {
108 let mut fs = Graph::labeled("fs");
109 fs.set_node("lonely.md", fs_node());
110 fs.set_node("a.md", fs_node());
111 fs.set_node("b.md", fs_node());
112 fs.add_edge(Edge::new("a.md", "b.md"));
113 let composed = compose(&GraphSet::new(vec![fs]));
114
115 let findings = evaluate(&composed);
116 let n = names(&findings);
117 assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
118 assert!(!n.contains(&("detached-node", "a.md")));
119 assert!(!n.contains(&("detached-node", "b.md")));
120 }
121}