mdbook_lint_core/rules/
mdbook025.rs

1//! MDBOOK025: Multiple H1 headings allowed in SUMMARY.md
2//!
3//! This rule overrides MD025 for mdBook projects to allow multiple H1 headings
4//! in SUMMARY.md files, which legitimately use them for organizing parts and sections.
5
6use crate::error::Result;
7use crate::rule::{AstRule, RuleCategory, RuleMetadata};
8use crate::{Document, violation::Violation};
9use comrak::nodes::AstNode;
10
11/// Rule that allows multiple H1 headings in SUMMARY.md files
12///
13/// In mdBook projects, SUMMARY.md files legitimately use multiple H1 headings
14/// to organize content into parts and sections. This rule overrides the standard
15/// MD025 behavior specifically for SUMMARY.md files.
16pub struct MDBOOK025;
17
18impl MDBOOK025 {
19    /// Create a new MDBOOK025 rule
20    pub fn new() -> Self {
21        Self
22    }
23}
24
25impl Default for MDBOOK025 {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl AstRule for MDBOOK025 {
32    fn id(&self) -> &'static str {
33        "MDBOOK025"
34    }
35
36    fn name(&self) -> &'static str {
37        "summary-multiple-h1-allowed"
38    }
39
40    fn description(&self) -> &'static str {
41        "Multiple H1 headings are allowed in SUMMARY.md files"
42    }
43
44    fn metadata(&self) -> RuleMetadata {
45        RuleMetadata::stable(RuleCategory::Structure)
46            .introduced_in("mdbook-lint v0.4.0")
47            .overrides("MD025")
48    }
49
50    fn check_ast<'a>(&self, document: &Document, _ast: &'a AstNode<'a>) -> Result<Vec<Violation>> {
51        // This rule only applies to SUMMARY.md files
52        if let Some(filename) = document.path.file_name()
53            && filename == "SUMMARY.md"
54        {
55            // Always allow multiple H1s in SUMMARY.md - return no violations
56            return Ok(Vec::new());
57        }
58
59        // For non-SUMMARY.md files, this rule doesn't apply
60        // The standard MD025 rule will handle them
61        Ok(Vec::new())
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::rule::Rule;
69    use std::path::PathBuf;
70
71    #[test]
72    fn test_mdbook025_summary_file_multiple_h1s_allowed() {
73        let content = r#"# Summary
74
75[Introduction](introduction.md)
76
77# Part I: Getting Started
78
79- [Chapter 1](chapter1.md)
80
81# Part II: Advanced Topics
82
83- [Chapter 2](chapter2.md)
84"#;
85        let document = Document::new(content.to_string(), PathBuf::from("SUMMARY.md")).unwrap();
86        let rule = MDBOOK025::new();
87        let violations = rule.check(&document).unwrap();
88
89        // SUMMARY.md should not trigger MDBOOK025 violations despite multiple H1s
90        assert_eq!(violations.len(), 0);
91    }
92
93    #[test]
94    fn test_mdbook025_non_summary_file_ignored() {
95        let content = r#"# First H1 heading
96Some content here.
97
98# Second H1 heading
99More content.
100"#;
101        let document = Document::new(content.to_string(), PathBuf::from("chapter.md")).unwrap();
102        let rule = MDBOOK025::new();
103        let violations = rule.check(&document).unwrap();
104
105        // Non-SUMMARY.md files are ignored by this rule (MD025 handles them)
106        assert_eq!(violations.len(), 0);
107    }
108
109    #[test]
110    fn test_mdbook025_summary_with_single_h1() {
111        let content = r#"# Summary
112
113- [Chapter 1](chapter1.md)
114- [Chapter 2](chapter2.md)
115"#;
116        let document = Document::new(content.to_string(), PathBuf::from("SUMMARY.md")).unwrap();
117        let rule = MDBOOK025::new();
118        let violations = rule.check(&document).unwrap();
119
120        // Single H1 in SUMMARY.md is also fine
121        assert_eq!(violations.len(), 0);
122    }
123
124    #[test]
125    fn test_mdbook025_rule_metadata() {
126        use crate::rule::AstRule;
127        let rule = MDBOOK025::new();
128
129        assert_eq!(AstRule::id(&rule), "MDBOOK025");
130        assert_eq!(AstRule::name(&rule), "summary-multiple-h1-allowed");
131        assert!(AstRule::description(&rule).contains("SUMMARY.md"));
132
133        let metadata = AstRule::metadata(&rule);
134        assert_eq!(metadata.category, RuleCategory::Structure);
135        assert!(metadata.overrides.as_ref().unwrap().contains("MD025"));
136    }
137}