Skip to main content

drft/rules/
indirect_link.rs

1use crate::diagnostic::Diagnostic;
2use crate::rules::{Rule, RuleContext};
3
4pub struct IndirectLinkRule;
5
6impl Rule for IndirectLinkRule {
7    fn name(&self) -> &str {
8        "indirect-link"
9    }
10
11    fn evaluate(&self, ctx: &RuleContext) -> Vec<Diagnostic> {
12        let graph = ctx.graph;
13        let root = ctx.root;
14
15        graph
16            .edges
17            .iter()
18            .filter_map(|edge| {
19                // Skip external URLs
20                if edge.target.starts_with("http://") || edge.target.starts_with("https://") {
21                    return None;
22                }
23
24                // Check if target path is a symlink on the filesystem
25                let target_path = root.join(&edge.target);
26                if target_path.is_symlink() {
27                    let resolved = std::fs::read_link(&target_path)
28                        .map(|p| p.to_string_lossy().to_string())
29                        .unwrap_or_else(|_| "unknown".to_string());
30                    Some(Diagnostic {
31                        rule: "indirect-link".into(),
32                        message: format!("target is a symlink to {resolved}"),
33                        source: Some(edge.source.clone()),
34                        target: Some(edge.target.clone()),
35                        fix: Some(format!(
36                            "{} is a symlink to {resolved} \u{2014} consider linking to the actual file directly in {}",
37                            edge.target, edge.source
38                        )),
39                        ..Default::default()
40                    })
41                } else {
42                    None
43                }
44            })
45            .collect()
46    }
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use crate::config::Config;
53    use crate::graph::{Edge, EdgeType, Graph, Node, NodeType};
54    use crate::rules::RuleContext;
55    use std::fs;
56    use std::os::unix::fs::symlink;
57    use std::path::Path;
58    use tempfile::TempDir;
59
60    fn make_ctx<'a>(graph: &'a Graph, root: &'a Path, config: &'a Config) -> RuleContext<'a> {
61        RuleContext {
62            graph,
63            root,
64            config,
65            lockfile: None,
66        }
67    }
68
69    #[test]
70    fn detects_symlink_target() {
71        let dir = TempDir::new().unwrap();
72        let shared = dir.path().join("shared");
73        fs::create_dir(&shared).unwrap();
74        fs::write(shared.join("setup.md"), "# Setup").unwrap();
75        symlink(shared.join("setup.md"), dir.path().join("setup.md")).unwrap();
76
77        let mut graph = Graph::new();
78        graph.add_node(Node {
79            path: "index.md".into(),
80            node_type: NodeType::Source,
81            hash: None,
82            graph: None,
83        });
84        graph.add_edge(Edge {
85            source: "index.md".into(),
86            target: "setup.md".into(),
87            edge_type: EdgeType::new("markdown", "inline"),
88            synthetic: false,
89        });
90
91        let config = Config::defaults();
92        let ctx = make_ctx(&graph, dir.path(), &config);
93        let diagnostics = IndirectLinkRule.evaluate(&ctx);
94        assert_eq!(diagnostics.len(), 1);
95        assert_eq!(diagnostics[0].rule, "indirect-link");
96        assert!(diagnostics[0].message.contains("symlink"));
97    }
98
99    #[test]
100    fn no_diagnostic_for_regular_file() {
101        let dir = TempDir::new().unwrap();
102        fs::write(dir.path().join("setup.md"), "# Setup").unwrap();
103
104        let mut graph = Graph::new();
105        graph.add_node(Node {
106            path: "index.md".into(),
107            node_type: NodeType::Source,
108            hash: None,
109            graph: None,
110        });
111        graph.add_edge(Edge {
112            source: "index.md".into(),
113            target: "setup.md".into(),
114            edge_type: EdgeType::new("markdown", "inline"),
115            synthetic: false,
116        });
117
118        let config = Config::defaults();
119        let ctx = make_ctx(&graph, dir.path(), &config);
120        let diagnostics = IndirectLinkRule.evaluate(&ctx);
121        assert!(diagnostics.is_empty());
122    }
123}