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 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
351 .map_err(crate::rule::LintError::InvalidInput)
352 }
353
354 fn category(&self) -> RuleCategory {
356 RuleCategory::Heading
357 }
358
359 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
361 if ctx.content.is_empty() {
363 return true;
364 }
365
366 if !ctx.likely_has_headings() {
368 return true;
369 }
370
371 let has_fm_title = self.has_front_matter_title(ctx);
372
373 let mut target_level_count = 0;
375 for line_info in &ctx.lines {
376 if let Some(heading) = &line_info.heading
377 && heading.level as usize == self.config.level.as_usize()
378 {
379 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
381 continue;
382 }
383 target_level_count += 1;
384
385 if has_fm_title {
387 return false;
388 }
389
390 if target_level_count > 1 {
392 return false;
393 }
394 }
395 }
396
397 target_level_count <= 1
399 }
400
401 fn as_any(&self) -> &dyn std::any::Any {
402 self
403 }
404
405 fn default_config_section(&self) -> Option<(String, toml::Value)> {
406 let json_value = serde_json::to_value(&self.config).ok()?;
407 Some((
408 self.name().to_string(),
409 crate::rule_config_serde::json_to_toml_value(&json_value)?,
410 ))
411 }
412
413 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
414 where
415 Self: Sized,
416 {
417 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
418 Box::new(Self::from_config_struct(rule_config))
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_with_cached_headings() {
428 let rule = MD025SingleTitle::default();
429
430 let content = "# Title\n\n## Section 1\n\n## Section 2";
432 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert!(result.is_empty());
435
436 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
438 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439 let result = rule.check(&ctx).unwrap();
440 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
442
443 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
445 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446 let result = rule.check(&ctx).unwrap();
447 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
448 assert_eq!(result[0].line, 5);
449 }
450
451 #[test]
452 fn test_allow_document_sections() {
453 let config = md025_config::MD025Config {
455 allow_document_sections: true,
456 ..Default::default()
457 };
458 let rule = MD025SingleTitle::from_config_struct(config);
459
460 let valid_cases = vec![
462 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
463 "# Introduction\n\nContent here\n\n# References\n\nRef content",
464 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
465 "# Manual\n\nContent\n\n# Index\n\nIndex content",
466 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
467 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
468 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
469 ];
470
471 for case in valid_cases {
472 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
473 let result = rule.check(&ctx).unwrap();
474 assert!(result.is_empty(), "Should not flag document sections in: {case}");
475 }
476
477 let invalid_cases = vec![
479 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
480 "# First\n\nContent\n\n# Second Title\n\nMore content",
481 ];
482
483 for case in invalid_cases {
484 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
487 }
488 }
489
490 #[test]
491 fn test_strict_mode() {
492 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
496 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497 let result = rule.check(&ctx).unwrap();
498 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
499 }
500
501 #[test]
502 fn test_bounds_checking_bug() {
503 let rule = MD025SingleTitle::default();
506
507 let content = "# First\n#";
509 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510
511 let result = rule.check(&ctx);
513 assert!(result.is_ok());
514
515 let fix_result = rule.fix(&ctx);
517 assert!(fix_result.is_ok());
518 }
519
520 #[test]
521 fn test_bounds_checking_edge_case() {
522 let rule = MD025SingleTitle::default();
525
526 let content = "# First Title\n#";
530 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531
532 let result = rule.check(&ctx);
534 assert!(result.is_ok());
535
536 if let Ok(warnings) = result
537 && !warnings.is_empty()
538 {
539 let fix_result = rule.fix(&ctx);
541 assert!(fix_result.is_ok());
542
543 if let Ok(fixed_content) = fix_result {
545 assert!(!fixed_content.is_empty());
546 assert!(fixed_content.contains("##"));
548 }
549 }
550 }
551
552 #[test]
553 fn test_horizontal_rule_separators() {
554 let config = md025_config::MD025Config {
556 allow_with_separators: true,
557 ..Default::default()
558 };
559 let rule = MD025SingleTitle::from_config_struct(config);
560
561 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.";
563 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564 let result = rule.check(&ctx).unwrap();
565 assert!(
566 result.is_empty(),
567 "Should not flag headings separated by horizontal rules"
568 );
569
570 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.";
572 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573 let result = rule.check(&ctx).unwrap();
574 assert_eq!(result.len(), 1, "Should flag the heading without separator");
575 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
579 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
580 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = strict_rule.check(&ctx).unwrap();
582 assert_eq!(
583 result.len(),
584 1,
585 "Strict mode should flag all multiple H1s regardless of separators"
586 );
587 }
588
589 #[test]
590 fn test_python_comments_in_code_blocks() {
591 let rule = MD025SingleTitle::default();
592
593 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.";
595 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597 assert!(
598 result.is_empty(),
599 "Should not flag Python comments in code blocks as headings"
600 );
601
602 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
604 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let fixed = rule.fix(&ctx).unwrap();
606 assert!(
607 fixed.contains("# Python comment"),
608 "Fix should preserve Python comments in code blocks"
609 );
610 assert!(
611 fixed.contains("## Second Title"),
612 "Fix should demote the actual second heading"
613 );
614 }
615
616 #[test]
617 fn test_fix_preserves_attribute_lists() {
618 let rule = MD025SingleTitle::strict();
619
620 let content = "# First Title\n\n# Second Title { #custom-id .special }";
622 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623
624 let warnings = rule.check(&ctx).unwrap();
626 assert_eq!(warnings.len(), 1);
627 assert!(warnings[0].fix.is_some());
629
630 let fixed = rule.fix(&ctx).unwrap();
632 assert!(
633 fixed.contains("## Second Title { #custom-id .special }"),
634 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
635 );
636 }
637
638 #[test]
639 fn test_frontmatter_title_counts_as_h1() {
640 let rule = MD025SingleTitle::default();
641
642 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
644 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645 let result = rule.check(&ctx).unwrap();
646 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
647 assert_eq!(result[0].line, 5);
648 }
649
650 #[test]
651 fn test_frontmatter_title_with_multiple_body_h1s() {
652 let config = md025_config::MD025Config {
653 front_matter_title: "title".to_string(),
654 ..Default::default()
655 };
656 let rule = MD025SingleTitle::from_config_struct(config);
657
658 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
660 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661 let result = rule.check(&ctx).unwrap();
662 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
663 assert_eq!(result[0].line, 5);
664 assert_eq!(result[1].line, 9);
665 }
666
667 #[test]
668 fn test_frontmatter_without_title_no_warning() {
669 let rule = MD025SingleTitle::default();
670
671 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
673 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674 let result = rule.check(&ctx).unwrap();
675 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
676 }
677
678 #[test]
679 fn test_no_frontmatter_single_h1_no_warning() {
680 let rule = MD025SingleTitle::default();
681
682 let content = "# Only Heading\n\nSome content.";
684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685 let result = rule.check(&ctx).unwrap();
686 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
687 }
688
689 #[test]
690 fn test_frontmatter_custom_title_key() {
691 let config = md025_config::MD025Config {
693 front_matter_title: "heading".to_string(),
694 ..Default::default()
695 };
696 let rule = MD025SingleTitle::from_config_struct(config);
697
698 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
700 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let result = rule.check(&ctx).unwrap();
702 assert_eq!(
703 result.len(),
704 1,
705 "Should flag body H1 when custom frontmatter key matches"
706 );
707 assert_eq!(result[0].line, 5);
708
709 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
711 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712 let result = rule.check(&ctx).unwrap();
713 assert!(
714 result.is_empty(),
715 "Should not flag when frontmatter key doesn't match config"
716 );
717 }
718
719 #[test]
720 fn test_frontmatter_title_empty_config_disables() {
721 let rule = MD025SingleTitle::new(1, "");
723
724 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
725 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let result = rule.check(&ctx).unwrap();
727 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
728 }
729
730 #[test]
731 fn test_frontmatter_title_with_level_config() {
732 let config = md025_config::MD025Config {
734 level: HeadingLevel::new(2).unwrap(),
735 front_matter_title: "title".to_string(),
736 ..Default::default()
737 };
738 let rule = MD025SingleTitle::from_config_struct(config);
739
740 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
742 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743 let result = rule.check(&ctx).unwrap();
744 assert_eq!(
745 result.len(),
746 1,
747 "Should flag body H2 when level=2 and frontmatter has title"
748 );
749 assert_eq!(result[0].line, 7);
750 }
751
752 #[test]
753 fn test_frontmatter_title_fix_demotes_body_heading() {
754 let config = md025_config::MD025Config {
755 front_matter_title: "title".to_string(),
756 ..Default::default()
757 };
758 let rule = MD025SingleTitle::from_config_struct(config);
759
760 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
761 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762 let fixed = rule.fix(&ctx).unwrap();
763 assert!(
764 fixed.contains("## Body Heading"),
765 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
766 );
767 assert!(fixed.contains("---\ntitle: FM Title\n---"));
769 }
770
771 #[test]
772 fn test_frontmatter_title_should_skip_respects_frontmatter() {
773 let rule = MD025SingleTitle::default();
774
775 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
777 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778 assert!(
779 !rule.should_skip(&ctx),
780 "should_skip must return false when frontmatter has title and body has H1"
781 );
782
783 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 assert!(
787 rule.should_skip(&ctx),
788 "should_skip should return true with no frontmatter title and single H1"
789 );
790 }
791
792 #[test]
793 fn test_section_indicator_whole_word_matching() {
794 let config = md025_config::MD025Config {
796 allow_document_sections: true,
797 ..Default::default()
798 };
799 let rule = MD025SingleTitle::from_config_struct(config);
800
801 let false_positive_cases = vec![
803 "# Main Title\n\n# Understanding Reindex Operations",
804 "# Main Title\n\n# The Summarization Pipeline",
805 "# Main Title\n\n# Data Indexing Strategy",
806 "# Main Title\n\n# Unsupported Browsers",
807 ];
808
809 for case in false_positive_cases {
810 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert_eq!(
813 result.len(),
814 1,
815 "Should flag duplicate H1 (not a section indicator): {case}"
816 );
817 }
818
819 let true_positive_cases = vec![
821 "# Main Title\n\n# Index",
822 "# Main Title\n\n# Summary",
823 "# Main Title\n\n# About",
824 "# Main Title\n\n# References",
825 ];
826
827 for case in true_positive_cases {
828 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
831 }
832 }
833}