Skip to main content

drft/rules/
broken_link.rs

1use crate::diagnostic::Diagnostic;
2use crate::graph::NodeType;
3use crate::rules::{Rule, RuleContext};
4
5pub struct BrokenLinkRule;
6
7impl Rule for BrokenLinkRule {
8    fn name(&self) -> &str {
9        "broken-link"
10    }
11
12    fn evaluate(&self, ctx: &RuleContext) -> Vec<Diagnostic> {
13        let graph = ctx.graph;
14        let root = ctx.root;
15
16        graph
17            .edges
18            .iter()
19            .filter_map(|edge| {
20                // Skip external URLs
21                if edge.target.starts_with("http://") || edge.target.starts_with("https://") {
22                    return None;
23                }
24
25                // If target exists in graph, it's valid
26                if let Some(node) = graph.nodes.get(&edge.target) {
27                    if node.node_type == NodeType::Graph {
28                        return None; // Frontier nodes are valid
29                    }
30                    // Check if target is a symlink (not broken)
31                    let target_path = root.join(&edge.target);
32                    if target_path.is_symlink() {
33                        return None; // Handled by indirect-link rule
34                    }
35                    return None; // Valid
36                }
37
38                // Target not in graph — filesystem checks
39                let target_path = root.join(&edge.target);
40
41                if target_path.is_dir() {
42                    return None; // Handled by directory-link rule
43                }
44
45                if target_path.is_symlink() {
46                    return None; // Handled by indirect-link rule
47                }
48
49                if target_path.exists() {
50                    // File exists but was excluded by ignore pattern
51                    return Some(Diagnostic {
52                        rule: "broken-link".into(),
53                        message: "file excluded by ignore pattern".into(),
54                        source: Some(edge.source.clone()),
55                        target: Some(edge.target.clone()),
56                        fix: Some(format!(
57                            "{} exists but is excluded by an ignore pattern \u{2014} either remove the link from {} or update the ignore config",
58                            edge.target, edge.source
59                        )),
60                        ..Default::default()
61                    });
62                }
63
64                // Truly broken
65                Some(Diagnostic {
66                    rule: "broken-link".into(),
67                    message: "file not found".into(),
68                    source: Some(edge.source.clone()),
69                    target: Some(edge.target.clone()),
70                    fix: Some(format!(
71                        "{} does not exist \u{2014} either create it or update the link in {}",
72                        edge.target, edge.source
73                    )),
74                    ..Default::default()
75                })
76            })
77            .collect()
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::config::Config;
85    use crate::graph::{Edge, EdgeType, Graph, Node, NodeType};
86    use crate::rules::RuleContext;
87    use std::fs;
88    use std::path::Path;
89    use tempfile::TempDir;
90
91    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> RuleContext<'a> {
92        RuleContext {
93            graph,
94            root,
95            config,
96            lockfile: None,
97        }
98    }
99
100    #[test]
101    fn detects_broken_link() {
102        let dir = TempDir::new().unwrap();
103        fs::write(dir.path().join("index.md"), "").unwrap();
104
105        let mut graph = Graph::new();
106        graph.add_node(Node {
107            path: "index.md".into(),
108            node_type: NodeType::Source,
109            hash: None,
110            graph: None,
111        });
112        graph.add_edge(Edge {
113            source: "index.md".into(),
114            target: "gone.md".into(),
115            edge_type: EdgeType::new("markdown", "inline"),
116            synthetic: false,
117        });
118
119        let config = Config::defaults();
120        let ctx = make_ctx(&graph, dir.path(), &config);
121        let diagnostics = BrokenLinkRule.evaluate(&ctx);
122        assert_eq!(diagnostics.len(), 1);
123        assert_eq!(diagnostics[0].rule, "broken-link");
124        assert_eq!(diagnostics[0].source.as_deref(), Some("index.md"));
125        assert_eq!(diagnostics[0].target.as_deref(), Some("gone.md"));
126    }
127
128    #[test]
129    fn no_diagnostic_for_valid_link() {
130        let dir = TempDir::new().unwrap();
131        fs::write(dir.path().join("index.md"), "").unwrap();
132        fs::write(dir.path().join("setup.md"), "").unwrap();
133
134        let mut graph = Graph::new();
135        graph.add_node(Node {
136            path: "index.md".into(),
137            node_type: NodeType::Source,
138            hash: None,
139            graph: None,
140        });
141        graph.add_node(Node {
142            path: "setup.md".into(),
143            node_type: NodeType::Source,
144            hash: None,
145            graph: None,
146        });
147        graph.add_edge(Edge {
148            source: "index.md".into(),
149            target: "setup.md".into(),
150            edge_type: EdgeType::new("markdown", "inline"),
151            synthetic: false,
152        });
153
154        let config = Config::defaults();
155        let ctx = make_ctx(&graph, dir.path(), &config);
156        let diagnostics = BrokenLinkRule.evaluate(&ctx);
157        assert!(diagnostics.is_empty());
158    }
159}