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 {
318 range: fix_range,
319 replacement,
320 })
321 };
322
323 warnings.push(LintWarning {
324 rule_name: Some(self.name().to_string()),
325 message: format!(
326 "Multiple top-level headings (level {}) in the same document",
327 self.config.level.as_usize()
328 ),
329 line: start_line,
330 column: start_col,
331 end_line,
332 end_column: end_col,
333 severity: Severity::Error,
334 fix,
335 });
336 }
337 }
338 }
339
340 Ok(warnings)
341 }
342
343 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
344 let warnings = self.check(ctx)?;
345 if warnings.is_empty() {
346 return Ok(ctx.content.to_string());
347 }
348 let warnings =
349 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
350
351 let mut all_warnings = warnings.clone();
356
357 let target_level = self.config.level.as_usize();
358
359 for warning in &warnings {
360 let heading_line = warning.line - 1;
362
363 let section_end = ctx
365 .lines
366 .iter()
367 .enumerate()
368 .skip(heading_line + 1)
369 .find(|(_, li)| {
370 li.heading.as_ref().is_some_and(|h| {
371 h.level as usize <= target_level && h.is_valid && !li.in_code_block && li.visual_indent < 4
372 })
373 })
374 .map(|(i, _)| i)
375 .unwrap_or(ctx.lines.len());
376
377 for line_num in (heading_line + 1)..section_end {
379 let line_info = &ctx.lines[line_num];
380 let Some(heading) = &line_info.heading else {
381 continue;
382 };
383 if !heading.is_valid || line_info.in_code_block || line_info.visual_indent >= 4 {
384 continue;
385 }
386
387 let new_level = heading.level as usize + 1;
388 if new_level > 6 {
389 continue;
391 }
392
393 let line_content = line_info.content(ctx.content);
394
395 let is_setext = matches!(
398 heading.style,
399 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
400 );
401 let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
402 let text_range = ctx.line_index.line_content_range(line_num + 1);
403 let underline_range = ctx.line_index.line_content_range(line_num + 2);
404 text_range.start..underline_range.end
405 } else {
406 ctx.line_index.line_content_range(line_num + 1)
407 };
408
409 let leading_spaces = line_content.len() - line_content.trim_start().len();
410 let indentation = " ".repeat(leading_spaces);
411 let hashes = "#".repeat(new_level);
412 let raw = &heading.raw_text;
413 let closing = if heading.has_closing_sequence {
414 format!(" {}", "#".repeat(new_level))
415 } else {
416 String::new()
417 };
418 let replacement = if raw.is_empty() {
419 format!("{indentation}{hashes}{closing}")
420 } else {
421 format!("{indentation}{hashes} {raw}{closing}")
422 };
423
424 all_warnings.push(crate::rule::LintWarning {
425 rule_name: Some(self.name().to_string()),
426 message: String::new(),
427 line: line_num + 1,
428 column: 1,
429 end_line: line_num + 1,
430 end_column: line_content.chars().count(),
431 severity: crate::rule::Severity::Error,
432 fix: Some(Fix {
433 range: fix_range,
434 replacement,
435 }),
436 });
437 }
438 }
439
440 let all_warnings =
444 crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
445
446 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
447 .map_err(crate::rule::LintError::InvalidInput)
448 }
449
450 fn category(&self) -> RuleCategory {
452 RuleCategory::Heading
453 }
454
455 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
457 if ctx.content.is_empty() {
459 return true;
460 }
461
462 if !ctx.likely_has_headings() {
464 return true;
465 }
466
467 let has_fm_title = self.has_front_matter_title(ctx);
468
469 let mut target_level_count = 0;
471 for line_info in &ctx.lines {
472 if let Some(heading) = &line_info.heading
473 && heading.level as usize == self.config.level.as_usize()
474 {
475 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
477 continue;
478 }
479 target_level_count += 1;
480
481 if has_fm_title {
483 return false;
484 }
485
486 if target_level_count > 1 {
488 return false;
489 }
490 }
491 }
492
493 target_level_count <= 1
495 }
496
497 fn as_any(&self) -> &dyn std::any::Any {
498 self
499 }
500
501 fn default_config_section(&self) -> Option<(String, toml::Value)> {
502 let json_value = serde_json::to_value(&self.config).ok()?;
503 Some((
504 self.name().to_string(),
505 crate::rule_config_serde::json_to_toml_value(&json_value)?,
506 ))
507 }
508
509 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
510 where
511 Self: Sized,
512 {
513 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
514 Box::new(Self::from_config_struct(rule_config))
515 }
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[test]
523 fn test_with_cached_headings() {
524 let rule = MD025SingleTitle::default();
525
526 let content = "# Title\n\n## Section 1\n\n## Section 2";
528 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
529 let result = rule.check(&ctx).unwrap();
530 assert!(result.is_empty());
531
532 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
535 let result = rule.check(&ctx).unwrap();
536 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
538
539 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
541 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542 let result = rule.check(&ctx).unwrap();
543 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
544 assert_eq!(result[0].line, 5);
545 }
546
547 #[test]
548 fn test_allow_document_sections() {
549 let config = md025_config::MD025Config {
551 allow_document_sections: true,
552 ..Default::default()
553 };
554 let rule = MD025SingleTitle::from_config_struct(config);
555
556 let valid_cases = vec![
558 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
559 "# Introduction\n\nContent here\n\n# References\n\nRef content",
560 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
561 "# Manual\n\nContent\n\n# Index\n\nIndex content",
562 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
563 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
564 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
565 ];
566
567 for case in valid_cases {
568 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(result.is_empty(), "Should not flag document sections in: {case}");
571 }
572
573 let invalid_cases = vec![
575 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
576 "# First\n\nContent\n\n# Second Title\n\nMore content",
577 ];
578
579 for case in invalid_cases {
580 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
583 }
584 }
585
586 #[test]
587 fn test_strict_mode() {
588 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
592 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
595 }
596
597 #[test]
598 fn test_bounds_checking_bug() {
599 let rule = MD025SingleTitle::default();
602
603 let content = "# First\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 let fix_result = rule.fix(&ctx);
613 assert!(fix_result.is_ok());
614 }
615
616 #[test]
617 fn test_bounds_checking_edge_case() {
618 let rule = MD025SingleTitle::default();
621
622 let content = "# First Title\n#";
626 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627
628 let result = rule.check(&ctx);
630 assert!(result.is_ok());
631
632 if let Ok(warnings) = result
633 && !warnings.is_empty()
634 {
635 let fix_result = rule.fix(&ctx);
637 assert!(fix_result.is_ok());
638
639 if let Ok(fixed_content) = fix_result {
641 assert!(!fixed_content.is_empty());
642 assert!(fixed_content.contains("##"));
644 }
645 }
646 }
647
648 #[test]
649 fn test_horizontal_rule_separators() {
650 let config = md025_config::MD025Config {
652 allow_with_separators: true,
653 ..Default::default()
654 };
655 let rule = MD025SingleTitle::from_config_struct(config);
656
657 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.";
659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert!(
662 result.is_empty(),
663 "Should not flag headings separated by horizontal rules"
664 );
665
666 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.";
668 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670 assert_eq!(result.len(), 1, "Should flag the heading without separator");
671 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
675 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
676 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677 let result = strict_rule.check(&ctx).unwrap();
678 assert_eq!(
679 result.len(),
680 1,
681 "Strict mode should flag all multiple H1s regardless of separators"
682 );
683 }
684
685 #[test]
686 fn test_python_comments_in_code_blocks() {
687 let rule = MD025SingleTitle::default();
688
689 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.";
691 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert!(
694 result.is_empty(),
695 "Should not flag Python comments in code blocks as headings"
696 );
697
698 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
700 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let fixed = rule.fix(&ctx).unwrap();
702 assert!(
703 fixed.contains("# Python comment"),
704 "Fix should preserve Python comments in code blocks"
705 );
706 assert!(
707 fixed.contains("## Second Title"),
708 "Fix should demote the actual second heading"
709 );
710 }
711
712 #[test]
713 fn test_fix_preserves_attribute_lists() {
714 let rule = MD025SingleTitle::strict();
715
716 let content = "# First Title\n\n# Second Title { #custom-id .special }";
718 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
719
720 let warnings = rule.check(&ctx).unwrap();
722 assert_eq!(warnings.len(), 1);
723 assert!(warnings[0].fix.is_some());
725
726 let fixed = rule.fix(&ctx).unwrap();
728 assert!(
729 fixed.contains("## Second Title { #custom-id .special }"),
730 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
731 );
732 }
733
734 #[test]
735 fn test_frontmatter_title_counts_as_h1() {
736 let rule = MD025SingleTitle::default();
737
738 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let result = rule.check(&ctx).unwrap();
742 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
743 assert_eq!(result[0].line, 5);
744 }
745
746 #[test]
747 fn test_frontmatter_title_with_multiple_body_h1s() {
748 let config = md025_config::MD025Config {
749 front_matter_title: "title".to_string(),
750 ..Default::default()
751 };
752 let rule = MD025SingleTitle::from_config_struct(config);
753
754 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
759 assert_eq!(result[0].line, 5);
760 assert_eq!(result[1].line, 9);
761 }
762
763 #[test]
764 fn test_frontmatter_without_title_no_warning() {
765 let rule = MD025SingleTitle::default();
766
767 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
770 let result = rule.check(&ctx).unwrap();
771 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
772 }
773
774 #[test]
775 fn test_no_frontmatter_single_h1_no_warning() {
776 let rule = MD025SingleTitle::default();
777
778 let content = "# Only Heading\n\nSome content.";
780 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
783 }
784
785 #[test]
786 fn test_frontmatter_custom_title_key() {
787 let config = md025_config::MD025Config {
789 front_matter_title: "heading".to_string(),
790 ..Default::default()
791 };
792 let rule = MD025SingleTitle::from_config_struct(config);
793
794 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert_eq!(
799 result.len(),
800 1,
801 "Should flag body H1 when custom frontmatter key matches"
802 );
803 assert_eq!(result[0].line, 5);
804
805 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
807 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert!(
810 result.is_empty(),
811 "Should not flag when frontmatter key doesn't match config"
812 );
813 }
814
815 #[test]
816 fn test_frontmatter_title_empty_config_disables() {
817 let rule = MD025SingleTitle::new(1, "");
819
820 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
822 let result = rule.check(&ctx).unwrap();
823 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
824 }
825
826 #[test]
827 fn test_frontmatter_title_with_level_config() {
828 let config = md025_config::MD025Config {
830 level: HeadingLevel::new(2).unwrap(),
831 front_matter_title: "title".to_string(),
832 ..Default::default()
833 };
834 let rule = MD025SingleTitle::from_config_struct(config);
835
836 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
838 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
839 let result = rule.check(&ctx).unwrap();
840 assert_eq!(
841 result.len(),
842 1,
843 "Should flag body H2 when level=2 and frontmatter has title"
844 );
845 assert_eq!(result[0].line, 7);
846 }
847
848 #[test]
849 fn test_frontmatter_title_fix_demotes_body_heading() {
850 let config = md025_config::MD025Config {
851 front_matter_title: "title".to_string(),
852 ..Default::default()
853 };
854 let rule = MD025SingleTitle::from_config_struct(config);
855
856 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
857 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let fixed = rule.fix(&ctx).unwrap();
859 assert!(
860 fixed.contains("## Body Heading"),
861 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
862 );
863 assert!(fixed.contains("---\ntitle: FM Title\n---"));
865 }
866
867 #[test]
868 fn test_frontmatter_title_should_skip_respects_frontmatter() {
869 let rule = MD025SingleTitle::default();
870
871 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 assert!(
875 !rule.should_skip(&ctx),
876 "should_skip must return false when frontmatter has title and body has H1"
877 );
878
879 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
881 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 assert!(
883 rule.should_skip(&ctx),
884 "should_skip should return true with no frontmatter title and single H1"
885 );
886 }
887
888 #[test]
889 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
890 let rule = MD025SingleTitle::default();
891
892 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";
894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let fixed = rule.fix(&ctx).unwrap();
896
897 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
898 assert!(
899 fixed.contains("## 1_2\n"),
900 "Duplicate H1 must be demoted to H2: {fixed}"
901 );
902 assert!(
903 fixed.contains("### 1_2-2_1"),
904 "H2 under demoted H1 must cascade to H3: {fixed}"
905 );
906 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
907 assert!(
908 fixed.contains("### 1_3-2_1"),
909 "H2 under third demoted H1 must cascade to H3: {fixed}"
910 );
911 assert!(
912 fixed.contains("#### 1_3-2_1-3_1"),
913 "H3 under third demoted H1 must cascade to H4: {fixed}"
914 );
915 }
916
917 #[test]
918 fn test_fix_cascades_single_section_only() {
919 let rule = MD025SingleTitle::default();
920
921 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
923 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let fixed = rule.fix(&ctx).unwrap();
925
926 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
927 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
928 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
929 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
930 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
931 }
932
933 #[test]
934 fn test_fix_cascade_stops_at_next_same_level() {
935 let rule = MD025SingleTitle::default();
936
937 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
941 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let fixed = rule.fix(&ctx).unwrap();
943
944 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
945 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
946 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
947 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
948 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
949 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
951 }
952
953 #[test]
954 fn test_fix_cascade_does_not_exceed_level_6() {
955 let rule = MD025SingleTitle::default();
957
958 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 let fixed = rule.fix(&ctx).unwrap();
962
963 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
964 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
965 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
966 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
967 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
968 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
969 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
971 }
972
973 #[test]
974 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
975 let rule = MD025SingleTitle::default();
978
979 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
980 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
981 let fixed = rule.fix(&ctx).unwrap();
982
983 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
984 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
987 assert!(
988 skip_line.starts_with("## Skip"),
989 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
990 );
991 assert!(
993 fixed.contains("### Cascade"),
994 "Non-disabled subordinate should cascade to level 3: {fixed}"
995 );
996 }
997
998 #[test]
999 fn test_section_indicator_whole_word_matching() {
1000 let config = md025_config::MD025Config {
1002 allow_document_sections: true,
1003 ..Default::default()
1004 };
1005 let rule = MD025SingleTitle::from_config_struct(config);
1006
1007 let false_positive_cases = vec![
1009 "# Main Title\n\n# Understanding Reindex Operations",
1010 "# Main Title\n\n# The Summarization Pipeline",
1011 "# Main Title\n\n# Data Indexing Strategy",
1012 "# Main Title\n\n# Unsupported Browsers",
1013 ];
1014
1015 for case in false_positive_cases {
1016 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1017 let result = rule.check(&ctx).unwrap();
1018 assert_eq!(
1019 result.len(),
1020 1,
1021 "Should flag duplicate H1 (not a section indicator): {case}"
1022 );
1023 }
1024
1025 let true_positive_cases = vec![
1027 "# Main Title\n\n# Index",
1028 "# Main Title\n\n# Summary",
1029 "# Main Title\n\n# About",
1030 "# Main Title\n\n# References",
1031 ];
1032
1033 for case in true_positive_cases {
1034 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1035 let result = rule.check(&ctx).unwrap();
1036 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1037 }
1038 }
1039}