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::regex_cache::{
8 HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
9};
10use toml;
11
12mod md025_config;
13use md025_config::MD025Config;
14
15#[derive(Clone, Default)]
16pub struct MD025SingleTitle {
17 config: MD025Config,
18}
19
20impl MD025SingleTitle {
21 pub fn new(level: usize, front_matter_title: &str) -> Self {
22 Self {
23 config: MD025Config {
24 level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
25 front_matter_title: front_matter_title.to_string(),
26 allow_document_sections: true,
27 allow_with_separators: true,
28 },
29 }
30 }
31
32 pub fn strict() -> Self {
33 Self {
34 config: MD025Config {
35 level: HeadingLevel::new(1).unwrap(),
36 front_matter_title: "title".to_string(),
37 allow_document_sections: false,
38 allow_with_separators: false,
39 },
40 }
41 }
42
43 pub fn from_config_struct(config: MD025Config) -> Self {
44 Self { config }
45 }
46
47 fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
49 if self.config.front_matter_title.is_empty() {
50 return false;
51 }
52
53 let content_lines = ctx.raw_lines();
54 if content_lines.first().map(|l| l.trim()) != Some("---") {
55 return false;
56 }
57
58 for (idx, line) in content_lines.iter().enumerate().skip(1) {
59 if line.trim() == "---" {
60 let front_matter_content = content_lines[1..idx].join("\n");
61 return front_matter_content
62 .lines()
63 .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
64 }
65 }
66
67 false
68 }
69
70 fn is_document_section_heading(&self, heading_text: &str) -> bool {
72 if !self.config.allow_document_sections {
73 return false;
74 }
75
76 let lower_text = heading_text.to_lowercase();
77
78 let section_indicators = [
80 "appendix",
81 "appendices",
82 "reference",
83 "references",
84 "bibliography",
85 "index",
86 "indices",
87 "glossary",
88 "glossaries",
89 "conclusion",
90 "conclusions",
91 "summary",
92 "executive summary",
93 "acknowledgment",
94 "acknowledgments",
95 "acknowledgement",
96 "acknowledgements",
97 "about",
98 "contact",
99 "license",
100 "legal",
101 "changelog",
102 "change log",
103 "history",
104 "faq",
105 "frequently asked questions",
106 "troubleshooting",
107 "support",
108 "installation",
109 "setup",
110 "getting started",
111 "api reference",
112 "api documentation",
113 "examples",
114 "tutorials",
115 "guides",
116 ];
117
118 let words: Vec<&str> = lower_text.split_whitespace().collect();
120 section_indicators.iter().any(|&indicator| {
121 let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
123 let starts_with_indicator = if indicator_words.len() == 1 {
124 words.first() == Some(&indicator)
125 } else {
126 words.len() >= indicator_words.len()
127 && words[..indicator_words.len()] == indicator_words[..]
128 };
129
130 starts_with_indicator ||
131 lower_text.starts_with(&format!("{indicator}:")) ||
132 words.contains(&indicator) ||
134 (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
136 (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
138 let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
139 matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
140 })
141 })
142 }
143
144 fn is_horizontal_rule(line: &str) -> bool {
146 let trimmed = line.trim();
147 HR_DASH.is_match(trimmed)
148 || HR_ASTERISK.is_match(trimmed)
149 || HR_UNDERSCORE.is_match(trimmed)
150 || HR_SPACED_DASH.is_match(trimmed)
151 || HR_SPACED_ASTERISK.is_match(trimmed)
152 || HR_SPACED_UNDERSCORE.is_match(trimmed)
153 }
154
155 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
157 if line_num == 0 || line_num >= ctx.lines.len() {
158 return false;
159 }
160
161 let line = ctx.lines[line_num].content(ctx.content).trim();
162 let prev_line = if line_num > 0 {
163 ctx.lines[line_num - 1].content(ctx.content).trim()
164 } else {
165 ""
166 };
167
168 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
169 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
170 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
171 (is_dash_line || is_equals_line) && prev_line_has_content
172 }
173
174 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
176 if !self.config.allow_with_separators || heading_line == 0 {
177 return false;
178 }
179
180 let search_start = heading_line.saturating_sub(5);
183
184 for line_num in search_start..heading_line {
185 if line_num >= ctx.lines.len() {
186 continue;
187 }
188
189 let line = &ctx.lines[line_num].content(ctx.content);
190 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
191 let has_intermediate_heading =
194 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
195
196 if !has_intermediate_heading {
197 return true;
198 }
199 }
200 }
201
202 false
203 }
204}
205
206impl Rule for MD025SingleTitle {
207 fn name(&self) -> &'static str {
208 "MD025"
209 }
210
211 fn description(&self) -> &'static str {
212 "Multiple top-level headings in the same document"
213 }
214
215 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
216 if ctx.lines.is_empty() {
218 return Ok(Vec::new());
219 }
220
221 let mut warnings = Vec::new();
222
223 let found_title_in_front_matter = self.has_front_matter_title(ctx);
224
225 let mut target_level_headings = Vec::new();
227 for (line_num, line_info) in ctx.lines.iter().enumerate() {
228 if let Some(heading) = &line_info.heading
229 && heading.level as usize == self.config.level.as_usize()
230 && heading.is_valid
231 {
233 if line_info.visual_indent >= 4 || line_info.in_code_block {
235 continue;
236 }
237 target_level_headings.push(line_num);
238 }
239 }
240
241 let headings_to_flag: &[usize] = if found_title_in_front_matter {
246 &target_level_headings
247 } else if target_level_headings.len() > 1 {
248 &target_level_headings[1..]
249 } else {
250 &[]
251 };
252
253 if !headings_to_flag.is_empty() {
254 for &line_num in headings_to_flag {
255 if let Some(heading) = &ctx.lines[line_num].heading {
256 let heading_text = &heading.text;
257
258 let should_allow = self.is_document_section_heading(heading_text)
260 || self.has_separator_before_heading(ctx, line_num);
261
262 if should_allow {
263 continue; }
265
266 let line_content = &ctx.lines[line_num].content(ctx.content);
268 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
269 pos
270 } else {
271 if line_content.trim_start().starts_with('#') {
273 let trimmed = line_content.trim_start();
274 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
275 let after_hashes = &trimmed[hash_count..];
276 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
277 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
278 } else {
279 0 }
281 };
282
283 let (start_line, start_col, end_line, end_col) = calculate_match_range(
284 line_num + 1, line_content,
286 text_start_in_line,
287 heading_text.len(),
288 );
289
290 warnings.push(LintWarning {
291 rule_name: Some(self.name().to_string()),
292 message: format!(
293 "Multiple top-level headings (level {}) in the same document",
294 self.config.level.as_usize()
295 ),
296 line: start_line,
297 column: start_col,
298 end_line,
299 end_column: end_col,
300 severity: Severity::Error,
301 fix: Some(Fix {
302 range: ctx.line_index.line_content_range(line_num + 1),
303 replacement: {
304 let leading_spaces = line_content.len() - line_content.trim_start().len();
305 let indentation = " ".repeat(leading_spaces);
306 let raw = &heading.raw_text;
308 if raw.is_empty() {
309 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
310 } else {
311 format!(
312 "{}{} {}",
313 indentation,
314 "#".repeat(self.config.level.as_usize() + 1),
315 raw
316 )
317 }
318 },
319 }),
320 });
321 }
322 }
323 }
324
325 Ok(warnings)
326 }
327
328 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329 let mut fixed_lines = Vec::new();
330 let mut found_first = self.has_front_matter_title(ctx);
333 let mut skip_next = false;
334
335 for (line_num, line_info) in ctx.lines.iter().enumerate() {
336 if skip_next {
337 skip_next = false;
338 continue;
339 }
340
341 if ctx.inline_config().is_rule_disabled(self.name(), line_num + 1) {
343 fixed_lines.push(line_info.content(ctx.content).to_string());
344 if let Some(heading) = &line_info.heading
346 && matches!(
347 heading.style,
348 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
349 )
350 && line_num + 1 < ctx.lines.len()
351 {
352 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
353 skip_next = true;
354 }
355 continue;
356 }
357
358 if let Some(heading) = &line_info.heading {
359 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
360 if !found_first {
361 found_first = true;
362 fixed_lines.push(line_info.content(ctx.content).to_string());
364
365 if matches!(
367 heading.style,
368 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
369 ) && line_num + 1 < ctx.lines.len()
370 {
371 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
372 skip_next = true;
373 }
374 } else {
375 let should_allow = self.is_document_section_heading(&heading.text)
377 || self.has_separator_before_heading(ctx, line_num);
378
379 if should_allow {
380 fixed_lines.push(line_info.content(ctx.content).to_string());
382
383 if matches!(
385 heading.style,
386 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
387 ) && line_num + 1 < ctx.lines.len()
388 {
389 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
390 skip_next = true;
391 }
392 } else {
393 let style = match heading.style {
395 crate::lint_context::HeadingStyle::ATX => {
396 if heading.has_closing_sequence {
397 crate::rules::heading_utils::HeadingStyle::AtxClosed
398 } else {
399 crate::rules::heading_utils::HeadingStyle::Atx
400 }
401 }
402 crate::lint_context::HeadingStyle::Setext1 => {
403 if self.config.level.as_usize() == 1 {
405 crate::rules::heading_utils::HeadingStyle::Setext2
406 } else {
407 crate::rules::heading_utils::HeadingStyle::Atx
409 }
410 }
411 crate::lint_context::HeadingStyle::Setext2 => {
412 crate::rules::heading_utils::HeadingStyle::Atx
414 }
415 };
416
417 let replacement = if heading.text.is_empty() {
418 match style {
420 crate::rules::heading_utils::HeadingStyle::Atx
421 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
422 "#".repeat(self.config.level.as_usize() + 1)
423 }
424 crate::rules::heading_utils::HeadingStyle::AtxClosed
425 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
426 format!(
427 "{} {}",
428 "#".repeat(self.config.level.as_usize() + 1),
429 "#".repeat(self.config.level.as_usize() + 1)
430 )
431 }
432 crate::rules::heading_utils::HeadingStyle::Setext1
433 | crate::rules::heading_utils::HeadingStyle::Setext2
434 | crate::rules::heading_utils::HeadingStyle::Consistent => {
435 "#".repeat(self.config.level.as_usize() + 1)
437 }
438 }
439 } else {
440 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
441 &heading.raw_text,
442 (self.config.level.as_usize() + 1) as u32,
443 style,
444 )
445 };
446
447 let line = line_info.content(ctx.content);
449 let original_indent = &line[..line_info.indent];
450 fixed_lines.push(format!("{original_indent}{replacement}"));
451
452 if matches!(
454 heading.style,
455 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
456 ) && line_num + 1 < ctx.lines.len()
457 {
458 skip_next = true;
459 }
460 }
461 }
462 } else {
463 fixed_lines.push(line_info.content(ctx.content).to_string());
465
466 if matches!(
468 heading.style,
469 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
470 ) && line_num + 1 < ctx.lines.len()
471 {
472 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
473 skip_next = true;
474 }
475 }
476 } else {
477 fixed_lines.push(line_info.content(ctx.content).to_string());
479 }
480 }
481
482 let result = fixed_lines.join("\n");
483 if ctx.content.ends_with('\n') {
484 Ok(result + "\n")
485 } else {
486 Ok(result)
487 }
488 }
489
490 fn category(&self) -> RuleCategory {
492 RuleCategory::Heading
493 }
494
495 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
497 if ctx.content.is_empty() {
499 return true;
500 }
501
502 if !ctx.likely_has_headings() {
504 return true;
505 }
506
507 let has_fm_title = self.has_front_matter_title(ctx);
508
509 let mut target_level_count = 0;
511 for line_info in &ctx.lines {
512 if let Some(heading) = &line_info.heading
513 && heading.level as usize == self.config.level.as_usize()
514 {
515 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
517 continue;
518 }
519 target_level_count += 1;
520
521 if has_fm_title {
523 return false;
524 }
525
526 if target_level_count > 1 {
528 return false;
529 }
530 }
531 }
532
533 target_level_count <= 1
535 }
536
537 fn as_any(&self) -> &dyn std::any::Any {
538 self
539 }
540
541 fn default_config_section(&self) -> Option<(String, toml::Value)> {
542 let json_value = serde_json::to_value(&self.config).ok()?;
543 Some((
544 self.name().to_string(),
545 crate::rule_config_serde::json_to_toml_value(&json_value)?,
546 ))
547 }
548
549 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
550 where
551 Self: Sized,
552 {
553 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
554 Box::new(Self::from_config_struct(rule_config))
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 #[test]
563 fn test_with_cached_headings() {
564 let rule = MD025SingleTitle::default();
565
566 let content = "# Title\n\n## Section 1\n\n## Section 2";
568 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(result.is_empty());
571
572 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
574 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
575 let result = rule.check(&ctx).unwrap();
576 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
578
579 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
581 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
584 assert_eq!(result[0].line, 5);
585 }
586
587 #[test]
588 fn test_allow_document_sections() {
589 let config = md025_config::MD025Config {
591 allow_document_sections: true,
592 ..Default::default()
593 };
594 let rule = MD025SingleTitle::from_config_struct(config);
595
596 let valid_cases = vec![
598 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
599 "# Introduction\n\nContent here\n\n# References\n\nRef content",
600 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
601 "# Manual\n\nContent\n\n# Index\n\nIndex content",
602 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
603 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
604 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
605 ];
606
607 for case in valid_cases {
608 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
609 let result = rule.check(&ctx).unwrap();
610 assert!(result.is_empty(), "Should not flag document sections in: {case}");
611 }
612
613 let invalid_cases = vec![
615 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
616 "# First\n\nContent\n\n# Second Title\n\nMore content",
617 ];
618
619 for case in invalid_cases {
620 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
621 let result = rule.check(&ctx).unwrap();
622 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
623 }
624 }
625
626 #[test]
627 fn test_strict_mode() {
628 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
632 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
633 let result = rule.check(&ctx).unwrap();
634 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
635 }
636
637 #[test]
638 fn test_bounds_checking_bug() {
639 let rule = MD025SingleTitle::default();
642
643 let content = "# First\n#";
645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646
647 let result = rule.check(&ctx);
649 assert!(result.is_ok());
650
651 let fix_result = rule.fix(&ctx);
653 assert!(fix_result.is_ok());
654 }
655
656 #[test]
657 fn test_bounds_checking_edge_case() {
658 let rule = MD025SingleTitle::default();
661
662 let content = "# First Title\n#";
666 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
667
668 let result = rule.check(&ctx);
670 assert!(result.is_ok());
671
672 if let Ok(warnings) = result
673 && !warnings.is_empty()
674 {
675 let fix_result = rule.fix(&ctx);
677 assert!(fix_result.is_ok());
678
679 if let Ok(fixed_content) = fix_result {
681 assert!(!fixed_content.is_empty());
682 assert!(fixed_content.contains("##"));
684 }
685 }
686 }
687
688 #[test]
689 fn test_horizontal_rule_separators() {
690 let config = md025_config::MD025Config {
692 allow_with_separators: true,
693 ..Default::default()
694 };
695 let rule = MD025SingleTitle::from_config_struct(config);
696
697 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.";
699 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = rule.check(&ctx).unwrap();
701 assert!(
702 result.is_empty(),
703 "Should not flag headings separated by horizontal rules"
704 );
705
706 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.";
708 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709 let result = rule.check(&ctx).unwrap();
710 assert_eq!(result.len(), 1, "Should flag the heading without separator");
711 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
715 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
716 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = strict_rule.check(&ctx).unwrap();
718 assert_eq!(
719 result.len(),
720 1,
721 "Strict mode should flag all multiple H1s regardless of separators"
722 );
723 }
724
725 #[test]
726 fn test_python_comments_in_code_blocks() {
727 let rule = MD025SingleTitle::default();
728
729 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.";
731 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732 let result = rule.check(&ctx).unwrap();
733 assert!(
734 result.is_empty(),
735 "Should not flag Python comments in code blocks as headings"
736 );
737
738 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let fixed = rule.fix(&ctx).unwrap();
742 assert!(
743 fixed.contains("# Python comment"),
744 "Fix should preserve Python comments in code blocks"
745 );
746 assert!(
747 fixed.contains("## Second Title"),
748 "Fix should demote the actual second heading"
749 );
750 }
751
752 #[test]
753 fn test_fix_preserves_attribute_lists() {
754 let rule = MD025SingleTitle::strict();
755
756 let content = "# First Title\n\n# Second Title { #custom-id .special }";
758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
759
760 let warnings = rule.check(&ctx).unwrap();
762 assert_eq!(warnings.len(), 1);
763 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
764 assert!(
765 fix.replacement.contains("{ #custom-id .special }"),
766 "check() fix should preserve attribute list, got: {}",
767 fix.replacement
768 );
769
770 let fixed = rule.fix(&ctx).unwrap();
772 assert!(
773 fixed.contains("## Second Title { #custom-id .special }"),
774 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
775 );
776 }
777
778 #[test]
779 fn test_frontmatter_title_counts_as_h1() {
780 let rule = MD025SingleTitle::default();
781
782 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
784 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785 let result = rule.check(&ctx).unwrap();
786 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
787 assert_eq!(result[0].line, 5);
788 }
789
790 #[test]
791 fn test_frontmatter_title_with_multiple_body_h1s() {
792 let config = md025_config::MD025Config {
793 front_matter_title: "title".to_string(),
794 ..Default::default()
795 };
796 let rule = MD025SingleTitle::from_config_struct(config);
797
798 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
800 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
803 assert_eq!(result[0].line, 5);
804 assert_eq!(result[1].line, 9);
805 }
806
807 #[test]
808 fn test_frontmatter_without_title_no_warning() {
809 let rule = MD025SingleTitle::default();
810
811 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
813 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
814 let result = rule.check(&ctx).unwrap();
815 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
816 }
817
818 #[test]
819 fn test_no_frontmatter_single_h1_no_warning() {
820 let rule = MD025SingleTitle::default();
821
822 let content = "# Only Heading\n\nSome content.";
824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
827 }
828
829 #[test]
830 fn test_frontmatter_custom_title_key() {
831 let config = md025_config::MD025Config {
833 front_matter_title: "heading".to_string(),
834 ..Default::default()
835 };
836 let rule = MD025SingleTitle::from_config_struct(config);
837
838 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
840 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
841 let result = rule.check(&ctx).unwrap();
842 assert_eq!(
843 result.len(),
844 1,
845 "Should flag body H1 when custom frontmatter key matches"
846 );
847 assert_eq!(result[0].line, 5);
848
849 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
851 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852 let result = rule.check(&ctx).unwrap();
853 assert!(
854 result.is_empty(),
855 "Should not flag when frontmatter key doesn't match config"
856 );
857 }
858
859 #[test]
860 fn test_frontmatter_title_empty_config_disables() {
861 let rule = MD025SingleTitle::new(1, "");
863
864 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
865 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
868 }
869
870 #[test]
871 fn test_frontmatter_title_with_level_config() {
872 let config = md025_config::MD025Config {
874 level: HeadingLevel::new(2).unwrap(),
875 front_matter_title: "title".to_string(),
876 ..Default::default()
877 };
878 let rule = MD025SingleTitle::from_config_struct(config);
879
880 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884 assert_eq!(
885 result.len(),
886 1,
887 "Should flag body H2 when level=2 and frontmatter has title"
888 );
889 assert_eq!(result[0].line, 7);
890 }
891
892 #[test]
893 fn test_frontmatter_title_fix_demotes_body_heading() {
894 let config = md025_config::MD025Config {
895 front_matter_title: "title".to_string(),
896 ..Default::default()
897 };
898 let rule = MD025SingleTitle::from_config_struct(config);
899
900 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
901 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
902 let fixed = rule.fix(&ctx).unwrap();
903 assert!(
904 fixed.contains("## Body Heading"),
905 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
906 );
907 assert!(fixed.contains("---\ntitle: FM Title\n---"));
909 }
910
911 #[test]
912 fn test_frontmatter_title_should_skip_respects_frontmatter() {
913 let rule = MD025SingleTitle::default();
914
915 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 assert!(
919 !rule.should_skip(&ctx),
920 "should_skip must return false when frontmatter has title and body has H1"
921 );
922
923 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
925 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 assert!(
927 rule.should_skip(&ctx),
928 "should_skip should return true with no frontmatter title and single H1"
929 );
930 }
931
932 #[test]
933 fn test_section_indicator_whole_word_matching() {
934 let config = md025_config::MD025Config {
936 allow_document_sections: true,
937 ..Default::default()
938 };
939 let rule = MD025SingleTitle::from_config_struct(config);
940
941 let false_positive_cases = vec![
943 "# Main Title\n\n# Understanding Reindex Operations",
944 "# Main Title\n\n# The Summarization Pipeline",
945 "# Main Title\n\n# Data Indexing Strategy",
946 "# Main Title\n\n# Unsupported Browsers",
947 ];
948
949 for case in false_positive_cases {
950 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952 assert_eq!(
953 result.len(),
954 1,
955 "Should flag duplicate H1 (not a section indicator): {case}"
956 );
957 }
958
959 let true_positive_cases = vec![
961 "# Main Title\n\n# Index",
962 "# Main Title\n\n# Summary",
963 "# Main Title\n\n# About",
964 "# Main Title\n\n# References",
965 ];
966
967 for case in true_positive_cases {
968 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
971 }
972 }
973}