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