Skip to main content

drft/rules/
dangling_edge.rs

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