1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::types::HeadingLevel;
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::thematic_break;
8use toml;
9
10mod md025_config;
11use md025_config::MD025Config;
12
13#[derive(Clone, Default)]
14pub struct MD025SingleTitle {
15 config: MD025Config,
16}
17
18impl MD025SingleTitle {
19 pub fn new(level: usize, front_matter_title: &str) -> Self {
20 Self {
21 config: MD025Config {
22 level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
23 front_matter_title: front_matter_title.to_string(),
24 allow_document_sections: true,
25 allow_with_separators: true,
26 },
27 }
28 }
29
30 pub fn strict() -> Self {
31 Self {
32 config: MD025Config {
33 level: HeadingLevel::new(1).unwrap(),
34 front_matter_title: "title".to_string(),
35 allow_document_sections: false,
36 allow_with_separators: false,
37 },
38 }
39 }
40
41 pub fn from_config_struct(config: MD025Config) -> Self {
42 Self { config }
43 }
44
45 fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
47 if self.config.front_matter_title.is_empty() {
48 return false;
49 }
50
51 let content_lines = ctx.raw_lines();
52 if content_lines.first().map(|l| l.trim()) != Some("---") {
53 return false;
54 }
55
56 for (idx, line) in content_lines.iter().enumerate().skip(1) {
57 if line.trim() == "---" {
58 let front_matter_content = content_lines[1..idx].join("\n");
59 return front_matter_content
60 .lines()
61 .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
62 }
63 }
64
65 false
66 }
67
68 fn is_document_section_heading(&self, heading_text: &str) -> bool {
70 if !self.config.allow_document_sections {
71 return false;
72 }
73
74 let lower_text = heading_text.to_lowercase();
75
76 let section_indicators = [
78 "appendix",
79 "appendices",
80 "reference",
81 "references",
82 "bibliography",
83 "index",
84 "indices",
85 "glossary",
86 "glossaries",
87 "conclusion",
88 "conclusions",
89 "summary",
90 "executive summary",
91 "acknowledgment",
92 "acknowledgments",
93 "acknowledgement",
94 "acknowledgements",
95 "about",
96 "contact",
97 "license",
98 "legal",
99 "changelog",
100 "change log",
101 "history",
102 "faq",
103 "frequently asked questions",
104 "troubleshooting",
105 "support",
106 "installation",
107 "setup",
108 "getting started",
109 "api reference",
110 "api documentation",
111 "examples",
112 "tutorials",
113 "guides",
114 ];
115
116 let words: Vec<&str> = lower_text.split_whitespace().collect();
118 section_indicators.iter().any(|&indicator| {
119 let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
121 let starts_with_indicator = if indicator_words.len() == 1 {
122 words.first() == Some(&indicator)
123 } else {
124 words.len() >= indicator_words.len()
125 && words[..indicator_words.len()] == indicator_words[..]
126 };
127
128 starts_with_indicator ||
129 lower_text.starts_with(&format!("{indicator}:")) ||
130 words.contains(&indicator) ||
132 (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
134 (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
136 let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
137 matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
138 })
139 })
140 }
141
142 fn is_horizontal_rule(line: &str) -> bool {
143 thematic_break::is_thematic_break(line)
144 }
145
146 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
148 if line_num == 0 || line_num >= ctx.lines.len() {
149 return false;
150 }
151
152 let line = ctx.lines[line_num].content(ctx.content).trim();
153 let prev_line = if line_num > 0 {
154 ctx.lines[line_num - 1].content(ctx.content).trim()
155 } else {
156 ""
157 };
158
159 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
160 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
161 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
162 (is_dash_line || is_equals_line) && prev_line_has_content
163 }
164
165 fn demotion_span(
172 ctx: &crate::lint_context::LintContext,
173 line_num: usize,
174 heading: &crate::lint_context::HeadingInfo,
175 ) -> (std::ops::Range<usize>, String) {
176 let first_idx = line_num + 1 - heading.text_lines;
177 let is_setext = matches!(
178 heading.style,
179 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180 );
181 let range = if is_setext && line_num + 2 <= ctx.lines.len() {
182 ctx.line_content_byte_range(first_idx + 1).start..ctx.line_content_byte_range(line_num + 2).end
183 } else {
184 ctx.line_content_byte_range(first_idx + 1)
185 };
186 let first_content = ctx.lines[first_idx].content(ctx.content);
187 let leading_spaces = first_content.len() - first_content.trim_start().len();
188 (range, " ".repeat(leading_spaces))
189 }
190
191 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
193 if !self.config.allow_with_separators || heading_line == 0 {
194 return false;
195 }
196
197 let search_start = heading_line.saturating_sub(5);
200
201 for line_num in search_start..heading_line {
202 if line_num >= ctx.lines.len() {
203 continue;
204 }
205
206 let line = &ctx.lines[line_num].content(ctx.content);
207 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
208 let has_intermediate_heading = ((line_num + 1)..heading_line).any(|idx| {
211 idx < ctx.lines.len() && (ctx.lines[idx].heading.is_some() || ctx.lines[idx].is_setext_heading_text)
212 });
213
214 if !has_intermediate_heading {
215 return true;
216 }
217 }
218 }
219
220 false
221 }
222}
223
224impl Rule for MD025SingleTitle {
225 fn name(&self) -> &'static str {
226 "MD025"
227 }
228
229 fn description(&self) -> &'static str {
230 "Multiple top-level headings in the same document"
231 }
232
233 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234 if ctx.lines.is_empty() {
236 return Ok(Vec::new());
237 }
238
239 let mut warnings = Vec::new();
240
241 let found_title_in_front_matter = self.has_front_matter_title(ctx);
242
243 let mut target_level_headings = Vec::new();
245 for (line_num, line_info) in ctx.lines.iter().enumerate() {
246 if let Some(heading) = &line_info.heading
247 && heading.level as usize == self.config.level.as_usize()
248 {
249 if line_info.visual_indent >= 4 || line_info.in_code_block {
251 continue;
252 }
253 target_level_headings.push(line_num);
254 }
255 }
256
257 let headings_to_flag: &[usize] = if found_title_in_front_matter {
262 &target_level_headings
263 } else if target_level_headings.len() > 1 {
264 &target_level_headings[1..]
265 } else {
266 &[]
267 };
268
269 if !headings_to_flag.is_empty() {
270 for &line_num in headings_to_flag {
271 if let Some(heading) = &ctx.lines[line_num].heading {
272 let heading_text = &heading.text;
273 let first_idx = line_num + 1 - heading.text_lines;
276
277 let should_allow = self.is_document_section_heading(heading_text)
279 || self.has_separator_before_heading(ctx, first_idx);
280
281 if should_allow {
282 continue; }
284
285 let line_content = &ctx.lines[line_num].content(ctx.content);
287 let (start_line, start_col, end_line, end_col) = if heading.text_lines > 1 {
288 let first_content = ctx.lines[first_idx].content(ctx.content);
291 let indent_chars = first_content.len() - first_content.trim_start().len();
292 (
293 first_idx + 1,
294 first_content[..indent_chars].chars().count() + 1,
295 line_num + 1,
296 line_content.trim_end().chars().count() + 1,
297 )
298 } else {
299 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
300 pos
301 } else {
302 if line_content.trim_start().starts_with('#') {
304 let trimmed = line_content.trim_start();
305 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
306 let after_hashes = &trimmed[hash_count..];
307 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
308 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
309 } else {
310 0 }
312 };
313 calculate_match_range(
314 line_num + 1, line_content,
316 text_start_in_line,
317 heading_text.len(),
318 )
319 };
320
321 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
322
323 let demoted_level = self.config.level.as_usize() + 1;
327 let fix = if demoted_level > 6 {
328 None
329 } else {
330 let raw = &heading.raw_text;
331 let hashes = "#".repeat(demoted_level);
332 let closing = if heading.has_closing_sequence {
333 format!(" {}", "#".repeat(demoted_level))
334 } else {
335 String::new()
336 };
337 let replacement = if raw.is_empty() {
338 format!("{indentation}{hashes}{closing}")
339 } else {
340 format!("{indentation}{hashes} {raw}{closing}")
341 };
342 Some(Fix::new(fix_range, replacement))
343 };
344
345 warnings.push(LintWarning {
346 rule_name: Some(self.name().to_string()),
347 message: format!(
348 "Multiple top-level headings (level {}) in the same document",
349 self.config.level.as_usize()
350 ),
351 line: start_line,
352 column: start_col,
353 end_line,
354 end_column: end_col,
355 severity: Severity::Error,
356 fix,
357 });
358 }
359 }
360 }
361
362 Ok(warnings)
363 }
364
365 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
366 let warnings = self.check(ctx)?;
367 if warnings.is_empty() {
368 return Ok(ctx.content.to_string());
369 }
370 let warnings =
371 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
372
373 let mut all_warnings = warnings.clone();
378
379 let target_level = self.config.level.as_usize();
380
381 for warning in &warnings {
382 let mut heading_line = warning.line - 1;
386 while heading_line + 1 < ctx.lines.len()
387 && ctx.lines[heading_line].heading.is_none()
388 && ctx.lines[heading_line].is_setext_heading_text
389 {
390 heading_line += 1;
391 }
392
393 let section_end = ctx
395 .lines
396 .iter()
397 .enumerate()
398 .skip(heading_line + 1)
399 .find(|(_, li)| {
400 li.heading
401 .as_ref()
402 .is_some_and(|h| h.level as usize <= target_level && !li.in_code_block && li.visual_indent < 4)
403 })
404 .map_or(ctx.lines.len(), |(i, _)| i);
405
406 for line_num in (heading_line + 1)..section_end {
408 let line_info = &ctx.lines[line_num];
409 let Some(heading) = &line_info.heading else {
410 continue;
411 };
412 if line_info.in_code_block || line_info.visual_indent >= 4 {
413 continue;
414 }
415
416 let new_level = heading.level as usize + 1;
417 if new_level > 6 {
418 continue;
420 }
421
422 let line_content = line_info.content(ctx.content);
423
424 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
427 let first_line = line_num + 2 - heading.text_lines;
428
429 let hashes = "#".repeat(new_level);
430 let raw = &heading.raw_text;
431 let closing = if heading.has_closing_sequence {
432 format!(" {}", "#".repeat(new_level))
433 } else {
434 String::new()
435 };
436 let replacement = if raw.is_empty() {
437 format!("{indentation}{hashes}{closing}")
438 } else {
439 format!("{indentation}{hashes} {raw}{closing}")
440 };
441
442 all_warnings.push(crate::rule::LintWarning {
443 rule_name: Some(self.name().to_string()),
444 message: String::new(),
445 line: first_line,
446 column: 1,
447 end_line: line_num + 1,
448 end_column: line_content.chars().count(),
449 severity: crate::rule::Severity::Error,
450 fix: Some(Fix::new(fix_range, replacement)),
451 });
452 }
453 }
454
455 let all_warnings =
459 crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
460
461 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
462 .map_err(crate::rule::LintError::InvalidInput)
463 }
464
465 fn category(&self) -> RuleCategory {
467 RuleCategory::Heading
468 }
469
470 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
472 if ctx.content.is_empty() {
474 return true;
475 }
476
477 if !ctx.likely_has_headings() {
479 return true;
480 }
481
482 let has_fm_title = self.has_front_matter_title(ctx);
483
484 let mut target_level_count = 0;
486 for line_info in &ctx.lines {
487 if let Some(heading) = &line_info.heading
488 && heading.level as usize == self.config.level.as_usize()
489 {
490 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
492 continue;
493 }
494 target_level_count += 1;
495
496 if has_fm_title {
498 return false;
499 }
500
501 if target_level_count > 1 {
503 return false;
504 }
505 }
506 }
507
508 target_level_count <= 1
510 }
511
512 fn as_any(&self) -> &dyn std::any::Any {
513 self
514 }
515
516 crate::impl_rule_config_methods!(MD025Config);
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_with_cached_headings() {
525 let rule = MD025SingleTitle::default();
526
527 let content = "# Title\n\n## Section 1\n\n## Section 2";
529 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
530 let result = rule.check(&ctx).unwrap();
531 assert!(result.is_empty());
532
533 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
535 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
536 let result = rule.check(&ctx).unwrap();
537 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
539
540 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
542 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
543 let result = rule.check(&ctx).unwrap();
544 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
545 assert_eq!(result[0].line, 5);
546 }
547
548 #[test]
549 fn test_allow_document_sections() {
550 let config = md025_config::MD025Config {
552 allow_document_sections: true,
553 ..Default::default()
554 };
555 let rule = MD025SingleTitle::from_config_struct(config);
556
557 let valid_cases = vec![
559 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
560 "# Introduction\n\nContent here\n\n# References\n\nRef content",
561 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
562 "# Manual\n\nContent\n\n# Index\n\nIndex content",
563 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
564 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
565 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
566 ];
567
568 for case in valid_cases {
569 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
570 let result = rule.check(&ctx).unwrap();
571 assert!(result.is_empty(), "Should not flag document sections in: {case}");
572 }
573
574 let invalid_cases = vec![
576 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
577 "# First\n\nContent\n\n# Second Title\n\nMore content",
578 ];
579
580 for case in invalid_cases {
581 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
584 }
585 }
586
587 #[test]
588 fn test_strict_mode() {
589 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
593 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594 let result = rule.check(&ctx).unwrap();
595 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
596 }
597
598 #[test]
599 fn test_bounds_checking_bug() {
600 let rule = MD025SingleTitle::default();
603
604 let content = "# First\n#";
606 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607
608 let result = rule.check(&ctx);
610 assert!(result.is_ok());
611
612 let fix_result = rule.fix(&ctx);
614 assert!(fix_result.is_ok());
615 }
616
617 #[test]
618 fn test_bounds_checking_edge_case() {
619 let rule = MD025SingleTitle::default();
622
623 let content = "# First Title\n#";
627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628
629 let result = rule.check(&ctx);
631 assert!(result.is_ok());
632
633 if let Ok(warnings) = result
634 && !warnings.is_empty()
635 {
636 let fix_result = rule.fix(&ctx);
638 assert!(fix_result.is_ok());
639
640 if let Ok(fixed_content) = fix_result {
642 assert!(!fixed_content.is_empty());
643 assert!(fixed_content.contains("##"));
645 }
646 }
647 }
648
649 #[test]
650 fn test_horizontal_rule_separators() {
651 let config = md025_config::MD025Config {
653 allow_with_separators: true,
654 ..Default::default()
655 };
656 let rule = MD025SingleTitle::from_config_struct(config);
657
658 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
660 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert!(
663 result.is_empty(),
664 "Should not flag headings separated by horizontal rules"
665 );
666
667 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
669 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = rule.check(&ctx).unwrap();
671 assert_eq!(result.len(), 1, "Should flag the heading without separator");
672 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
676 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = strict_rule.check(&ctx).unwrap();
679 assert_eq!(
680 result.len(),
681 1,
682 "Strict mode should flag all multiple H1s regardless of separators"
683 );
684 }
685
686 #[test]
687 fn test_python_comments_in_code_blocks() {
688 let rule = MD025SingleTitle::default();
689
690 let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
692 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694 assert!(
695 result.is_empty(),
696 "Should not flag Python comments in code blocks as headings"
697 );
698
699 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
701 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let fixed = rule.fix(&ctx).unwrap();
703 assert!(
704 fixed.contains("# Python comment"),
705 "Fix should preserve Python comments in code blocks"
706 );
707 assert!(
708 fixed.contains("## Second Title"),
709 "Fix should demote the actual second heading"
710 );
711 }
712
713 #[test]
714 fn test_fix_preserves_attribute_lists() {
715 let rule = MD025SingleTitle::strict();
716
717 let content = "# First Title\n\n# Second Title { #custom-id .special }";
719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720
721 let warnings = rule.check(&ctx).unwrap();
723 assert_eq!(warnings.len(), 1);
724 assert!(warnings[0].fix.is_some());
726
727 let fixed = rule.fix(&ctx).unwrap();
729 assert!(
730 fixed.contains("## Second Title { #custom-id .special }"),
731 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
732 );
733 }
734
735 #[test]
736 fn test_frontmatter_title_counts_as_h1() {
737 let rule = MD025SingleTitle::default();
738
739 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx).unwrap();
743 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
744 assert_eq!(result[0].line, 5);
745 }
746
747 #[test]
748 fn test_frontmatter_title_with_multiple_body_h1s() {
749 let config = md025_config::MD025Config {
750 front_matter_title: "title".to_string(),
751 ..Default::default()
752 };
753 let rule = MD025SingleTitle::from_config_struct(config);
754
755 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
757 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
758 let result = rule.check(&ctx).unwrap();
759 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
760 assert_eq!(result[0].line, 5);
761 assert_eq!(result[1].line, 9);
762 }
763
764 #[test]
765 fn test_frontmatter_without_title_no_warning() {
766 let rule = MD025SingleTitle::default();
767
768 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
770 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
773 }
774
775 #[test]
776 fn test_no_frontmatter_single_h1_no_warning() {
777 let rule = MD025SingleTitle::default();
778
779 let content = "# Only Heading\n\nSome content.";
781 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
782 let result = rule.check(&ctx).unwrap();
783 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
784 }
785
786 #[test]
787 fn test_frontmatter_custom_title_key() {
788 let config = md025_config::MD025Config {
790 front_matter_title: "heading".to_string(),
791 ..Default::default()
792 };
793 let rule = MD025SingleTitle::from_config_struct(config);
794
795 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
797 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
798 let result = rule.check(&ctx).unwrap();
799 assert_eq!(
800 result.len(),
801 1,
802 "Should flag body H1 when custom frontmatter key matches"
803 );
804 assert_eq!(result[0].line, 5);
805
806 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
808 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810 assert!(
811 result.is_empty(),
812 "Should not flag when frontmatter key doesn't match config"
813 );
814 }
815
816 #[test]
817 fn test_frontmatter_title_empty_config_disables() {
818 let rule = MD025SingleTitle::new(1, "");
820
821 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
822 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
823 let result = rule.check(&ctx).unwrap();
824 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
825 }
826
827 #[test]
828 fn test_frontmatter_title_with_level_config() {
829 let config = md025_config::MD025Config {
831 level: HeadingLevel::new(2).unwrap(),
832 front_matter_title: "title".to_string(),
833 ..Default::default()
834 };
835 let rule = MD025SingleTitle::from_config_struct(config);
836
837 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
839 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840 let result = rule.check(&ctx).unwrap();
841 assert_eq!(
842 result.len(),
843 1,
844 "Should flag body H2 when level=2 and frontmatter has title"
845 );
846 assert_eq!(result[0].line, 7);
847 }
848
849 #[test]
850 fn test_frontmatter_title_fix_demotes_body_heading() {
851 let config = md025_config::MD025Config {
852 front_matter_title: "title".to_string(),
853 ..Default::default()
854 };
855 let rule = MD025SingleTitle::from_config_struct(config);
856
857 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let fixed = rule.fix(&ctx).unwrap();
860 assert!(
861 fixed.contains("## Body Heading"),
862 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
863 );
864 assert!(fixed.contains("---\ntitle: FM Title\n---"));
866 }
867
868 #[test]
869 fn test_frontmatter_title_should_skip_respects_frontmatter() {
870 let rule = MD025SingleTitle::default();
871
872 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
874 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 assert!(
876 !rule.should_skip(&ctx),
877 "should_skip must return false when frontmatter has title and body has H1"
878 );
879
880 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 assert!(
884 rule.should_skip(&ctx),
885 "should_skip should return true with no frontmatter title and single H1"
886 );
887 }
888
889 #[test]
890 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
891 let rule = MD025SingleTitle::default();
892
893 let content = "abcd\n\n# 1_1\n\n# 1_2\n\n## 1_2-2_1\n\n# 1_3\n\n## 1_3-2_1\n\n### 1_3-2_1-3_1\n";
895 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let fixed = rule.fix(&ctx).unwrap();
897
898 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
899 assert!(
900 fixed.contains("## 1_2\n"),
901 "Duplicate H1 must be demoted to H2: {fixed}"
902 );
903 assert!(
904 fixed.contains("### 1_2-2_1"),
905 "H2 under demoted H1 must cascade to H3: {fixed}"
906 );
907 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
908 assert!(
909 fixed.contains("### 1_3-2_1"),
910 "H2 under third demoted H1 must cascade to H3: {fixed}"
911 );
912 assert!(
913 fixed.contains("#### 1_3-2_1-3_1"),
914 "H3 under third demoted H1 must cascade to H4: {fixed}"
915 );
916 }
917
918 #[test]
919 fn test_fix_cascades_single_section_only() {
920 let rule = MD025SingleTitle::default();
921
922 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
924 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let fixed = rule.fix(&ctx).unwrap();
926
927 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
928 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
929 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
930 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
931 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
932 }
933
934 #[test]
935 fn test_fix_cascade_stops_at_next_same_level() {
936 let rule = MD025SingleTitle::default();
937
938 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
942 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
943 let fixed = rule.fix(&ctx).unwrap();
944
945 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
946 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
947 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
948 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
949 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
950 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
952 }
953
954 #[test]
955 fn test_fix_cascade_does_not_exceed_level_6() {
956 let rule = MD025SingleTitle::default();
958
959 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
961 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let fixed = rule.fix(&ctx).unwrap();
963
964 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
965 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
966 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
967 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
968 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
969 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
970 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
972 }
973
974 #[test]
975 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
976 let rule = MD025SingleTitle::default();
979
980 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
981 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982 let fixed = rule.fix(&ctx).unwrap();
983
984 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
985 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
988 assert!(
989 skip_line.starts_with("## Skip"),
990 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
991 );
992 assert!(
994 fixed.contains("### Cascade"),
995 "Non-disabled subordinate should cascade to level 3: {fixed}"
996 );
997 }
998
999 #[test]
1000 fn test_section_indicator_whole_word_matching() {
1001 let config = md025_config::MD025Config {
1003 allow_document_sections: true,
1004 ..Default::default()
1005 };
1006 let rule = MD025SingleTitle::from_config_struct(config);
1007
1008 let false_positive_cases = vec![
1010 "# Main Title\n\n# Understanding Reindex Operations",
1011 "# Main Title\n\n# The Summarization Pipeline",
1012 "# Main Title\n\n# Data Indexing Strategy",
1013 "# Main Title\n\n# Unsupported Browsers",
1014 ];
1015
1016 for case in false_positive_cases {
1017 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1018 let result = rule.check(&ctx).unwrap();
1019 assert_eq!(
1020 result.len(),
1021 1,
1022 "Should flag duplicate H1 (not a section indicator): {case}"
1023 );
1024 }
1025
1026 let true_positive_cases = vec![
1028 "# Main Title\n\n# Index",
1029 "# Main Title\n\n# Summary",
1030 "# Main Title\n\n# About",
1031 "# Main Title\n\n# References",
1032 ];
1033
1034 for case in true_positive_cases {
1035 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1038 }
1039 }
1040
1041 #[test]
1042 fn test_mdg_enforces_single_title() {
1043 let rule = MD025SingleTitle::strict();
1046 let content = "# Feature: Checkout\n\n# Rule: Registered customers\n\n# Scenario: Purchase\n";
1047
1048 let standard_ctx =
1049 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1051
1052 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 2);
1053 assert_eq!(
1054 rule.check(&mdg_ctx).unwrap().len(),
1055 rule.check(&standard_ctx).unwrap().len(),
1056 "MDG must not differ from Standard"
1057 );
1058
1059 let fixed = rule.fix(&mdg_ctx).unwrap();
1060 assert_eq!(
1061 fixed, "# Feature: Checkout\n\n## Rule: Registered customers\n\n## Scenario: Purchase\n",
1062 "the Gherkin keywords must survive the demotion"
1063 );
1064
1065 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1066 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
1067 }
1068}