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 section_indicators.iter().any(|&indicator| {
120 lower_text.starts_with(indicator) ||
121 lower_text.starts_with(&format!("{indicator}:")) ||
122 lower_text.contains(&format!(" {indicator}")) ||
123 (indicator == "appendix" && (
125 lower_text.matches("appendix").count() == 1 &&
126 (lower_text.contains(" a") || lower_text.contains(" b") ||
127 lower_text.contains(" 1") || lower_text.contains(" 2") ||
128 lower_text.contains(" i") || lower_text.contains(" ii"))
129 ))
130 })
131 }
132
133 fn is_horizontal_rule(line: &str) -> bool {
135 let trimmed = line.trim();
136 HR_DASH.is_match(trimmed)
137 || HR_ASTERISK.is_match(trimmed)
138 || HR_UNDERSCORE.is_match(trimmed)
139 || HR_SPACED_DASH.is_match(trimmed)
140 || HR_SPACED_ASTERISK.is_match(trimmed)
141 || HR_SPACED_UNDERSCORE.is_match(trimmed)
142 }
143
144 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
146 if line_num == 0 || line_num >= ctx.lines.len() {
147 return false;
148 }
149
150 let line = ctx.lines[line_num].content(ctx.content).trim();
151 let prev_line = if line_num > 0 {
152 ctx.lines[line_num - 1].content(ctx.content).trim()
153 } else {
154 ""
155 };
156
157 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
158 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
159 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
160 (is_dash_line || is_equals_line) && prev_line_has_content
161 }
162
163 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
165 if !self.config.allow_with_separators || heading_line == 0 {
166 return false;
167 }
168
169 let search_start = heading_line.saturating_sub(5);
172
173 for line_num in search_start..heading_line {
174 if line_num >= ctx.lines.len() {
175 continue;
176 }
177
178 let line = &ctx.lines[line_num].content(ctx.content);
179 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
180 let has_intermediate_heading =
183 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
184
185 if !has_intermediate_heading {
186 return true;
187 }
188 }
189 }
190
191 false
192 }
193}
194
195impl Rule for MD025SingleTitle {
196 fn name(&self) -> &'static str {
197 "MD025"
198 }
199
200 fn description(&self) -> &'static str {
201 "Multiple top-level headings in the same document"
202 }
203
204 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
205 if ctx.lines.is_empty() {
207 return Ok(Vec::new());
208 }
209
210 let mut warnings = Vec::new();
211
212 let found_title_in_front_matter = self.has_front_matter_title(ctx);
213
214 let mut target_level_headings = Vec::new();
216 for (line_num, line_info) in ctx.lines.iter().enumerate() {
217 if let Some(heading) = &line_info.heading
218 && heading.level as usize == self.config.level.as_usize()
219 && heading.is_valid
220 {
222 if line_info.visual_indent >= 4 || line_info.in_code_block {
224 continue;
225 }
226 target_level_headings.push(line_num);
227 }
228 }
229
230 let headings_to_flag: &[usize] = if found_title_in_front_matter {
235 &target_level_headings
236 } else if target_level_headings.len() > 1 {
237 &target_level_headings[1..]
238 } else {
239 &[]
240 };
241
242 if !headings_to_flag.is_empty() {
243 for &line_num in headings_to_flag {
244 if let Some(heading) = &ctx.lines[line_num].heading {
245 let heading_text = &heading.text;
246
247 let should_allow = self.is_document_section_heading(heading_text)
249 || self.has_separator_before_heading(ctx, line_num);
250
251 if should_allow {
252 continue; }
254
255 let line_content = &ctx.lines[line_num].content(ctx.content);
257 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
258 pos
259 } else {
260 if line_content.trim_start().starts_with('#') {
262 let trimmed = line_content.trim_start();
263 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
264 let after_hashes = &trimmed[hash_count..];
265 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
266 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
267 } else {
268 0 }
270 };
271
272 let (start_line, start_col, end_line, end_col) = calculate_match_range(
273 line_num + 1, line_content,
275 text_start_in_line,
276 heading_text.len(),
277 );
278
279 warnings.push(LintWarning {
280 rule_name: Some(self.name().to_string()),
281 message: format!(
282 "Multiple top-level headings (level {}) in the same document",
283 self.config.level.as_usize()
284 ),
285 line: start_line,
286 column: start_col,
287 end_line,
288 end_column: end_col,
289 severity: Severity::Error,
290 fix: Some(Fix {
291 range: ctx.line_index.line_content_range(line_num + 1),
292 replacement: {
293 let leading_spaces = line_content.len() - line_content.trim_start().len();
294 let indentation = " ".repeat(leading_spaces);
295 let raw = &heading.raw_text;
297 if raw.is_empty() {
298 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
299 } else {
300 format!(
301 "{}{} {}",
302 indentation,
303 "#".repeat(self.config.level.as_usize() + 1),
304 raw
305 )
306 }
307 },
308 }),
309 });
310 }
311 }
312 }
313
314 Ok(warnings)
315 }
316
317 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
318 let mut fixed_lines = Vec::new();
319 let mut found_first = self.has_front_matter_title(ctx);
322 let mut skip_next = false;
323
324 for (line_num, line_info) in ctx.lines.iter().enumerate() {
325 if skip_next {
326 skip_next = false;
327 continue;
328 }
329
330 if let Some(heading) = &line_info.heading {
331 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
332 if !found_first {
333 found_first = true;
334 fixed_lines.push(line_info.content(ctx.content).to_string());
336
337 if matches!(
339 heading.style,
340 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
341 ) && line_num + 1 < ctx.lines.len()
342 {
343 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
344 skip_next = true;
345 }
346 } else {
347 let should_allow = self.is_document_section_heading(&heading.text)
349 || self.has_separator_before_heading(ctx, line_num);
350
351 if should_allow {
352 fixed_lines.push(line_info.content(ctx.content).to_string());
354
355 if matches!(
357 heading.style,
358 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
359 ) && line_num + 1 < ctx.lines.len()
360 {
361 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
362 skip_next = true;
363 }
364 } else {
365 let style = match heading.style {
367 crate::lint_context::HeadingStyle::ATX => {
368 if heading.has_closing_sequence {
369 crate::rules::heading_utils::HeadingStyle::AtxClosed
370 } else {
371 crate::rules::heading_utils::HeadingStyle::Atx
372 }
373 }
374 crate::lint_context::HeadingStyle::Setext1 => {
375 if self.config.level.as_usize() == 1 {
377 crate::rules::heading_utils::HeadingStyle::Setext2
378 } else {
379 crate::rules::heading_utils::HeadingStyle::Atx
381 }
382 }
383 crate::lint_context::HeadingStyle::Setext2 => {
384 crate::rules::heading_utils::HeadingStyle::Atx
386 }
387 };
388
389 let replacement = if heading.text.is_empty() {
390 match style {
392 crate::rules::heading_utils::HeadingStyle::Atx
393 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
394 "#".repeat(self.config.level.as_usize() + 1)
395 }
396 crate::rules::heading_utils::HeadingStyle::AtxClosed
397 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
398 format!(
399 "{} {}",
400 "#".repeat(self.config.level.as_usize() + 1),
401 "#".repeat(self.config.level.as_usize() + 1)
402 )
403 }
404 crate::rules::heading_utils::HeadingStyle::Setext1
405 | crate::rules::heading_utils::HeadingStyle::Setext2
406 | crate::rules::heading_utils::HeadingStyle::Consistent => {
407 "#".repeat(self.config.level.as_usize() + 1)
409 }
410 }
411 } else {
412 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
413 &heading.raw_text,
414 (self.config.level.as_usize() + 1) as u32,
415 style,
416 )
417 };
418
419 let line = line_info.content(ctx.content);
421 let original_indent = &line[..line_info.indent];
422 fixed_lines.push(format!("{original_indent}{replacement}"));
423
424 if matches!(
426 heading.style,
427 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
428 ) && line_num + 1 < ctx.lines.len()
429 {
430 skip_next = true;
431 }
432 }
433 }
434 } else {
435 fixed_lines.push(line_info.content(ctx.content).to_string());
437
438 if matches!(
440 heading.style,
441 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
442 ) && line_num + 1 < ctx.lines.len()
443 {
444 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
445 skip_next = true;
446 }
447 }
448 } else {
449 fixed_lines.push(line_info.content(ctx.content).to_string());
451 }
452 }
453
454 let result = fixed_lines.join("\n");
455 if ctx.content.ends_with('\n') {
456 Ok(result + "\n")
457 } else {
458 Ok(result)
459 }
460 }
461
462 fn category(&self) -> RuleCategory {
464 RuleCategory::Heading
465 }
466
467 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
469 if ctx.content.is_empty() {
471 return true;
472 }
473
474 if !ctx.likely_has_headings() {
476 return true;
477 }
478
479 let has_fm_title = self.has_front_matter_title(ctx);
480
481 let mut target_level_count = 0;
483 for line_info in &ctx.lines {
484 if let Some(heading) = &line_info.heading
485 && heading.level as usize == self.config.level.as_usize()
486 {
487 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
489 continue;
490 }
491 target_level_count += 1;
492
493 if has_fm_title {
495 return false;
496 }
497
498 if target_level_count > 1 {
500 return false;
501 }
502 }
503 }
504
505 target_level_count <= 1
507 }
508
509 fn as_any(&self) -> &dyn std::any::Any {
510 self
511 }
512
513 fn default_config_section(&self) -> Option<(String, toml::Value)> {
514 let json_value = serde_json::to_value(&self.config).ok()?;
515 Some((
516 self.name().to_string(),
517 crate::rule_config_serde::json_to_toml_value(&json_value)?,
518 ))
519 }
520
521 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
522 where
523 Self: Sized,
524 {
525 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
526 Box::new(Self::from_config_struct(rule_config))
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn test_with_cached_headings() {
536 let rule = MD025SingleTitle::default();
537
538 let content = "# Title\n\n## Section 1\n\n## Section 2";
540 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
541 let result = rule.check(&ctx).unwrap();
542 assert!(result.is_empty());
543
544 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
546 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
547 let result = rule.check(&ctx).unwrap();
548 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
550
551 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
553 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
554 let result = rule.check(&ctx).unwrap();
555 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
556 assert_eq!(result[0].line, 5);
557 }
558
559 #[test]
560 fn test_allow_document_sections() {
561 let config = md025_config::MD025Config {
563 allow_document_sections: true,
564 ..Default::default()
565 };
566 let rule = MD025SingleTitle::from_config_struct(config);
567
568 let valid_cases = vec![
570 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
571 "# Introduction\n\nContent here\n\n# References\n\nRef content",
572 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
573 "# Manual\n\nContent\n\n# Index\n\nIndex content",
574 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
575 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
576 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
577 ];
578
579 for case in valid_cases {
580 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert!(result.is_empty(), "Should not flag document sections in: {case}");
583 }
584
585 let invalid_cases = vec![
587 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
588 "# First\n\nContent\n\n# Second Title\n\nMore content",
589 ];
590
591 for case in invalid_cases {
592 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
595 }
596 }
597
598 #[test]
599 fn test_strict_mode() {
600 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
604 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605 let result = rule.check(&ctx).unwrap();
606 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
607 }
608
609 #[test]
610 fn test_bounds_checking_bug() {
611 let rule = MD025SingleTitle::default();
614
615 let content = "# First\n#";
617 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618
619 let result = rule.check(&ctx);
621 assert!(result.is_ok());
622
623 let fix_result = rule.fix(&ctx);
625 assert!(fix_result.is_ok());
626 }
627
628 #[test]
629 fn test_bounds_checking_edge_case() {
630 let rule = MD025SingleTitle::default();
633
634 let content = "# First Title\n#";
638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
639
640 let result = rule.check(&ctx);
642 assert!(result.is_ok());
643
644 if let Ok(warnings) = result
645 && !warnings.is_empty()
646 {
647 let fix_result = rule.fix(&ctx);
649 assert!(fix_result.is_ok());
650
651 if let Ok(fixed_content) = fix_result {
653 assert!(!fixed_content.is_empty());
654 assert!(fixed_content.contains("##"));
656 }
657 }
658 }
659
660 #[test]
661 fn test_horizontal_rule_separators() {
662 let config = md025_config::MD025Config {
664 allow_with_separators: true,
665 ..Default::default()
666 };
667 let rule = MD025SingleTitle::from_config_struct(config);
668
669 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.";
671 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert!(
674 result.is_empty(),
675 "Should not flag headings separated by horizontal rules"
676 );
677
678 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.";
680 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
681 let result = rule.check(&ctx).unwrap();
682 assert_eq!(result.len(), 1, "Should flag the heading without separator");
683 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
687 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
688 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = strict_rule.check(&ctx).unwrap();
690 assert_eq!(
691 result.len(),
692 1,
693 "Strict mode should flag all multiple H1s regardless of separators"
694 );
695 }
696
697 #[test]
698 fn test_python_comments_in_code_blocks() {
699 let rule = MD025SingleTitle::default();
700
701 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.";
703 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let result = rule.check(&ctx).unwrap();
705 assert!(
706 result.is_empty(),
707 "Should not flag Python comments in code blocks as headings"
708 );
709
710 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
712 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let fixed = rule.fix(&ctx).unwrap();
714 assert!(
715 fixed.contains("# Python comment"),
716 "Fix should preserve Python comments in code blocks"
717 );
718 assert!(
719 fixed.contains("## Second Title"),
720 "Fix should demote the actual second heading"
721 );
722 }
723
724 #[test]
725 fn test_fix_preserves_attribute_lists() {
726 let rule = MD025SingleTitle::strict();
727
728 let content = "# First Title\n\n# Second Title { #custom-id .special }";
730 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
731
732 let warnings = rule.check(&ctx).unwrap();
734 assert_eq!(warnings.len(), 1);
735 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
736 assert!(
737 fix.replacement.contains("{ #custom-id .special }"),
738 "check() fix should preserve attribute list, got: {}",
739 fix.replacement
740 );
741
742 let fixed = rule.fix(&ctx).unwrap();
744 assert!(
745 fixed.contains("## Second Title { #custom-id .special }"),
746 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
747 );
748 }
749
750 #[test]
751 fn test_frontmatter_title_counts_as_h1() {
752 let rule = MD025SingleTitle::default();
753
754 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757 let result = rule.check(&ctx).unwrap();
758 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
759 assert_eq!(result[0].line, 5);
760 }
761
762 #[test]
763 fn test_frontmatter_title_with_multiple_body_h1s() {
764 let config = md025_config::MD025Config {
765 front_matter_title: "title".to_string(),
766 ..Default::default()
767 };
768 let rule = MD025SingleTitle::from_config_struct(config);
769
770 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
775 assert_eq!(result[0].line, 5);
776 assert_eq!(result[1].line, 9);
777 }
778
779 #[test]
780 fn test_frontmatter_without_title_no_warning() {
781 let rule = MD025SingleTitle::default();
782
783 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786 let result = rule.check(&ctx).unwrap();
787 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
788 }
789
790 #[test]
791 fn test_no_frontmatter_single_h1_no_warning() {
792 let rule = MD025SingleTitle::default();
793
794 let content = "# Only Heading\n\nSome content.";
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 single H1 without frontmatter");
799 }
800
801 #[test]
802 fn test_frontmatter_custom_title_key() {
803 let config = md025_config::MD025Config {
805 front_matter_title: "heading".to_string(),
806 ..Default::default()
807 };
808 let rule = MD025SingleTitle::from_config_struct(config);
809
810 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
812 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
813 let result = rule.check(&ctx).unwrap();
814 assert_eq!(
815 result.len(),
816 1,
817 "Should flag body H1 when custom frontmatter key matches"
818 );
819 assert_eq!(result[0].line, 5);
820
821 let content = "---\ntitle: My Title\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!(
826 result.is_empty(),
827 "Should not flag when frontmatter key doesn't match config"
828 );
829 }
830
831 #[test]
832 fn test_frontmatter_title_empty_config_disables() {
833 let rule = MD025SingleTitle::new(1, "");
835
836 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
837 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
840 }
841
842 #[test]
843 fn test_frontmatter_title_with_level_config() {
844 let config = md025_config::MD025Config {
846 level: HeadingLevel::new(2).unwrap(),
847 front_matter_title: "title".to_string(),
848 ..Default::default()
849 };
850 let rule = MD025SingleTitle::from_config_struct(config);
851
852 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
854 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856 assert_eq!(
857 result.len(),
858 1,
859 "Should flag body H2 when level=2 and frontmatter has title"
860 );
861 assert_eq!(result[0].line, 7);
862 }
863
864 #[test]
865 fn test_frontmatter_title_fix_demotes_body_heading() {
866 let config = md025_config::MD025Config {
867 front_matter_title: "title".to_string(),
868 ..Default::default()
869 };
870 let rule = MD025SingleTitle::from_config_struct(config);
871
872 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
874 let fixed = rule.fix(&ctx).unwrap();
875 assert!(
876 fixed.contains("## Body Heading"),
877 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
878 );
879 assert!(fixed.contains("---\ntitle: FM Title\n---"));
881 }
882
883 #[test]
884 fn test_frontmatter_title_should_skip_respects_frontmatter() {
885 let rule = MD025SingleTitle::default();
886
887 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
889 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 assert!(
891 !rule.should_skip(&ctx),
892 "should_skip must return false when frontmatter has title and body has H1"
893 );
894
895 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 assert!(
899 rule.should_skip(&ctx),
900 "should_skip should return true with no frontmatter title and single H1"
901 );
902 }
903}