1use crate::diagnostic::Diagnostic;
2use crate::rules::{Rule, RuleContext};
3
4pub struct DanglingEdgeRule;
5
6impl Rule for DanglingEdgeRule {
7 fn name(&self) -> &str {
8 "dangling-edge"
9 }
10
11 fn evaluate(&self, ctx: &RuleContext) -> Vec<Diagnostic> {
12 let graph = &ctx.graph.graph;
13
14 graph
15 .edges
16 .iter()
17 .filter_map(|edge| {
18 if crate::graph::is_uri(&edge.target) {
20 return None;
21 }
22
23 let props = graph.target_properties.get(&edge.target);
24
25 if graph.nodes.contains_key(&edge.target) {
27 return None;
28 }
29
30 if props.is_some_and(|p| p.is_symlink) {
32 return None; }
34
35 Some(Diagnostic {
38 rule: "dangling-edge".into(),
39 message: "file not found".into(),
40 source: Some(edge.source.clone()),
41 target: Some(edge.target.clone()),
42 fix: Some(format!(
43 "{} does not exist \u{2014} either create it or update the link in {}",
44 edge.target, edge.source
45 )),
46 ..Default::default()
47 })
48 })
49 .collect()
50 }
51}
52
53#[cfg(test)]
54mod tests {
55 use super::*;
56 use crate::graph::test_helpers::make_enriched;
57 use crate::graph::{Edge, Graph, Node, NodeType, TargetProperties};
58 use crate::rules::RuleContext;
59 use std::collections::HashMap;
60
61 #[test]
62 fn detects_dangling_edge() {
63 let mut graph = Graph::new();
64 graph.add_node(Node {
65 path: "index.md".into(),
66 node_type: NodeType::File,
67 hash: None,
68 graph: None,
69 is_graph: false,
70 metadata: HashMap::new(),
71 included: true,
72 });
73 graph.add_edge(Edge {
74 source: "index.md".into(),
75 target: "gone.md".into(),
76 link: None,
77 parser: "markdown".into(),
78 });
79
80 let enriched = make_enriched(graph);
81 let ctx = RuleContext {
82 graph: &enriched,
83 options: None,
84 };
85 let diagnostics = DanglingEdgeRule.evaluate(&ctx);
86 assert_eq!(diagnostics.len(), 1);
87 assert_eq!(diagnostics[0].rule, "dangling-edge");
88 assert_eq!(diagnostics[0].source.as_deref(), Some("index.md"));
89 assert_eq!(diagnostics[0].target.as_deref(), Some("gone.md"));
90 }
91
92 #[test]
93 fn no_diagnostic_for_valid_link() {
94 let mut graph = Graph::new();
95 graph.add_node(Node {
96 path: "index.md".into(),
97 node_type: NodeType::File,
98 hash: None,
99 graph: None,
100 is_graph: false,
101 metadata: HashMap::new(),
102 included: true,
103 });
104 graph.add_node(Node {
105 path: "setup.md".into(),
106 node_type: NodeType::File,
107 hash: None,
108 graph: None,
109 is_graph: false,
110 metadata: HashMap::new(),
111 included: true,
112 });
113 graph.add_edge(Edge {
114 source: "index.md".into(),
115 target: "setup.md".into(),
116 link: None,
117 parser: "markdown".into(),
118 });
119
120 let enriched = make_enriched(graph);
121 let ctx = RuleContext {
122 graph: &enriched,
123 options: None,
124 };
125 let diagnostics = DanglingEdgeRule.evaluate(&ctx);
126 assert!(diagnostics.is_empty());
127 }
128
129 #[test]
130 fn skips_symlink_targets() {
131 let mut graph = Graph::new();
132 graph.add_node(Node {
133 path: "index.md".into(),
134 node_type: NodeType::File,
135 hash: None,
136 graph: None,
137 is_graph: false,
138 metadata: HashMap::new(),
139 included: true,
140 });
141 graph.target_properties.insert(
142 "linked.md".into(),
143 TargetProperties {
144 is_symlink: true,
145 is_directory: false,
146 symlink_target: Some("real.md".into()),
147 },
148 );
149 graph.add_edge(Edge {
150 source: "index.md".into(),
151 target: "linked.md".into(),
152 link: None,
153 parser: "markdown".into(),
154 });
155
156 let enriched = make_enriched(graph);
157 let ctx = RuleContext {
158 graph: &enriched,
159 options: None,
160 };
161 let diagnostics = DanglingEdgeRule.evaluate(&ctx);
162 assert!(diagnostics.is_empty());
163 }
164
165 #[test]
166 fn skips_directory_targets() {
167 let mut graph = Graph::new();
168 graph.add_node(Node {
169 path: "index.md".into(),
170 node_type: NodeType::File,
171 hash: None,
172 graph: None,
173 is_graph: false,
174 metadata: HashMap::new(),
175 included: true,
176 });
177 graph.add_node(Node {
179 path: "guides".into(),
180 node_type: NodeType::Directory,
181 hash: None,
182 graph: None,
183 is_graph: false,
184 metadata: HashMap::new(),
185 included: false,
186 });
187 graph.add_edge(Edge {
188 source: "index.md".into(),
189 target: "guides".into(),
190 link: None,
191 parser: "markdown".into(),
192 });
193
194 let enriched = make_enriched(graph);
195 let ctx = RuleContext {
196 graph: &enriched,
197 options: None,
198 };
199 let diagnostics = DanglingEdgeRule.evaluate(&ctx);
200 assert!(diagnostics.is_empty());
201 }
202}