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 .with_lines(edge.lines()),
34 );
35 }
36 }
37
38 let mut connected: HashSet<&str> = HashSet::new();
42 for edge in &graph.edges {
43 connected.insert(edge.source.as_str());
44 connected.insert(edge.target.as_str());
45 }
46 for (path, node) in &graph.nodes {
47 if node.fs_type() == Some("directory") {
48 continue;
49 }
50 if !connected.contains(path.as_str()) {
51 findings.push(Finding::warn(
52 "detached-node",
53 path,
54 provenance(&node.metadata),
55 "no connections",
56 ));
57 }
58 }
59
60 findings
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66 use crate::compose::compose;
67 use crate::model::{Edge, GraphSet, Metadata, Node};
68 use serde_json::json;
69
70 fn fs_node() -> Node {
71 Node::new(
72 json!({ "type": "file", "hash": "b3:x" })
73 .as_object()
74 .unwrap()
75 .clone(),
76 )
77 }
78
79 fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
80 findings
81 .iter()
82 .map(|f| (f.name.as_str(), f.subject.as_str()))
83 .collect()
84 }
85
86 #[test]
87 fn flags_unresolved_target() {
88 let mut fs = Graph::labeled("fs");
89 fs.set_node("index.md", fs_node());
90 fs.add_edge(Edge::new("index.md", "gone.md"));
91 let composed = compose(&GraphSet::new(vec![fs]));
92
93 let findings = evaluate(&composed);
94 assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
95 }
96
97 #[test]
98 fn unresolved_edge_carries_link_lines() {
99 let mut markdown = Graph::labeled("markdown");
101 let mut meta = Metadata::new();
102 meta.insert("lines".into(), json!([3]));
103 markdown.add_edge(Edge::with_metadata("index.md", "gone.md", meta));
104 let mut fs = Graph::labeled("fs");
105 fs.set_node("index.md", fs_node());
106 let composed = compose(&GraphSet::new(vec![fs, markdown]));
107
108 let findings = evaluate(&composed);
109 let f = findings
110 .iter()
111 .find(|f| f.name == "unresolved-edge")
112 .unwrap();
113 assert_eq!(f.lines, vec![3]);
114 assert!(
115 f.format_text().contains("index.md:3 → gone.md"),
116 "got: {}",
117 f.format_text()
118 );
119 }
120
121 #[test]
122 fn does_not_flag_uri_target() {
123 let mut markdown = Graph::labeled("markdown");
124 markdown.add_edge(Edge::new("index.md", "https://example.com"));
125 let mut fs = Graph::labeled("fs");
126 fs.set_node("index.md", fs_node());
127 let composed = compose(&GraphSet::new(vec![fs, markdown]));
128
129 assert!(
130 !names(&evaluate(&composed))
131 .iter()
132 .any(|(name, _)| *name == "unresolved-edge")
133 );
134 }
135
136 #[test]
137 fn flags_detached_node() {
138 let mut fs = Graph::labeled("fs");
139 fs.set_node("lonely.md", fs_node());
140 fs.set_node("a.md", fs_node());
141 fs.set_node("b.md", fs_node());
142 fs.add_edge(Edge::new("a.md", "b.md"));
143 let composed = compose(&GraphSet::new(vec![fs]));
144
145 let findings = evaluate(&composed);
146 let n = names(&findings);
147 assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
148 assert!(!n.contains(&("detached-node", "a.md")));
149 assert!(!n.contains(&("detached-node", "b.md")));
150 }
151
152 #[test]
153 fn directory_node_is_not_detached() {
154 let mut fs = Graph::labeled("fs");
156 fs.set_node(
157 "guides",
158 Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
159 );
160 fs.set_node("lonely.md", fs_node());
161 let composed = compose(&GraphSet::new(vec![fs]));
162
163 let findings = evaluate(&composed);
164 let n = names(&findings);
165 assert!(
166 !n.contains(&("detached-node", "guides")),
167 "directory should not be flagged detached, got {n:?}"
168 );
169 assert!(n.contains(&("detached-node", "lonely.md")));
171 }
172}