use mdbook_lint_core::rule::{Rule, RuleCategory, RuleMetadata};
use mdbook_lint_core::{
Document,
ignore::path_is_ignored,
violation::{Severity, Violation},
};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::{fs, io};
pub struct MDBOOK005 {
ignored_files: HashSet<String>,
ignore_patterns: Vec<String>,
check_nested: bool,
}
impl Default for MDBOOK005 {
fn default() -> Self {
Self {
ignored_files: default_ignored_files(true),
ignore_patterns: Vec::new(),
check_nested: true,
}
}
}
fn default_ignored_files(exclude_readme: bool) -> HashSet<String> {
let mut ignored_files = HashSet::new();
if exclude_readme {
ignored_files.insert("readme.md".to_string());
}
ignored_files.insert("contributing.md".to_string());
ignored_files.insert("license.md".to_string());
ignored_files.insert("changelog.md".to_string());
ignored_files.insert("summary.md".to_string()); ignored_files
}
impl MDBOOK005 {
#[cfg(test)]
pub fn with_ignored_files(additional_ignored: Vec<String>) -> Self {
let mut instance = Self::default();
for file in additional_ignored {
instance.ignored_files.insert(file.to_lowercase());
}
instance
}
pub fn from_config(config: &toml::Value) -> Self {
let get = |snake: &str, kebab: &str| config.get(snake).or_else(|| config.get(kebab));
let exclude_readme = get("exclude_readme", "exclude-readme")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let check_nested = get("check_nested", "check-nested")
.and_then(|v| v.as_bool())
.unwrap_or(true);
let ignore_patterns = get("ignore_patterns", "ignore-patterns")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
Self {
ignored_files: default_ignored_files(exclude_readme),
ignore_patterns,
check_nested,
}
}
}
impl Rule for MDBOOK005 {
fn id(&self) -> &'static str {
"MDBOOK005"
}
fn name(&self) -> &'static str {
"orphaned-files"
}
fn description(&self) -> &'static str {
"Detect orphaned markdown files not referenced in SUMMARY.md"
}
fn metadata(&self) -> RuleMetadata {
RuleMetadata::stable(RuleCategory::MdBook).introduced_in("mdbook-lint v0.2.0")
}
fn check_with_ast<'a>(
&self,
document: &Document,
_ast: Option<&'a comrak::nodes::AstNode<'a>>,
) -> mdbook_lint_core::error::Result<Vec<Violation>> {
let mut violations = Vec::new();
if !is_summary_file(document) {
return Ok(violations);
}
let book_src_dir = book_src_dir_for(&document.path);
let book_src_dir = book_src_dir.canonicalize().unwrap_or(book_src_dir);
let referenced_files = match self.parse_referenced_files(document) {
Ok(files) => files,
Err(_) => {
return Ok(violations);
}
};
let all_markdown_files = match self.find_markdown_files(&book_src_dir) {
Ok(files) => files,
Err(_) => {
return Ok(violations);
}
};
let orphaned_files =
self.find_orphaned_files(&referenced_files, &all_markdown_files, &book_src_dir);
for orphaned_file in orphaned_files {
let relative_path = orphaned_file
.strip_prefix(&book_src_dir)
.unwrap_or(orphaned_file.as_path())
.to_string_lossy()
.replace('\\', "/") .to_string();
violations.push(self.create_violation(
format!("Orphaned file '{relative_path}' is not referenced in SUMMARY.md"),
1, 1,
Severity::Warning,
));
}
Ok(violations)
}
}
impl MDBOOK005 {
fn parse_referenced_files(
&self,
document: &Document,
) -> Result<HashSet<PathBuf>, Box<dyn std::error::Error>> {
let mut referenced = HashSet::new();
let project_root = document.path.parent().unwrap_or(Path::new("."));
for line in &document.lines {
if let Some(path) = self.extract_file_path(line) {
let absolute_path = project_root.join(&path);
if let Ok(canonical) = absolute_path.canonicalize() {
referenced.insert(canonical);
} else {
referenced.insert(absolute_path);
}
}
}
Ok(referenced)
}
fn extract_file_path(&self, line: &str) -> Option<String> {
if let Some(start) = line.find("](") {
let after_bracket = &line[start + 2..];
if let Some(end) = after_bracket.find(')') {
let path = &after_bracket[..end];
if path.is_empty() || path.starts_with("http://") || path.starts_with("https://") {
return None;
}
let path_without_anchor = path.split('#').next().unwrap_or(path);
if path_without_anchor.ends_with(".md")
|| path_without_anchor.ends_with(".markdown")
{
return Some(path_without_anchor.to_string());
}
}
}
None
}
fn find_markdown_files(&self, book_src_dir: &Path) -> io::Result<HashSet<PathBuf>> {
let mut markdown_files = HashSet::new();
scan_directory(book_src_dir, &mut markdown_files, self.check_nested)?;
Ok(markdown_files)
}
fn find_orphaned_files(
&self,
referenced: &HashSet<PathBuf>,
all_files: &HashSet<PathBuf>,
book_src_dir: &Path,
) -> Vec<PathBuf> {
all_files
.iter()
.filter(|&file| {
if referenced.contains(file) {
return false;
}
if let Some(filename) = file.file_name().and_then(|n| n.to_str())
&& self.ignored_files.contains(&filename.to_lowercase())
{
return false;
}
if !self.ignore_patterns.is_empty() {
let relative = file.strip_prefix(book_src_dir).unwrap_or(file.as_path());
if path_is_ignored(relative, &self.ignore_patterns) {
return false;
}
}
true
})
.cloned()
.collect()
}
}
fn book_src_dir_for(summary_path: &Path) -> PathBuf {
match summary_path.parent() {
Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
_ => PathBuf::from("."),
}
}
fn is_summary_file(document: &Document) -> bool {
document
.path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.eq_ignore_ascii_case("summary.md"))
.unwrap_or(false)
}
fn scan_directory(
dir: &Path,
markdown_files: &mut HashSet<PathBuf>,
recursive: bool,
) -> io::Result<()> {
let entries = fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if !recursive {
continue;
}
if let Some(dir_name) = path.file_name().and_then(|n| n.to_str())
&& matches!(
dir_name,
"target" | "node_modules" | ".git" | ".svn" | ".hg"
)
{
continue;
}
scan_directory(&path, markdown_files, recursive)?;
} else if let Some(extension) = path.extension().and_then(|e| e.to_str())
&& matches!(extension, "md" | "markdown")
{
if let Ok(canonical) = path.canonicalize() {
markdown_files.insert(canonical);
} else {
markdown_files.insert(path);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_document(
content: &str,
file_path: &Path,
) -> mdbook_lint_core::error::Result<Document> {
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(file_path, content)?;
Document::new(content.to_string(), file_path.to_path_buf())
}
#[test]
fn test_mdbook005_no_orphans() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
[Introduction](intro.md)
- [Chapter 1](chapter1.md)
- [Chapter 2](chapter2.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Intro", &root.join("intro.md"))?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Chapter 2", &root.join("chapter2.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Should have no violations when all files are referenced"
);
Ok(())
}
#[test]
fn test_mdbook005_detect_orphans() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
[Introduction](intro.md)
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Intro", &root.join("intro.md"))?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Orphan", &root.join("orphan.md"))?;
create_test_document("# Another", &root.join("another.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 2, "Should detect 2 orphaned files");
let messages: Vec<_> = violations.iter().map(|v| &v.message).collect();
assert!(messages.iter().any(|m| m.contains("orphan.md")));
assert!(messages.iter().any(|m| m.contains("another.md")));
Ok(())
}
#[test]
fn test_mdbook005_ignore_common_files() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# README", &root.join("README.md"))?;
create_test_document("# Contributing", &root.join("CONTRIBUTING.md"))?;
create_test_document("# License", &root.join("LICENSE.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Should ignore common files like README.md"
);
Ok(())
}
#[test]
fn test_mdbook005_nested_directories() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](guide/chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("guide/chapter1.md"))?;
create_test_document("# Orphan", &root.join("guide/orphan.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
1,
"Should detect orphaned files in subdirectories"
);
assert!(violations[0].message.contains("guide/orphan.md"));
Ok(())
}
#[test]
fn test_mdbook005_draft_chapters() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
- [Draft Chapter]()
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Orphan", &root.join("orphan.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("orphan.md"));
Ok(())
}
#[test]
fn test_mdbook005_non_summary_files() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let content = "# Regular File";
let doc_path = temp_dir.path().join("README.md");
let doc = create_test_document(content, &doc_path)?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
0,
"Should not run on non-SUMMARY.md files"
);
Ok(())
}
#[test]
fn test_mdbook005_scope_limited_to_src_dir() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let book_src = root.join("src");
fs::create_dir_all(&book_src)?;
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = book_src.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &book_src.join("chapter1.md"))?;
create_test_document("# Orphan in src", &book_src.join("orphan_in_src.md"))?;
create_test_document("# Outside", &root.join("outside.md"))?;
create_test_document("# Config docs", &root.join("CONFIGURATION.md"))?;
let docs_dir = root.join("docs");
fs::create_dir_all(&docs_dir)?;
create_test_document("# Docs", &docs_dir.join("documentation.md"))?;
let rule = MDBOOK005::default();
let violations = rule.check(&doc)?;
assert_eq!(
violations.len(),
1,
"Should only detect orphans within src/"
);
assert!(violations[0].message.contains("orphan_in_src.md"));
assert!(!violations[0].message.contains("outside.md"));
assert!(!violations[0].message.contains("CONFIGURATION.md"));
assert!(!violations[0].message.contains("documentation.md"));
Ok(())
}
#[test]
fn test_extract_file_path() {
let rule = MDBOOK005::default();
assert_eq!(
rule.extract_file_path("- [Chapter](chapter.md)"),
Some("chapter.md".to_string())
);
assert_eq!(
rule.extract_file_path("[Intro](intro.md)"),
Some("intro.md".to_string())
);
assert_eq!(
rule.extract_file_path(" - [Nested](sub/nested.md)"),
Some("sub/nested.md".to_string())
);
assert_eq!(
rule.extract_file_path("- [Link](file.md#section)"),
Some("file.md".to_string())
);
assert_eq!(rule.extract_file_path("- [Draft]()"), None);
assert_eq!(
rule.extract_file_path("- [External](https://example.com)"),
None
);
assert_eq!(rule.extract_file_path("- [Non-MD](image.png)"), None);
assert_eq!(rule.extract_file_path("Regular text"), None);
}
#[test]
fn test_custom_ignored_files() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Custom", &root.join("custom.md"))?;
create_test_document("# Orphan", &root.join("orphan.md"))?;
let rule = MDBOOK005::with_ignored_files(vec!["custom.md".to_string()]);
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1);
assert!(violations[0].message.contains("orphan.md"));
assert!(!violations[0].message.contains("custom.md"));
Ok(())
}
#[test]
fn test_from_config_defaults() {
let cfg: toml::Value = toml::from_str("").unwrap();
let rule = MDBOOK005::from_config(&cfg);
assert!(rule.ignore_patterns.is_empty());
assert!(rule.check_nested);
assert!(rule.ignored_files.contains("readme.md"));
}
#[test]
fn test_ignore_patterns_config() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Not Found", &root.join("not-found.md"))?;
create_test_document("# Real Orphan", &root.join("orphan.md"))?;
let cfg: toml::Value =
toml::from_str("ignore_patterns = [\"not-found.md\", \"**/not-found.md\"]").unwrap();
let rule = MDBOOK005::from_config(&cfg);
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1, "only the real orphan should remain");
assert!(violations[0].message.contains("orphan.md"));
assert!(!violations[0].message.contains("not-found.md"));
Ok(())
}
#[test]
fn test_ignore_patterns_config_nested_dir() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Draft", &root.join("drafts/wip.md"))?;
create_test_document("# Draft 2", &root.join("drafts/nested/wip2.md"))?;
create_test_document("# Real Orphan", &root.join("orphan.md"))?;
let cfg: toml::Value = toml::from_str("ignore_patterns = [\"drafts/\"]").unwrap();
let rule = MDBOOK005::from_config(&cfg);
let violations = rule.check(&doc)?;
assert_eq!(violations.len(), 1, "drafts/ should be fully ignored");
assert!(violations[0].message.contains("orphan.md"));
Ok(())
}
#[test]
fn test_exclude_readme_false_reports_readme() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Readme", &root.join("README.md"))?;
assert_eq!(MDBOOK005::default().check(&doc)?.len(), 0);
let cfg: toml::Value = toml::from_str("exclude_readme = false").unwrap();
let violations = MDBOOK005::from_config(&cfg).check(&doc)?;
assert_eq!(violations.len(), 1);
assert!(violations[0].message.to_lowercase().contains("readme.md"));
Ok(())
}
#[test]
fn test_check_nested_false_skips_subdirs() -> mdbook_lint_core::error::Result<()> {
let temp_dir = TempDir::new()?;
let root = temp_dir.path();
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
let summary_path = root.join("SUMMARY.md");
let doc = create_test_document(summary_content, &summary_path)?;
create_test_document("# Chapter 1", &root.join("chapter1.md"))?;
create_test_document("# Nested Orphan", &root.join("sub/orphan.md"))?;
assert_eq!(MDBOOK005::default().check(&doc)?.len(), 1);
let cfg: toml::Value = toml::from_str("check_nested = false").unwrap();
assert_eq!(MDBOOK005::from_config(&cfg).check(&doc)?.len(), 0);
Ok(())
}
#[test]
fn test_book_src_dir_for_scopes_to_summary_parent() {
assert_eq!(
book_src_dir_for(Path::new("docs/SUMMARY.md")),
PathBuf::from("docs")
);
assert_eq!(
book_src_dir_for(Path::new("book/src/SUMMARY.md")),
PathBuf::from("book/src")
);
assert_eq!(
book_src_dir_for(Path::new("/abs/docs/SUMMARY.md")),
PathBuf::from("/abs/docs")
);
assert_eq!(
book_src_dir_for(Path::new("SUMMARY.md")),
PathBuf::from(".")
);
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct CwdGuard(PathBuf);
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
#[test]
fn test_mdbook005_relative_summary_path_does_not_scan_cwd()
-> mdbook_lint_core::error::Result<()> {
let _lock = CWD_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let temp_dir = TempDir::new()?;
let root = temp_dir.path().canonicalize()?;
let docs = root.join("docs");
fs::create_dir_all(&docs)?;
let summary_content = r#"# Summary
- [Chapter 1](chapter1.md)
"#;
create_test_document(summary_content, &docs.join("SUMMARY.md"))?;
create_test_document("# Chapter 1", &docs.join("chapter1.md"))?;
create_test_document("# Orphan", &docs.join("orphan.md"))?;
create_test_document("# Claude", &root.join("CLAUDE.md"))?;
create_test_document("# Readme", &root.join("NOTES.md"))?;
create_test_document("# Skill", &root.join(".claude/skills/SKILL.md"))?;
let guard = CwdGuard(std::env::current_dir()?);
std::env::set_current_dir(&root)?;
let relative_doc = Document::new(
summary_content.to_string(),
PathBuf::from("docs/SUMMARY.md"),
)?;
let relative = MDBOOK005::default().check(&relative_doc)?;
let absolute_doc = Document::new(summary_content.to_string(), docs.join("SUMMARY.md"))?;
let absolute = MDBOOK005::default().check(&absolute_doc)?;
drop(guard);
assert_eq!(
relative.len(),
1,
"relative path should report only the orphan inside docs/, got: {:?}",
relative.iter().map(|v| &v.message).collect::<Vec<_>>()
);
assert!(relative[0].message.contains("orphan.md"));
for unexpected in ["CLAUDE.md", "NOTES.md", "SKILL.md"] {
assert!(
!relative[0].message.contains(unexpected),
"must not scan outside the book source directory: {unexpected}"
);
}
let messages = |vs: &[Violation]| {
let mut m: Vec<String> = vs.iter().map(|v| v.message.clone()).collect();
m.sort();
m
};
assert_eq!(
messages(&relative),
messages(&absolute),
"relative and absolute invocations must produce identical results"
);
Ok(())
}
}