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 has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
167 if !self.config.allow_with_separators || heading_line == 0 {
168 return false;
169 }
170
171 let search_start = heading_line.saturating_sub(5);
174
175 for line_num in search_start..heading_line {
176 if line_num >= ctx.lines.len() {
177 continue;
178 }
179
180 let line = &ctx.lines[line_num].content(ctx.content);
181 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
182 let has_intermediate_heading =
185 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
186
187 if !has_intermediate_heading {
188 return true;
189 }
190 }
191 }
192
193 false
194 }
195}
196
197impl Rule for MD025SingleTitle {
198 fn name(&self) -> &'static str {
199 "MD025"
200 }
201
202 fn description(&self) -> &'static str {
203 "Multiple top-level headings in the same document"
204 }
205
206 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
207 if ctx.lines.is_empty() {
209 return Ok(Vec::new());
210 }
211
212 let mut warnings = Vec::new();
213
214 let found_title_in_front_matter = self.has_front_matter_title(ctx);
215
216 let mut target_level_headings = Vec::new();
218 for (line_num, line_info) in ctx.lines.iter().enumerate() {
219 if let Some(heading) = &line_info.heading
220 && heading.level as usize == self.config.level.as_usize()
221 && heading.is_valid
222 {
224 if line_info.visual_indent >= 4 || line_info.in_code_block {
226 continue;
227 }
228 target_level_headings.push(line_num);
229 }
230 }
231
232 let headings_to_flag: &[usize] = if found_title_in_front_matter {
237 &target_level_headings
238 } else if target_level_headings.len() > 1 {
239 &target_level_headings[1..]
240 } else {
241 &[]
242 };
243
244 if !headings_to_flag.is_empty() {
245 for &line_num in headings_to_flag {
246 if let Some(heading) = &ctx.lines[line_num].heading {
247 let heading_text = &heading.text;
248
249 let should_allow = self.is_document_section_heading(heading_text)
251 || self.has_separator_before_heading(ctx, line_num);
252
253 if should_allow {
254 continue; }
256
257 let line_content = &ctx.lines[line_num].content(ctx.content);
259 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
260 pos
261 } else {
262 if line_content.trim_start().starts_with('#') {
264 let trimmed = line_content.trim_start();
265 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
266 let after_hashes = &trimmed[hash_count..];
267 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
268 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
269 } else {
270 0 }
272 };
273
274 let (start_line, start_col, end_line, end_col) = calculate_match_range(
275 line_num + 1, line_content,
277 text_start_in_line,
278 heading_text.len(),
279 );
280
281 let is_setext = matches!(
284 heading.style,
285 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
286 );
287 let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
288 let text_range = ctx.line_index.line_content_range(line_num + 1);
290 let underline_range = ctx.line_index.line_content_range(line_num + 2);
291 text_range.start..underline_range.end
292 } else {
293 ctx.line_index.line_content_range(line_num + 1)
294 };
295
296 let demoted_level = self.config.level.as_usize() + 1;
300 let fix = if demoted_level > 6 {
301 None
302 } else {
303 let leading_spaces = line_content.len() - line_content.trim_start().len();
304 let indentation = " ".repeat(leading_spaces);
305 let raw = &heading.raw_text;
306 let hashes = "#".repeat(demoted_level);
307 let closing = if heading.has_closing_sequence {
308 format!(" {}", "#".repeat(demoted_level))
309 } else {
310 String::new()
311 };
312 let replacement = if raw.is_empty() {
313 format!("{indentation}{hashes}{closing}")
314 } else {
315 format!("{indentation}{hashes} {raw}{closing}")
316 };
317 Some(Fix::new(fix_range, replacement))
318 };
319
320 warnings.push(LintWarning {
321 rule_name: Some(self.name().to_string()),
322 message: format!(
323 "Multiple top-level headings (level {}) in the same document",
324 self.config.level.as_usize()
325 ),
326 line: start_line,
327 column: start_col,
328 end_line,
329 end_column: end_col,
330 severity: Severity::Error,
331 fix,
332 });
333 }
334 }
335 }
336
337 Ok(warnings)
338 }
339
340 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
341 let warnings = self.check(ctx)?;
342 if warnings.is_empty() {
343 return Ok(ctx.content.to_string());
344 }
345 let warnings =
346 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
347
348 let mut all_warnings = warnings.clone();
353
354 let target_level = self.config.level.as_usize();
355
356 for warning in &warnings {
357 let heading_line = warning.line - 1;
359
360 let section_end = ctx
362 .lines
363 .iter()
364 .enumerate()
365 .skip(heading_line + 1)
366 .find(|(_, li)| {
367 li.heading.as_ref().is_some_and(|h| {
368 h.level as usize <= target_level && h.is_valid && !li.in_code_block && li.visual_indent < 4
369 })
370 })
371 .map_or(ctx.lines.len(), |(i, _)| i);
372
373 for line_num in (heading_line + 1)..section_end {
375 let line_info = &ctx.lines[line_num];
376 let Some(heading) = &line_info.heading else {
377 continue;
378 };
379 if !heading.is_valid || line_info.in_code_block || line_info.visual_indent >= 4 {
380 continue;
381 }
382
383 let new_level = heading.level as usize + 1;
384 if new_level > 6 {
385 continue;
387 }
388
389 let line_content = line_info.content(ctx.content);
390
391 let is_setext = matches!(
394 heading.style,
395 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
396 );
397 let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
398 let text_range = ctx.line_index.line_content_range(line_num + 1);
399 let underline_range = ctx.line_index.line_content_range(line_num + 2);
400 text_range.start..underline_range.end
401 } else {
402 ctx.line_index.line_content_range(line_num + 1)
403 };
404
405 let leading_spaces = line_content.len() - line_content.trim_start().len();
406 let indentation = " ".repeat(leading_spaces);
407 let hashes = "#".repeat(new_level);
408 let raw = &heading.raw_text;
409 let closing = if heading.has_closing_sequence {
410 format!(" {}", "#".repeat(new_level))
411 } else {
412 String::new()
413 };
414 let replacement = if raw.is_empty() {
415 format!("{indentation}{hashes}{closing}")
416 } else {
417 format!("{indentation}{hashes} {raw}{closing}")
418 };
419
420 all_warnings.push(crate::rule::LintWarning {
421 rule_name: Some(self.name().to_string()),
422 message: String::new(),
423 line: line_num + 1,
424 column: 1,
425 end_line: line_num + 1,
426 end_column: line_content.chars().count(),
427 severity: crate::rule::Severity::Error,
428 fix: Some(Fix::new(fix_range, replacement)),
429 });
430 }
431 }
432
433 let all_warnings =
437 crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
438
439 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
440 .map_err(crate::rule::LintError::InvalidInput)
441 }
442
443 fn category(&self) -> RuleCategory {
445 RuleCategory::Heading
446 }
447
448 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
450 if ctx.content.is_empty() {
452 return true;
453 }
454
455 if !ctx.likely_has_headings() {
457 return true;
458 }
459
460 let has_fm_title = self.has_front_matter_title(ctx);
461
462 let mut target_level_count = 0;
464 for line_info in &ctx.lines {
465 if let Some(heading) = &line_info.heading
466 && heading.level as usize == self.config.level.as_usize()
467 {
468 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
470 continue;
471 }
472 target_level_count += 1;
473
474 if has_fm_title {
476 return false;
477 }
478
479 if target_level_count > 1 {
481 return false;
482 }
483 }
484 }
485
486 target_level_count <= 1
488 }
489
490 fn as_any(&self) -> &dyn std::any::Any {
491 self
492 }
493
494 crate::impl_rule_config_methods!(MD025Config);
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn test_with_cached_headings() {
503 let rule = MD025SingleTitle::default();
504
505 let content = "# Title\n\n## Section 1\n\n## Section 2";
507 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
508 let result = rule.check(&ctx).unwrap();
509 assert!(result.is_empty());
510
511 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
513 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
514 let result = rule.check(&ctx).unwrap();
515 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
517
518 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
520 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
523 assert_eq!(result[0].line, 5);
524 }
525
526 #[test]
527 fn test_allow_document_sections() {
528 let config = md025_config::MD025Config {
530 allow_document_sections: true,
531 ..Default::default()
532 };
533 let rule = MD025SingleTitle::from_config_struct(config);
534
535 let valid_cases = vec![
537 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
538 "# Introduction\n\nContent here\n\n# References\n\nRef content",
539 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
540 "# Manual\n\nContent\n\n# Index\n\nIndex content",
541 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
542 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
543 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
544 ];
545
546 for case in valid_cases {
547 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert!(result.is_empty(), "Should not flag document sections in: {case}");
550 }
551
552 let invalid_cases = vec![
554 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
555 "# First\n\nContent\n\n# Second Title\n\nMore content",
556 ];
557
558 for case in invalid_cases {
559 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
560 let result = rule.check(&ctx).unwrap();
561 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
562 }
563 }
564
565 #[test]
566 fn test_strict_mode() {
567 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
571 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
574 }
575
576 #[test]
577 fn test_bounds_checking_bug() {
578 let rule = MD025SingleTitle::default();
581
582 let content = "# First\n#";
584 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585
586 let result = rule.check(&ctx);
588 assert!(result.is_ok());
589
590 let fix_result = rule.fix(&ctx);
592 assert!(fix_result.is_ok());
593 }
594
595 #[test]
596 fn test_bounds_checking_edge_case() {
597 let rule = MD025SingleTitle::default();
600
601 let content = "# First Title\n#";
605 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606
607 let result = rule.check(&ctx);
609 assert!(result.is_ok());
610
611 if let Ok(warnings) = result
612 && !warnings.is_empty()
613 {
614 let fix_result = rule.fix(&ctx);
616 assert!(fix_result.is_ok());
617
618 if let Ok(fixed_content) = fix_result {
620 assert!(!fixed_content.is_empty());
621 assert!(fixed_content.contains("##"));
623 }
624 }
625 }
626
627 #[test]
628 fn test_horizontal_rule_separators() {
629 let config = md025_config::MD025Config {
631 allow_with_separators: true,
632 ..Default::default()
633 };
634 let rule = MD025SingleTitle::from_config_struct(config);
635
636 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.";
638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639 let result = rule.check(&ctx).unwrap();
640 assert!(
641 result.is_empty(),
642 "Should not flag headings separated by horizontal rules"
643 );
644
645 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.";
647 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert_eq!(result.len(), 1, "Should flag the heading without separator");
650 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
654 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
655 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = strict_rule.check(&ctx).unwrap();
657 assert_eq!(
658 result.len(),
659 1,
660 "Strict mode should flag all multiple H1s regardless of separators"
661 );
662 }
663
664 #[test]
665 fn test_python_comments_in_code_blocks() {
666 let rule = MD025SingleTitle::default();
667
668 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.";
670 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671 let result = rule.check(&ctx).unwrap();
672 assert!(
673 result.is_empty(),
674 "Should not flag Python comments in code blocks as headings"
675 );
676
677 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let fixed = rule.fix(&ctx).unwrap();
681 assert!(
682 fixed.contains("# Python comment"),
683 "Fix should preserve Python comments in code blocks"
684 );
685 assert!(
686 fixed.contains("## Second Title"),
687 "Fix should demote the actual second heading"
688 );
689 }
690
691 #[test]
692 fn test_fix_preserves_attribute_lists() {
693 let rule = MD025SingleTitle::strict();
694
695 let content = "# First Title\n\n# Second Title { #custom-id .special }";
697 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
698
699 let warnings = rule.check(&ctx).unwrap();
701 assert_eq!(warnings.len(), 1);
702 assert!(warnings[0].fix.is_some());
704
705 let fixed = rule.fix(&ctx).unwrap();
707 assert!(
708 fixed.contains("## Second Title { #custom-id .special }"),
709 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
710 );
711 }
712
713 #[test]
714 fn test_frontmatter_title_counts_as_h1() {
715 let rule = MD025SingleTitle::default();
716
717 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
722 assert_eq!(result[0].line, 5);
723 }
724
725 #[test]
726 fn test_frontmatter_title_with_multiple_body_h1s() {
727 let config = md025_config::MD025Config {
728 front_matter_title: "title".to_string(),
729 ..Default::default()
730 };
731 let rule = MD025SingleTitle::from_config_struct(config);
732
733 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
735 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
736 let result = rule.check(&ctx).unwrap();
737 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
738 assert_eq!(result[0].line, 5);
739 assert_eq!(result[1].line, 9);
740 }
741
742 #[test]
743 fn test_frontmatter_without_title_no_warning() {
744 let rule = MD025SingleTitle::default();
745
746 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
748 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
751 }
752
753 #[test]
754 fn test_no_frontmatter_single_h1_no_warning() {
755 let rule = MD025SingleTitle::default();
756
757 let content = "# Only Heading\n\nSome content.";
759 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
762 }
763
764 #[test]
765 fn test_frontmatter_custom_title_key() {
766 let config = md025_config::MD025Config {
768 front_matter_title: "heading".to_string(),
769 ..Default::default()
770 };
771 let rule = MD025SingleTitle::from_config_struct(config);
772
773 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
775 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777 assert_eq!(
778 result.len(),
779 1,
780 "Should flag body H1 when custom frontmatter key matches"
781 );
782 assert_eq!(result[0].line, 5);
783
784 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
786 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
787 let result = rule.check(&ctx).unwrap();
788 assert!(
789 result.is_empty(),
790 "Should not flag when frontmatter key doesn't match config"
791 );
792 }
793
794 #[test]
795 fn test_frontmatter_title_empty_config_disables() {
796 let rule = MD025SingleTitle::new(1, "");
798
799 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
800 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
803 }
804
805 #[test]
806 fn test_frontmatter_title_with_level_config() {
807 let config = md025_config::MD025Config {
809 level: HeadingLevel::new(2).unwrap(),
810 front_matter_title: "title".to_string(),
811 ..Default::default()
812 };
813 let rule = MD025SingleTitle::from_config_struct(config);
814
815 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819 assert_eq!(
820 result.len(),
821 1,
822 "Should flag body H2 when level=2 and frontmatter has title"
823 );
824 assert_eq!(result[0].line, 7);
825 }
826
827 #[test]
828 fn test_frontmatter_title_fix_demotes_body_heading() {
829 let config = md025_config::MD025Config {
830 front_matter_title: "title".to_string(),
831 ..Default::default()
832 };
833 let rule = MD025SingleTitle::from_config_struct(config);
834
835 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
836 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let fixed = rule.fix(&ctx).unwrap();
838 assert!(
839 fixed.contains("## Body Heading"),
840 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
841 );
842 assert!(fixed.contains("---\ntitle: FM Title\n---"));
844 }
845
846 #[test]
847 fn test_frontmatter_title_should_skip_respects_frontmatter() {
848 let rule = MD025SingleTitle::default();
849
850 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
852 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 assert!(
854 !rule.should_skip(&ctx),
855 "should_skip must return false when frontmatter has title and body has H1"
856 );
857
858 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
860 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 assert!(
862 rule.should_skip(&ctx),
863 "should_skip should return true with no frontmatter title and single H1"
864 );
865 }
866
867 #[test]
868 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
869 let rule = MD025SingleTitle::default();
870
871 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";
873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let fixed = rule.fix(&ctx).unwrap();
875
876 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
877 assert!(
878 fixed.contains("## 1_2\n"),
879 "Duplicate H1 must be demoted to H2: {fixed}"
880 );
881 assert!(
882 fixed.contains("### 1_2-2_1"),
883 "H2 under demoted H1 must cascade to H3: {fixed}"
884 );
885 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
886 assert!(
887 fixed.contains("### 1_3-2_1"),
888 "H2 under third demoted H1 must cascade to H3: {fixed}"
889 );
890 assert!(
891 fixed.contains("#### 1_3-2_1-3_1"),
892 "H3 under third demoted H1 must cascade to H4: {fixed}"
893 );
894 }
895
896 #[test]
897 fn test_fix_cascades_single_section_only() {
898 let rule = MD025SingleTitle::default();
899
900 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
902 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let fixed = rule.fix(&ctx).unwrap();
904
905 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
906 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
907 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
908 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
909 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
910 }
911
912 #[test]
913 fn test_fix_cascade_stops_at_next_same_level() {
914 let rule = MD025SingleTitle::default();
915
916 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let fixed = rule.fix(&ctx).unwrap();
922
923 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
924 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
925 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
926 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
927 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
928 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
930 }
931
932 #[test]
933 fn test_fix_cascade_does_not_exceed_level_6() {
934 let rule = MD025SingleTitle::default();
936
937 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let fixed = rule.fix(&ctx).unwrap();
941
942 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
943 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
944 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
945 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
946 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
947 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
948 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
950 }
951
952 #[test]
953 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
954 let rule = MD025SingleTitle::default();
957
958 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
959 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let fixed = rule.fix(&ctx).unwrap();
961
962 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
963 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
966 assert!(
967 skip_line.starts_with("## Skip"),
968 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
969 );
970 assert!(
972 fixed.contains("### Cascade"),
973 "Non-disabled subordinate should cascade to level 3: {fixed}"
974 );
975 }
976
977 #[test]
978 fn test_section_indicator_whole_word_matching() {
979 let config = md025_config::MD025Config {
981 allow_document_sections: true,
982 ..Default::default()
983 };
984 let rule = MD025SingleTitle::from_config_struct(config);
985
986 let false_positive_cases = vec![
988 "# Main Title\n\n# Understanding Reindex Operations",
989 "# Main Title\n\n# The Summarization Pipeline",
990 "# Main Title\n\n# Data Indexing Strategy",
991 "# Main Title\n\n# Unsupported Browsers",
992 ];
993
994 for case in false_positive_cases {
995 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997 assert_eq!(
998 result.len(),
999 1,
1000 "Should flag duplicate H1 (not a section indicator): {case}"
1001 );
1002 }
1003
1004 let true_positive_cases = vec![
1006 "# Main Title\n\n# Index",
1007 "# Main Title\n\n# Summary",
1008 "# Main Title\n\n# About",
1009 "# Main Title\n\n# References",
1010 ];
1011
1012 for case in true_positive_cases {
1013 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1014 let result = rule.check(&ctx).unwrap();
1015 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1016 }
1017 }
1018}