Skip to main content

rumdl_lib/rules/
md025_single_title.rs

1/// Rule MD025: Document must have a single top-level heading
2///
3/// See [docs/md025.md](../../docs/md025.md) for full documentation, configuration, and examples.
4use 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    /// Check if the document's frontmatter contains a title field matching the configured key
46    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    /// Check if a heading text suggests it's a legitimate document section
69    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        // Common section names that are legitimate as separate H1s
77        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        // Check if the heading matches these patterns using whole-word matching
117        let words: Vec<&str> = lower_text.split_whitespace().collect();
118        section_indicators.iter().any(|&indicator| {
119            // Multi-word indicators need contiguous word matching
120            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            // Whole-word match anywhere in the heading
131            words.contains(&indicator) ||
132            // Handle multi-word indicators appearing as a contiguous subsequence
133            (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
134            // Handle appendix numbering like "Appendix A", "Appendix 1"
135            (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    /// Check if a line might be a Setext heading underline
147    fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
148        if line_num == 0 || line_num >= ctx.lines.len() {
149            return false;
150        }
151
152        let line = ctx.lines[line_num].content(ctx.content).trim();
153        let prev_line = if line_num > 0 {
154            ctx.lines[line_num - 1].content(ctx.content).trim()
155        } else {
156            ""
157        };
158
159        let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
160        let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
161        let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
162        (is_dash_line || is_equals_line) && prev_line_has_content
163    }
164
165    /// Check if headings are separated by horizontal rules
166    fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
167        if !self.config.allow_with_separators || heading_line == 0 {
168            return false;
169        }
170
171        // Look for horizontal rules in the lines before this heading
172        // Check up to 5 lines before the heading for a horizontal rule
173        let search_start = heading_line.saturating_sub(5);
174
175        for line_num in search_start..heading_line {
176            if line_num >= ctx.lines.len() {
177                continue;
178            }
179
180            let line = &ctx.lines[line_num].content(ctx.content);
181            if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
182                // Found a horizontal rule before this heading
183                // Check that there's no other heading between the HR and this heading
184                let has_intermediate_heading =
185                    ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
186
187                if !has_intermediate_heading {
188                    return true;
189                }
190            }
191        }
192
193        false
194    }
195}
196
197impl Rule for MD025SingleTitle {
198    fn name(&self) -> &'static str {
199        "MD025"
200    }
201
202    fn description(&self) -> &'static str {
203        "Multiple top-level headings in the same document"
204    }
205
206    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
207        // Early return for empty content
208        if ctx.lines.is_empty() {
209            return Ok(Vec::new());
210        }
211
212        let mut warnings = Vec::new();
213
214        let found_title_in_front_matter = self.has_front_matter_title(ctx);
215
216        // Find all headings at the target level using cached information
217        let mut target_level_headings = Vec::new();
218        for (line_num, line_info) in ctx.lines.iter().enumerate() {
219            if let Some(heading) = &line_info.heading
220                && heading.level as usize == self.config.level.as_usize()
221                && heading.is_valid
222            // Skip malformed headings like `#NoSpace`
223            {
224                // Ignore if indented 4+ spaces (indented code block) or inside fenced code block
225                if line_info.visual_indent >= 4 || line_info.in_code_block {
226                    continue;
227                }
228                target_level_headings.push(line_num);
229            }
230        }
231
232        // Determine which headings to flag as duplicates.
233        // If frontmatter has a title, it counts as the first heading,
234        // so ALL body headings at the target level are duplicates.
235        // Otherwise, skip the first body heading and flag the rest.
236        let headings_to_flag: &[usize] = if found_title_in_front_matter {
237            &target_level_headings
238        } else if target_level_headings.len() > 1 {
239            &target_level_headings[1..]
240        } else {
241            &[]
242        };
243
244        if !headings_to_flag.is_empty() {
245            for &line_num in headings_to_flag {
246                if let Some(heading) = &ctx.lines[line_num].heading {
247                    let heading_text = &heading.text;
248
249                    // Check if this heading should be allowed
250                    let should_allow = self.is_document_section_heading(heading_text)
251                        || self.has_separator_before_heading(ctx, line_num);
252
253                    if should_allow {
254                        continue; // Skip flagging this heading
255                    }
256
257                    // Calculate precise character range for the heading text content
258                    let line_content = &ctx.lines[line_num].content(ctx.content);
259                    let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
260                        pos
261                    } else {
262                        // Fallback: find after hash markers for ATX headings
263                        if line_content.trim_start().starts_with('#') {
264                            let trimmed = line_content.trim_start();
265                            let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
266                            let after_hashes = &trimmed[hash_count..];
267                            let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
268                            (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
269                        } else {
270                            0 // Setext headings start at beginning
271                        }
272                    };
273
274                    let (start_line, start_col, end_line, end_col) = calculate_match_range(
275                        line_num + 1, // Convert to 1-indexed
276                        line_content,
277                        text_start_in_line,
278                        heading_text.len(),
279                    );
280
281                    // For Setext headings, the fix range must cover both
282                    // the text line and the underline line
283                    let is_setext = matches!(
284                        heading.style,
285                        crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
286                    );
287                    let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
288                        // Cover text line + underline line
289                        let text_range = ctx.line_index.line_content_range(line_num + 1);
290                        let underline_range = ctx.line_index.line_content_range(line_num + 2);
291                        text_range.start..underline_range.end
292                    } else {
293                        ctx.line_index.line_content_range(line_num + 1)
294                    };
295
296                    // Demote to one level below the configured top-level heading.
297                    // Markdown only supports levels 1-6, so if the configured level
298                    // is already 6, the heading cannot be demoted.
299                    let demoted_level = self.config.level.as_usize() + 1;
300                    let fix = if demoted_level > 6 {
301                        None
302                    } else {
303                        let leading_spaces = line_content.len() - line_content.trim_start().len();
304                        let indentation = " ".repeat(leading_spaces);
305                        let raw = &heading.raw_text;
306                        let hashes = "#".repeat(demoted_level);
307                        let closing = if heading.has_closing_sequence {
308                            format!(" {}", "#".repeat(demoted_level))
309                        } else {
310                            String::new()
311                        };
312                        let replacement = if raw.is_empty() {
313                            format!("{indentation}{hashes}{closing}")
314                        } else {
315                            format!("{indentation}{hashes} {raw}{closing}")
316                        };
317                        Some(Fix {
318                            range: fix_range,
319                            replacement,
320                        })
321                    };
322
323                    warnings.push(LintWarning {
324                        rule_name: Some(self.name().to_string()),
325                        message: format!(
326                            "Multiple top-level headings (level {}) in the same document",
327                            self.config.level.as_usize()
328                        ),
329                        line: start_line,
330                        column: start_col,
331                        end_line,
332                        end_column: end_col,
333                        severity: Severity::Error,
334                        fix,
335                    });
336                }
337            }
338        }
339
340        Ok(warnings)
341    }
342
343    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
344        let warnings = self.check(ctx)?;
345        if warnings.is_empty() {
346            return Ok(ctx.content.to_string());
347        }
348        let warnings =
349            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
350        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings)
351            .map_err(crate::rule::LintError::InvalidInput)
352    }
353
354    /// Get the category of this rule for selective processing
355    fn category(&self) -> RuleCategory {
356        RuleCategory::Heading
357    }
358
359    /// Check if this rule should be skipped for performance
360    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
361        // Skip if content is empty
362        if ctx.content.is_empty() {
363            return true;
364        }
365
366        // Skip if no heading markers at all
367        if !ctx.likely_has_headings() {
368            return true;
369        }
370
371        let has_fm_title = self.has_front_matter_title(ctx);
372
373        // Fast path: count target level headings efficiently
374        let mut target_level_count = 0;
375        for line_info in &ctx.lines {
376            if let Some(heading) = &line_info.heading
377                && heading.level as usize == self.config.level.as_usize()
378            {
379                // Ignore if indented 4+ spaces (indented code block), inside fenced code block, or PyMdown block
380                if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
381                    continue;
382                }
383                target_level_count += 1;
384
385                // If frontmatter has a title, even 1 body heading is a duplicate
386                if has_fm_title {
387                    return false;
388                }
389
390                // Otherwise, we need more than 1 to have duplicates
391                if target_level_count > 1 {
392                    return false;
393                }
394            }
395        }
396
397        // If we have 0 or 1 target level headings (without frontmatter title), skip
398        target_level_count <= 1
399    }
400
401    fn as_any(&self) -> &dyn std::any::Any {
402        self
403    }
404
405    fn default_config_section(&self) -> Option<(String, toml::Value)> {
406        let json_value = serde_json::to_value(&self.config).ok()?;
407        Some((
408            self.name().to_string(),
409            crate::rule_config_serde::json_to_toml_value(&json_value)?,
410        ))
411    }
412
413    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
414    where
415        Self: Sized,
416    {
417        let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
418        Box::new(Self::from_config_struct(rule_config))
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_with_cached_headings() {
428        let rule = MD025SingleTitle::default();
429
430        // Test with only one level-1 heading
431        let content = "# Title\n\n## Section 1\n\n## Section 2";
432        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433        let result = rule.check(&ctx).unwrap();
434        assert!(result.is_empty());
435
436        // Test with multiple level-1 headings (non-section names) - should flag
437        let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
438        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
439        let result = rule.check(&ctx).unwrap();
440        assert_eq!(result.len(), 1); // Should flag the second level-1 heading
441        assert_eq!(result[0].line, 5);
442
443        // Test with front matter title and a level-1 heading - should flag the body H1
444        let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
445        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
446        let result = rule.check(&ctx).unwrap();
447        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
448        assert_eq!(result[0].line, 5);
449    }
450
451    #[test]
452    fn test_allow_document_sections() {
453        // Need to create rule with allow_document_sections = true
454        let config = md025_config::MD025Config {
455            allow_document_sections: true,
456            ..Default::default()
457        };
458        let rule = MD025SingleTitle::from_config_struct(config);
459
460        // Test valid document sections that should NOT be flagged
461        let valid_cases = vec![
462            "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
463            "# Introduction\n\nContent here\n\n# References\n\nRef content",
464            "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
465            "# Manual\n\nContent\n\n# Index\n\nIndex content",
466            "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
467            "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
468            "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
469        ];
470
471        for case in valid_cases {
472            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
473            let result = rule.check(&ctx).unwrap();
474            assert!(result.is_empty(), "Should not flag document sections in: {case}");
475        }
476
477        // Test invalid cases that should still be flagged
478        let invalid_cases = vec![
479            "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
480            "# First\n\nContent\n\n# Second Title\n\nMore content",
481        ];
482
483        for case in invalid_cases {
484            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
485            let result = rule.check(&ctx).unwrap();
486            assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
487        }
488    }
489
490    #[test]
491    fn test_strict_mode() {
492        let rule = MD025SingleTitle::strict(); // Has allow_document_sections = false
493
494        // Even document sections should be flagged in strict mode
495        let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
496        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
497        let result = rule.check(&ctx).unwrap();
498        assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
499    }
500
501    #[test]
502    fn test_bounds_checking_bug() {
503        // Test case that could trigger bounds error in fix generation
504        // When col + self.config.level.as_usize() exceeds line_content.len()
505        let rule = MD025SingleTitle::default();
506
507        // Create content with very short second heading
508        let content = "# First\n#";
509        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
510
511        // This should not panic
512        let result = rule.check(&ctx);
513        assert!(result.is_ok());
514
515        // Test the fix as well
516        let fix_result = rule.fix(&ctx);
517        assert!(fix_result.is_ok());
518    }
519
520    #[test]
521    fn test_bounds_checking_edge_case() {
522        // Test case that specifically targets the bounds checking fix
523        // Create a heading where col + self.config.level.as_usize() would exceed line length
524        let rule = MD025SingleTitle::default();
525
526        // Create content where the second heading is just "#" (length 1)
527        // col will be 0, self.config.level.as_usize() is 1, so col + self.config.level.as_usize() = 1
528        // This should not exceed bounds for "#" but tests the edge case
529        let content = "# First Title\n#";
530        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531
532        // This should not panic and should handle the edge case gracefully
533        let result = rule.check(&ctx);
534        assert!(result.is_ok());
535
536        if let Ok(warnings) = result
537            && !warnings.is_empty()
538        {
539            // Check that the fix doesn't cause a panic
540            let fix_result = rule.fix(&ctx);
541            assert!(fix_result.is_ok());
542
543            // The fix should produce valid content
544            if let Ok(fixed_content) = fix_result {
545                assert!(!fixed_content.is_empty());
546                // Should convert the second "#" to "##" (or "## " if there's content)
547                assert!(fixed_content.contains("##"));
548            }
549        }
550    }
551
552    #[test]
553    fn test_horizontal_rule_separators() {
554        // Need to create rule with allow_with_separators = true
555        let config = md025_config::MD025Config {
556            allow_with_separators: true,
557            ..Default::default()
558        };
559        let rule = MD025SingleTitle::from_config_struct(config);
560
561        // Test that headings separated by horizontal rules are allowed
562        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
563        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
564        let result = rule.check(&ctx).unwrap();
565        assert!(
566            result.is_empty(),
567            "Should not flag headings separated by horizontal rules"
568        );
569
570        // Test that headings without separators are still flagged
571        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
572        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
573        let result = rule.check(&ctx).unwrap();
574        assert_eq!(result.len(), 1, "Should flag the heading without separator");
575        assert_eq!(result[0].line, 11); // Third title on line 11
576
577        // Test with allow_with_separators = false
578        let strict_rule = MD025SingleTitle::strict();
579        let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
580        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581        let result = strict_rule.check(&ctx).unwrap();
582        assert_eq!(
583            result.len(),
584            1,
585            "Strict mode should flag all multiple H1s regardless of separators"
586        );
587    }
588
589    #[test]
590    fn test_python_comments_in_code_blocks() {
591        let rule = MD025SingleTitle::default();
592
593        // Test that Python comments in code blocks are not treated as headers
594        let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
595        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597        assert!(
598            result.is_empty(),
599            "Should not flag Python comments in code blocks as headings"
600        );
601
602        // Test the fix method doesn't modify Python comments
603        let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
604        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
605        let fixed = rule.fix(&ctx).unwrap();
606        assert!(
607            fixed.contains("# Python comment"),
608            "Fix should preserve Python comments in code blocks"
609        );
610        assert!(
611            fixed.contains("## Second Title"),
612            "Fix should demote the actual second heading"
613        );
614    }
615
616    #[test]
617    fn test_fix_preserves_attribute_lists() {
618        let rule = MD025SingleTitle::strict();
619
620        // Duplicate H1 with attribute list - fix should demote to H2 while preserving attrs
621        let content = "# First Title\n\n# Second Title { #custom-id .special }";
622        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623
624        // Should flag the second H1
625        let warnings = rule.check(&ctx).unwrap();
626        assert_eq!(warnings.len(), 1);
627        // Per-warning fix demotes the heading itself (cascade is handled by fix() method)
628        assert!(warnings[0].fix.is_some());
629
630        // Verify fix() preserves attribute list
631        let fixed = rule.fix(&ctx).unwrap();
632        assert!(
633            fixed.contains("## Second Title { #custom-id .special }"),
634            "fix() should demote to H2 while preserving attribute list, got: {fixed}"
635        );
636    }
637
638    #[test]
639    fn test_frontmatter_title_counts_as_h1() {
640        let rule = MD025SingleTitle::default();
641
642        // Frontmatter with title + one body H1 → should warn on the body H1
643        let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
644        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
645        let result = rule.check(&ctx).unwrap();
646        assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
647        assert_eq!(result[0].line, 5);
648    }
649
650    #[test]
651    fn test_frontmatter_title_with_multiple_body_h1s() {
652        let config = md025_config::MD025Config {
653            front_matter_title: "title".to_string(),
654            ..Default::default()
655        };
656        let rule = MD025SingleTitle::from_config_struct(config);
657
658        // Frontmatter with title + multiple body H1s → should warn on ALL body H1s
659        let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
660        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662        assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
663        assert_eq!(result[0].line, 5);
664        assert_eq!(result[1].line, 9);
665    }
666
667    #[test]
668    fn test_frontmatter_without_title_no_warning() {
669        let rule = MD025SingleTitle::default();
670
671        // Frontmatter without title key + one body H1 → no warning
672        let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
673        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
674        let result = rule.check(&ctx).unwrap();
675        assert!(result.is_empty(), "Should not flag when frontmatter has no title");
676    }
677
678    #[test]
679    fn test_no_frontmatter_single_h1_no_warning() {
680        let rule = MD025SingleTitle::default();
681
682        // No frontmatter + single body H1 → no warning
683        let content = "# Only Heading\n\nSome content.";
684        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
685        let result = rule.check(&ctx).unwrap();
686        assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
687    }
688
689    #[test]
690    fn test_frontmatter_custom_title_key() {
691        // Custom front_matter_title key
692        let config = md025_config::MD025Config {
693            front_matter_title: "heading".to_string(),
694            ..Default::default()
695        };
696        let rule = MD025SingleTitle::from_config_struct(config);
697
698        // Frontmatter with "heading:" key → should count as H1
699        let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
700        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let result = rule.check(&ctx).unwrap();
702        assert_eq!(
703            result.len(),
704            1,
705            "Should flag body H1 when custom frontmatter key matches"
706        );
707        assert_eq!(result[0].line, 5);
708
709        // Frontmatter with "title:" but configured for "heading:" → should not count
710        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
711        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
712        let result = rule.check(&ctx).unwrap();
713        assert!(
714            result.is_empty(),
715            "Should not flag when frontmatter key doesn't match config"
716        );
717    }
718
719    #[test]
720    fn test_frontmatter_title_empty_config_disables() {
721        // Empty front_matter_title disables frontmatter title detection
722        let rule = MD025SingleTitle::new(1, "");
723
724        let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
725        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726        let result = rule.check(&ctx).unwrap();
727        assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
728    }
729
730    #[test]
731    fn test_frontmatter_title_with_level_config() {
732        // When level is set to 2, frontmatter title counts as the first heading at that level
733        let config = md025_config::MD025Config {
734            level: HeadingLevel::new(2).unwrap(),
735            front_matter_title: "title".to_string(),
736            ..Default::default()
737        };
738        let rule = MD025SingleTitle::from_config_struct(config);
739
740        // Frontmatter with title + body H2 → should flag body H2
741        let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
742        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
743        let result = rule.check(&ctx).unwrap();
744        assert_eq!(
745            result.len(),
746            1,
747            "Should flag body H2 when level=2 and frontmatter has title"
748        );
749        assert_eq!(result[0].line, 7);
750    }
751
752    #[test]
753    fn test_frontmatter_title_fix_demotes_body_heading() {
754        let config = md025_config::MD025Config {
755            front_matter_title: "title".to_string(),
756            ..Default::default()
757        };
758        let rule = MD025SingleTitle::from_config_struct(config);
759
760        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
761        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let fixed = rule.fix(&ctx).unwrap();
763        assert!(
764            fixed.contains("## Body Heading"),
765            "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
766        );
767        // Frontmatter should be preserved
768        assert!(fixed.contains("---\ntitle: FM Title\n---"));
769    }
770
771    #[test]
772    fn test_frontmatter_title_should_skip_respects_frontmatter() {
773        let rule = MD025SingleTitle::default();
774
775        // With frontmatter title + 1 body H1, should_skip should return false
776        let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
777        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
778        assert!(
779            !rule.should_skip(&ctx),
780            "should_skip must return false when frontmatter has title and body has H1"
781        );
782
783        // Without frontmatter title + 1 body H1, should_skip should return true
784        let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
785        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        assert!(
787            rule.should_skip(&ctx),
788            "should_skip should return true with no frontmatter title and single H1"
789        );
790    }
791
792    #[test]
793    fn test_section_indicator_whole_word_matching() {
794        // Bug: substring matching causes false matches (e.g., "reindex" matches " index")
795        let config = md025_config::MD025Config {
796            allow_document_sections: true,
797            ..Default::default()
798        };
799        let rule = MD025SingleTitle::from_config_struct(config);
800
801        // These should NOT match section indicators (they contain indicators as substrings)
802        let false_positive_cases = vec![
803            "# Main Title\n\n# Understanding Reindex Operations",
804            "# Main Title\n\n# The Summarization Pipeline",
805            "# Main Title\n\n# Data Indexing Strategy",
806            "# Main Title\n\n# Unsupported Browsers",
807        ];
808
809        for case in false_positive_cases {
810            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
811            let result = rule.check(&ctx).unwrap();
812            assert_eq!(
813                result.len(),
814                1,
815                "Should flag duplicate H1 (not a section indicator): {case}"
816            );
817        }
818
819        // These SHOULD still match as legitimate section indicators
820        let true_positive_cases = vec![
821            "# Main Title\n\n# Index",
822            "# Main Title\n\n# Summary",
823            "# Main Title\n\n# About",
824            "# Main Title\n\n# References",
825        ];
826
827        for case in true_positive_cases {
828            let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
829            let result = rule.check(&ctx).unwrap();
830            assert!(result.is_empty(), "Should allow section indicator heading: {case}");
831        }
832    }
833}