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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
495 let json_value = serde_json::to_value(&self.config).ok()?;
496 Some((
497 self.name().to_string(),
498 crate::rule_config_serde::json_to_toml_value(&json_value)?,
499 ))
500 }
501
502 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
503 where
504 Self: Sized,
505 {
506 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
507 Box::new(Self::from_config_struct(rule_config))
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn test_with_cached_headings() {
517 let rule = MD025SingleTitle::default();
518
519 let content = "# Title\n\n## Section 1\n\n## Section 2";
521 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
522 let result = rule.check(&ctx).unwrap();
523 assert!(result.is_empty());
524
525 let content = "# Title 1\n\n## Section 1\n\n# Another Title\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_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
531
532 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
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, "Should flag body H1 when frontmatter has title");
537 assert_eq!(result[0].line, 5);
538 }
539
540 #[test]
541 fn test_allow_document_sections() {
542 let config = md025_config::MD025Config {
544 allow_document_sections: true,
545 ..Default::default()
546 };
547 let rule = MD025SingleTitle::from_config_struct(config);
548
549 let valid_cases = vec![
551 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
552 "# Introduction\n\nContent here\n\n# References\n\nRef content",
553 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
554 "# Manual\n\nContent\n\n# Index\n\nIndex content",
555 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
556 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
557 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
558 ];
559
560 for case in valid_cases {
561 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
562 let result = rule.check(&ctx).unwrap();
563 assert!(result.is_empty(), "Should not flag document sections in: {case}");
564 }
565
566 let invalid_cases = vec![
568 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
569 "# First\n\nContent\n\n# Second Title\n\nMore content",
570 ];
571
572 for case in invalid_cases {
573 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
574 let result = rule.check(&ctx).unwrap();
575 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
576 }
577 }
578
579 #[test]
580 fn test_strict_mode() {
581 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
585 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
586 let result = rule.check(&ctx).unwrap();
587 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
588 }
589
590 #[test]
591 fn test_bounds_checking_bug() {
592 let rule = MD025SingleTitle::default();
595
596 let content = "# First\n#";
598 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
599
600 let result = rule.check(&ctx);
602 assert!(result.is_ok());
603
604 let fix_result = rule.fix(&ctx);
606 assert!(fix_result.is_ok());
607 }
608
609 #[test]
610 fn test_bounds_checking_edge_case() {
611 let rule = MD025SingleTitle::default();
614
615 let content = "# First Title\n#";
619 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
620
621 let result = rule.check(&ctx);
623 assert!(result.is_ok());
624
625 if let Ok(warnings) = result
626 && !warnings.is_empty()
627 {
628 let fix_result = rule.fix(&ctx);
630 assert!(fix_result.is_ok());
631
632 if let Ok(fixed_content) = fix_result {
634 assert!(!fixed_content.is_empty());
635 assert!(fixed_content.contains("##"));
637 }
638 }
639 }
640
641 #[test]
642 fn test_horizontal_rule_separators() {
643 let config = md025_config::MD025Config {
645 allow_with_separators: true,
646 ..Default::default()
647 };
648 let rule = MD025SingleTitle::from_config_struct(config);
649
650 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.";
652 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
653 let result = rule.check(&ctx).unwrap();
654 assert!(
655 result.is_empty(),
656 "Should not flag headings separated by horizontal rules"
657 );
658
659 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.";
661 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663 assert_eq!(result.len(), 1, "Should flag the heading without separator");
664 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
668 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
669 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670 let result = strict_rule.check(&ctx).unwrap();
671 assert_eq!(
672 result.len(),
673 1,
674 "Strict mode should flag all multiple H1s regardless of separators"
675 );
676 }
677
678 #[test]
679 fn test_python_comments_in_code_blocks() {
680 let rule = MD025SingleTitle::default();
681
682 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.";
684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert!(
687 result.is_empty(),
688 "Should not flag Python comments in code blocks as headings"
689 );
690
691 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed = rule.fix(&ctx).unwrap();
695 assert!(
696 fixed.contains("# Python comment"),
697 "Fix should preserve Python comments in code blocks"
698 );
699 assert!(
700 fixed.contains("## Second Title"),
701 "Fix should demote the actual second heading"
702 );
703 }
704
705 #[test]
706 fn test_fix_preserves_attribute_lists() {
707 let rule = MD025SingleTitle::strict();
708
709 let content = "# First Title\n\n# Second Title { #custom-id .special }";
711 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712
713 let warnings = rule.check(&ctx).unwrap();
715 assert_eq!(warnings.len(), 1);
716 assert!(warnings[0].fix.is_some());
718
719 let fixed = rule.fix(&ctx).unwrap();
721 assert!(
722 fixed.contains("## Second Title { #custom-id .special }"),
723 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
724 );
725 }
726
727 #[test]
728 fn test_frontmatter_title_counts_as_h1() {
729 let rule = MD025SingleTitle::default();
730
731 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
733 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
734 let result = rule.check(&ctx).unwrap();
735 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
736 assert_eq!(result[0].line, 5);
737 }
738
739 #[test]
740 fn test_frontmatter_title_with_multiple_body_h1s() {
741 let config = md025_config::MD025Config {
742 front_matter_title: "title".to_string(),
743 ..Default::default()
744 };
745 let rule = MD025SingleTitle::from_config_struct(config);
746
747 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
749 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750 let result = rule.check(&ctx).unwrap();
751 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
752 assert_eq!(result[0].line, 5);
753 assert_eq!(result[1].line, 9);
754 }
755
756 #[test]
757 fn test_frontmatter_without_title_no_warning() {
758 let rule = MD025SingleTitle::default();
759
760 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
762 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
765 }
766
767 #[test]
768 fn test_no_frontmatter_single_h1_no_warning() {
769 let rule = MD025SingleTitle::default();
770
771 let content = "# Only Heading\n\nSome content.";
773 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
776 }
777
778 #[test]
779 fn test_frontmatter_custom_title_key() {
780 let config = md025_config::MD025Config {
782 front_matter_title: "heading".to_string(),
783 ..Default::default()
784 };
785 let rule = MD025SingleTitle::from_config_struct(config);
786
787 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791 assert_eq!(
792 result.len(),
793 1,
794 "Should flag body H1 when custom frontmatter key matches"
795 );
796 assert_eq!(result[0].line, 5);
797
798 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!(
803 result.is_empty(),
804 "Should not flag when frontmatter key doesn't match config"
805 );
806 }
807
808 #[test]
809 fn test_frontmatter_title_empty_config_disables() {
810 let rule = MD025SingleTitle::new(1, "");
812
813 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
814 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815 let result = rule.check(&ctx).unwrap();
816 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
817 }
818
819 #[test]
820 fn test_frontmatter_title_with_level_config() {
821 let config = md025_config::MD025Config {
823 level: HeadingLevel::new(2).unwrap(),
824 front_matter_title: "title".to_string(),
825 ..Default::default()
826 };
827 let rule = MD025SingleTitle::from_config_struct(config);
828
829 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
831 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule.check(&ctx).unwrap();
833 assert_eq!(
834 result.len(),
835 1,
836 "Should flag body H2 when level=2 and frontmatter has title"
837 );
838 assert_eq!(result[0].line, 7);
839 }
840
841 #[test]
842 fn test_frontmatter_title_fix_demotes_body_heading() {
843 let config = md025_config::MD025Config {
844 front_matter_title: "title".to_string(),
845 ..Default::default()
846 };
847 let rule = MD025SingleTitle::from_config_struct(config);
848
849 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
850 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851 let fixed = rule.fix(&ctx).unwrap();
852 assert!(
853 fixed.contains("## Body Heading"),
854 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
855 );
856 assert!(fixed.contains("---\ntitle: FM Title\n---"));
858 }
859
860 #[test]
861 fn test_frontmatter_title_should_skip_respects_frontmatter() {
862 let rule = MD025SingleTitle::default();
863
864 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
866 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
867 assert!(
868 !rule.should_skip(&ctx),
869 "should_skip must return false when frontmatter has title and body has H1"
870 );
871
872 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
874 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
875 assert!(
876 rule.should_skip(&ctx),
877 "should_skip should return true with no frontmatter title and single H1"
878 );
879 }
880
881 #[test]
882 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
883 let rule = MD025SingleTitle::default();
884
885 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";
887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let fixed = rule.fix(&ctx).unwrap();
889
890 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
891 assert!(
892 fixed.contains("## 1_2\n"),
893 "Duplicate H1 must be demoted to H2: {fixed}"
894 );
895 assert!(
896 fixed.contains("### 1_2-2_1"),
897 "H2 under demoted H1 must cascade to H3: {fixed}"
898 );
899 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
900 assert!(
901 fixed.contains("### 1_3-2_1"),
902 "H2 under third demoted H1 must cascade to H3: {fixed}"
903 );
904 assert!(
905 fixed.contains("#### 1_3-2_1-3_1"),
906 "H3 under third demoted H1 must cascade to H4: {fixed}"
907 );
908 }
909
910 #[test]
911 fn test_fix_cascades_single_section_only() {
912 let rule = MD025SingleTitle::default();
913
914 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
916 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let fixed = rule.fix(&ctx).unwrap();
918
919 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
920 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
921 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
922 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
923 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
924 }
925
926 #[test]
927 fn test_fix_cascade_stops_at_next_same_level() {
928 let rule = MD025SingleTitle::default();
929
930 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let fixed = rule.fix(&ctx).unwrap();
936
937 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
938 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
939 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
940 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
941 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
942 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
944 }
945
946 #[test]
947 fn test_fix_cascade_does_not_exceed_level_6() {
948 let rule = MD025SingleTitle::default();
950
951 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
953 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let fixed = rule.fix(&ctx).unwrap();
955
956 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
957 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
958 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
959 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
960 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
961 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
962 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
964 }
965
966 #[test]
967 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
968 let rule = MD025SingleTitle::default();
971
972 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
973 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975
976 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
977 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
980 assert!(
981 skip_line.starts_with("## Skip"),
982 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
983 );
984 assert!(
986 fixed.contains("### Cascade"),
987 "Non-disabled subordinate should cascade to level 3: {fixed}"
988 );
989 }
990
991 #[test]
992 fn test_section_indicator_whole_word_matching() {
993 let config = md025_config::MD025Config {
995 allow_document_sections: true,
996 ..Default::default()
997 };
998 let rule = MD025SingleTitle::from_config_struct(config);
999
1000 let false_positive_cases = vec![
1002 "# Main Title\n\n# Understanding Reindex Operations",
1003 "# Main Title\n\n# The Summarization Pipeline",
1004 "# Main Title\n\n# Data Indexing Strategy",
1005 "# Main Title\n\n# Unsupported Browsers",
1006 ];
1007
1008 for case in false_positive_cases {
1009 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1010 let result = rule.check(&ctx).unwrap();
1011 assert_eq!(
1012 result.len(),
1013 1,
1014 "Should flag duplicate H1 (not a section indicator): {case}"
1015 );
1016 }
1017
1018 let true_positive_cases = vec![
1020 "# Main Title\n\n# Index",
1021 "# Main Title\n\n# Summary",
1022 "# Main Title\n\n# About",
1023 "# Main Title\n\n# References",
1024 ];
1025
1026 for case in true_positive_cases {
1027 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1028 let result = rule.check(&ctx).unwrap();
1029 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1030 }
1031 }
1032}