ailint_core/rules/structural/
broken_local_link.rs1use std::path::{Path, PathBuf};
7
8use crate::file_type::FileType;
9use crate::parser::{DocumentContent, ParsedDocument};
10use crate::rules::structural::AIL040;
11use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
12
13#[derive(Debug, Default)]
15pub struct BrokenLocalLinkRule;
16
17impl Rule for BrokenLocalLinkRule {
18 fn id(&self) -> RuleId {
19 AIL040
20 }
21
22 fn default_severity(&self) -> Severity {
23 Severity::Warning
24 }
25
26 fn description(&self) -> &'static str {
27 "Markdown link points to a path that does not exist on disk."
28 }
29
30 fn fix_hint(&self) -> &'static str {
31 "Correct the path, or remove the link."
32 }
33
34 fn applies_to(&self, file_type: FileType) -> bool {
37 file_type.is_markdown()
38 }
39
40 fn run(&self, doc: &ParsedDocument, _ctx: &RuleContext<'_>) -> Vec<Violation> {
41 let md = match &doc.content {
42 DocumentContent::Markdown(m) => m,
43 _ => return Vec::new(),
44 };
45
46 let doc_dir = doc.path.parent().unwrap_or(Path::new(""));
47 let mut out = Vec::new();
48
49 for link in &md.links {
50 if !is_local_link(&link.url) {
51 continue;
52 }
53 let (path_part, _fragment) = split_fragment(&link.url);
54 if path_part.is_empty() {
55 continue;
57 }
58 let target = resolve_target(doc_dir, path_part);
59 if target.exists() {
60 continue;
61 }
62 let v = Violation::new(
63 AIL040,
64 self.default_severity(),
65 doc.path.clone(),
66 "broken local link",
67 )
68 .at(link.line, 1)
69 .with_detail(link.url.clone());
70 out.push(v);
71 }
72
73 out
74 }
75}
76
77fn is_local_link(url: &str) -> bool {
80 if url.is_empty() {
81 return false;
82 }
83 if url.starts_with('#') {
84 return false;
85 }
86 if url.starts_with("mailto:") || url.starts_with("tel:") {
87 return false;
88 }
89 if has_scheme(url) {
91 return false;
92 }
93 if url.starts_with("//") {
95 return false;
96 }
97 true
98}
99
100fn has_scheme(url: &str) -> bool {
101 let bytes = url.as_bytes();
103 if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
104 return false;
105 }
106 for (i, &b) in bytes.iter().enumerate().skip(1) {
107 if b == b':' {
108 return i > 0;
109 }
110 if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
111 return false;
112 }
113 }
114 false
115}
116
117fn split_fragment(url: &str) -> (&str, Option<&str>) {
118 match url.find('#') {
119 Some(i) => (&url[..i], Some(&url[i + 1..])),
120 None => (url, None),
121 }
122}
123
124fn resolve_target(doc_dir: &Path, rel: &str) -> PathBuf {
125 let trimmed = rel.trim_start_matches('/');
130 doc_dir.join(trimmed)
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn detects_scheme() {
139 assert!(has_scheme("http://example.com"));
140 assert!(has_scheme("https://example.com"));
141 assert!(has_scheme("mailto:a@b"));
142 assert!(has_scheme("ftp://x"));
143 assert!(!has_scheme("relative/path.md"));
144 assert!(!has_scheme("./relative.md"));
145 assert!(!has_scheme("#anchor"));
146 }
147
148 #[test]
149 fn classifies_local_links() {
150 assert!(is_local_link("README.md"));
151 assert!(is_local_link("./sub/doc.md"));
152 assert!(is_local_link("../up.md"));
153 assert!(is_local_link("/absolute/rel.md"));
154 assert!(!is_local_link("https://example.com"));
155 assert!(!is_local_link("mailto:a@b"));
156 assert!(!is_local_link("#anchor"));
157 assert!(!is_local_link(""));
158 assert!(!is_local_link("//cdn.example.com/a.js"));
159 }
160
161 #[test]
162 fn splits_fragment() {
163 assert_eq!(split_fragment("a.md#sec"), ("a.md", Some("sec")));
164 assert_eq!(split_fragment("a.md"), ("a.md", None));
165 }
166}