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 let Some(heading) = &line_info.heading {
342 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
343 if !found_first {
344 found_first = true;
345 fixed_lines.push(line_info.content(ctx.content).to_string());
347
348 if matches!(
350 heading.style,
351 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
352 ) && line_num + 1 < ctx.lines.len()
353 {
354 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
355 skip_next = true;
356 }
357 } else {
358 let should_allow = self.is_document_section_heading(&heading.text)
360 || self.has_separator_before_heading(ctx, line_num);
361
362 if should_allow {
363 fixed_lines.push(line_info.content(ctx.content).to_string());
365
366 if matches!(
368 heading.style,
369 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
370 ) && line_num + 1 < ctx.lines.len()
371 {
372 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
373 skip_next = true;
374 }
375 } else {
376 let style = match heading.style {
378 crate::lint_context::HeadingStyle::ATX => {
379 if heading.has_closing_sequence {
380 crate::rules::heading_utils::HeadingStyle::AtxClosed
381 } else {
382 crate::rules::heading_utils::HeadingStyle::Atx
383 }
384 }
385 crate::lint_context::HeadingStyle::Setext1 => {
386 if self.config.level.as_usize() == 1 {
388 crate::rules::heading_utils::HeadingStyle::Setext2
389 } else {
390 crate::rules::heading_utils::HeadingStyle::Atx
392 }
393 }
394 crate::lint_context::HeadingStyle::Setext2 => {
395 crate::rules::heading_utils::HeadingStyle::Atx
397 }
398 };
399
400 let replacement = if heading.text.is_empty() {
401 match style {
403 crate::rules::heading_utils::HeadingStyle::Atx
404 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
405 "#".repeat(self.config.level.as_usize() + 1)
406 }
407 crate::rules::heading_utils::HeadingStyle::AtxClosed
408 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
409 format!(
410 "{} {}",
411 "#".repeat(self.config.level.as_usize() + 1),
412 "#".repeat(self.config.level.as_usize() + 1)
413 )
414 }
415 crate::rules::heading_utils::HeadingStyle::Setext1
416 | crate::rules::heading_utils::HeadingStyle::Setext2
417 | crate::rules::heading_utils::HeadingStyle::Consistent => {
418 "#".repeat(self.config.level.as_usize() + 1)
420 }
421 }
422 } else {
423 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
424 &heading.raw_text,
425 (self.config.level.as_usize() + 1) as u32,
426 style,
427 )
428 };
429
430 let line = line_info.content(ctx.content);
432 let original_indent = &line[..line_info.indent];
433 fixed_lines.push(format!("{original_indent}{replacement}"));
434
435 if matches!(
437 heading.style,
438 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
439 ) && line_num + 1 < ctx.lines.len()
440 {
441 skip_next = true;
442 }
443 }
444 }
445 } else {
446 fixed_lines.push(line_info.content(ctx.content).to_string());
448
449 if matches!(
451 heading.style,
452 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
453 ) && line_num + 1 < ctx.lines.len()
454 {
455 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
456 skip_next = true;
457 }
458 }
459 } else {
460 fixed_lines.push(line_info.content(ctx.content).to_string());
462 }
463 }
464
465 let result = fixed_lines.join("\n");
466 if ctx.content.ends_with('\n') {
467 Ok(result + "\n")
468 } else {
469 Ok(result)
470 }
471 }
472
473 fn category(&self) -> RuleCategory {
475 RuleCategory::Heading
476 }
477
478 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
480 if ctx.content.is_empty() {
482 return true;
483 }
484
485 if !ctx.likely_has_headings() {
487 return true;
488 }
489
490 let has_fm_title = self.has_front_matter_title(ctx);
491
492 let mut target_level_count = 0;
494 for line_info in &ctx.lines {
495 if let Some(heading) = &line_info.heading
496 && heading.level as usize == self.config.level.as_usize()
497 {
498 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
500 continue;
501 }
502 target_level_count += 1;
503
504 if has_fm_title {
506 return false;
507 }
508
509 if target_level_count > 1 {
511 return false;
512 }
513 }
514 }
515
516 target_level_count <= 1
518 }
519
520 fn as_any(&self) -> &dyn std::any::Any {
521 self
522 }
523
524 fn default_config_section(&self) -> Option<(String, toml::Value)> {
525 let json_value = serde_json::to_value(&self.config).ok()?;
526 Some((
527 self.name().to_string(),
528 crate::rule_config_serde::json_to_toml_value(&json_value)?,
529 ))
530 }
531
532 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
533 where
534 Self: Sized,
535 {
536 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
537 Box::new(Self::from_config_struct(rule_config))
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn test_with_cached_headings() {
547 let rule = MD025SingleTitle::default();
548
549 let content = "# Title\n\n## Section 1\n\n## Section 2";
551 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
552 let result = rule.check(&ctx).unwrap();
553 assert!(result.is_empty());
554
555 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
557 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
558 let result = rule.check(&ctx).unwrap();
559 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
561
562 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
564 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
565 let result = rule.check(&ctx).unwrap();
566 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
567 assert_eq!(result[0].line, 5);
568 }
569
570 #[test]
571 fn test_allow_document_sections() {
572 let config = md025_config::MD025Config {
574 allow_document_sections: true,
575 ..Default::default()
576 };
577 let rule = MD025SingleTitle::from_config_struct(config);
578
579 let valid_cases = vec![
581 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
582 "# Introduction\n\nContent here\n\n# References\n\nRef content",
583 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
584 "# Manual\n\nContent\n\n# Index\n\nIndex content",
585 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
586 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
587 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
588 ];
589
590 for case in valid_cases {
591 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
592 let result = rule.check(&ctx).unwrap();
593 assert!(result.is_empty(), "Should not flag document sections in: {case}");
594 }
595
596 let invalid_cases = vec![
598 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
599 "# First\n\nContent\n\n# Second Title\n\nMore content",
600 ];
601
602 for case in invalid_cases {
603 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
604 let result = rule.check(&ctx).unwrap();
605 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
606 }
607 }
608
609 #[test]
610 fn test_strict_mode() {
611 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
618 }
619
620 #[test]
621 fn test_bounds_checking_bug() {
622 let rule = MD025SingleTitle::default();
625
626 let content = "# First\n#";
628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629
630 let result = rule.check(&ctx);
632 assert!(result.is_ok());
633
634 let fix_result = rule.fix(&ctx);
636 assert!(fix_result.is_ok());
637 }
638
639 #[test]
640 fn test_bounds_checking_edge_case() {
641 let rule = MD025SingleTitle::default();
644
645 let content = "# First Title\n#";
649 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
650
651 let result = rule.check(&ctx);
653 assert!(result.is_ok());
654
655 if let Ok(warnings) = result
656 && !warnings.is_empty()
657 {
658 let fix_result = rule.fix(&ctx);
660 assert!(fix_result.is_ok());
661
662 if let Ok(fixed_content) = fix_result {
664 assert!(!fixed_content.is_empty());
665 assert!(fixed_content.contains("##"));
667 }
668 }
669 }
670
671 #[test]
672 fn test_horizontal_rule_separators() {
673 let config = md025_config::MD025Config {
675 allow_with_separators: true,
676 ..Default::default()
677 };
678 let rule = MD025SingleTitle::from_config_struct(config);
679
680 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.";
682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(
685 result.is_empty(),
686 "Should not flag headings separated by horizontal rules"
687 );
688
689 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.";
691 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let result = rule.check(&ctx).unwrap();
693 assert_eq!(result.len(), 1, "Should flag the heading without separator");
694 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
698 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
699 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700 let result = strict_rule.check(&ctx).unwrap();
701 assert_eq!(
702 result.len(),
703 1,
704 "Strict mode should flag all multiple H1s regardless of separators"
705 );
706 }
707
708 #[test]
709 fn test_python_comments_in_code_blocks() {
710 let rule = MD025SingleTitle::default();
711
712 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.";
714 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715 let result = rule.check(&ctx).unwrap();
716 assert!(
717 result.is_empty(),
718 "Should not flag Python comments in code blocks as headings"
719 );
720
721 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
723 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
724 let fixed = rule.fix(&ctx).unwrap();
725 assert!(
726 fixed.contains("# Python comment"),
727 "Fix should preserve Python comments in code blocks"
728 );
729 assert!(
730 fixed.contains("## Second Title"),
731 "Fix should demote the actual second heading"
732 );
733 }
734
735 #[test]
736 fn test_fix_preserves_attribute_lists() {
737 let rule = MD025SingleTitle::strict();
738
739 let content = "# First Title\n\n# Second Title { #custom-id .special }";
741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742
743 let warnings = rule.check(&ctx).unwrap();
745 assert_eq!(warnings.len(), 1);
746 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
747 assert!(
748 fix.replacement.contains("{ #custom-id .special }"),
749 "check() fix should preserve attribute list, got: {}",
750 fix.replacement
751 );
752
753 let fixed = rule.fix(&ctx).unwrap();
755 assert!(
756 fixed.contains("## Second Title { #custom-id .special }"),
757 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
758 );
759 }
760
761 #[test]
762 fn test_frontmatter_title_counts_as_h1() {
763 let rule = MD025SingleTitle::default();
764
765 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
767 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
770 assert_eq!(result[0].line, 5);
771 }
772
773 #[test]
774 fn test_frontmatter_title_with_multiple_body_h1s() {
775 let config = md025_config::MD025Config {
776 front_matter_title: "title".to_string(),
777 ..Default::default()
778 };
779 let rule = MD025SingleTitle::from_config_struct(config);
780
781 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
786 assert_eq!(result[0].line, 5);
787 assert_eq!(result[1].line, 9);
788 }
789
790 #[test]
791 fn test_frontmatter_without_title_no_warning() {
792 let rule = MD025SingleTitle::default();
793
794 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
799 }
800
801 #[test]
802 fn test_no_frontmatter_single_h1_no_warning() {
803 let rule = MD025SingleTitle::default();
804
805 let content = "# Only Heading\n\nSome content.";
807 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
810 }
811
812 #[test]
813 fn test_frontmatter_custom_title_key() {
814 let config = md025_config::MD025Config {
816 front_matter_title: "heading".to_string(),
817 ..Default::default()
818 };
819 let rule = MD025SingleTitle::from_config_struct(config);
820
821 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
823 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825 assert_eq!(
826 result.len(),
827 1,
828 "Should flag body H1 when custom frontmatter key matches"
829 );
830 assert_eq!(result[0].line, 5);
831
832 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
834 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
835 let result = rule.check(&ctx).unwrap();
836 assert!(
837 result.is_empty(),
838 "Should not flag when frontmatter key doesn't match config"
839 );
840 }
841
842 #[test]
843 fn test_frontmatter_title_empty_config_disables() {
844 let rule = MD025SingleTitle::new(1, "");
846
847 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
848 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
851 }
852
853 #[test]
854 fn test_frontmatter_title_with_level_config() {
855 let config = md025_config::MD025Config {
857 level: HeadingLevel::new(2).unwrap(),
858 front_matter_title: "title".to_string(),
859 ..Default::default()
860 };
861 let rule = MD025SingleTitle::from_config_struct(config);
862
863 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\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_eq!(
868 result.len(),
869 1,
870 "Should flag body H2 when level=2 and frontmatter has title"
871 );
872 assert_eq!(result[0].line, 7);
873 }
874
875 #[test]
876 fn test_frontmatter_title_fix_demotes_body_heading() {
877 let config = md025_config::MD025Config {
878 front_matter_title: "title".to_string(),
879 ..Default::default()
880 };
881 let rule = MD025SingleTitle::from_config_struct(config);
882
883 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
884 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let fixed = rule.fix(&ctx).unwrap();
886 assert!(
887 fixed.contains("## Body Heading"),
888 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
889 );
890 assert!(fixed.contains("---\ntitle: FM Title\n---"));
892 }
893
894 #[test]
895 fn test_frontmatter_title_should_skip_respects_frontmatter() {
896 let rule = MD025SingleTitle::default();
897
898 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
900 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
901 assert!(
902 !rule.should_skip(&ctx),
903 "should_skip must return false when frontmatter has title and body has H1"
904 );
905
906 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
908 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 assert!(
910 rule.should_skip(&ctx),
911 "should_skip should return true with no frontmatter title and single H1"
912 );
913 }
914
915 #[test]
916 fn test_section_indicator_whole_word_matching() {
917 let config = md025_config::MD025Config {
919 allow_document_sections: true,
920 ..Default::default()
921 };
922 let rule = MD025SingleTitle::from_config_struct(config);
923
924 let false_positive_cases = vec![
926 "# Main Title\n\n# Understanding Reindex Operations",
927 "# Main Title\n\n# The Summarization Pipeline",
928 "# Main Title\n\n# Data Indexing Strategy",
929 "# Main Title\n\n# Unsupported Browsers",
930 ];
931
932 for case in false_positive_cases {
933 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
934 let result = rule.check(&ctx).unwrap();
935 assert_eq!(
936 result.len(),
937 1,
938 "Should flag duplicate H1 (not a section indicator): {case}"
939 );
940 }
941
942 let true_positive_cases = vec![
944 "# Main Title\n\n# Index",
945 "# Main Title\n\n# Summary",
946 "# Main Title\n\n# About",
947 "# Main Title\n\n# References",
948 ];
949
950 for case in true_positive_cases {
951 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
954 }
955 }
956}