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        });
72        graph.add_edge(Edge {
73            source: "index.md".into(),
74            target: "gone.md".into(),
75            link: None,
76            parser: "markdown".into(),
77        });
78
79        let enriched = make_enriched(graph);
80        let ctx = RuleContext {
81            graph: &enriched,
82            options: None,
83        };
84        let diagnostics = DanglingEdgeRule.evaluate(&ctx);
85        assert_eq!(diagnostics.len(), 1);
86        assert_eq!(diagnostics[0].rule, "dangling-edge");
87        assert_eq!(diagnostics[0].source.as_deref(), Some("index.md"));
88        assert_eq!(diagnostics[0].target.as_deref(), Some("gone.md"));
89    }
90
91    #[test]
92    fn no_diagnostic_for_valid_link() {
93        let mut graph = Graph::new();
94        graph.add_node(Node {
95            path: "index.md".into(),
96            node_type: NodeType::File,
97            hash: None,
98            graph: None,
99            is_graph: false,
100            metadata: HashMap::new(),
101        });
102        graph.add_node(Node {
103            path: "setup.md".into(),
104            node_type: NodeType::File,
105            hash: None,
106            graph: None,
107            is_graph: false,
108            metadata: HashMap::new(),
109        });
110        graph.add_edge(Edge {
111            source: "index.md".into(),
112            target: "setup.md".into(),
113            link: None,
114            parser: "markdown".into(),
115        });
116
117        let enriched = make_enriched(graph);
118        let ctx = RuleContext {
119            graph: &enriched,
120            options: None,
121        };
122        let diagnostics = DanglingEdgeRule.evaluate(&ctx);
123        assert!(diagnostics.is_empty());
124    }
125
126    #[test]
127    fn skips_symlink_targets() {
128        let mut graph = Graph::new();
129        graph.add_node(Node {
130            path: "index.md".into(),
131            node_type: NodeType::File,
132            hash: None,
133            graph: None,
134            is_graph: false,
135            metadata: HashMap::new(),
136        });
137        graph.target_properties.insert(
138            "linked.md".into(),
139            TargetProperties {
140                is_symlink: true,
141                is_directory: false,
142                symlink_target: Some("real.md".into()),
143            },
144        );
145        graph.add_edge(Edge {
146            source: "index.md".into(),
147            target: "linked.md".into(),
148            link: None,
149            parser: "markdown".into(),
150        });
151
152        let enriched = make_enriched(graph);
153        let ctx = RuleContext {
154            graph: &enriched,
155            options: None,
156        };
157        let diagnostics = DanglingEdgeRule.evaluate(&ctx);
158        assert!(diagnostics.is_empty());
159    }
160
161    #[test]
162    fn skips_directory_targets() {
163        let mut graph = Graph::new();
164        graph.add_node(Node {
165            path: "index.md".into(),
166            node_type: NodeType::File,
167            hash: None,
168            graph: None,
169            is_graph: false,
170            metadata: HashMap::new(),
171        });
172        // Directories now get proper Directory nodes in the graph
173        graph.add_node(Node {
174            path: "guides".into(),
175            node_type: NodeType::Directory,
176            hash: None,
177            graph: None,
178            is_graph: false,
179            metadata: HashMap::new(),
180        });
181        graph.add_edge(Edge {
182            source: "index.md".into(),
183            target: "guides".into(),
184            link: None,
185            parser: "markdown".into(),
186        });
187
188        let enriched = make_enriched(graph);
189        let ctx = RuleContext {
190            graph: &enriched,
191            options: None,
192        };
193        let diagnostics = DanglingEdgeRule.evaluate(&ctx);
194        assert!(diagnostics.is_empty());
195    }
196}