1use crate::Document;
6use crate::rule::{Rule, RuleCategory, RuleMetadata};
7use crate::violation::{Severity, Violation};
8
9pub 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 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
59struct SummaryChecker<'a> {
61 rule: &'a MDBOOK003,
63 seen_numbered_chapters: bool,
65 list_delimiter: Option<char>,
67 current_nesting_level: usize,
69 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; let trimmed = line.trim();
88
89 if trimmed.is_empty() {
90 continue;
91 }
92
93 if let Some(title) = self.parse_part_title(trimmed) {
95 self.validate_part_title(line_num, &title, violations);
96 continue;
97 }
98
99 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 if self.is_separator(trimmed) {
112 self.validate_separator(line_num, trimmed, violations);
113 continue;
114 }
115
116 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 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; 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 if trimmed.starts_with('[') && trimmed.contains("](") {
195 return Some(Chapter {
196 is_numbered: false,
197 indent_level,
198 delimiter: ' ', 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 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 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 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 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 if self.seen_numbered_chapters {
294 }
297 }
298
299 fn validate_chapter_link(
300 &self,
301 line_num: usize,
302 content: &str,
303 violations: &mut Vec<Violation>,
304 ) {
305 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 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 if let Some(paren_end) = rest.find(')') {
332 let path = &rest[..paren_end];
333
334 if path.is_empty() {
336 } else {
338 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#[derive(Debug)]
369struct Chapter {
370 is_numbered: bool,
372 indent_level: usize,
374 delimiter: char,
376 content: String,
378}
379
380fn 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 let draft_violations: Vec<_> = violations
568 .iter()
569 .filter(|v| v.line == 4) .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}