ailint_core/rules/consistency/
orphaned_document.rs1use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
8use std::path::{Path, PathBuf};
9
10use crate::file_type::FileType;
11use crate::parser::{DocumentContent, ParsedDocument};
12use crate::rules::consistency::AIL340;
13use crate::rules::{BatchRule, RuleContext, RuleId, Severity, Violation};
14
15#[derive(Debug, Default)]
17pub struct OrphanedDocumentRule;
18
19impl BatchRule for OrphanedDocumentRule {
20 fn id(&self) -> RuleId {
21 AIL340
22 }
23
24 fn default_severity(&self) -> Severity {
25 Severity::Info
26 }
27
28 fn applies_to(&self, file_type: FileType) -> bool {
31 file_type.is_markdown()
32 }
33
34 fn run_batch(&self, docs: &[ParsedDocument], _ctx: &RuleContext<'_>) -> Vec<Violation> {
35 let mut canonical: HashMap<PathBuf, usize> = HashMap::new();
37 let mut paths: Vec<PathBuf> = Vec::with_capacity(docs.len());
38 for (i, d) in docs.iter().enumerate() {
39 let p = canonicalize(&d.path);
40 canonical.insert(p.clone(), i);
41 paths.push(p);
42 }
43
44 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); docs.len()];
46 for (i, d) in docs.iter().enumerate() {
47 let md = match &d.content {
48 DocumentContent::Markdown(m) => m,
49 _ => continue,
50 };
51 let doc_dir = d.path.parent().unwrap_or(Path::new(""));
52 for link in &md.links {
53 let Some(target) = resolve_link(doc_dir, &link.url) else {
54 continue;
55 };
56 if let Some(&j) = canonical.get(&target) {
57 adj[i].push(j);
58 }
59 }
60 }
61
62 let roots = find_roots(docs, &paths);
66 if roots.is_empty() {
67 return Vec::new();
68 }
69
70 let mut reached: HashSet<usize> = HashSet::new();
72 let mut queue: VecDeque<usize> = VecDeque::new();
73 for &r in &roots {
74 reached.insert(r);
75 queue.push_back(r);
76 }
77 while let Some(i) = queue.pop_front() {
78 for &j in &adj[i] {
79 if reached.insert(j) {
80 queue.push_back(j);
81 }
82 }
83 }
84
85 let mut out = Vec::new();
87 let mut orphans: BTreeSet<usize> = BTreeSet::new();
88 for i in 0..docs.len() {
89 if !reached.contains(&i) {
90 orphans.insert(i);
91 }
92 }
93 for i in orphans {
94 let doc = &docs[i];
95 let mut v = Violation::new(
96 AIL340,
97 self.default_severity(),
98 doc.path.clone(),
99 "document is not reachable via local links from any README root",
100 )
101 .at(1, 1);
102 v.fix_hint = Some(
103 "add a link from a README or another reachable document, or remove the file"
104 .to_string(),
105 );
106 out.push(v);
107 }
108 out
109 }
110}
111
112fn canonicalize(path: &Path) -> PathBuf {
113 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
114}
115
116fn resolve_link(doc_dir: &Path, url: &str) -> Option<PathBuf> {
117 if url.is_empty() || url.starts_with('#') || url.starts_with("//") {
118 return None;
119 }
120 if url.starts_with("mailto:") || url.starts_with("tel:") {
121 return None;
122 }
123 if has_scheme(url) {
124 return None;
125 }
126 let path_part = match url.find('#') {
127 Some(i) => &url[..i],
128 None => url,
129 };
130 if path_part.is_empty() {
131 return None;
132 }
133 let joined = doc_dir.join(path_part.trim_start_matches('/'));
134 Some(canonicalize(&joined))
135}
136
137fn has_scheme(url: &str) -> bool {
138 let bytes = url.as_bytes();
139 if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
140 return false;
141 }
142 for (i, &b) in bytes.iter().enumerate().skip(1) {
143 if b == b':' {
144 return i > 0;
145 }
146 if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
147 return false;
148 }
149 }
150 false
151}
152
153fn find_roots(docs: &[ParsedDocument], paths: &[PathBuf]) -> Vec<usize> {
156 let mut roots: BTreeSet<usize> = BTreeSet::new();
157
158 for (i, d) in docs.iter().enumerate() {
159 if d.file_type.is_ai_guidance() {
160 roots.insert(i);
161 }
162 }
163
164 let mut readme_candidates: Vec<(usize, usize)> = Vec::new();
165 for (i, p) in paths.iter().enumerate() {
166 if p.file_name().and_then(|n| n.to_str()) == Some("README.md") {
167 readme_candidates.push((i, p.components().count()));
168 }
169 }
170 if let Some(min_depth) = readme_candidates.iter().map(|(_, d)| *d).min() {
171 for (i, d) in readme_candidates {
172 if d == min_depth {
173 roots.insert(i);
174 }
175 }
176 }
177
178 roots.into_iter().collect()
179}