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 build_demoted_heading(
149 heading: &crate::lint_context::types::HeadingInfo,
150 line_info: &crate::lint_context::types::LineInfo,
151 content: &str,
152 delta: usize,
153 ) -> (String, bool) {
154 let new_level = (heading.level as usize + delta).min(6);
155
156 let style = match heading.style {
157 crate::lint_context::HeadingStyle::ATX => {
158 if heading.has_closing_sequence {
159 crate::rules::heading_utils::HeadingStyle::AtxClosed
160 } else {
161 crate::rules::heading_utils::HeadingStyle::Atx
162 }
163 }
164 crate::lint_context::HeadingStyle::Setext1 => {
165 if new_level <= 2 {
166 if new_level == 1 {
167 crate::rules::heading_utils::HeadingStyle::Setext1
168 } else {
169 crate::rules::heading_utils::HeadingStyle::Setext2
170 }
171 } else {
172 crate::rules::heading_utils::HeadingStyle::Atx
173 }
174 }
175 crate::lint_context::HeadingStyle::Setext2 => {
176 if new_level <= 2 {
177 crate::rules::heading_utils::HeadingStyle::Setext2
178 } else {
179 crate::rules::heading_utils::HeadingStyle::Atx
180 }
181 }
182 };
183
184 let replacement = if heading.text.is_empty() {
185 match style {
186 crate::rules::heading_utils::HeadingStyle::Atx
187 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => "#".repeat(new_level),
188 crate::rules::heading_utils::HeadingStyle::AtxClosed
189 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
190 format!("{} {}", "#".repeat(new_level), "#".repeat(new_level))
191 }
192 crate::rules::heading_utils::HeadingStyle::Setext1
193 | crate::rules::heading_utils::HeadingStyle::Setext2
194 | crate::rules::heading_utils::HeadingStyle::Consistent => "#".repeat(new_level),
195 }
196 } else {
197 crate::rules::heading_utils::HeadingUtils::convert_heading_style(&heading.raw_text, new_level as u32, style)
198 };
199
200 let line = line_info.content(content);
201 let original_indent = &line[..line_info.indent];
202 let result = format!("{original_indent}{replacement}");
203
204 let should_skip_next = matches!(
205 heading.style,
206 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
207 );
208
209 (result, should_skip_next)
210 }
211
212 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
214 if line_num == 0 || line_num >= ctx.lines.len() {
215 return false;
216 }
217
218 let line = ctx.lines[line_num].content(ctx.content).trim();
219 let prev_line = if line_num > 0 {
220 ctx.lines[line_num - 1].content(ctx.content).trim()
221 } else {
222 ""
223 };
224
225 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
226 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
227 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
228 (is_dash_line || is_equals_line) && prev_line_has_content
229 }
230
231 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
233 if !self.config.allow_with_separators || heading_line == 0 {
234 return false;
235 }
236
237 let search_start = heading_line.saturating_sub(5);
240
241 for line_num in search_start..heading_line {
242 if line_num >= ctx.lines.len() {
243 continue;
244 }
245
246 let line = &ctx.lines[line_num].content(ctx.content);
247 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
248 let has_intermediate_heading =
251 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
252
253 if !has_intermediate_heading {
254 return true;
255 }
256 }
257 }
258
259 false
260 }
261}
262
263impl Rule for MD025SingleTitle {
264 fn name(&self) -> &'static str {
265 "MD025"
266 }
267
268 fn description(&self) -> &'static str {
269 "Multiple top-level headings in the same document"
270 }
271
272 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
273 if ctx.lines.is_empty() {
275 return Ok(Vec::new());
276 }
277
278 let mut warnings = Vec::new();
279
280 let found_title_in_front_matter = self.has_front_matter_title(ctx);
281
282 let mut target_level_headings = Vec::new();
284 for (line_num, line_info) in ctx.lines.iter().enumerate() {
285 if let Some(heading) = &line_info.heading
286 && heading.level as usize == self.config.level.as_usize()
287 && heading.is_valid
288 {
290 if line_info.visual_indent >= 4 || line_info.in_code_block {
292 continue;
293 }
294 target_level_headings.push(line_num);
295 }
296 }
297
298 let headings_to_flag: &[usize] = if found_title_in_front_matter {
303 &target_level_headings
304 } else if target_level_headings.len() > 1 {
305 &target_level_headings[1..]
306 } else {
307 &[]
308 };
309
310 if !headings_to_flag.is_empty() {
311 for &line_num in headings_to_flag {
312 if let Some(heading) = &ctx.lines[line_num].heading {
313 let heading_text = &heading.text;
314
315 let should_allow = self.is_document_section_heading(heading_text)
317 || self.has_separator_before_heading(ctx, line_num);
318
319 if should_allow {
320 continue; }
322
323 let line_content = &ctx.lines[line_num].content(ctx.content);
325 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
326 pos
327 } else {
328 if line_content.trim_start().starts_with('#') {
330 let trimmed = line_content.trim_start();
331 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
332 let after_hashes = &trimmed[hash_count..];
333 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
334 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
335 } else {
336 0 }
338 };
339
340 let (start_line, start_col, end_line, end_col) = calculate_match_range(
341 line_num + 1, line_content,
343 text_start_in_line,
344 heading_text.len(),
345 );
346
347 let is_setext = matches!(
350 heading.style,
351 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
352 );
353 let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
354 let text_range = ctx.line_index.line_content_range(line_num + 1);
356 let underline_range = ctx.line_index.line_content_range(line_num + 2);
357 text_range.start..underline_range.end
358 } else {
359 ctx.line_index.line_content_range(line_num + 1)
360 };
361
362 let replacement = {
363 let leading_spaces = line_content.len() - line_content.trim_start().len();
364 let indentation = " ".repeat(leading_spaces);
365 let raw = &heading.raw_text;
366 if raw.is_empty() {
367 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
368 } else {
369 format!(
370 "{}{} {}",
371 indentation,
372 "#".repeat(self.config.level.as_usize() + 1),
373 raw
374 )
375 }
376 };
377
378 warnings.push(LintWarning {
379 rule_name: Some(self.name().to_string()),
380 message: format!(
381 "Multiple top-level headings (level {}) in the same document",
382 self.config.level.as_usize()
383 ),
384 line: start_line,
385 column: start_col,
386 end_line,
387 end_column: end_col,
388 severity: Severity::Error,
389 fix: Some(Fix {
390 range: fix_range,
391 replacement,
392 }),
393 });
394 }
395 }
396 }
397
398 Ok(warnings)
399 }
400
401 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
402 let mut fixed_lines = Vec::new();
403 let mut found_first = self.has_front_matter_title(ctx);
406 let mut skip_next = false;
407 let mut current_delta: usize = 0;
411
412 for (line_num, line_info) in ctx.lines.iter().enumerate() {
413 if skip_next {
414 skip_next = false;
415 continue;
416 }
417
418 if ctx.inline_config().is_rule_disabled(self.name(), line_num + 1) {
420 fixed_lines.push(line_info.content(ctx.content).to_string());
421 if let Some(heading) = &line_info.heading {
422 if heading.level as usize == self.config.level.as_usize() {
425 current_delta = 0;
426 }
427 if matches!(
429 heading.style,
430 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
431 ) && line_num + 1 < ctx.lines.len()
432 {
433 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
434 skip_next = true;
435 }
436 }
437 continue;
438 }
439
440 if let Some(heading) = &line_info.heading {
441 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
442 if !found_first {
443 found_first = true;
444 current_delta = 0;
445 fixed_lines.push(line_info.content(ctx.content).to_string());
447
448 if matches!(
450 heading.style,
451 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
452 ) && line_num + 1 < ctx.lines.len()
453 {
454 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
455 skip_next = true;
456 }
457 } else {
458 let should_allow = self.is_document_section_heading(&heading.text)
460 || self.has_separator_before_heading(ctx, line_num);
461
462 if should_allow {
463 current_delta = 0;
464 fixed_lines.push(line_info.content(ctx.content).to_string());
466
467 if matches!(
469 heading.style,
470 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
471 ) && line_num + 1 < ctx.lines.len()
472 {
473 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
474 skip_next = true;
475 }
476 } else {
477 current_delta = 1;
479 let (demoted, should_skip) =
480 Self::build_demoted_heading(heading, line_info, ctx.content, 1);
481 fixed_lines.push(demoted);
482 if should_skip && line_num + 1 < ctx.lines.len() {
483 skip_next = true;
484 }
485 }
486 }
487 } else if current_delta > 0 && !line_info.in_code_block {
488 let new_level = heading.level as usize + current_delta;
490 if new_level > 6 {
491 fixed_lines.push(line_info.content(ctx.content).to_string());
493 if matches!(
494 heading.style,
495 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
496 ) && line_num + 1 < ctx.lines.len()
497 {
498 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
499 skip_next = true;
500 }
501 } else {
502 let (demoted, should_skip) =
503 Self::build_demoted_heading(heading, line_info, ctx.content, current_delta);
504 fixed_lines.push(demoted);
505 if should_skip && line_num + 1 < ctx.lines.len() {
506 skip_next = true;
507 }
508 }
509 } else {
510 fixed_lines.push(line_info.content(ctx.content).to_string());
512
513 if matches!(
515 heading.style,
516 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
517 ) && line_num + 1 < ctx.lines.len()
518 {
519 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
520 skip_next = true;
521 }
522 }
523 } else {
524 fixed_lines.push(line_info.content(ctx.content).to_string());
526 }
527 }
528
529 let result = fixed_lines.join("\n");
530 if ctx.content.ends_with('\n') {
531 Ok(result + "\n")
532 } else {
533 Ok(result)
534 }
535 }
536
537 fn category(&self) -> RuleCategory {
539 RuleCategory::Heading
540 }
541
542 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
544 if ctx.content.is_empty() {
546 return true;
547 }
548
549 if !ctx.likely_has_headings() {
551 return true;
552 }
553
554 let has_fm_title = self.has_front_matter_title(ctx);
555
556 let mut target_level_count = 0;
558 for line_info in &ctx.lines {
559 if let Some(heading) = &line_info.heading
560 && heading.level as usize == self.config.level.as_usize()
561 {
562 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
564 continue;
565 }
566 target_level_count += 1;
567
568 if has_fm_title {
570 return false;
571 }
572
573 if target_level_count > 1 {
575 return false;
576 }
577 }
578 }
579
580 target_level_count <= 1
582 }
583
584 fn as_any(&self) -> &dyn std::any::Any {
585 self
586 }
587
588 fn default_config_section(&self) -> Option<(String, toml::Value)> {
589 let json_value = serde_json::to_value(&self.config).ok()?;
590 Some((
591 self.name().to_string(),
592 crate::rule_config_serde::json_to_toml_value(&json_value)?,
593 ))
594 }
595
596 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
597 where
598 Self: Sized,
599 {
600 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
601 Box::new(Self::from_config_struct(rule_config))
602 }
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn test_with_cached_headings() {
611 let rule = MD025SingleTitle::default();
612
613 let content = "# Title\n\n## Section 1\n\n## Section 2";
615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
616 let result = rule.check(&ctx).unwrap();
617 assert!(result.is_empty());
618
619 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
621 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
622 let result = rule.check(&ctx).unwrap();
623 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
625
626 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
629 let result = rule.check(&ctx).unwrap();
630 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
631 assert_eq!(result[0].line, 5);
632 }
633
634 #[test]
635 fn test_allow_document_sections() {
636 let config = md025_config::MD025Config {
638 allow_document_sections: true,
639 ..Default::default()
640 };
641 let rule = MD025SingleTitle::from_config_struct(config);
642
643 let valid_cases = vec![
645 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
646 "# Introduction\n\nContent here\n\n# References\n\nRef content",
647 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
648 "# Manual\n\nContent\n\n# Index\n\nIndex content",
649 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
650 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
651 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
652 ];
653
654 for case in valid_cases {
655 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657 assert!(result.is_empty(), "Should not flag document sections in: {case}");
658 }
659
660 let invalid_cases = vec![
662 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
663 "# First\n\nContent\n\n# Second Title\n\nMore content",
664 ];
665
666 for case in invalid_cases {
667 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
668 let result = rule.check(&ctx).unwrap();
669 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
670 }
671 }
672
673 #[test]
674 fn test_strict_mode() {
675 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
682 }
683
684 #[test]
685 fn test_bounds_checking_bug() {
686 let rule = MD025SingleTitle::default();
689
690 let content = "# First\n#";
692 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693
694 let result = rule.check(&ctx);
696 assert!(result.is_ok());
697
698 let fix_result = rule.fix(&ctx);
700 assert!(fix_result.is_ok());
701 }
702
703 #[test]
704 fn test_bounds_checking_edge_case() {
705 let rule = MD025SingleTitle::default();
708
709 let content = "# First Title\n#";
713 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714
715 let result = rule.check(&ctx);
717 assert!(result.is_ok());
718
719 if let Ok(warnings) = result
720 && !warnings.is_empty()
721 {
722 let fix_result = rule.fix(&ctx);
724 assert!(fix_result.is_ok());
725
726 if let Ok(fixed_content) = fix_result {
728 assert!(!fixed_content.is_empty());
729 assert!(fixed_content.contains("##"));
731 }
732 }
733 }
734
735 #[test]
736 fn test_horizontal_rule_separators() {
737 let config = md025_config::MD025Config {
739 allow_with_separators: true,
740 ..Default::default()
741 };
742 let rule = MD025SingleTitle::from_config_struct(config);
743
744 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.";
746 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
747 let result = rule.check(&ctx).unwrap();
748 assert!(
749 result.is_empty(),
750 "Should not flag headings separated by horizontal rules"
751 );
752
753 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.";
755 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert_eq!(result.len(), 1, "Should flag the heading without separator");
758 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
762 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
763 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
764 let result = strict_rule.check(&ctx).unwrap();
765 assert_eq!(
766 result.len(),
767 1,
768 "Strict mode should flag all multiple H1s regardless of separators"
769 );
770 }
771
772 #[test]
773 fn test_python_comments_in_code_blocks() {
774 let rule = MD025SingleTitle::default();
775
776 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.";
778 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780 assert!(
781 result.is_empty(),
782 "Should not flag Python comments in code blocks as headings"
783 );
784
785 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
787 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let fixed = rule.fix(&ctx).unwrap();
789 assert!(
790 fixed.contains("# Python comment"),
791 "Fix should preserve Python comments in code blocks"
792 );
793 assert!(
794 fixed.contains("## Second Title"),
795 "Fix should demote the actual second heading"
796 );
797 }
798
799 #[test]
800 fn test_fix_preserves_attribute_lists() {
801 let rule = MD025SingleTitle::strict();
802
803 let content = "# First Title\n\n# Second Title { #custom-id .special }";
805 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806
807 let warnings = rule.check(&ctx).unwrap();
809 assert_eq!(warnings.len(), 1);
810 assert!(warnings[0].fix.is_some());
812
813 let fixed = rule.fix(&ctx).unwrap();
815 assert!(
816 fixed.contains("## Second Title { #custom-id .special }"),
817 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
818 );
819 }
820
821 #[test]
822 fn test_frontmatter_title_counts_as_h1() {
823 let rule = MD025SingleTitle::default();
824
825 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
827 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
828 let result = rule.check(&ctx).unwrap();
829 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
830 assert_eq!(result[0].line, 5);
831 }
832
833 #[test]
834 fn test_frontmatter_title_with_multiple_body_h1s() {
835 let config = md025_config::MD025Config {
836 front_matter_title: "title".to_string(),
837 ..Default::default()
838 };
839 let rule = MD025SingleTitle::from_config_struct(config);
840
841 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
843 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
844 let result = rule.check(&ctx).unwrap();
845 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
846 assert_eq!(result[0].line, 5);
847 assert_eq!(result[1].line, 9);
848 }
849
850 #[test]
851 fn test_frontmatter_without_title_no_warning() {
852 let rule = MD025SingleTitle::default();
853
854 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
856 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
859 }
860
861 #[test]
862 fn test_no_frontmatter_single_h1_no_warning() {
863 let rule = MD025SingleTitle::default();
864
865 let content = "# Only Heading\n\nSome content.";
867 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
868 let result = rule.check(&ctx).unwrap();
869 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
870 }
871
872 #[test]
873 fn test_frontmatter_custom_title_key() {
874 let config = md025_config::MD025Config {
876 front_matter_title: "heading".to_string(),
877 ..Default::default()
878 };
879 let rule = MD025SingleTitle::from_config_struct(config);
880
881 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884 let result = rule.check(&ctx).unwrap();
885 assert_eq!(
886 result.len(),
887 1,
888 "Should flag body H1 when custom frontmatter key matches"
889 );
890 assert_eq!(result[0].line, 5);
891
892 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896 assert!(
897 result.is_empty(),
898 "Should not flag when frontmatter key doesn't match config"
899 );
900 }
901
902 #[test]
903 fn test_frontmatter_title_empty_config_disables() {
904 let rule = MD025SingleTitle::new(1, "");
906
907 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
908 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
911 }
912
913 #[test]
914 fn test_frontmatter_title_with_level_config() {
915 let config = md025_config::MD025Config {
917 level: HeadingLevel::new(2).unwrap(),
918 front_matter_title: "title".to_string(),
919 ..Default::default()
920 };
921 let rule = MD025SingleTitle::from_config_struct(config);
922
923 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
925 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927 assert_eq!(
928 result.len(),
929 1,
930 "Should flag body H2 when level=2 and frontmatter has title"
931 );
932 assert_eq!(result[0].line, 7);
933 }
934
935 #[test]
936 fn test_frontmatter_title_fix_demotes_body_heading() {
937 let config = md025_config::MD025Config {
938 front_matter_title: "title".to_string(),
939 ..Default::default()
940 };
941 let rule = MD025SingleTitle::from_config_struct(config);
942
943 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
944 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let fixed = rule.fix(&ctx).unwrap();
946 assert!(
947 fixed.contains("## Body Heading"),
948 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
949 );
950 assert!(fixed.contains("---\ntitle: FM Title\n---"));
952 }
953
954 #[test]
955 fn test_frontmatter_title_should_skip_respects_frontmatter() {
956 let rule = MD025SingleTitle::default();
957
958 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
960 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
961 assert!(
962 !rule.should_skip(&ctx),
963 "should_skip must return false when frontmatter has title and body has H1"
964 );
965
966 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
968 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 assert!(
970 rule.should_skip(&ctx),
971 "should_skip should return true with no frontmatter title and single H1"
972 );
973 }
974
975 #[test]
976 fn test_section_indicator_whole_word_matching() {
977 let config = md025_config::MD025Config {
979 allow_document_sections: true,
980 ..Default::default()
981 };
982 let rule = MD025SingleTitle::from_config_struct(config);
983
984 let false_positive_cases = vec![
986 "# Main Title\n\n# Understanding Reindex Operations",
987 "# Main Title\n\n# The Summarization Pipeline",
988 "# Main Title\n\n# Data Indexing Strategy",
989 "# Main Title\n\n# Unsupported Browsers",
990 ];
991
992 for case in false_positive_cases {
993 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
994 let result = rule.check(&ctx).unwrap();
995 assert_eq!(
996 result.len(),
997 1,
998 "Should flag duplicate H1 (not a section indicator): {case}"
999 );
1000 }
1001
1002 let true_positive_cases = vec![
1004 "# Main Title\n\n# Index",
1005 "# Main Title\n\n# Summary",
1006 "# Main Title\n\n# About",
1007 "# Main Title\n\n# References",
1008 ];
1009
1010 for case in true_positive_cases {
1011 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1012 let result = rule.check(&ctx).unwrap();
1013 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1014 }
1015 }
1016}