mdbook_lint_core/rules/
mdbook003.rs

1//! MDBOOK003: SUMMARY.md structure validation
2//!
3//! Validates that SUMMARY.md follows the mdBook specification for structure and formatting.
4
5use crate::Document;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::violation::{Severity, Violation};
8
9/// MDBOOK003: Validates SUMMARY.md structure and formatting
10///
11/// This rule checks:
12/// - File must be named SUMMARY.md (case-sensitive)
13/// - Consistent list delimiters (don't mix - and *)
14/// - Proper nesting hierarchy (no skipped indentation levels)
15/// - Part titles must be h1 headers only
16/// - Prefix chapters cannot be nested
17/// - No prefix chapters after numbered chapters begin
18/// - Valid link syntax for chapters
19/// - Draft chapters use empty parentheses
20/// - Separators contain only dashes (minimum 3)
21pub struct MDBOOK003;
22
23impl Rule for MDBOOK003 {
24    fn id(&self) -> &'static str {
25        "MDBOOK003"
26    }
27
28    fn name(&self) -> &'static str {
29        "summary-structure"
30    }
31
32    fn description(&self) -> &'static str {
33        "SUMMARY.md must follow mdBook format requirements"
34    }
35
36    fn metadata(&self) -> RuleMetadata {
37        RuleMetadata::stable(RuleCategory::MdBook).introduced_in("mdbook-lint v0.1.0")
38    }
39
40    fn check_with_ast<'a>(
41        &self,
42        document: &Document,
43        _ast: Option<&'a comrak::nodes::AstNode<'a>>,
44    ) -> crate::error::Result<Vec<Violation>> {
45        let mut violations = Vec::new();
46
47        // Check if this is a SUMMARY.md file
48        if !is_summary_file(document) {
49            return Ok(violations);
50        }
51
52        let mut checker = SummaryChecker::new(self);
53        checker.validate(document, &mut violations);
54
55        Ok(violations)
56    }
57}
58
59/// Internal state tracker for SUMMARY.md validation
60struct SummaryChecker<'a> {
61    /// Reference to the rule for creating violations
62    rule: &'a MDBOOK003,
63    /// Track if we've seen numbered chapters (to detect prefix chapters after)
64    seen_numbered_chapters: bool,
65    /// Track the list delimiter used (- or *)
66    list_delimiter: Option<char>,
67    /// Track current nesting level for hierarchy validation
68    current_nesting_level: usize,
69    /// Track line numbers of part titles for context
70    part_title_lines: Vec<usize>,
71}
72
73impl<'a> SummaryChecker<'a> {
74    fn new(rule: &'a MDBOOK003) -> Self {
75        Self {
76            rule,
77            seen_numbered_chapters: false,
78            list_delimiter: None,
79            current_nesting_level: 0,
80            part_title_lines: Vec::new(),
81        }
82    }
83
84    fn validate(&mut self, document: &Document, violations: &mut Vec<Violation>) {
85        for (line_num, line) in document.lines.iter().enumerate() {
86            let line_num = line_num + 1; // Convert to 1-based
87            let trimmed = line.trim();
88
89            if trimmed.is_empty() {
90                continue;
91            }
92
93            // Check for part titles (h1 headers)
94            if let Some(title) = self.parse_part_title(trimmed) {
95                self.validate_part_title(line_num, &title, violations);
96                continue;
97            }
98
99            // Check for invalid part titles (h2, h3, etc.)
100            if self.is_invalid_part_title(trimmed) {
101                violations.push(self.rule.create_violation(
102                    "Part titles must be h1 headers (single #)".to_string(),
103                    line_num,
104                    1,
105                    Severity::Error,
106                ));
107                continue;
108            }
109
110            // Check for separators
111            if self.is_separator(trimmed) {
112                self.validate_separator(line_num, trimmed, violations);
113                continue;
114            }
115
116            // Check for chapters (both numbered and prefix/suffix)
117            if let Some(chapter) = self.parse_chapter(line) {
118                self.validate_chapter(line_num, line, &chapter, violations);
119            }
120        }
121    }
122
123    fn parse_part_title(&self, line: &str) -> Option<String> {
124        line.strip_prefix("# ")
125            .map(|stripped| stripped.trim().to_string())
126    }
127
128    fn is_invalid_part_title(&self, line: &str) -> bool {
129        line.starts_with("##") && !line.starts_with("###")
130            || line.starts_with("###")
131            || line.starts_with("####")
132            || line.starts_with("#####")
133            || line.starts_with("######")
134    }
135
136    fn validate_part_title(
137        &mut self,
138        line_num: usize,
139        title: &str,
140        violations: &mut Vec<Violation>,
141    ) {
142        self.part_title_lines.push(line_num);
143
144        // Part titles should not be empty
145        if title.is_empty() {
146            violations.push(self.rule.create_violation(
147                "Part titles cannot be empty".to_string(),
148                line_num,
149                1,
150                Severity::Error,
151            ));
152        }
153    }
154
155    fn is_separator(&self, line: &str) -> bool {
156        !line.is_empty() && line.chars().all(|c| c == '-')
157    }
158
159    fn validate_separator(&self, line_num: usize, line: &str, violations: &mut Vec<Violation>) {
160        if line.len() < 3 {
161            violations.push(self.rule.create_violation(
162                "Separators must contain at least 3 dashes".to_string(),
163                line_num,
164                1,
165                Severity::Error,
166            ));
167        }
168    }
169
170    fn parse_chapter(&self, line: &str) -> Option<Chapter> {
171        let trimmed = line.trim_start();
172        let indent_level = (line.len() - trimmed.len()) / 4; // Assume 4-space indentation
173
174        // Check for numbered chapters (list items)
175        if let Some(rest) = trimmed.strip_prefix("- ") {
176            return Some(Chapter {
177                is_numbered: true,
178                indent_level,
179                delimiter: '-',
180                content: rest.to_string(),
181            });
182        }
183
184        if let Some(rest) = trimmed.strip_prefix("* ") {
185            return Some(Chapter {
186                is_numbered: true,
187                indent_level,
188                delimiter: '*',
189                content: rest.to_string(),
190            });
191        }
192
193        // Check for prefix/suffix chapters (plain links)
194        if trimmed.starts_with('[') && trimmed.contains("](") {
195            return Some(Chapter {
196                is_numbered: false,
197                indent_level,
198                delimiter: ' ', // Not applicable for prefix/suffix
199                content: trimmed.to_string(),
200            });
201        }
202
203        None
204    }
205
206    fn validate_chapter(
207        &mut self,
208        line_num: usize,
209        line: &str,
210        chapter: &Chapter,
211        violations: &mut Vec<Violation>,
212    ) {
213        if chapter.is_numbered {
214            self.validate_numbered_chapter(line_num, line, chapter, violations);
215        } else {
216            self.validate_prefix_suffix_chapter(line_num, chapter, violations);
217        }
218
219        // Validate the link syntax
220        self.validate_chapter_link(line_num, &chapter.content, violations);
221    }
222
223    fn validate_numbered_chapter(
224        &mut self,
225        line_num: usize,
226        line: &str,
227        chapter: &Chapter,
228        violations: &mut Vec<Violation>,
229    ) {
230        self.seen_numbered_chapters = true;
231
232        // Check for consistent delimiters
233        if let Some(existing_delimiter) = self.list_delimiter {
234            if existing_delimiter != chapter.delimiter {
235                violations.push(self.rule.create_violation(
236                    format!(
237                        "Inconsistent list delimiter. Expected '{}' but found '{}'",
238                        existing_delimiter, chapter.delimiter
239                    ),
240                    line_num,
241                    line.len() - line.trim_start().len() + 1,
242                    Severity::Error,
243                ));
244            }
245        } else {
246            self.list_delimiter = Some(chapter.delimiter);
247        }
248
249        // Check nesting hierarchy
250        self.validate_nesting_hierarchy(line_num, chapter, violations);
251    }
252
253    fn validate_nesting_hierarchy(
254        &mut self,
255        line_num: usize,
256        chapter: &Chapter,
257        violations: &mut Vec<Violation>,
258    ) {
259        let expected_max_level = self.current_nesting_level + 1;
260
261        if chapter.indent_level > expected_max_level {
262            violations.push(self.rule.create_violation(
263                format!(
264                    "Invalid nesting level. Skipped from level {} to level {}",
265                    self.current_nesting_level, chapter.indent_level
266                ),
267                line_num,
268                1,
269                Severity::Error,
270            ));
271        }
272
273        self.current_nesting_level = chapter.indent_level;
274    }
275
276    fn validate_prefix_suffix_chapter(
277        &mut self,
278        line_num: usize,
279        chapter: &Chapter,
280        violations: &mut Vec<Violation>,
281    ) {
282        // Prefix chapters cannot be nested
283        if chapter.indent_level > 0 {
284            violations.push(self.rule.create_violation(
285                "Prefix and suffix chapters cannot be nested".to_string(),
286                line_num,
287                1,
288                Severity::Error,
289            ));
290        }
291
292        // Cannot add prefix chapters after numbered chapters have started
293        if self.seen_numbered_chapters {
294            // This is a suffix chapter, which is allowed
295            // Only prefix chapters (before numbered) are restricted
296        }
297    }
298
299    fn validate_chapter_link(
300        &self,
301        line_num: usize,
302        content: &str,
303        violations: &mut Vec<Violation>,
304    ) {
305        // Basic link syntax validation
306        if !content.trim().starts_with('[') {
307            violations.push(self.rule.create_violation(
308                "Chapter entries must be in link format [title](path)".to_string(),
309                line_num,
310                1,
311                Severity::Error,
312            ));
313            return;
314        }
315
316        // Find the closing bracket and opening parenthesis
317        if let Some(bracket_end) = content.find("](") {
318            let title = &content[1..bracket_end];
319            let rest = &content[bracket_end + 2..];
320
321            if title.is_empty() {
322                violations.push(self.rule.create_violation(
323                    "Chapter title cannot be empty".to_string(),
324                    line_num,
325                    2,
326                    Severity::Error,
327                ));
328            }
329
330            // Find closing parenthesis
331            if let Some(paren_end) = rest.find(')') {
332                let path = &rest[..paren_end];
333
334                // Draft chapters should have empty path
335                if path.is_empty() {
336                    // This is a draft chapter, which is valid
337                } else {
338                    // Validate path format (basic checks)
339                    if path.contains("\\") {
340                        violations.push(self.rule.create_violation(
341                            "Use forward slashes in paths, not backslashes".to_string(),
342                            line_num,
343                            bracket_end + 3,
344                            Severity::Warning,
345                        ));
346                    }
347                }
348            } else {
349                violations.push(self.rule.create_violation(
350                    "Missing closing parenthesis in chapter link".to_string(),
351                    line_num,
352                    content.len(),
353                    Severity::Error,
354                ));
355            }
356        } else if content.contains('[') && content.contains(']') {
357            violations.push(self.rule.create_violation(
358                "Invalid link syntax. Missing '](' between title and path".to_string(),
359                line_num,
360                content.find(']').unwrap_or(0) + 1,
361                Severity::Error,
362            ));
363        }
364    }
365}
366
367/// Represents a parsed chapter entry
368#[derive(Debug)]
369struct Chapter {
370    /// Whether this is a numbered chapter (list item) or prefix/suffix
371    is_numbered: bool,
372    /// Indentation level (number of 4-space indents)
373    indent_level: usize,
374    /// List delimiter used (- or *)
375    delimiter: char,
376    /// The content of the chapter line
377    content: String,
378}
379
380/// Check if the document represents a SUMMARY.md file
381fn is_summary_file(document: &Document) -> bool {
382    document
383        .path
384        .file_name()
385        .and_then(|name| name.to_str())
386        .map(|name| name == "SUMMARY.md")
387        .unwrap_or(false)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::Document;
394    use crate::rule::Rule;
395    use std::path::PathBuf;
396
397    fn create_test_document(content: &str, filename: &str) -> Document {
398        Document::new(content.to_string(), PathBuf::from(filename)).unwrap()
399    }
400
401    #[test]
402    fn test_valid_summary() {
403        let content = r#"# Summary
404
405[Introduction](README.md)
406
407# User Guide
408
409- [Installation](guide/installation.md)
410- [Reading Books](guide/reading.md)
411    - [Sub Chapter](guide/sub.md)
412
413---
414
415[Contributors](misc/contributors.md)
416"#;
417        let doc = create_test_document(content, "SUMMARY.md");
418        let rule = MDBOOK003;
419        let violations = rule.check(&doc).unwrap();
420        assert_eq!(
421            violations.len(),
422            0,
423            "Valid SUMMARY.md should have no violations"
424        );
425    }
426
427    #[test]
428    fn test_not_summary_file() {
429        let content = "# Some Random File\n\n- [Link](file.md)";
430        let doc = create_test_document(content, "README.md");
431        let rule = MDBOOK003;
432        let violations = rule.check(&doc).unwrap();
433        assert_eq!(
434            violations.len(),
435            0,
436            "Non-SUMMARY.md files should be ignored"
437        );
438    }
439
440    #[test]
441    fn test_mixed_delimiters() {
442        let content = r#"# Summary
443
444- [First](first.md)
445* [Second](second.md)
446- [Third](third.md)
447"#;
448        let doc = create_test_document(content, "SUMMARY.md");
449        let rule = MDBOOK003;
450        let violations = rule.check(&doc).unwrap();
451
452        let delimiter_violations: Vec<_> = violations
453            .iter()
454            .filter(|v| v.message.contains("Inconsistent list delimiter"))
455            .collect();
456        assert!(
457            !delimiter_violations.is_empty(),
458            "Should detect mixed delimiters"
459        );
460    }
461
462    #[test]
463    fn test_invalid_part_titles() {
464        let content = r#"# Summary
465
466## Invalid Part Title
467
468- [Chapter](chapter.md)
469
470### Another Invalid
471
472- [Another](another.md)
473"#;
474        let doc = create_test_document(content, "SUMMARY.md");
475        let rule = MDBOOK003;
476        let violations = rule.check(&doc).unwrap();
477
478        let part_title_violations: Vec<_> = violations
479            .iter()
480            .filter(|v| v.message.contains("Part titles must be h1 headers"))
481            .collect();
482        assert_eq!(
483            part_title_violations.len(),
484            2,
485            "Should detect invalid part title levels"
486        );
487    }
488
489    #[test]
490    fn test_nested_prefix_chapters() {
491        let content = r#"# Summary
492
493[Introduction](README.md)
494    [Nested Prefix](nested.md)
495
496- [Chapter](chapter.md)
497"#;
498        let doc = create_test_document(content, "SUMMARY.md");
499        let rule = MDBOOK003;
500        let violations = rule.check(&doc).unwrap();
501
502        let nesting_violations: Vec<_> = violations
503            .iter()
504            .filter(|v| v.message.contains("cannot be nested"))
505            .collect();
506        assert!(
507            !nesting_violations.is_empty(),
508            "Should detect nested prefix chapters"
509        );
510    }
511
512    #[test]
513    fn test_bad_nesting_hierarchy() {
514        let content = r#"# Summary
515
516- [Chapter](chapter.md)
517        - [Skip Level](skip.md)
518"#;
519        let doc = create_test_document(content, "SUMMARY.md");
520        let rule = MDBOOK003;
521        let violations = rule.check(&doc).unwrap();
522
523        let hierarchy_violations: Vec<_> = violations
524            .iter()
525            .filter(|v| v.message.contains("Invalid nesting level"))
526            .collect();
527        assert!(
528            !hierarchy_violations.is_empty(),
529            "Should detect skipped nesting levels"
530        );
531    }
532
533    #[test]
534    fn test_invalid_link_syntax() {
535        let content = r#"# Summary
536
537- [Missing Path]
538- [Bad Syntax(missing-bracket.md)
539- Missing Link Format
540"#;
541        let doc = create_test_document(content, "SUMMARY.md");
542        let rule = MDBOOK003;
543        let violations = rule.check(&doc).unwrap();
544
545        let link_violations: Vec<_> = violations
546            .iter()
547            .filter(|v| v.message.contains("link") || v.message.contains("format"))
548            .collect();
549        assert!(
550            !link_violations.is_empty(),
551            "Should detect invalid link syntax"
552        );
553    }
554
555    #[test]
556    fn test_draft_chapters() {
557        let content = r#"# Summary
558
559- [Regular Chapter](chapter.md)
560- [Draft Chapter]()
561"#;
562        let doc = create_test_document(content, "SUMMARY.md");
563        let rule = MDBOOK003;
564        let violations = rule.check(&doc).unwrap();
565
566        // Draft chapters should not generate violations
567        let draft_violations: Vec<_> = violations
568            .iter()
569            .filter(|v| v.line == 4) // Line with draft chapter
570            .collect();
571        assert_eq!(draft_violations.len(), 0, "Draft chapters should be valid");
572    }
573
574    #[test]
575    fn test_separator_validation() {
576        let content = r#"# Summary
577
578- [Chapter](chapter.md)
579
580--
581
582- [Another](another.md)
583
584---
585
586[Suffix](suffix.md)
587"#;
588        let doc = create_test_document(content, "SUMMARY.md");
589        let rule = MDBOOK003;
590        let violations = rule.check(&doc).unwrap();
591
592        let separator_violations: Vec<_> = violations
593            .iter()
594            .filter(|v| v.message.contains("at least 3 dashes"))
595            .collect();
596        assert!(
597            !separator_violations.is_empty(),
598            "Should detect invalid separator length"
599        );
600    }
601}