Skip to main content

drft/rules/
dangling_edge.rs

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                // Skip URIs
19                if crate::graph::is_uri(&edge.target) {
20                    return None;
21                }
22
23                let props = graph.target_properties.get(&edge.target);
24
25                // If target exists in graph, it's valid
26                if graph.nodes.contains_key(&edge.target) {
27                    return None;
28                }
29
30                // Target not in graph — check target properties
31                if props.is_some_and(|p| p.is_symlink) {
32                    return None; // Handled by symlink-edge rule
33                }
34
35                // Truly broken — file not found
36                // (If the target existed on disk, build_graph would have created a node)
37                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        // Directories now get proper Directory nodes in the graph
178        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}