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 let mut finding = Finding::warn(
26 "unresolved-edge",
27 &edge.source,
28 edge_provenance(edge),
29 "no defining node",
30 )
31 .with_target(&edge.target)
32 .with_lines(edge.lines());
33 if let Some(hint) = wrong_base_hint(graph, edge) {
34 finding = finding.with_hint(hint);
35 }
36 findings.push(finding);
37 }
38 }
39
40 let mut connected: HashSet<&str> = HashSet::new();
44 for edge in &graph.edges {
45 connected.insert(edge.source.as_str());
46 connected.insert(edge.target.as_str());
47 }
48 for (path, node) in &graph.nodes {
49 if node.fs_type() == Some("directory") {
50 continue;
51 }
52 if !connected.contains(path.as_str()) {
53 findings.push(Finding::warn(
54 "detached-node",
55 path,
56 provenance(&node.metadata),
57 "no connections",
58 ));
59 }
60 }
61
62 findings
63}
64
65fn wrong_base_hint(graph: &Graph, edge: &crate::model::Edge) -> Option<String> {
74 let raw = edge.raw_links().into_iter().find(|raw| {
75 !(raw.starts_with("./") || raw.starts_with("../") || raw.starts_with('/'))
76 && graph.nodes.get(*raw).is_some_and(Node::is_resolved)
77 })?;
78 let suggestion = crate::util::relative_from(&edge.source, raw);
79 Some(format!(
80 "`{raw}` resolves from the graph root, but paths resolve relative to the declaring file (did you mean `{suggestion}`?)"
81 ))
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::compose::compose;
88 use crate::model::{Edge, GraphSet, Metadata, Node};
89 use serde_json::json;
90
91 fn fs_node() -> Node {
92 Node::new(
93 json!({ "type": "file", "hash": "b3:x" })
94 .as_object()
95 .unwrap()
96 .clone(),
97 )
98 }
99
100 fn names(findings: &[Finding]) -> Vec<(&str, &str)> {
101 findings
102 .iter()
103 .map(|f| (f.name.as_str(), f.subject.as_str()))
104 .collect()
105 }
106
107 #[test]
108 fn flags_unresolved_target() {
109 let mut fs = Graph::labeled("fs");
110 fs.set_node("index.md", fs_node());
111 fs.add_edge(Edge::new("index.md", "gone.md"));
112 let composed = compose(&GraphSet::new(vec![fs]));
113
114 let findings = evaluate(&composed);
115 assert!(names(&findings).contains(&("unresolved-edge", "index.md")));
116 }
117
118 fn graph_with_raw_edge(files: &[&str], source: &str, target: &str, raw: &str) -> Graph {
120 let mut fs = Graph::labeled("fs");
121 for f in files {
122 fs.set_node(*f, fs_node());
123 }
124 let mut meta = Metadata::new();
125 meta.insert("raw".into(), json!(raw));
126 fs.add_edge(Edge::with_metadata(source, target, meta));
127 compose(&GraphSet::new(vec![fs]))
128 }
129
130 #[test]
131 fn wrong_base_hint_names_the_cause() {
132 let composed = graph_with_raw_edge(
135 &["docs/taxonomy.md", "predicated/artifact/src/lib.rs"],
136 "docs/taxonomy.md",
137 "docs/predicated/artifact/src/lib.rs",
138 "predicated/artifact/src/lib.rs",
139 );
140 let findings = evaluate(&composed);
141 let hint = findings
142 .iter()
143 .find(|f| f.name == "unresolved-edge")
144 .and_then(|f| f.hint.as_deref())
145 .expect("expected a hint");
146 assert!(hint.contains("resolves from the graph root"), "got: {hint}");
147 assert!(
148 hint.contains("../predicated/artifact/src/lib.rs"),
149 "suggestion missing: {hint}"
150 );
151 }
152
153 #[test]
154 fn no_hint_when_root_path_also_missing() {
155 let composed = graph_with_raw_edge(
158 &["docs/taxonomy.md"],
159 "docs/taxonomy.md",
160 "docs/typo.rs",
161 "typo.rs",
162 );
163 let findings = evaluate(&composed);
164 let f = findings
165 .iter()
166 .find(|f| f.name == "unresolved-edge")
167 .unwrap();
168 assert!(f.hint.is_none(), "got: {:?}", f.hint);
169 }
170
171 #[test]
172 fn no_hint_for_explicitly_relative_paths() {
173 let composed = graph_with_raw_edge(
176 &["docs/taxonomy.md", "x.md"],
177 "docs/taxonomy.md",
178 "docs/x.md",
179 "./x.md",
180 );
181 let findings = evaluate(&composed);
182 let f = findings
183 .iter()
184 .find(|f| f.name == "unresolved-edge")
185 .unwrap();
186 assert!(f.hint.is_none(), "got: {:?}", f.hint);
187 }
188
189 #[test]
190 fn unresolved_edge_carries_link_lines() {
191 let mut markdown = Graph::labeled("markdown");
193 let mut meta = Metadata::new();
194 meta.insert("lines".into(), json!([3]));
195 markdown.add_edge(Edge::with_metadata("index.md", "gone.md", meta));
196 let mut fs = Graph::labeled("fs");
197 fs.set_node("index.md", fs_node());
198 let composed = compose(&GraphSet::new(vec![fs, markdown]));
199
200 let findings = evaluate(&composed);
201 let f = findings
202 .iter()
203 .find(|f| f.name == "unresolved-edge")
204 .unwrap();
205 assert_eq!(f.lines, vec![3]);
206 assert!(
207 f.format_text().contains("index.md:3 → gone.md"),
208 "got: {}",
209 f.format_text()
210 );
211 }
212
213 #[test]
214 fn does_not_flag_uri_target() {
215 let mut markdown = Graph::labeled("markdown");
216 markdown.add_edge(Edge::new("index.md", "https://example.com"));
217 let mut fs = Graph::labeled("fs");
218 fs.set_node("index.md", fs_node());
219 let composed = compose(&GraphSet::new(vec![fs, markdown]));
220
221 assert!(
222 !names(&evaluate(&composed))
223 .iter()
224 .any(|(name, _)| *name == "unresolved-edge")
225 );
226 }
227
228 #[test]
229 fn flags_detached_node() {
230 let mut fs = Graph::labeled("fs");
231 fs.set_node("lonely.md", fs_node());
232 fs.set_node("a.md", fs_node());
233 fs.set_node("b.md", fs_node());
234 fs.add_edge(Edge::new("a.md", "b.md"));
235 let composed = compose(&GraphSet::new(vec![fs]));
236
237 let findings = evaluate(&composed);
238 let n = names(&findings);
239 assert!(n.contains(&("detached-node", "lonely.md")), "got {n:?}");
240 assert!(!n.contains(&("detached-node", "a.md")));
241 assert!(!n.contains(&("detached-node", "b.md")));
242 }
243
244 #[test]
245 fn directory_node_is_not_detached() {
246 let mut fs = Graph::labeled("fs");
248 fs.set_node(
249 "guides",
250 Node::new(json!({ "type": "directory" }).as_object().unwrap().clone()),
251 );
252 fs.set_node("lonely.md", fs_node());
253 let composed = compose(&GraphSet::new(vec![fs]));
254
255 let findings = evaluate(&composed);
256 let n = names(&findings);
257 assert!(
258 !n.contains(&("detached-node", "guides")),
259 "directory should not be flagged detached, got {n:?}"
260 );
261 assert!(n.contains(&("detached-node", "lonely.md")));
263 }
264}