mdbook_lint_core/rules/
mdbook004.rs

1//! MDBOOK004: No duplicate chapter titles across the book
2//!
3//! This rule validates that chapter titles are unique across the entire book.
4
5use crate::rule::{AstRule, RuleCategory, RuleMetadata};
6use crate::{
7    Document,
8    violation::{Severity, Violation},
9};
10use comrak::nodes::{AstNode, NodeValue};
11use std::collections::HashMap;
12
13/// Type alias for complex document title data structure
14type DocumentTitleList = [(String, Vec<(String, usize, usize)>)];
15
16/// MDBOOK004: No duplicate chapter titles across the book
17///
18/// This rule checks that each chapter has a unique title within the book.
19/// Note: This rule is designed to work with individual chapters and will
20/// need cross-file coordination to detect duplicates across the entire book.
21pub struct MDBOOK004;
22
23impl AstRule for MDBOOK004 {
24    fn id(&self) -> &'static str {
25        "MDBOOK004"
26    }
27
28    fn name(&self) -> &'static str {
29        "no-duplicate-chapter-titles"
30    }
31
32    fn description(&self) -> &'static str {
33        "Chapter titles should be unique across the book"
34    }
35
36    fn metadata(&self) -> RuleMetadata {
37        RuleMetadata::stable(RuleCategory::MdBook).introduced_in("mdbook-lint v0.1.0")
38    }
39
40    fn check_ast<'a>(
41        &self,
42        document: &Document,
43        ast: &'a AstNode<'a>,
44    ) -> crate::error::Result<Vec<Violation>> {
45        let mut violations = Vec::new();
46        let mut title_positions = HashMap::new();
47
48        // Extract all heading titles and their positions
49        for node in ast.descendants() {
50            if let NodeValue::Heading(_heading) = &node.data.borrow().value
51                && let Some((line, column)) = document.node_position(node)
52            {
53                let title = document.node_text(node).trim().to_string();
54
55                if !title.is_empty() {
56                    // Check for duplicates within the same document
57                    if let Some((prev_line, _)) = title_positions.get(&title) {
58                        violations.push(self.create_violation(
59                            format!(
60                                "Duplicate chapter title '{title}' found (also at line {prev_line})"
61                            ),
62                            line,
63                            column,
64                            Severity::Error,
65                        ));
66                    } else {
67                        title_positions.insert(title, (line, column));
68                    }
69                }
70            }
71        }
72
73        Ok(violations)
74    }
75}
76
77impl MDBOOK004 {
78    /// Extract all heading titles from a document for cross-file analysis
79    pub fn extract_chapter_titles(
80        document: &Document,
81    ) -> crate::error::Result<Vec<(String, usize, usize)>> {
82        use comrak::Arena;
83
84        let arena = Arena::new();
85        let ast = document.parse_ast(&arena);
86        let mut titles = Vec::new();
87
88        for node in ast.descendants() {
89            if let NodeValue::Heading(_) = &node.data.borrow().value
90                && let Some((line, column)) = document.node_position(node)
91            {
92                let title = document.node_text(node).trim().to_string();
93                if !title.is_empty() {
94                    titles.push((title, line, column));
95                }
96            }
97        }
98
99        Ok(titles)
100    }
101
102    /// Check for duplicate titles across multiple documents
103    pub fn check_cross_document_duplicates(
104        documents_with_titles: &DocumentTitleList,
105    ) -> Vec<(String, String, usize, usize, String)> {
106        let mut title_to_files = HashMap::new();
107        let mut duplicates = Vec::new();
108
109        // Build a map of titles to the files they appear in
110        for (file_path, titles) in documents_with_titles {
111            for (title, line, column) in titles {
112                title_to_files
113                    .entry(title.clone())
114                    .or_insert_with(Vec::new)
115                    .push((file_path.clone(), *line, *column));
116            }
117        }
118
119        // Find duplicates
120        for (title, occurrences) in &title_to_files {
121            if occurrences.len() > 1 {
122                for (file_path, line, column) in occurrences {
123                    let other_files: Vec<String> = title_to_files[title]
124                        .iter()
125                        .filter(|(f, _, _)| f != file_path)
126                        .map(|(f, l, _)| format!("{f}:{l}"))
127                        .collect();
128
129                    if !other_files.is_empty() {
130                        duplicates.push((
131                            file_path.clone(),
132                            title.clone(),
133                            *line,
134                            *column,
135                            other_files.join(", "),
136                        ));
137                    }
138                }
139            }
140        }
141
142        duplicates
143    }
144
145    /// Create violations for cross-document duplicates
146    pub fn create_cross_document_violations(
147        &self,
148        duplicates: &[(String, String, usize, usize, String)],
149    ) -> Vec<(String, Violation)> {
150        duplicates
151            .iter()
152            .map(|(file_path, title, line, column, other_locations)| {
153                let violation = Violation {
154                    rule_id: self.id().to_string(),
155                    rule_name: self.name().to_string(),
156                    message: format!(
157                        "Duplicate chapter title '{title}' found in other files: {other_locations}"
158                    ),
159                    line: *line,
160                    column: *column,
161                    severity: Severity::Error,
162                };
163                (file_path.clone(), violation)
164            })
165            .collect()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::test_helpers::*;
173
174    #[test]
175    fn test_mdbook004_no_duplicates() {
176        let content = MarkdownBuilder::new()
177            .heading(1, "Introduction")
178            .blank_line()
179            .paragraph("This is the introduction.")
180            .blank_line()
181            .heading(2, "Getting Started")
182            .blank_line()
183            .paragraph("How to get started.")
184            .blank_line()
185            .heading(2, "Advanced Topics")
186            .blank_line()
187            .paragraph("Advanced material.")
188            .build();
189
190        assert_no_violations(MDBOOK004, &content);
191    }
192
193    #[test]
194    fn test_mdbook004_within_document_duplicates() {
195        let content = MarkdownBuilder::new()
196            .heading(1, "Introduction")
197            .blank_line()
198            .paragraph("First introduction.")
199            .blank_line()
200            .heading(2, "Getting Started")
201            .blank_line()
202            .paragraph("How to get started.")
203            .blank_line()
204            .heading(1, "Introduction")
205            .blank_line()
206            .paragraph("Second introduction - duplicate!")
207            .build();
208
209        let violations = assert_violation_count(MDBOOK004, &content, 1);
210        assert_violation_contains_message(&violations, "Duplicate chapter title 'Introduction'");
211        assert_violation_contains_message(&violations, "also at line 1");
212        assert_violation_at_line(&violations, 9);
213    }
214
215    #[test]
216    fn test_mdbook004_case_sensitive() {
217        let content = MarkdownBuilder::new()
218            .heading(1, "Introduction")
219            .blank_line()
220            .heading(1, "introduction")
221            .blank_line()
222            .heading(1, "INTRODUCTION")
223            .build();
224
225        // These should be treated as different titles (case-sensitive)
226        assert_no_violations(MDBOOK004, &content);
227    }
228
229    #[test]
230    fn test_mdbook004_different_heading_levels() {
231        let content = MarkdownBuilder::new()
232            .heading(1, "Setup")
233            .blank_line()
234            .heading(2, "Setup")
235            .blank_line()
236            .heading(3, "Setup")
237            .build();
238
239        // Even different heading levels should be considered duplicates
240        let violations = assert_violation_count(MDBOOK004, &content, 2);
241        assert_violation_contains_message(&violations, "Duplicate chapter title 'Setup'");
242    }
243
244    #[test]
245    fn test_mdbook004_extract_titles() {
246        let content = MarkdownBuilder::new()
247            .heading(1, "Chapter One")
248            .blank_line()
249            .paragraph("Content.")
250            .blank_line()
251            .heading(2, "Section A")
252            .blank_line()
253            .heading(2, "Section B")
254            .build();
255
256        let document = create_document(&content);
257        let titles = MDBOOK004::extract_chapter_titles(&document).unwrap();
258
259        assert_eq!(titles.len(), 3);
260        assert_eq!(titles[0].0, "Chapter One");
261        assert_eq!(titles[1].0, "Section A");
262        assert_eq!(titles[2].0, "Section B");
263
264        // Check line numbers
265        assert_eq!(titles[0].1, 1); // Line 1
266        assert_eq!(titles[1].1, 5); // Line 5
267        assert_eq!(titles[2].1, 7); // Line 7
268    }
269
270    #[test]
271    fn test_mdbook004_cross_document_analysis() {
272        let documents = vec![
273            (
274                "chapter1.md".to_string(),
275                vec![
276                    ("Introduction".to_string(), 1, 1),
277                    ("Getting Started".to_string(), 5, 1),
278                ],
279            ),
280            (
281                "chapter2.md".to_string(),
282                vec![
283                    ("Advanced Topics".to_string(), 1, 1),
284                    ("Introduction".to_string(), 8, 1), // Duplicate!
285                ],
286            ),
287            (
288                "chapter3.md".to_string(),
289                vec![
290                    ("Conclusion".to_string(), 1, 1),
291                    ("Getting Started".to_string(), 3, 1), // Another duplicate!
292                ],
293            ),
294        ];
295
296        let duplicates = MDBOOK004::check_cross_document_duplicates(&documents);
297
298        // Should find 4 violations (2 for "Introduction", 2 for "Getting Started")
299        assert_eq!(duplicates.len(), 4);
300
301        // Check that we found duplicates for both titles
302        let duplicate_titles: Vec<&String> =
303            duplicates.iter().map(|(_, title, _, _, _)| title).collect();
304        assert!(duplicate_titles.contains(&&"Introduction".to_string()));
305        assert!(duplicate_titles.contains(&&"Getting Started".to_string()));
306    }
307
308    #[test]
309    fn test_mdbook004_create_cross_document_violations() {
310        let rule = MDBOOK004;
311        let duplicates = vec![
312            (
313                "chapter1.md".to_string(),
314                "Introduction".to_string(),
315                1,
316                1,
317                "chapter2.md:5".to_string(),
318            ),
319            (
320                "chapter2.md".to_string(),
321                "Introduction".to_string(),
322                5,
323                1,
324                "chapter1.md:1".to_string(),
325            ),
326        ];
327
328        let violations = rule.create_cross_document_violations(&duplicates);
329
330        assert_eq!(violations.len(), 2);
331        assert_eq!(violations[0].0, "chapter1.md");
332        assert_eq!(violations[1].0, "chapter2.md");
333
334        assert!(
335            violations[0]
336                .1
337                .message
338                .contains("Duplicate chapter title 'Introduction'")
339        );
340        assert!(violations[0].1.message.contains("chapter2.md:5"));
341        assert!(violations[1].1.message.contains("chapter1.md:1"));
342    }
343
344    #[test]
345    fn test_mdbook004_empty_headings_ignored() {
346        let content = MarkdownBuilder::new()
347            .line("# ")
348            .blank_line()
349            .line("## ")
350            .blank_line()
351            .heading(1, "Real Title")
352            .build();
353
354        // Empty headings should be ignored
355        assert_no_violations(MDBOOK004, &content);
356    }
357
358    #[test]
359    fn test_mdbook004_whitespace_handling() {
360        let content = MarkdownBuilder::new()
361            .line("# Introduction ")
362            .blank_line()
363            .line("#  Introduction")
364            .blank_line()
365            .line("# Introduction  ")
366            .build();
367
368        // Whitespace should be trimmed, so these are duplicates
369        let violations = assert_violation_count(MDBOOK004, &content, 2);
370        assert_violation_contains_message(&violations, "Duplicate chapter title 'Introduction'");
371    }
372}