use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use crate::file_type::FileType;
use crate::parser::{DocumentContent, ParsedDocument};
use crate::rules::consistency::AIL340;
use crate::rules::{BatchRule, RuleContext, RuleId, Severity, Violation};
#[derive(Debug, Default)]
pub struct OrphanedDocumentRule;
impl BatchRule for OrphanedDocumentRule {
fn id(&self) -> RuleId {
AIL340
}
fn default_severity(&self) -> Severity {
Severity::Info
}
fn description(&self) -> &'static str {
"Document is not reachable via local links from any README or AGENTS root."
}
fn fix_hint(&self) -> &'static str {
"Link it from a README or AGENTS file, or delete it."
}
fn applies_to(&self, file_type: FileType) -> bool {
file_type.is_markdown()
}
fn run_batch(&self, docs: &[ParsedDocument], _ctx: &RuleContext<'_>) -> Vec<Violation> {
let mut canonical: HashMap<PathBuf, usize> = HashMap::new();
let mut paths: Vec<PathBuf> = Vec::with_capacity(docs.len());
for (i, d) in docs.iter().enumerate() {
let p = canonicalize(&d.path);
canonical.insert(p.clone(), i);
paths.push(p);
}
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); docs.len()];
for (i, d) in docs.iter().enumerate() {
let md = match &d.content {
DocumentContent::Markdown(m) => m,
_ => continue,
};
let doc_dir = d.path.parent().unwrap_or(Path::new(""));
for link in &md.links {
let Some(target) = resolve_link(doc_dir, &link.url) else {
continue;
};
if let Some(&j) = canonical.get(&target) {
adj[i].push(j);
}
}
}
let roots = find_roots(docs, &paths);
if roots.is_empty() {
return Vec::new();
}
let mut reached: HashSet<usize> = HashSet::new();
let mut queue: VecDeque<usize> = VecDeque::new();
for &r in &roots {
reached.insert(r);
queue.push_back(r);
}
while let Some(i) = queue.pop_front() {
for &j in &adj[i] {
if reached.insert(j) {
queue.push_back(j);
}
}
}
let mut out = Vec::new();
let mut orphans: BTreeSet<usize> = BTreeSet::new();
for i in 0..docs.len() {
if !reached.contains(&i) {
orphans.insert(i);
}
}
for i in orphans {
let doc = &docs[i];
let v = Violation::new(
AIL340,
self.default_severity(),
doc.path.clone(),
"orphaned document",
);
out.push(v);
}
out
}
}
fn canonicalize(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn resolve_link(doc_dir: &Path, url: &str) -> Option<PathBuf> {
if url.is_empty() || url.starts_with('#') || url.starts_with("//") {
return None;
}
if url.starts_with("mailto:") || url.starts_with("tel:") {
return None;
}
if has_scheme(url) {
return None;
}
let path_part = match url.find('#') {
Some(i) => &url[..i],
None => url,
};
if path_part.is_empty() {
return None;
}
let joined = doc_dir.join(path_part.trim_start_matches('/'));
Some(canonicalize(&joined))
}
fn has_scheme(url: &str) -> bool {
let bytes = url.as_bytes();
if bytes.is_empty() || !bytes[0].is_ascii_alphabetic() {
return false;
}
for (i, &b) in bytes.iter().enumerate().skip(1) {
if b == b':' {
return i > 0;
}
if !(b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') {
return false;
}
}
false
}
fn find_roots(docs: &[ParsedDocument], paths: &[PathBuf]) -> Vec<usize> {
let mut roots: BTreeSet<usize> = BTreeSet::new();
for (i, d) in docs.iter().enumerate() {
if d.file_type.is_ai_guidance() {
roots.insert(i);
}
}
for target in ["README.md", "AGENTS.md"] {
let mut candidates: Vec<(usize, usize)> = Vec::new();
for (i, p) in paths.iter().enumerate() {
if p.file_name().and_then(|n| n.to_str()) == Some(target) {
candidates.push((i, p.components().count()));
}
}
if let Some(min_depth) = candidates.iter().map(|(_, d)| *d).min() {
for (i, d) in candidates {
if d == min_depth {
roots.insert(i);
}
}
}
}
roots.into_iter().collect()
}