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