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