Skip to main content

rumdl_lib/rules/
md041_first_line_heading.rs

1mod md041_config;
2
3pub(super) use md041_config::MD041Config;
4
5use crate::filtered_lines::FilteredLinesExt;
6use crate::lint_context::HeadingStyle;
7use crate::rule::{Fix, FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
8use crate::rules::front_matter_utils::FrontMatterUtils;
9use crate::utils::mkdocs_attr_list::is_mkdocs_anchor_line;
10use crate::utils::range_utils::calculate_line_range;
11use crate::utils::regex_cache::HTML_HEADING_PATTERN;
12use regex::Regex;
13
14/// Rule MD041: First line in file should be a top-level heading
15///
16/// See [docs/md041.md](../../docs/md041.md) for full documentation, configuration, and examples.
17
18#[derive(Clone)]
19pub struct MD041FirstLineHeading {
20    pub level: usize,
21    pub front_matter_title: bool,
22    pub front_matter_title_pattern: Option<Regex>,
23    pub allow_preamble: bool,
24    pub fix_enabled: bool,
25}
26
27impl Default for MD041FirstLineHeading {
28    fn default() -> Self {
29        Self {
30            level: 1,
31            front_matter_title: true,
32            front_matter_title_pattern: None,
33            allow_preamble: false,
34            fix_enabled: false,
35        }
36    }
37}
38
39/// How to make this document compliant with MD041 (internal helper)
40enum FixPlan {
41    /// Move an existing heading to the top (after front matter), optionally releveling it.
42    MoveOrRelevel {
43        front_matter_end_idx: usize,
44        heading_idx: usize,
45        is_setext: bool,
46        current_level: usize,
47        needs_level_fix: bool,
48    },
49    /// Promote the first plain-text title line to a level-N heading, moving it to the top.
50    PromotePlainText {
51        front_matter_end_idx: usize,
52        title_line_idx: usize,
53        title_text: String,
54    },
55    /// Insert a heading derived from the source filename at the top of the document.
56    /// Used when the document contains only directive blocks and no heading or title line.
57    InsertDerived {
58        front_matter_end_idx: usize,
59        derived_title: String,
60    },
61    /// Rewrite an existing heading to the required level, leaving it where it is.
62    /// Used when preamble is allowed: moving the heading to the top would delete the
63    /// preamble that the configuration exists to permit.
64    RelevelInPlace {
65        heading_idx: usize,
66        is_setext: bool,
67        current_level: usize,
68    },
69}
70
71impl MD041FirstLineHeading {
72    pub fn new(level: usize, front_matter_title: bool) -> Self {
73        Self {
74            level,
75            front_matter_title,
76            front_matter_title_pattern: None,
77            allow_preamble: false,
78            fix_enabled: false,
79        }
80    }
81
82    pub fn with_pattern(level: usize, front_matter_title: bool, pattern: Option<String>, fix_enabled: bool) -> Self {
83        Self::with_pattern_from(level, front_matter_title, pattern, fix_enabled, false)
84    }
85
86    /// [`Self::with_pattern`], told whether a message about the pattern may quote it.
87    /// See [`crate::rule_config_serde::compile_config_regex`].
88    fn with_pattern_from(
89        level: usize,
90        front_matter_title: bool,
91        pattern: Option<String>,
92        fix_enabled: bool,
93        values_withheld: bool,
94    ) -> Self {
95        let front_matter_title_pattern = pattern.and_then(|p| {
96            crate::rule_config_serde::compile_config_regex(&p, "MD041", "front-matter-title-pattern", values_withheld)
97        });
98
99        Self {
100            level,
101            front_matter_title,
102            front_matter_title_pattern,
103            allow_preamble: false,
104            fix_enabled,
105        }
106    }
107
108    /// Allow content before the document's first heading.
109    pub fn with_allow_preamble(mut self, allow_preamble: bool) -> Self {
110        self.allow_preamble = allow_preamble;
111        self
112    }
113
114    fn has_front_matter_title(&self, content: &str) -> bool {
115        if !self.front_matter_title {
116            return false;
117        }
118
119        // If we have a custom pattern, use it to search front matter content
120        if let Some(ref pattern) = self.front_matter_title_pattern {
121            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
122            for line in front_matter_lines {
123                if pattern.is_match(line) {
124                    return true;
125                }
126            }
127            return false;
128        }
129
130        // Default behavior: check for "title:" field
131        FrontMatterUtils::has_front_matter_field(content, "title:")
132    }
133
134    /// Check if a line is a non-content token that should be skipped
135    fn is_non_content_line(line: &str) -> bool {
136        let trimmed = line.trim();
137
138        // Skip reference definitions
139        if trimmed.starts_with('[') && trimmed.contains("]: ") {
140            return true;
141        }
142
143        // Skip abbreviation definitions
144        if trimmed.starts_with('*') && trimmed.contains("]: ") {
145            return true;
146        }
147
148        // Skip badge/shield images - common pattern at top of READMEs
149        // Matches: ![badge](url) or [![badge](url)](url)
150        if Self::is_badge_image_line(trimmed) {
151            return true;
152        }
153
154        false
155    }
156
157    /// Find the first content line index (0-indexed) in the document.
158    ///
159    /// Skips front matter, blank lines, HTML/MDX comments, ESM blocks,
160    /// kramdown extensions, MkDocs anchors, reference definitions, and badges.
161    /// Used by both check() and fix() to ensure consistent behavior.
162    fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
163        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
164
165        let filtered = ctx
166            .filtered_lines()
167            .skip_front_matter()
168            .skip_esm_blocks()
169            .skip_html_comments()
170            .skip_mdx_comments();
171
172        for filtered_line in filtered {
173            let idx = filtered_line.line_num - 1;
174            let line_info = &ctx.lines[idx];
175
176            if line_info.is_blank || line_info.is_kramdown_block_ial {
177                continue;
178            }
179
180            let line_content = filtered_line.content;
181            if is_mkdocs && is_mkdocs_anchor_line(line_content) {
182                continue;
183            }
184            if Self::is_non_content_line(line_content) {
185                continue;
186            }
187            return Some(idx);
188        }
189        None
190    }
191
192    /// Find the index (0-indexed) of the document's first top-level heading.
193    ///
194    /// Used when preamble is allowed, where the rule judges the level of the first
195    /// heading rather than requiring the document to open with one. Only top-level
196    /// headings count: one inside a list, blockquote, directive block or HTML element
197    /// is that container's content, so the scan passes over it and keeps looking.
198    /// Returns `None` for a document with no top-level heading, which the rule then
199    /// has nothing to judge.
200    fn first_top_level_heading_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> {
201        for (idx, line_info) in ctx.lines.iter().enumerate() {
202            if line_info.is_blank
203                || line_info.in_front_matter
204                || line_info.in_code_block
205                || line_info.in_html_comment
206                || line_info.in_mdx_comment
207                || line_info.in_math_block
208            {
209                continue;
210            }
211
212            let in_container = line_info.in_list_block
213                || line_info.blockquote.is_some()
214                || line_info.in_admonition
215                || line_info.in_content_tab
216                || line_info.in_pandoc_div
217                || line_info.in_pymdown_block
218                || line_info.in_kramdown_extension_block;
219            if in_container {
220                continue;
221            }
222
223            if line_info.heading.is_some() {
224                return Some(idx);
225            }
226
227            // An HTML heading counts only where its block begins. An `<h1>` on a later
228            // line of the block is nested in whatever element opened it.
229            let continues_html_block = idx > 0 && line_info.in_html_block && ctx.lines[idx - 1].in_html_block;
230            if !continues_html_block && (1..=6).any(|level| Self::is_html_heading(ctx, idx, level)) {
231                return Some(idx);
232            }
233        }
234        None
235    }
236
237    /// The line this rule judges, which is also the line it reports on and the line
238    /// whose inline directives govern it.
239    ///
240    /// Allowing preamble moves the subject of the rule from the document's first
241    /// content line to its first heading, so a document with no heading has nothing
242    /// to judge.
243    fn checked_line_idx(&self, ctx: &crate::lint_context::LintContext) -> Option<usize> {
244        if self.allow_preamble {
245            Self::first_top_level_heading_idx(ctx)
246        } else {
247            Self::first_content_line_idx(ctx)
248        }
249    }
250
251    /// Check if a line consists only of badge/shield images
252    /// Common patterns:
253    /// - `![badge](url)`
254    /// - `[![badge](url)](url)` (linked badge)
255    /// - Multiple badges on one line
256    fn is_badge_image_line(line: &str) -> bool {
257        if line.is_empty() {
258            return false;
259        }
260
261        // Must start with image syntax
262        if !line.starts_with('!') && !line.starts_with('[') {
263            return false;
264        }
265
266        // Check if line contains only image/link patterns and whitespace
267        let mut remaining = line;
268        while !remaining.is_empty() {
269            remaining = remaining.trim_start();
270            if remaining.is_empty() {
271                break;
272            }
273
274            // Linked image: [![alt](img-url)](link-url)
275            if remaining.starts_with("[![") {
276                if let Some(end) = Self::find_linked_image_end(remaining) {
277                    remaining = &remaining[end..];
278                    continue;
279                }
280                return false;
281            }
282
283            // Simple image: ![alt](url)
284            if remaining.starts_with("![") {
285                if let Some(end) = Self::find_image_end(remaining) {
286                    remaining = &remaining[end..];
287                    continue;
288                }
289                return false;
290            }
291
292            // Not an image pattern
293            return false;
294        }
295
296        true
297    }
298
299    /// Find the end of an image pattern ![alt](url)
300    fn find_image_end(s: &str) -> Option<usize> {
301        if !s.starts_with("![") {
302            return None;
303        }
304        // Find ]( after ![
305        let alt_end = s[2..].find("](")?;
306        let paren_start = 2 + alt_end + 2; // Position after ](
307        // Find closing )
308        let paren_end = s[paren_start..].find(')')?;
309        Some(paren_start + paren_end + 1)
310    }
311
312    /// Find the end of a linked image pattern [![alt](img-url)](link-url)
313    fn find_linked_image_end(s: &str) -> Option<usize> {
314        if !s.starts_with("[![") {
315            return None;
316        }
317        // Find the inner image first
318        let inner_end = Self::find_image_end(&s[1..])?;
319        let after_inner = 1 + inner_end;
320        // Should be followed by ](url)
321        if !s[after_inner..].starts_with("](") {
322            return None;
323        }
324        let link_start = after_inner + 2;
325        let link_end = s[link_start..].find(')')?;
326        Some(link_start + link_end + 1)
327    }
328
329    /// Fix a heading line to use the specified level
330    fn fix_heading_level(&self, line: &str, _current_level: usize, target_level: usize) -> String {
331        let trimmed = line.trim_start();
332
333        // ATX-style heading (# Heading)
334        if trimmed.starts_with('#') {
335            let hashes = "#".repeat(target_level);
336            // Find where the content starts (after # and optional space)
337            let content_start = trimmed.chars().position(|c| c != '#').unwrap_or(trimmed.len());
338            let after_hashes = &trimmed[content_start..];
339            let content = after_hashes.trim_start();
340
341            // Preserve leading whitespace from original line
342            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
343            format!("{leading_ws}{hashes} {content}")
344        } else {
345            // Setext-style heading - convert to ATX
346            // The underline would be on the next line, so we just convert the text line
347            let hashes = "#".repeat(target_level);
348            let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
349            format!("{leading_ws}{hashes} {trimmed}")
350        }
351    }
352
353    /// Returns true if `text` looks like a document title rather than a body paragraph.
354    ///
355    /// Criteria:
356    /// - Non-empty and ≤80 characters
357    /// - Does not end with sentence-ending punctuation (. ? ! : ;)
358    /// - Not a Markdown structural element (heading, list, blockquote)
359    /// - Followed by a blank line or EOF (visually separated from body text)
360    fn is_title_candidate(text: &str, next_is_blank_or_eof: bool) -> bool {
361        if text.is_empty() {
362            return false;
363        }
364
365        if !next_is_blank_or_eof {
366            return false;
367        }
368
369        if text.len() > 80 {
370            return false;
371        }
372
373        let last_char = text.chars().next_back().unwrap_or(' ');
374        if matches!(last_char, '.' | '?' | '!' | ':' | ';') {
375            return false;
376        }
377
378        // Already a heading or structural Markdown element
379        if text.starts_with('#')
380            || text.starts_with("- ")
381            || text.starts_with("* ")
382            || text.starts_with("+ ")
383            || text.starts_with("> ")
384        {
385            return false;
386        }
387
388        true
389    }
390
391    /// Derive a title string from the source file's stem.
392    /// Converts kebab-case and underscores to Title Case words.
393    /// Returns None when no source file is available.
394    fn derive_title(ctx: &crate::lint_context::LintContext) -> Option<String> {
395        let path = ctx.source_file.as_ref()?;
396        let stem = path.file_stem().and_then(|s| s.to_str())?;
397
398        // For index/readme files, use the parent directory name instead.
399        // If no parent directory exists, return None — "Index" or "README" are not useful titles.
400        let effective_stem = if stem.eq_ignore_ascii_case("index") || stem.eq_ignore_ascii_case("readme") {
401            path.parent().and_then(|p| p.file_name()).and_then(|s| s.to_str())?
402        } else {
403            stem
404        };
405
406        let title: String = effective_stem
407            .split(['-', '_'])
408            .filter(|w| !w.is_empty())
409            .map(|word| {
410                let mut chars = word.chars();
411                match chars.next() {
412                    None => String::new(),
413                    Some(first) => {
414                        let upper: String = first.to_uppercase().collect();
415                        upper + chars.as_str()
416                    }
417                }
418            })
419            .collect::<Vec<_>>()
420            .join(" ");
421
422        if title.is_empty() { None } else { Some(title) }
423    }
424
425    /// Check if a line is an HTML heading using the centralized HTML parser
426    fn is_html_heading(ctx: &crate::lint_context::LintContext, first_line_idx: usize, level: usize) -> bool {
427        // Check for single-line HTML heading using regex (fast path)
428        let first_line_content = ctx.lines[first_line_idx].content(ctx.content);
429        if let Ok(Some(captures)) = HTML_HEADING_PATTERN.captures(first_line_content.trim())
430            && let Some(h_level) = captures.get(1)
431            && h_level.as_str().parse::<usize>().unwrap_or(0) == level
432        {
433            return true;
434        }
435
436        // Use centralized HTML parser for multi-line headings
437        let html_tags = ctx.html_tags();
438        let target_tag = format!("h{level}");
439
440        // Find opening tag on first line
441        let opening_index = html_tags.iter().position(|tag| {
442            tag.line == first_line_idx + 1 // HtmlTag uses 1-indexed lines
443                && tag.tag_name == target_tag
444                && !tag.is_closing
445        });
446
447        let Some(open_idx) = opening_index else {
448            return false;
449        };
450
451        // Walk HTML tags to find the corresponding closing tag, allowing arbitrary nesting depth.
452        // This avoids brittle line-count heuristics and handles long headings with nested content.
453        let mut depth = 1usize;
454        for tag in html_tags.iter().skip(open_idx + 1) {
455            // Ignore tags that appear before the first heading line (possible when multiple tags share a line)
456            if tag.line <= first_line_idx + 1 {
457                continue;
458            }
459
460            if tag.tag_name == target_tag {
461                if tag.is_closing {
462                    depth -= 1;
463                    if depth == 0 {
464                        return true;
465                    }
466                } else if !tag.is_self_closing {
467                    depth += 1;
468                }
469            }
470        }
471
472        false
473    }
474
475    /// Analyze the document to determine how (if at all) it can be auto-fixed.
476    fn analyze_for_fix(&self, ctx: &crate::lint_context::LintContext) -> Option<FixPlan> {
477        if ctx.lines.is_empty() {
478            return None;
479        }
480
481        // Preamble is allowed, so the only safe repair is releveling the heading where
482        // it stands. Every other plan promotes something to the top of the document,
483        // which would remove the preamble this configuration permits.
484        if self.allow_preamble {
485            let heading_idx = Self::first_top_level_heading_idx(ctx)?;
486            let heading = ctx.lines[heading_idx].heading.as_ref()?;
487            if heading.level as usize == self.level {
488                return None;
489            }
490            return Some(FixPlan::RelevelInPlace {
491                heading_idx,
492                is_setext: matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2),
493                current_level: heading.level as usize,
494            });
495        }
496
497        // Find front matter end (handles YAML, TOML, JSON, malformed)
498        let mut front_matter_end_idx = 0;
499        for line_info in &ctx.lines {
500            if line_info.in_front_matter {
501                front_matter_end_idx += 1;
502            } else {
503                break;
504            }
505        }
506
507        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
508
509        // (idx, is_setext, current_level) of the first ATX/Setext heading found
510        let mut found_heading: Option<(usize, bool, usize)> = None;
511        // First non-preamble, non-directive line that looks like a title
512        let mut first_title_candidate: Option<(usize, String)> = None;
513        // True once we see a non-preamble, non-directive line that is NOT a title candidate
514        let mut found_non_title_content = false;
515        // True when any non-directive, non-preamble line is encountered
516        let mut saw_non_directive_content = false;
517
518        'scan: for (idx, line_info) in ctx.lines.iter().enumerate().skip(front_matter_end_idx) {
519            let line_content = line_info.content(ctx.content);
520            let trimmed = line_content.trim();
521
522            // Preamble: invisible/structural tokens that don't count as content
523            let is_preamble = trimmed.is_empty()
524                || line_info.in_html_comment
525                || line_info.in_mdx_comment
526                || line_info.in_html_block
527                || Self::is_non_content_line(line_content)
528                || (is_mkdocs && is_mkdocs_anchor_line(line_content))
529                || line_info.in_kramdown_extension_block
530                || line_info.is_kramdown_block_ial;
531
532            if is_preamble {
533                continue;
534            }
535
536            // Directive blocks (admonitions, content tabs, Quarto/Pandoc divs, PyMdown Blocks)
537            // are structural containers, not narrative content.
538            let is_directive_block = line_info.in_admonition
539                || line_info.in_content_tab
540                || line_info.in_pandoc_div
541                || line_info.is_div_marker
542                || line_info.in_pymdown_block;
543
544            if !is_directive_block {
545                saw_non_directive_content = true;
546            }
547
548            // ATX or Setext heading (HTML headings cannot be moved/converted)
549            if let Some(heading) = &line_info.heading {
550                let is_setext = matches!(heading.style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
551                found_heading = Some((idx, is_setext, heading.level as usize));
552                break 'scan;
553            }
554
555            // Track non-heading, non-directive content for PromotePlainText detection
556            if !is_directive_block && !found_non_title_content && first_title_candidate.is_none() {
557                let next_is_blank_or_eof = ctx
558                    .lines
559                    .get(idx + 1)
560                    .is_none_or(|l| l.content(ctx.content).trim().is_empty());
561
562                if Self::is_title_candidate(trimmed, next_is_blank_or_eof) {
563                    first_title_candidate = Some((idx, trimmed.to_string()));
564                } else {
565                    found_non_title_content = true;
566                }
567            }
568        }
569
570        if let Some((h_idx, is_setext, current_level)) = found_heading {
571            // Heading exists. Can we move/relevel it?
572            // If real content or a title candidate appeared before it, the heading is not the
573            // first significant element - reordering would change document meaning.
574            if found_non_title_content || first_title_candidate.is_some() {
575                return None;
576            }
577
578            let needs_level_fix = current_level != self.level;
579            let needs_move = h_idx > front_matter_end_idx;
580
581            if needs_level_fix || needs_move {
582                return Some(FixPlan::MoveOrRelevel {
583                    front_matter_end_idx,
584                    heading_idx: h_idx,
585                    is_setext,
586                    current_level,
587                    needs_level_fix,
588                });
589            }
590            return None; // Already at the correct position and level
591        }
592
593        // No heading found. Try to create one.
594
595        if let Some((title_idx, title_text)) = first_title_candidate {
596            return Some(FixPlan::PromotePlainText {
597                front_matter_end_idx,
598                title_line_idx: title_idx,
599                title_text,
600            });
601        }
602
603        // Document has no heading and no title candidate. If it contains only directive
604        // blocks (plus preamble), we can insert a heading derived from the filename.
605        if !saw_non_directive_content && let Some(derived_title) = Self::derive_title(ctx) {
606            return Some(FixPlan::InsertDerived {
607                front_matter_end_idx,
608                derived_title,
609            });
610        }
611
612        None
613    }
614
615    /// Determine if this document can be auto-fixed.
616    fn can_fix(&self, ctx: &crate::lint_context::LintContext) -> bool {
617        self.fix_enabled && self.analyze_for_fix(ctx).is_some()
618    }
619}
620
621impl Rule for MD041FirstLineHeading {
622    fn name(&self) -> &'static str {
623        "MD041"
624    }
625
626    fn description(&self) -> &'static str {
627        "First line in file should be a top level heading"
628    }
629
630    /// Fixing is opt-in: adding a document title is a content decision, so the rule
631    /// reports no fix capability until `fix = true` turns it on. Once on it is only
632    /// ever conditional, because a document with no heading to promote or move has
633    /// nothing the fixer can safely do.
634    fn fix_capability(&self) -> FixCapability {
635        if self.fix_enabled {
636            FixCapability::ConditionallyFixable
637        } else {
638            FixCapability::Unfixable
639        }
640    }
641
642    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
643        let mut warnings = Vec::new();
644
645        // Check if we should skip this file
646        if self.should_skip(ctx) {
647            return Ok(warnings);
648        }
649
650        let Some(first_line_idx) = self.checked_line_idx(ctx) else {
651            return Ok(warnings);
652        };
653
654        // Check if the first non-blank line is a heading of the required level
655        let first_line_info = &ctx.lines[first_line_idx];
656        let is_correct_heading = if let Some(heading) = &first_line_info.heading {
657            heading.level as usize == self.level
658        } else {
659            // Check for HTML heading (both single-line and multi-line)
660            Self::is_html_heading(ctx, first_line_idx, self.level)
661        };
662
663        if !is_correct_heading {
664            // Calculate precise character range for the entire first line
665            let first_line = first_line_idx + 1; // Convert to 1-indexed
666            let first_line_content = first_line_info.content(ctx.content);
667            let (start_line, start_col, end_line, end_col) = calculate_line_range(first_line, first_line_content);
668
669            // Compute the actual replacement so that LSP quick-fix can apply it
670            // directly without calling fix(). For simple cases (releveling,
671            // promote-plain-text at the first content line), we use a targeted
672            // range. For complex cases (moving headings, inserting derived
673            // titles), we replace the entire document via fix().
674            let fix = if self.can_fix(ctx) {
675                self.analyze_for_fix(ctx).and_then(|plan| {
676                    let range_start = first_line_info.byte_offset;
677                    let range_end = range_start + first_line_info.byte_len;
678                    match &plan {
679                        FixPlan::MoveOrRelevel {
680                            heading_idx,
681                            current_level,
682                            needs_level_fix,
683                            is_setext,
684                            ..
685                        } if *heading_idx == first_line_idx => {
686                            // Heading is already at the correct position, just needs releveling
687                            let heading_line = ctx.lines[*heading_idx].content(ctx.content);
688                            let replacement = if *needs_level_fix || *is_setext {
689                                self.fix_heading_level(heading_line, *current_level, self.level)
690                            } else {
691                                heading_line.to_string()
692                            };
693                            Some(Fix::new(range_start..range_end, replacement))
694                        }
695                        FixPlan::RelevelInPlace {
696                            heading_idx,
697                            current_level,
698                            is_setext,
699                        } if *heading_idx == first_line_idx && !*is_setext => {
700                            let replacement = self.fix_heading_level(
701                                ctx.lines[*heading_idx].content(ctx.content),
702                                *current_level,
703                                self.level,
704                            );
705                            Some(Fix::new(range_start..range_end, replacement))
706                        }
707                        FixPlan::PromotePlainText { title_line_idx, .. } if *title_line_idx == first_line_idx => {
708                            let replacement = format!(
709                                "{} {}",
710                                "#".repeat(self.level),
711                                ctx.lines[*title_line_idx].content(ctx.content).trim()
712                            );
713                            Some(Fix::new(range_start..range_end, replacement))
714                        }
715                        _ => {
716                            // Complex multi-line operations (moving headings, inserting
717                            // derived titles, promoting non-first-line text): replace
718                            // the entire document via fix().
719                            self.fix(ctx)
720                                .ok()
721                                .map(|fixed_content| Fix::new(0..ctx.content.len(), fixed_content))
722                        }
723                    }
724                })
725            } else {
726                None
727            };
728
729            warnings.push(LintWarning {
730                rule_name: Some(self.name().to_string()),
731                line: start_line,
732                column: start_col,
733                end_line,
734                end_column: end_col,
735                message: if self.allow_preamble {
736                    format!("First heading in file should be a level {} heading", self.level)
737                } else {
738                    format!("First line in file should be a level {} heading", self.level)
739                },
740                severity: Severity::Warning,
741                fix,
742            });
743        }
744        Ok(warnings)
745    }
746
747    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
748        if !self.fix_enabled {
749            return Ok(ctx.content.to_string());
750        }
751
752        if self.should_skip(ctx) {
753            return Ok(ctx.content.to_string());
754        }
755
756        // Respect inline disable comments, resolving the line the same way check()
757        // does so a directive suppresses exactly the warning it appears to suppress.
758        let checked_line = self.checked_line_idx(ctx).map_or(1, |i| i + 1);
759        if ctx.inline_config().is_rule_disabled(self.name(), checked_line) {
760            return Ok(ctx.content.to_string());
761        }
762
763        let Some(plan) = self.analyze_for_fix(ctx) else {
764            return Ok(ctx.content.to_string());
765        };
766
767        let lines = ctx.raw_lines();
768
769        let mut result = String::new();
770        let preserve_trailing_newline = ctx.content.ends_with('\n');
771
772        match plan {
773            FixPlan::MoveOrRelevel {
774                front_matter_end_idx,
775                heading_idx,
776                is_setext,
777                current_level,
778                needs_level_fix,
779            } => {
780                let heading_line = ctx.lines[heading_idx].content(ctx.content);
781                let fixed_heading = if needs_level_fix || is_setext {
782                    self.fix_heading_level(heading_line, current_level, self.level)
783                } else {
784                    heading_line.to_string()
785                };
786
787                for line in lines.iter().take(front_matter_end_idx) {
788                    result.push_str(line);
789                    result.push('\n');
790                }
791                result.push_str(&fixed_heading);
792                result.push('\n');
793                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
794                    if idx == heading_idx {
795                        continue;
796                    }
797                    if is_setext && idx == heading_idx + 1 {
798                        continue;
799                    }
800                    result.push_str(line);
801                    result.push('\n');
802                }
803            }
804
805            FixPlan::PromotePlainText {
806                front_matter_end_idx,
807                title_line_idx,
808                title_text,
809            } => {
810                let hashes = "#".repeat(self.level);
811                let new_heading = format!("{hashes} {title_text}");
812
813                for line in lines.iter().take(front_matter_end_idx) {
814                    result.push_str(line);
815                    result.push('\n');
816                }
817                result.push_str(&new_heading);
818                result.push('\n');
819                for (idx, line) in lines.iter().enumerate().skip(front_matter_end_idx) {
820                    if idx == title_line_idx {
821                        continue;
822                    }
823                    result.push_str(line);
824                    result.push('\n');
825                }
826            }
827
828            FixPlan::RelevelInPlace {
829                heading_idx,
830                is_setext,
831                current_level,
832            } => {
833                for (idx, line) in lines.iter().enumerate() {
834                    if idx == heading_idx {
835                        result.push_str(&self.fix_heading_level(line, current_level, self.level));
836                        result.push('\n');
837                        continue;
838                    }
839                    // The underline is gone: releveling rewrites a setext heading as ATX.
840                    if is_setext && idx == heading_idx + 1 {
841                        continue;
842                    }
843                    result.push_str(line);
844                    result.push('\n');
845                }
846            }
847
848            FixPlan::InsertDerived {
849                front_matter_end_idx,
850                derived_title,
851            } => {
852                let hashes = "#".repeat(self.level);
853                let new_heading = format!("{hashes} {derived_title}");
854
855                for line in lines.iter().take(front_matter_end_idx) {
856                    result.push_str(line);
857                    result.push('\n');
858                }
859                result.push_str(&new_heading);
860                result.push('\n');
861                result.push('\n');
862                for line in lines.iter().skip(front_matter_end_idx) {
863                    result.push_str(line);
864                    result.push('\n');
865                }
866            }
867        }
868
869        if !preserve_trailing_newline && result.ends_with('\n') {
870            result.pop();
871        }
872
873        Ok(result)
874    }
875
876    /// Check if this rule should be skipped
877    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
878        // Skip files that are purely preprocessor directives (e.g., mdBook includes).
879        // These files are composition/routing metadata, not standalone content.
880        // Example: A file containing only "{{#include ../../README.md}}" is a
881        // pointer to content, not content itself, and shouldn't need a heading.
882        let only_directives = !ctx.content.is_empty()
883            && ctx.content.lines().filter(|l| !l.trim().is_empty()).all(|l| {
884                let t = l.trim();
885                // mdBook directives: {{#include}}, {{#playground}}, {{#rustdoc_include}}, etc.
886                (t.starts_with("{{#") && t.ends_with("}}"))
887                        // HTML comments often accompany directives
888                        || (t.starts_with("<!--") && t.ends_with("-->"))
889            });
890
891        ctx.content.is_empty()
892            || (self.front_matter_title && self.has_front_matter_title(ctx.content))
893            || only_directives
894    }
895
896    fn as_any(&self) -> &dyn std::any::Any {
897        self
898    }
899
900    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
901    where
902        Self: Sized,
903    {
904        // Load config using serde with kebab-case support
905        let md041_config = crate::rule_config_serde::load_rule_config::<MD041Config>(config);
906
907        let use_front_matter = !md041_config.front_matter_title.is_empty();
908
909        Box::new(
910            MD041FirstLineHeading::with_pattern_from(
911                md041_config.level.as_usize(),
912                use_front_matter,
913                md041_config.front_matter_title_pattern,
914                md041_config.fix,
915                config.withheld_rule_values.contains("MD041"),
916            )
917            .with_allow_preamble(md041_config.allow_preamble),
918        )
919    }
920
921    fn default_config_section(&self) -> Option<(String, toml::Value)> {
922        Some((
923            "MD041".to_string(),
924            toml::toml! {
925                level = 1
926                front-matter-title = "title"
927                front-matter-title-pattern = ""
928                allow-preamble = false
929                fix = false
930            }
931            .into(),
932        ))
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use super::*;
939    use crate::lint_context::LintContext;
940
941    #[test]
942    fn test_first_line_is_heading_correct_level() {
943        let rule = MD041FirstLineHeading::default();
944
945        // First line is a level 1 heading (should pass)
946        let content = "# My Document\n\nSome content here.";
947        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948        let result = rule.check(&ctx).unwrap();
949        assert!(
950            result.is_empty(),
951            "Expected no warnings when first line is a level 1 heading"
952        );
953    }
954
955    #[test]
956    fn test_first_line_is_heading_wrong_level() {
957        let rule = MD041FirstLineHeading::default();
958
959        // First line is a level 2 heading (should fail with level 1 requirement)
960        let content = "## My Document\n\nSome content here.";
961        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962        let result = rule.check(&ctx).unwrap();
963        assert_eq!(result.len(), 1);
964        assert_eq!(result[0].line, 1);
965        assert!(result[0].message.contains("level 1 heading"));
966    }
967
968    #[test]
969    fn test_first_line_not_heading() {
970        let rule = MD041FirstLineHeading::default();
971
972        // First line is plain text (should fail)
973        let content = "This is not a heading\n\n# This is a heading";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let result = rule.check(&ctx).unwrap();
976        assert_eq!(result.len(), 1);
977        assert_eq!(result[0].line, 1);
978        assert!(result[0].message.contains("level 1 heading"));
979    }
980
981    #[test]
982    fn test_empty_lines_before_heading() {
983        let rule = MD041FirstLineHeading::default();
984
985        // Empty lines before first heading (should pass - rule skips empty lines)
986        let content = "\n\n# My Document\n\nSome content.";
987        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988        let result = rule.check(&ctx).unwrap();
989        assert!(
990            result.is_empty(),
991            "Expected no warnings when empty lines precede a valid heading"
992        );
993
994        // Empty lines before non-heading content (should fail)
995        let content = "\n\nNot a heading\n\nSome content.";
996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
997        let result = rule.check(&ctx).unwrap();
998        assert_eq!(result.len(), 1);
999        assert_eq!(result[0].line, 3); // First non-empty line
1000        assert!(result[0].message.contains("level 1 heading"));
1001    }
1002
1003    #[test]
1004    fn test_front_matter_with_title() {
1005        let rule = MD041FirstLineHeading::new(1, true);
1006
1007        // Front matter with title field (should pass)
1008        let content = "---\ntitle: My Document\nauthor: John Doe\n---\n\nSome content here.";
1009        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1010        let result = rule.check(&ctx).unwrap();
1011        assert!(
1012            result.is_empty(),
1013            "Expected no warnings when front matter has title field"
1014        );
1015    }
1016
1017    #[test]
1018    fn test_front_matter_without_title() {
1019        let rule = MD041FirstLineHeading::new(1, true);
1020
1021        // Front matter without title field (should fail)
1022        let content = "---\nauthor: John Doe\ndate: 2024-01-01\n---\n\nSome content here.";
1023        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024        let result = rule.check(&ctx).unwrap();
1025        assert_eq!(result.len(), 1);
1026        assert_eq!(result[0].line, 6); // First content line after front matter
1027    }
1028
1029    #[test]
1030    fn test_front_matter_disabled() {
1031        let rule = MD041FirstLineHeading::new(1, false);
1032
1033        // Front matter with title field but front_matter_title is false (should fail)
1034        let content = "---\ntitle: My Document\n---\n\nSome content here.";
1035        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036        let result = rule.check(&ctx).unwrap();
1037        assert_eq!(result.len(), 1);
1038        assert_eq!(result[0].line, 5); // First content line after front matter
1039    }
1040
1041    #[test]
1042    fn test_html_comments_before_heading() {
1043        let rule = MD041FirstLineHeading::default();
1044
1045        // HTML comment before heading (should pass - comments are skipped, issue #155)
1046        let content = "<!-- This is a comment -->\n# My Document\n\nContent.";
1047        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048        let result = rule.check(&ctx).unwrap();
1049        assert!(
1050            result.is_empty(),
1051            "HTML comments should be skipped when checking for first heading"
1052        );
1053    }
1054
1055    #[test]
1056    fn test_multiline_html_comment_before_heading() {
1057        let rule = MD041FirstLineHeading::default();
1058
1059        // Multi-line HTML comment before heading (should pass - issue #155)
1060        let content = "<!--\nThis is a multi-line\nHTML comment\n-->\n# My Document\n\nContent.";
1061        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062        let result = rule.check(&ctx).unwrap();
1063        assert!(
1064            result.is_empty(),
1065            "Multi-line HTML comments should be skipped when checking for first heading"
1066        );
1067    }
1068
1069    #[test]
1070    fn test_html_comment_with_blank_lines_before_heading() {
1071        let rule = MD041FirstLineHeading::default();
1072
1073        // HTML comment with blank lines before heading (should pass - issue #155)
1074        let content = "<!-- This is a comment -->\n\n# My Document\n\nContent.";
1075        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1076        let result = rule.check(&ctx).unwrap();
1077        assert!(
1078            result.is_empty(),
1079            "HTML comments with blank lines should be skipped when checking for first heading"
1080        );
1081    }
1082
1083    #[test]
1084    fn test_html_comment_before_html_heading() {
1085        let rule = MD041FirstLineHeading::default();
1086
1087        // HTML comment before HTML heading (should pass - issue #155)
1088        let content = "<!-- This is a comment -->\n<h1>My Document</h1>\n\nContent.";
1089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1090        let result = rule.check(&ctx).unwrap();
1091        assert!(
1092            result.is_empty(),
1093            "HTML comments should be skipped before HTML headings"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_document_with_only_html_comments() {
1099        let rule = MD041FirstLineHeading::default();
1100
1101        // Document with only HTML comments (should pass - no warnings for comment-only files)
1102        let content = "<!-- This is a comment -->\n<!-- Another comment -->";
1103        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1104        let result = rule.check(&ctx).unwrap();
1105        assert!(
1106            result.is_empty(),
1107            "Documents with only HTML comments should not trigger MD041"
1108        );
1109    }
1110
1111    #[test]
1112    fn test_html_comment_followed_by_non_heading() {
1113        let rule = MD041FirstLineHeading::default();
1114
1115        // HTML comment followed by non-heading content (should still fail - issue #155)
1116        let content = "<!-- This is a comment -->\nThis is not a heading\n\nSome content.";
1117        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118        let result = rule.check(&ctx).unwrap();
1119        assert_eq!(
1120            result.len(),
1121            1,
1122            "HTML comment followed by non-heading should still trigger MD041"
1123        );
1124        assert_eq!(
1125            result[0].line, 2,
1126            "Warning should be on the first non-comment, non-heading line"
1127        );
1128    }
1129
1130    #[test]
1131    fn test_multiple_html_comments_before_heading() {
1132        let rule = MD041FirstLineHeading::default();
1133
1134        // Multiple HTML comments before heading (should pass - issue #155)
1135        let content = "<!-- First comment -->\n<!-- Second comment -->\n# My Document\n\nContent.";
1136        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137        let result = rule.check(&ctx).unwrap();
1138        assert!(
1139            result.is_empty(),
1140            "Multiple HTML comments should all be skipped before heading"
1141        );
1142    }
1143
1144    #[test]
1145    fn test_html_comment_with_wrong_level_heading() {
1146        let rule = MD041FirstLineHeading::default();
1147
1148        // HTML comment followed by wrong-level heading (should fail - issue #155)
1149        let content = "<!-- This is a comment -->\n## Wrong Level Heading\n\nContent.";
1150        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151        let result = rule.check(&ctx).unwrap();
1152        assert_eq!(
1153            result.len(),
1154            1,
1155            "HTML comment followed by wrong-level heading should still trigger MD041"
1156        );
1157        assert!(
1158            result[0].message.contains("level 1 heading"),
1159            "Should require level 1 heading"
1160        );
1161    }
1162
1163    #[test]
1164    fn test_html_comment_mixed_with_reference_definitions() {
1165        let rule = MD041FirstLineHeading::default();
1166
1167        // HTML comment mixed with reference definitions before heading (should pass - issue #155)
1168        let content = "<!-- Comment -->\n[ref]: https://example.com\n# My Document\n\nContent.";
1169        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170        let result = rule.check(&ctx).unwrap();
1171        assert!(
1172            result.is_empty(),
1173            "HTML comments and reference definitions should both be skipped before heading"
1174        );
1175    }
1176
1177    #[test]
1178    fn test_html_comment_after_front_matter() {
1179        let rule = MD041FirstLineHeading::default();
1180
1181        // HTML comment after front matter, before heading (should pass - issue #155)
1182        let content = "---\nauthor: John\n---\n<!-- Comment -->\n# My Document\n\nContent.";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184        let result = rule.check(&ctx).unwrap();
1185        assert!(
1186            result.is_empty(),
1187            "HTML comments after front matter should be skipped before heading"
1188        );
1189    }
1190
1191    #[test]
1192    fn test_html_comment_not_at_start_should_not_affect_rule() {
1193        let rule = MD041FirstLineHeading::default();
1194
1195        // HTML comment in middle of document should not affect MD041 check
1196        let content = "# Valid Heading\n\nSome content.\n\n<!-- Comment in middle -->\n\nMore content.";
1197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1198        let result = rule.check(&ctx).unwrap();
1199        assert!(
1200            result.is_empty(),
1201            "HTML comments in middle of document should not affect MD041 (only first content matters)"
1202        );
1203    }
1204
1205    #[test]
1206    fn test_multiline_html_comment_followed_by_non_heading() {
1207        let rule = MD041FirstLineHeading::default();
1208
1209        // Multi-line HTML comment followed by non-heading (should still fail - issue #155)
1210        let content = "<!--\nMulti-line\ncomment\n-->\nThis is not a heading\n\nContent.";
1211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1212        let result = rule.check(&ctx).unwrap();
1213        assert_eq!(
1214            result.len(),
1215            1,
1216            "Multi-line HTML comment followed by non-heading should still trigger MD041"
1217        );
1218        assert_eq!(
1219            result[0].line, 5,
1220            "Warning should be on the first non-comment, non-heading line"
1221        );
1222    }
1223
1224    #[test]
1225    fn test_different_heading_levels() {
1226        // Test with level 2 requirement
1227        let rule = MD041FirstLineHeading::new(2, false);
1228
1229        let content = "## Second Level Heading\n\nContent.";
1230        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231        let result = rule.check(&ctx).unwrap();
1232        assert!(result.is_empty(), "Expected no warnings for correct level 2 heading");
1233
1234        // Wrong level
1235        let content = "# First Level Heading\n\nContent.";
1236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237        let result = rule.check(&ctx).unwrap();
1238        assert_eq!(result.len(), 1);
1239        assert!(result[0].message.contains("level 2 heading"));
1240    }
1241
1242    #[test]
1243    fn test_setext_headings() {
1244        let rule = MD041FirstLineHeading::default();
1245
1246        // Setext style level 1 heading (should pass)
1247        let content = "My Document\n===========\n\nContent.";
1248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249        let result = rule.check(&ctx).unwrap();
1250        assert!(result.is_empty(), "Expected no warnings for setext level 1 heading");
1251
1252        // Setext style level 2 heading (should fail with level 1 requirement)
1253        let content = "My Document\n-----------\n\nContent.";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255        let result = rule.check(&ctx).unwrap();
1256        assert_eq!(result.len(), 1);
1257        assert!(result[0].message.contains("level 1 heading"));
1258    }
1259
1260    #[test]
1261    fn test_empty_document() {
1262        let rule = MD041FirstLineHeading::default();
1263
1264        // Empty document (should pass - no warnings)
1265        let content = "";
1266        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1267        let result = rule.check(&ctx).unwrap();
1268        assert!(result.is_empty(), "Expected no warnings for empty document");
1269    }
1270
1271    #[test]
1272    fn test_whitespace_only_document() {
1273        let rule = MD041FirstLineHeading::default();
1274
1275        // Document with only whitespace (should pass - no warnings)
1276        let content = "   \n\n   \t\n";
1277        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278        let result = rule.check(&ctx).unwrap();
1279        assert!(result.is_empty(), "Expected no warnings for whitespace-only document");
1280    }
1281
1282    #[test]
1283    fn test_front_matter_then_whitespace() {
1284        let rule = MD041FirstLineHeading::default();
1285
1286        // Front matter followed by only whitespace (should pass - no warnings)
1287        let content = "---\ntitle: Test\n---\n\n   \n\n";
1288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290        assert!(
1291            result.is_empty(),
1292            "Expected no warnings when no content after front matter"
1293        );
1294    }
1295
1296    #[test]
1297    fn test_multiple_front_matter_types() {
1298        let rule = MD041FirstLineHeading::new(1, true);
1299
1300        // TOML front matter with title (should pass - title satisfies heading requirement)
1301        let content = "+++\ntitle = \"My Document\"\n+++\n\nContent.";
1302        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1303        let result = rule.check(&ctx).unwrap();
1304        assert!(
1305            result.is_empty(),
1306            "Expected no warnings for TOML front matter with title"
1307        );
1308
1309        // JSON front matter with title (should pass)
1310        let content = "{\n\"title\": \"My Document\"\n}\n\nContent.";
1311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312        let result = rule.check(&ctx).unwrap();
1313        assert!(
1314            result.is_empty(),
1315            "Expected no warnings for JSON front matter with title"
1316        );
1317
1318        // YAML front matter with title field (standard case)
1319        let content = "---\ntitle: My Document\n---\n\nContent.";
1320        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1321        let result = rule.check(&ctx).unwrap();
1322        assert!(
1323            result.is_empty(),
1324            "Expected no warnings for YAML front matter with title"
1325        );
1326    }
1327
1328    #[test]
1329    fn test_toml_front_matter_with_heading() {
1330        let rule = MD041FirstLineHeading::default();
1331
1332        // TOML front matter followed by correct heading (should pass)
1333        let content = "+++\nauthor = \"John\"\n+++\n\n# My Document\n\nContent.";
1334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1335        let result = rule.check(&ctx).unwrap();
1336        assert!(
1337            result.is_empty(),
1338            "Expected no warnings when heading follows TOML front matter"
1339        );
1340    }
1341
1342    #[test]
1343    fn test_toml_front_matter_without_title_no_heading() {
1344        let rule = MD041FirstLineHeading::new(1, true);
1345
1346        // TOML front matter without title, no heading (should warn)
1347        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\n+++\n\nSome content here.";
1348        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1349        let result = rule.check(&ctx).unwrap();
1350        assert_eq!(result.len(), 1);
1351        assert_eq!(result[0].line, 6);
1352    }
1353
1354    #[test]
1355    fn test_toml_front_matter_level_2_heading() {
1356        // Reproduces the exact scenario from issue #427
1357        let rule = MD041FirstLineHeading::new(2, true);
1358
1359        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1360        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361        let result = rule.check(&ctx).unwrap();
1362        assert!(
1363            result.is_empty(),
1364            "Issue #427: TOML front matter with title and correct heading level should not warn"
1365        );
1366    }
1367
1368    #[test]
1369    fn test_toml_front_matter_level_2_heading_with_yaml_style_pattern() {
1370        // Reproduces the exact config shape from issue #427
1371        let rule = MD041FirstLineHeading::with_pattern(2, true, Some("^(title|header):".to_string()), false);
1372
1373        let content = "+++\ntitle = \"Title\"\n+++\n\n## Documentation\n\nWrite stuff here...";
1374        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375        let result = rule.check(&ctx).unwrap();
1376        assert!(
1377            result.is_empty(),
1378            "Issue #427 regression: TOML front matter must be skipped when locating first heading"
1379        );
1380    }
1381
1382    #[test]
1383    fn test_json_front_matter_with_heading() {
1384        let rule = MD041FirstLineHeading::default();
1385
1386        // JSON front matter followed by correct heading
1387        let content = "{\n\"author\": \"John\"\n}\n\n# My Document\n\nContent.";
1388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389        let result = rule.check(&ctx).unwrap();
1390        assert!(
1391            result.is_empty(),
1392            "Expected no warnings when heading follows JSON front matter"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_malformed_front_matter() {
1398        let rule = MD041FirstLineHeading::new(1, true);
1399
1400        // Malformed front matter with title
1401        let content = "- --\ntitle: My Document\n- --\n\nContent.";
1402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403        let result = rule.check(&ctx).unwrap();
1404        assert!(
1405            result.is_empty(),
1406            "Expected no warnings for malformed front matter with title"
1407        );
1408    }
1409
1410    #[test]
1411    fn test_front_matter_with_heading() {
1412        let rule = MD041FirstLineHeading::default();
1413
1414        // Front matter without title field followed by correct heading
1415        let content = "---\nauthor: John Doe\n---\n\n# My Document\n\nContent.";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        let result = rule.check(&ctx).unwrap();
1418        assert!(
1419            result.is_empty(),
1420            "Expected no warnings when first line after front matter is correct heading"
1421        );
1422    }
1423
1424    #[test]
1425    fn test_no_fix_suggestion() {
1426        let rule = MD041FirstLineHeading::default();
1427
1428        // Check that NO fix suggestion is provided (MD041 is now detection-only)
1429        let content = "Not a heading\n\nContent.";
1430        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1431        let result = rule.check(&ctx).unwrap();
1432        assert_eq!(result.len(), 1);
1433        assert!(result[0].fix.is_none(), "MD041 should not provide fix suggestions");
1434    }
1435
1436    #[test]
1437    fn test_complex_document_structure() {
1438        let rule = MD041FirstLineHeading::default();
1439
1440        // Complex document with various elements - HTML comment should be skipped (issue #155)
1441        let content =
1442            "---\nauthor: John\n---\n\n<!-- Comment -->\n\n\n# Valid Heading\n\n## Subheading\n\nContent here.";
1443        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1444        let result = rule.check(&ctx).unwrap();
1445        assert!(
1446            result.is_empty(),
1447            "HTML comments should be skipped, so first heading after comment should be valid"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_heading_with_special_characters() {
1453        let rule = MD041FirstLineHeading::default();
1454
1455        // Heading with special characters and formatting
1456        let content = "# Welcome to **My** _Document_ with `code`\n\nContent.";
1457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458        let result = rule.check(&ctx).unwrap();
1459        assert!(
1460            result.is_empty(),
1461            "Expected no warnings for heading with inline formatting"
1462        );
1463    }
1464
1465    #[test]
1466    fn test_level_configuration() {
1467        // Test various level configurations
1468        for level in 1..=6 {
1469            let rule = MD041FirstLineHeading::new(level, false);
1470
1471            // Correct level
1472            let content = format!("{} Heading at Level {}\n\nContent.", "#".repeat(level), level);
1473            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1474            let result = rule.check(&ctx).unwrap();
1475            assert!(
1476                result.is_empty(),
1477                "Expected no warnings for correct level {level} heading"
1478            );
1479
1480            // Wrong level
1481            let wrong_level = if level == 1 { 2 } else { 1 };
1482            let content = format!("{} Wrong Level Heading\n\nContent.", "#".repeat(wrong_level));
1483            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1484            let result = rule.check(&ctx).unwrap();
1485            assert_eq!(result.len(), 1);
1486            assert!(result[0].message.contains(&format!("level {level} heading")));
1487        }
1488    }
1489
1490    #[test]
1491    fn test_issue_152_multiline_html_heading() {
1492        let rule = MD041FirstLineHeading::default();
1493
1494        // Multi-line HTML h1 heading (should pass - issue #152)
1495        let content = "<h1>\nSome text\n</h1>";
1496        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1497        let result = rule.check(&ctx).unwrap();
1498        assert!(
1499            result.is_empty(),
1500            "Issue #152: Multi-line HTML h1 should be recognized as valid heading"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_multiline_html_heading_with_attributes() {
1506        let rule = MD041FirstLineHeading::default();
1507
1508        // Multi-line HTML heading with attributes
1509        let content = "<h1 class=\"title\" id=\"main\">\nHeading Text\n</h1>\n\nContent.";
1510        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511        let result = rule.check(&ctx).unwrap();
1512        assert!(
1513            result.is_empty(),
1514            "Multi-line HTML heading with attributes should be recognized"
1515        );
1516    }
1517
1518    #[test]
1519    fn test_multiline_html_heading_wrong_level() {
1520        let rule = MD041FirstLineHeading::default();
1521
1522        // Multi-line HTML h2 heading (should fail with level 1 requirement)
1523        let content = "<h2>\nSome text\n</h2>";
1524        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1525        let result = rule.check(&ctx).unwrap();
1526        assert_eq!(result.len(), 1);
1527        assert!(result[0].message.contains("level 1 heading"));
1528    }
1529
1530    #[test]
1531    fn test_multiline_html_heading_with_content_after() {
1532        let rule = MD041FirstLineHeading::default();
1533
1534        // Multi-line HTML heading followed by content
1535        let content = "<h1>\nMy Document\n</h1>\n\nThis is the document content.";
1536        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537        let result = rule.check(&ctx).unwrap();
1538        assert!(
1539            result.is_empty(),
1540            "Multi-line HTML heading followed by content should be valid"
1541        );
1542    }
1543
1544    #[test]
1545    fn test_multiline_html_heading_incomplete() {
1546        let rule = MD041FirstLineHeading::default();
1547
1548        // Incomplete multi-line HTML heading (missing closing tag)
1549        let content = "<h1>\nSome text\n\nMore content without closing tag";
1550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1551        let result = rule.check(&ctx).unwrap();
1552        assert_eq!(result.len(), 1);
1553        assert!(result[0].message.contains("level 1 heading"));
1554    }
1555
1556    #[test]
1557    fn test_singleline_html_heading_still_works() {
1558        let rule = MD041FirstLineHeading::default();
1559
1560        // Single-line HTML heading should still work
1561        let content = "<h1>My Document</h1>\n\nContent.";
1562        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1563        let result = rule.check(&ctx).unwrap();
1564        assert!(
1565            result.is_empty(),
1566            "Single-line HTML headings should still be recognized"
1567        );
1568    }
1569
1570    #[test]
1571    fn test_multiline_html_heading_with_nested_tags() {
1572        let rule = MD041FirstLineHeading::default();
1573
1574        // Multi-line HTML heading with nested tags
1575        let content = "<h1>\n<strong>Bold</strong> Heading\n</h1>";
1576        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1577        let result = rule.check(&ctx).unwrap();
1578        assert!(
1579            result.is_empty(),
1580            "Multi-line HTML heading with nested tags should be recognized"
1581        );
1582    }
1583
1584    #[test]
1585    fn test_multiline_html_heading_various_levels() {
1586        // Test multi-line headings at different levels
1587        for level in 1..=6 {
1588            let rule = MD041FirstLineHeading::new(level, false);
1589
1590            // Correct level multi-line
1591            let content = format!("<h{level}>\nHeading Text\n</h{level}>\n\nContent.");
1592            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1593            let result = rule.check(&ctx).unwrap();
1594            assert!(
1595                result.is_empty(),
1596                "Multi-line HTML heading at level {level} should be recognized"
1597            );
1598
1599            // Wrong level multi-line
1600            let wrong_level = if level == 1 { 2 } else { 1 };
1601            let content = format!("<h{wrong_level}>\nHeading Text\n</h{wrong_level}>\n\nContent.");
1602            let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1603            let result = rule.check(&ctx).unwrap();
1604            assert_eq!(result.len(), 1);
1605            assert!(result[0].message.contains(&format!("level {level} heading")));
1606        }
1607    }
1608
1609    #[test]
1610    fn test_issue_152_nested_heading_spans_many_lines() {
1611        let rule = MD041FirstLineHeading::default();
1612
1613        let content = "<h1>\n  <div>\n    <img\n      href=\"https://example.com/image.png\"\n      alt=\"Example Image\"\n    />\n    <a\n      href=\"https://example.com\"\n    >Example Project</a>\n    <span>Documentation</span>\n  </div>\n</h1>";
1614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1615        let result = rule.check(&ctx).unwrap();
1616        assert!(result.is_empty(), "Nested multi-line HTML heading should be recognized");
1617    }
1618
1619    #[test]
1620    fn test_issue_152_picture_tag_heading() {
1621        let rule = MD041FirstLineHeading::default();
1622
1623        let content = "<h1>\n  <picture>\n    <source\n      srcset=\"https://example.com/light.png\"\n      media=\"(prefers-color-scheme: light)\"\n    />\n    <source\n      srcset=\"https://example.com/dark.png\"\n      media=\"(prefers-color-scheme: dark)\"\n    />\n    <img src=\"https://example.com/default.png\" />\n  </picture>\n</h1>";
1624        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1625        let result = rule.check(&ctx).unwrap();
1626        assert!(
1627            result.is_empty(),
1628            "Picture tag inside multi-line HTML heading should be recognized"
1629        );
1630    }
1631
1632    #[test]
1633    fn test_badge_images_before_heading() {
1634        let rule = MD041FirstLineHeading::default();
1635
1636        // Single badge before heading
1637        let content = "![badge](https://img.shields.io/badge/test-passing-green)\n\n# My Project";
1638        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639        let result = rule.check(&ctx).unwrap();
1640        assert!(result.is_empty(), "Badge image should be skipped");
1641
1642        // Multiple badges on one line
1643        let content = "![badge1](url1) ![badge2](url2)\n\n# My Project";
1644        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1645        let result = rule.check(&ctx).unwrap();
1646        assert!(result.is_empty(), "Multiple badges should be skipped");
1647
1648        // Linked badge (clickable)
1649        let content = "[![badge](https://img.shields.io/badge/test-pass-green)](https://example.com)\n\n# My Project";
1650        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1651        let result = rule.check(&ctx).unwrap();
1652        assert!(result.is_empty(), "Linked badge should be skipped");
1653    }
1654
1655    #[test]
1656    fn test_multiple_badge_lines_before_heading() {
1657        let rule = MD041FirstLineHeading::default();
1658
1659        // Multiple lines of badges
1660        let content = "[![Crates.io](https://img.shields.io/crates/v/example)](https://crates.io)\n[![docs.rs](https://img.shields.io/docsrs/example)](https://docs.rs)\n\n# My Project";
1661        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662        let result = rule.check(&ctx).unwrap();
1663        assert!(result.is_empty(), "Multiple badge lines should be skipped");
1664    }
1665
1666    #[test]
1667    fn test_badges_without_heading_still_warns() {
1668        let rule = MD041FirstLineHeading::default();
1669
1670        // Badges followed by paragraph (not heading)
1671        let content = "![badge](url)\n\nThis is not a heading.";
1672        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673        let result = rule.check(&ctx).unwrap();
1674        assert_eq!(result.len(), 1, "Should warn when badges followed by non-heading");
1675    }
1676
1677    #[test]
1678    fn test_mixed_content_not_badge_line() {
1679        let rule = MD041FirstLineHeading::default();
1680
1681        // Image with text is not a badge line
1682        let content = "![badge](url) Some text here\n\n# Heading";
1683        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1684        let result = rule.check(&ctx).unwrap();
1685        assert_eq!(result.len(), 1, "Mixed content line should not be skipped");
1686    }
1687
1688    #[test]
1689    fn test_is_badge_image_line_unit() {
1690        // Unit tests for is_badge_image_line
1691        assert!(MD041FirstLineHeading::is_badge_image_line("![badge](url)"));
1692        assert!(MD041FirstLineHeading::is_badge_image_line("[![badge](img)](link)"));
1693        assert!(MD041FirstLineHeading::is_badge_image_line("![a](b) ![c](d)"));
1694        assert!(MD041FirstLineHeading::is_badge_image_line("[![a](b)](c) [![d](e)](f)"));
1695
1696        // Not badge lines
1697        assert!(!MD041FirstLineHeading::is_badge_image_line(""));
1698        assert!(!MD041FirstLineHeading::is_badge_image_line("Some text"));
1699        assert!(!MD041FirstLineHeading::is_badge_image_line("![badge](url) text"));
1700        assert!(!MD041FirstLineHeading::is_badge_image_line("# Heading"));
1701    }
1702
1703    // Integration tests for MkDocs anchor line detection (issue #365)
1704    // Unit tests for is_mkdocs_anchor_line are in utils/mkdocs_attr_list.rs
1705
1706    #[test]
1707    fn test_mkdocs_anchor_before_heading_in_mkdocs_flavor() {
1708        let rule = MD041FirstLineHeading::default();
1709
1710        // MkDocs anchor line before heading in MkDocs flavor (should pass)
1711        let content = "[](){ #example }\n# Title";
1712        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1713        let result = rule.check(&ctx).unwrap();
1714        assert!(
1715            result.is_empty(),
1716            "MkDocs anchor line should be skipped in MkDocs flavor"
1717        );
1718    }
1719
1720    #[test]
1721    fn test_mkdocs_anchor_before_heading_in_standard_flavor() {
1722        let rule = MD041FirstLineHeading::default();
1723
1724        // MkDocs anchor line before heading in Standard flavor (should fail)
1725        let content = "[](){ #example }\n# Title";
1726        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1727        let result = rule.check(&ctx).unwrap();
1728        assert_eq!(
1729            result.len(),
1730            1,
1731            "MkDocs anchor line should NOT be skipped in Standard flavor"
1732        );
1733    }
1734
1735    #[test]
1736    fn test_multiple_mkdocs_anchors_before_heading() {
1737        let rule = MD041FirstLineHeading::default();
1738
1739        // Multiple MkDocs anchor lines before heading in MkDocs flavor
1740        let content = "[](){ #first }\n[](){ #second }\n# Title";
1741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1742        let result = rule.check(&ctx).unwrap();
1743        assert!(
1744            result.is_empty(),
1745            "Multiple MkDocs anchor lines should all be skipped in MkDocs flavor"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_mkdocs_anchor_with_front_matter() {
1751        let rule = MD041FirstLineHeading::default();
1752
1753        // MkDocs anchor after front matter
1754        let content = "---\nauthor: John\n---\n[](){ #anchor }\n# Title";
1755        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1756        let result = rule.check(&ctx).unwrap();
1757        assert!(
1758            result.is_empty(),
1759            "MkDocs anchor line after front matter should be skipped in MkDocs flavor"
1760        );
1761    }
1762
1763    #[test]
1764    fn test_mkdocs_anchor_kramdown_style() {
1765        let rule = MD041FirstLineHeading::default();
1766
1767        // Kramdown-style with colon
1768        let content = "[](){: #anchor }\n# Title";
1769        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1770        let result = rule.check(&ctx).unwrap();
1771        assert!(
1772            result.is_empty(),
1773            "Kramdown-style MkDocs anchor should be skipped in MkDocs flavor"
1774        );
1775    }
1776
1777    #[test]
1778    fn test_mkdocs_anchor_without_heading_still_warns() {
1779        let rule = MD041FirstLineHeading::default();
1780
1781        // MkDocs anchor followed by non-heading content
1782        let content = "[](){ #anchor }\nThis is not a heading.";
1783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1784        let result = rule.check(&ctx).unwrap();
1785        assert_eq!(
1786            result.len(),
1787            1,
1788            "MkDocs anchor followed by non-heading should still trigger MD041"
1789        );
1790    }
1791
1792    #[test]
1793    fn test_mkdocs_anchor_with_html_comment() {
1794        let rule = MD041FirstLineHeading::default();
1795
1796        // MkDocs anchor combined with HTML comment before heading
1797        let content = "<!-- Comment -->\n[](){ #anchor }\n# Title";
1798        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1799        let result = rule.check(&ctx).unwrap();
1800        assert!(
1801            result.is_empty(),
1802            "MkDocs anchor with HTML comment should both be skipped in MkDocs flavor"
1803        );
1804    }
1805
1806    // Tests for auto-fix functionality (issue #359)
1807
1808    #[test]
1809    fn test_fix_disabled_by_default() {
1810        use crate::rule::Rule;
1811        let rule = MD041FirstLineHeading::default();
1812
1813        // Fix should not change content when disabled
1814        let content = "## Wrong Level\n\nContent.";
1815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816        let fixed = rule.fix(&ctx).unwrap();
1817        assert_eq!(fixed, content, "Fix should not change content when disabled");
1818    }
1819
1820    #[test]
1821    fn test_fix_wrong_heading_level() {
1822        use crate::rule::Rule;
1823        let rule = MD041FirstLineHeading {
1824            level: 1,
1825            front_matter_title: false,
1826            front_matter_title_pattern: None,
1827            allow_preamble: false,
1828            fix_enabled: true,
1829        };
1830
1831        // ## should become #
1832        let content = "## Wrong Level\n\nContent.\n";
1833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834        let fixed = rule.fix(&ctx).unwrap();
1835        assert_eq!(fixed, "# Wrong Level\n\nContent.\n", "Should fix heading level");
1836    }
1837
1838    #[test]
1839    fn test_fix_heading_after_preamble() {
1840        use crate::rule::Rule;
1841        let rule = MD041FirstLineHeading {
1842            level: 1,
1843            front_matter_title: false,
1844            front_matter_title_pattern: None,
1845            allow_preamble: false,
1846            fix_enabled: true,
1847        };
1848
1849        // Heading after blank lines should be moved up
1850        let content = "\n\n# Title\n\nContent.\n";
1851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1852        let fixed = rule.fix(&ctx).unwrap();
1853        assert!(
1854            fixed.starts_with("# Title\n"),
1855            "Heading should be moved to first line, got: {fixed}"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_fix_heading_after_html_comment() {
1861        use crate::rule::Rule;
1862        let rule = MD041FirstLineHeading {
1863            level: 1,
1864            front_matter_title: false,
1865            front_matter_title_pattern: None,
1866            allow_preamble: false,
1867            fix_enabled: true,
1868        };
1869
1870        // Heading after HTML comment should be moved up
1871        let content = "<!-- Comment -->\n# Title\n\nContent.\n";
1872        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873        let fixed = rule.fix(&ctx).unwrap();
1874        assert!(
1875            fixed.starts_with("# Title\n"),
1876            "Heading should be moved above comment, got: {fixed}"
1877        );
1878    }
1879
1880    #[test]
1881    fn test_fix_heading_level_and_move() {
1882        use crate::rule::Rule;
1883        let rule = MD041FirstLineHeading {
1884            level: 1,
1885            front_matter_title: false,
1886            front_matter_title_pattern: None,
1887            allow_preamble: false,
1888            fix_enabled: true,
1889        };
1890
1891        // Heading with wrong level after preamble should be fixed and moved
1892        let content = "<!-- Comment -->\n\n## Wrong Level\n\nContent.\n";
1893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894        let fixed = rule.fix(&ctx).unwrap();
1895        assert!(
1896            fixed.starts_with("# Wrong Level\n"),
1897            "Heading should be fixed and moved, got: {fixed}"
1898        );
1899    }
1900
1901    #[test]
1902    fn test_fix_with_front_matter() {
1903        use crate::rule::Rule;
1904        let rule = MD041FirstLineHeading {
1905            level: 1,
1906            front_matter_title: false,
1907            front_matter_title_pattern: None,
1908            allow_preamble: false,
1909            fix_enabled: true,
1910        };
1911
1912        // Heading after front matter and preamble
1913        let content = "---\nauthor: John\n---\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915        let fixed = rule.fix(&ctx).unwrap();
1916        assert!(
1917            fixed.starts_with("---\nauthor: John\n---\n# Title\n"),
1918            "Heading should be right after front matter, got: {fixed}"
1919        );
1920    }
1921
1922    #[test]
1923    fn test_fix_with_toml_front_matter() {
1924        use crate::rule::Rule;
1925        let rule = MD041FirstLineHeading {
1926            level: 1,
1927            front_matter_title: false,
1928            front_matter_title_pattern: None,
1929            allow_preamble: false,
1930            fix_enabled: true,
1931        };
1932
1933        // Heading after TOML front matter and preamble
1934        let content = "+++\nauthor = \"John\"\n+++\n\n<!-- Comment -->\n## Title\n\nContent.\n";
1935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936        let fixed = rule.fix(&ctx).unwrap();
1937        assert!(
1938            fixed.starts_with("+++\nauthor = \"John\"\n+++\n# Title\n"),
1939            "Heading should be right after TOML front matter, got: {fixed}"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_fix_cannot_fix_no_heading() {
1945        use crate::rule::Rule;
1946        let rule = MD041FirstLineHeading {
1947            level: 1,
1948            front_matter_title: false,
1949            front_matter_title_pattern: None,
1950            allow_preamble: false,
1951            fix_enabled: true,
1952        };
1953
1954        // No heading in document - cannot fix
1955        let content = "Just some text.\n\nMore text.\n";
1956        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1957        let fixed = rule.fix(&ctx).unwrap();
1958        assert_eq!(fixed, content, "Should not change content when no heading exists");
1959    }
1960
1961    #[test]
1962    fn test_fix_cannot_fix_content_before_heading() {
1963        use crate::rule::Rule;
1964        let rule = MD041FirstLineHeading {
1965            level: 1,
1966            front_matter_title: false,
1967            front_matter_title_pattern: None,
1968            allow_preamble: false,
1969            fix_enabled: true,
1970        };
1971
1972        // Real content before heading - cannot safely fix
1973        let content = "Some intro text.\n\n# Title\n\nContent.\n";
1974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1975        let fixed = rule.fix(&ctx).unwrap();
1976        assert_eq!(
1977            fixed, content,
1978            "Should not change content when real content exists before heading"
1979        );
1980    }
1981
1982    #[test]
1983    fn test_fix_already_correct() {
1984        use crate::rule::Rule;
1985        let rule = MD041FirstLineHeading {
1986            level: 1,
1987            front_matter_title: false,
1988            front_matter_title_pattern: None,
1989            allow_preamble: false,
1990            fix_enabled: true,
1991        };
1992
1993        // Already correct - no changes needed
1994        let content = "# Title\n\nContent.\n";
1995        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1996        let fixed = rule.fix(&ctx).unwrap();
1997        assert_eq!(fixed, content, "Should not change already correct content");
1998    }
1999
2000    #[test]
2001    fn test_fix_setext_heading_removes_underline() {
2002        use crate::rule::Rule;
2003        let rule = MD041FirstLineHeading {
2004            level: 1,
2005            front_matter_title: false,
2006            front_matter_title_pattern: None,
2007            allow_preamble: false,
2008            fix_enabled: true,
2009        };
2010
2011        // Setext heading (level 2 with --- underline)
2012        let content = "Wrong Level\n-----------\n\nContent.\n";
2013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014        let fixed = rule.fix(&ctx).unwrap();
2015        assert_eq!(
2016            fixed, "# Wrong Level\n\nContent.\n",
2017            "Setext heading should be converted to ATX and underline removed"
2018        );
2019    }
2020
2021    #[test]
2022    fn test_fix_setext_h1_heading() {
2023        use crate::rule::Rule;
2024        let rule = MD041FirstLineHeading {
2025            level: 1,
2026            front_matter_title: false,
2027            front_matter_title_pattern: None,
2028            allow_preamble: false,
2029            fix_enabled: true,
2030        };
2031
2032        // Setext h1 heading (=== underline) after preamble - needs move but not level fix
2033        let content = "<!-- comment -->\n\nTitle\n=====\n\nContent.\n";
2034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035        let fixed = rule.fix(&ctx).unwrap();
2036        assert_eq!(
2037            fixed, "# Title\n<!-- comment -->\n\n\nContent.\n",
2038            "Setext h1 should be moved and converted to ATX"
2039        );
2040    }
2041
2042    #[test]
2043    fn test_html_heading_not_claimed_fixable() {
2044        use crate::rule::Rule;
2045        let rule = MD041FirstLineHeading {
2046            level: 1,
2047            front_matter_title: false,
2048            front_matter_title_pattern: None,
2049            allow_preamble: false,
2050            fix_enabled: true,
2051        };
2052
2053        // HTML heading - should NOT be claimed as fixable (we can't convert HTML to ATX)
2054        let content = "<h2>Title</h2>\n\nContent.\n";
2055        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2056        let warnings = rule.check(&ctx).unwrap();
2057        assert_eq!(warnings.len(), 1);
2058        assert!(
2059            warnings[0].fix.is_none(),
2060            "HTML heading should not be claimed as fixable"
2061        );
2062    }
2063
2064    #[test]
2065    fn test_no_heading_not_claimed_fixable() {
2066        use crate::rule::Rule;
2067        let rule = MD041FirstLineHeading {
2068            level: 1,
2069            front_matter_title: false,
2070            front_matter_title_pattern: None,
2071            allow_preamble: false,
2072            fix_enabled: true,
2073        };
2074
2075        // No heading in document - should NOT be claimed as fixable
2076        let content = "Just some text.\n\nMore text.\n";
2077        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2078        let warnings = rule.check(&ctx).unwrap();
2079        assert_eq!(warnings.len(), 1);
2080        assert!(
2081            warnings[0].fix.is_none(),
2082            "Document without heading should not be claimed as fixable"
2083        );
2084    }
2085
2086    #[test]
2087    fn test_content_before_heading_not_claimed_fixable() {
2088        use crate::rule::Rule;
2089        let rule = MD041FirstLineHeading {
2090            level: 1,
2091            front_matter_title: false,
2092            front_matter_title_pattern: None,
2093            allow_preamble: false,
2094            fix_enabled: true,
2095        };
2096
2097        // Content before heading - should NOT be claimed as fixable
2098        let content = "Intro text.\n\n## Heading\n\nMore.\n";
2099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100        let warnings = rule.check(&ctx).unwrap();
2101        assert_eq!(warnings.len(), 1);
2102        assert!(
2103            warnings[0].fix.is_none(),
2104            "Document with content before heading should not be claimed as fixable"
2105        );
2106    }
2107
2108    // ── Phase 1 (Case C): HTML blocks treated as preamble ──────────────────────
2109
2110    #[test]
2111    fn test_fix_html_block_before_heading_is_now_fixable() {
2112        use crate::rule::Rule;
2113        let rule = MD041FirstLineHeading {
2114            level: 1,
2115            front_matter_title: false,
2116            front_matter_title_pattern: None,
2117            allow_preamble: false,
2118            fix_enabled: true,
2119        };
2120
2121        // HTML block (badges div) before the real heading – was unfixable before Phase 1
2122        let content = "<div>\n  Some HTML\n</div>\n\n# My Document\n\nContent.\n";
2123        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124
2125        let warnings = rule.check(&ctx).unwrap();
2126        assert_eq!(warnings.len(), 1, "Warning should fire because first line is HTML");
2127        assert!(
2128            warnings[0].fix.is_some(),
2129            "Should be fixable: heading exists after HTML block preamble"
2130        );
2131
2132        let fixed = rule.fix(&ctx).unwrap();
2133        assert!(
2134            fixed.starts_with("# My Document\n"),
2135            "Heading should be moved to the top, got: {fixed}"
2136        );
2137    }
2138
2139    #[test]
2140    fn test_fix_html_block_wrong_level_before_heading() {
2141        use crate::rule::Rule;
2142        let rule = MD041FirstLineHeading {
2143            level: 1,
2144            front_matter_title: false,
2145            front_matter_title_pattern: None,
2146            allow_preamble: false,
2147            fix_enabled: true,
2148        };
2149
2150        let content = "<div>\n  badge\n</div>\n\n## Wrong Level\n\nContent.\n";
2151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2152        let fixed = rule.fix(&ctx).unwrap();
2153        assert!(
2154            fixed.starts_with("# Wrong Level\n"),
2155            "Heading should be fixed to level 1 and moved to top, got: {fixed}"
2156        );
2157    }
2158
2159    // ── Phase 2 (Case A): PromotePlainText ──────────────────────────────────────
2160
2161    #[test]
2162    fn test_fix_promote_plain_text_title() {
2163        use crate::rule::Rule;
2164        let rule = MD041FirstLineHeading {
2165            level: 1,
2166            front_matter_title: false,
2167            front_matter_title_pattern: None,
2168            allow_preamble: false,
2169            fix_enabled: true,
2170        };
2171
2172        let content = "My Project\n\nSome content.\n";
2173        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2174
2175        let warnings = rule.check(&ctx).unwrap();
2176        assert_eq!(warnings.len(), 1, "Should warn: first line is not a heading");
2177        assert!(
2178            warnings[0].fix.is_some(),
2179            "Should be fixable: first line is a title candidate"
2180        );
2181
2182        let fixed = rule.fix(&ctx).unwrap();
2183        assert_eq!(
2184            fixed, "# My Project\n\nSome content.\n",
2185            "Title line should be promoted to heading"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_fix_promote_plain_text_title_with_front_matter() {
2191        use crate::rule::Rule;
2192        let rule = MD041FirstLineHeading {
2193            level: 1,
2194            front_matter_title: false,
2195            front_matter_title_pattern: None,
2196            allow_preamble: false,
2197            fix_enabled: true,
2198        };
2199
2200        let content = "---\nauthor: John\n---\n\nMy Project\n\nContent.\n";
2201        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2202        let fixed = rule.fix(&ctx).unwrap();
2203        assert!(
2204            fixed.starts_with("---\nauthor: John\n---\n# My Project\n"),
2205            "Title should be promoted and placed right after front matter, got: {fixed}"
2206        );
2207    }
2208
2209    #[test]
2210    fn test_fix_no_promote_ends_with_period() {
2211        use crate::rule::Rule;
2212        let rule = MD041FirstLineHeading {
2213            level: 1,
2214            front_matter_title: false,
2215            front_matter_title_pattern: None,
2216            allow_preamble: false,
2217            fix_enabled: true,
2218        };
2219
2220        // Sentence-ending punctuation → NOT a title candidate
2221        let content = "This is a sentence.\n\nContent.\n";
2222        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2223        let fixed = rule.fix(&ctx).unwrap();
2224        assert_eq!(fixed, content, "Sentence-ending line should not be promoted");
2225
2226        let warnings = rule.check(&ctx).unwrap();
2227        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2228    }
2229
2230    #[test]
2231    fn test_fix_no_promote_ends_with_colon() {
2232        use crate::rule::Rule;
2233        let rule = MD041FirstLineHeading {
2234            level: 1,
2235            front_matter_title: false,
2236            front_matter_title_pattern: None,
2237            allow_preamble: false,
2238            fix_enabled: true,
2239        };
2240
2241        let content = "Note:\n\nContent.\n";
2242        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2243        let fixed = rule.fix(&ctx).unwrap();
2244        assert_eq!(fixed, content, "Colon-ending line should not be promoted");
2245    }
2246
2247    #[test]
2248    fn test_fix_no_promote_if_too_long() {
2249        use crate::rule::Rule;
2250        let rule = MD041FirstLineHeading {
2251            level: 1,
2252            front_matter_title: false,
2253            front_matter_title_pattern: None,
2254            allow_preamble: false,
2255            fix_enabled: true,
2256        };
2257
2258        // >80 chars → not a title candidate
2259        let long_line = "A".repeat(81);
2260        let content = format!("{long_line}\n\nContent.\n");
2261        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
2262        let fixed = rule.fix(&ctx).unwrap();
2263        assert_eq!(fixed, content, "Lines over 80 chars should not be promoted");
2264    }
2265
2266    #[test]
2267    fn test_fix_no_promote_if_no_blank_after() {
2268        use crate::rule::Rule;
2269        let rule = MD041FirstLineHeading {
2270            level: 1,
2271            front_matter_title: false,
2272            front_matter_title_pattern: None,
2273            allow_preamble: false,
2274            fix_enabled: true,
2275        };
2276
2277        // No blank line after potential title → NOT a title candidate
2278        let content = "My Project\nImmediately continues.\n\nContent.\n";
2279        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2280        let fixed = rule.fix(&ctx).unwrap();
2281        assert_eq!(fixed, content, "Line without following blank should not be promoted");
2282    }
2283
2284    #[test]
2285    fn test_fix_no_promote_when_heading_exists_after_title_candidate() {
2286        use crate::rule::Rule;
2287        let rule = MD041FirstLineHeading {
2288            level: 1,
2289            front_matter_title: false,
2290            front_matter_title_pattern: None,
2291            allow_preamble: false,
2292            fix_enabled: true,
2293        };
2294
2295        // Title candidate exists but so does a heading later → can't safely fix
2296        // (the title candidate is content before the heading)
2297        let content = "My Project\n\n# Actual Heading\n\nContent.\n";
2298        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2299        let fixed = rule.fix(&ctx).unwrap();
2300        assert_eq!(
2301            fixed, content,
2302            "Should not fix when title candidate exists before a heading"
2303        );
2304
2305        let warnings = rule.check(&ctx).unwrap();
2306        assert!(warnings[0].fix.is_none(), "No fix should be offered");
2307    }
2308
2309    #[test]
2310    fn test_fix_promote_title_at_eof_no_trailing_newline() {
2311        use crate::rule::Rule;
2312        let rule = MD041FirstLineHeading {
2313            level: 1,
2314            front_matter_title: false,
2315            front_matter_title_pattern: None,
2316            allow_preamble: false,
2317            fix_enabled: true,
2318        };
2319
2320        // Single title line at EOF with no trailing newline
2321        let content = "My Project";
2322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2323        let fixed = rule.fix(&ctx).unwrap();
2324        assert_eq!(fixed, "# My Project", "Should promote title at EOF");
2325    }
2326
2327    // ── Phase 3 (Case B): InsertDerived ─────────────────────────────────────────
2328
2329    #[test]
2330    fn test_fix_insert_derived_directive_only_document() {
2331        use crate::rule::Rule;
2332        use std::path::PathBuf;
2333        let rule = MD041FirstLineHeading {
2334            level: 1,
2335            front_matter_title: false,
2336            front_matter_title_pattern: None,
2337            allow_preamble: false,
2338            fix_enabled: true,
2339        };
2340
2341        // Document with only a note admonition and no heading
2342        // (LintContext constructed with a source file path for title derivation)
2343        let content = "!!! note\n    This is a note.\n";
2344        let ctx = LintContext::new(
2345            content,
2346            crate::config::MarkdownFlavor::MkDocs,
2347            Some(PathBuf::from("setup-guide.md")),
2348        );
2349
2350        let can_fix = rule.can_fix(&ctx);
2351        assert!(can_fix, "Directive-only document with source file should be fixable");
2352
2353        let fixed = rule.fix(&ctx).unwrap();
2354        assert!(
2355            fixed.starts_with("# Setup Guide\n"),
2356            "Should insert derived heading, got: {fixed}"
2357        );
2358    }
2359
2360    #[test]
2361    fn test_fix_no_insert_derived_without_source_file() {
2362        use crate::rule::Rule;
2363        let rule = MD041FirstLineHeading {
2364            level: 1,
2365            front_matter_title: false,
2366            front_matter_title_pattern: None,
2367            allow_preamble: false,
2368            fix_enabled: true,
2369        };
2370
2371        // No source_file → derive_title returns None → InsertDerived unavailable
2372        let content = "!!! note\n    This is a note.\n";
2373        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
2374        let fixed = rule.fix(&ctx).unwrap();
2375        assert_eq!(fixed, content, "Without a source file, cannot derive a title");
2376    }
2377
2378    #[test]
2379    fn test_fix_no_insert_derived_when_has_real_content() {
2380        use crate::rule::Rule;
2381        use std::path::PathBuf;
2382        let rule = MD041FirstLineHeading {
2383            level: 1,
2384            front_matter_title: false,
2385            front_matter_title_pattern: None,
2386            allow_preamble: false,
2387            fix_enabled: true,
2388        };
2389
2390        // Document has real paragraph content in addition to directive blocks
2391        let content = "!!! note\n    A note.\n\nSome paragraph text.\n";
2392        let ctx = LintContext::new(
2393            content,
2394            crate::config::MarkdownFlavor::MkDocs,
2395            Some(PathBuf::from("guide.md")),
2396        );
2397        let fixed = rule.fix(&ctx).unwrap();
2398        assert_eq!(
2399            fixed, content,
2400            "Should not insert derived heading when real content is present"
2401        );
2402    }
2403
2404    #[test]
2405    fn test_derive_title_converts_kebab_case() {
2406        use std::path::PathBuf;
2407        let ctx = LintContext::new(
2408            "",
2409            crate::config::MarkdownFlavor::Standard,
2410            Some(PathBuf::from("my-setup-guide.md")),
2411        );
2412        let title = MD041FirstLineHeading::derive_title(&ctx);
2413        assert_eq!(title, Some("My Setup Guide".to_string()));
2414    }
2415
2416    #[test]
2417    fn test_derive_title_converts_underscores() {
2418        use std::path::PathBuf;
2419        let ctx = LintContext::new(
2420            "",
2421            crate::config::MarkdownFlavor::Standard,
2422            Some(PathBuf::from("api_reference.md")),
2423        );
2424        let title = MD041FirstLineHeading::derive_title(&ctx);
2425        assert_eq!(title, Some("Api Reference".to_string()));
2426    }
2427
2428    #[test]
2429    fn test_derive_title_none_without_source_file() {
2430        let ctx = LintContext::new("", crate::config::MarkdownFlavor::Standard, None);
2431        let title = MD041FirstLineHeading::derive_title(&ctx);
2432        assert_eq!(title, None);
2433    }
2434
2435    #[test]
2436    fn test_derive_title_index_file_uses_parent_dir() {
2437        use std::path::PathBuf;
2438        let ctx = LintContext::new(
2439            "",
2440            crate::config::MarkdownFlavor::Standard,
2441            Some(PathBuf::from("docs/getting-started/index.md")),
2442        );
2443        let title = MD041FirstLineHeading::derive_title(&ctx);
2444        assert_eq!(title, Some("Getting Started".to_string()));
2445    }
2446
2447    #[test]
2448    fn test_derive_title_readme_file_uses_parent_dir() {
2449        use std::path::PathBuf;
2450        let ctx = LintContext::new(
2451            "",
2452            crate::config::MarkdownFlavor::Standard,
2453            Some(PathBuf::from("my-project/README.md")),
2454        );
2455        let title = MD041FirstLineHeading::derive_title(&ctx);
2456        assert_eq!(title, Some("My Project".to_string()));
2457    }
2458
2459    #[test]
2460    fn test_derive_title_index_without_parent_returns_none() {
2461        use std::path::PathBuf;
2462        // Root-level index.md has no meaningful parent — "Index" is not a useful title
2463        let ctx = LintContext::new(
2464            "",
2465            crate::config::MarkdownFlavor::Standard,
2466            Some(PathBuf::from("index.md")),
2467        );
2468        let title = MD041FirstLineHeading::derive_title(&ctx);
2469        assert_eq!(title, None);
2470    }
2471
2472    #[test]
2473    fn test_derive_title_readme_without_parent_returns_none() {
2474        use std::path::PathBuf;
2475        let ctx = LintContext::new(
2476            "",
2477            crate::config::MarkdownFlavor::Standard,
2478            Some(PathBuf::from("README.md")),
2479        );
2480        let title = MD041FirstLineHeading::derive_title(&ctx);
2481        assert_eq!(title, None);
2482    }
2483
2484    #[test]
2485    fn test_derive_title_readme_case_insensitive() {
2486        use std::path::PathBuf;
2487        // Lowercase readme.md should also use parent dir
2488        let ctx = LintContext::new(
2489            "",
2490            crate::config::MarkdownFlavor::Standard,
2491            Some(PathBuf::from("docs/api/readme.md")),
2492        );
2493        let title = MD041FirstLineHeading::derive_title(&ctx);
2494        assert_eq!(title, Some("Api".to_string()));
2495    }
2496
2497    #[test]
2498    fn test_is_title_candidate_basic() {
2499        assert!(MD041FirstLineHeading::is_title_candidate("My Project", true));
2500        assert!(MD041FirstLineHeading::is_title_candidate("Getting Started", true));
2501        assert!(MD041FirstLineHeading::is_title_candidate("API Reference", true));
2502    }
2503
2504    #[test]
2505    fn test_is_title_candidate_rejects_sentence_punctuation() {
2506        assert!(!MD041FirstLineHeading::is_title_candidate("This is a sentence.", true));
2507        assert!(!MD041FirstLineHeading::is_title_candidate("Is this correct?", true));
2508        assert!(!MD041FirstLineHeading::is_title_candidate("Note:", true));
2509        assert!(!MD041FirstLineHeading::is_title_candidate("Stop!", true));
2510        assert!(!MD041FirstLineHeading::is_title_candidate("Step 1;", true));
2511    }
2512
2513    #[test]
2514    fn test_is_title_candidate_rejects_when_no_blank_after() {
2515        assert!(!MD041FirstLineHeading::is_title_candidate("My Project", false));
2516    }
2517
2518    #[test]
2519    fn test_is_title_candidate_rejects_long_lines() {
2520        let long = "A".repeat(81);
2521        assert!(!MD041FirstLineHeading::is_title_candidate(&long, true));
2522        // 80 chars is the boundary – exactly 80 is OK
2523        let ok = "A".repeat(80);
2524        assert!(MD041FirstLineHeading::is_title_candidate(&ok, true));
2525    }
2526
2527    #[test]
2528    fn test_is_title_candidate_rejects_structural_markdown() {
2529        assert!(!MD041FirstLineHeading::is_title_candidate("# Heading", true));
2530        assert!(!MD041FirstLineHeading::is_title_candidate("- list item", true));
2531        assert!(!MD041FirstLineHeading::is_title_candidate("* bullet", true));
2532        assert!(!MD041FirstLineHeading::is_title_candidate("> blockquote", true));
2533    }
2534
2535    #[test]
2536    fn test_fix_replacement_not_empty_for_plain_text_promotion() {
2537        // Verify that the fix replacement for plain-text-to-heading promotion is
2538        // non-empty, so applying the fix does not delete the line.
2539        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2540        // Title candidate: short text, no trailing punctuation, followed by blank line
2541        let content = "My Document Title\n\nMore content follows.";
2542        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2543        let warnings = rule.check(&ctx).unwrap();
2544        assert_eq!(warnings.len(), 1);
2545        let fix = warnings[0]
2546            .fix
2547            .as_ref()
2548            .expect("Fix should be present for promotable text");
2549        assert!(
2550            !fix.replacement.is_empty(),
2551            "Fix replacement must not be empty — applying it directly must produce valid output"
2552        );
2553        assert!(
2554            fix.replacement.starts_with("# "),
2555            "Fix replacement should be a level-1 heading, got: {:?}",
2556            fix.replacement
2557        );
2558        assert_eq!(fix.replacement, "# My Document Title");
2559    }
2560
2561    #[test]
2562    fn test_fix_replacement_not_empty_for_releveling() {
2563        // When the first line is a heading at the wrong level, the Fix should
2564        // contain the correctly-leveled heading, not an empty string.
2565        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2566        let content = "## Wrong Level\n\nContent.";
2567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568        let warnings = rule.check(&ctx).unwrap();
2569        assert_eq!(warnings.len(), 1);
2570        let fix = warnings[0].fix.as_ref().expect("Fix should be present for releveling");
2571        assert!(
2572            !fix.replacement.is_empty(),
2573            "Fix replacement must not be empty for releveling"
2574        );
2575        assert_eq!(fix.replacement, "# Wrong Level");
2576    }
2577
2578    #[test]
2579    fn test_fix_replacement_applied_produces_valid_output() {
2580        // Verify that applying the Fix from check() produces the same result as fix()
2581        let rule = MD041FirstLineHeading::with_pattern(1, false, None, true);
2582        // Title candidate: short, no trailing punctuation, followed by blank line
2583        let content = "My Document\n\nMore content.";
2584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2585
2586        let warnings = rule.check(&ctx).unwrap();
2587        assert_eq!(warnings.len(), 1);
2588        let fix = warnings[0].fix.as_ref().expect("Fix should be present");
2589
2590        // Apply Fix directly (like LSP would)
2591        let mut patched = content.to_string();
2592        patched.replace_range(fix.range.clone(), &fix.replacement);
2593
2594        // Apply via fix() method
2595        let fixed = rule.fix(&ctx).unwrap();
2596
2597        assert_eq!(patched, fixed, "Applying Fix directly should match fix() output");
2598    }
2599
2600    #[test]
2601    fn test_mdx_disable_on_line_1_no_heading() {
2602        // The exact user scenario from issue #538:
2603        // MDX disable comment on line 1, NO heading anywhere.
2604        // The disable is the ONLY reason MD041 should not fire.
2605        let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>";
2606        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2607
2608        // check() should produce a warning on line 2 (<Note> is the first content line)
2609        let rule = MD041FirstLineHeading::default();
2610        let warnings = rule.check(&ctx).unwrap();
2611        // The rule itself produces a warning, but the engine filters it via inline config.
2612        // MD041's check() doesn't filter inline config itself — the engine does.
2613        // What matters is that the warning is on line 2 (not line 1), so the engine
2614        // can see the disable is active at line 2 and suppress it.
2615        if !warnings.is_empty() {
2616            assert_eq!(
2617                warnings[0].line, 2,
2618                "Warning must be on line 2 (first content line after MDX comment), not line 1"
2619            );
2620        }
2621    }
2622
2623    #[test]
2624    fn test_mdx_disable_fix_returns_unchanged() {
2625        // fix() should return content unchanged when MDX disable is active
2626        let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>";
2627        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2628        let rule = MD041FirstLineHeading {
2629            fix_enabled: true,
2630            ..MD041FirstLineHeading::default()
2631        };
2632        let result = rule.fix(&ctx).unwrap();
2633        assert_eq!(
2634            result, content,
2635            "fix() should not modify content when MD041 is disabled via MDX comment"
2636        );
2637    }
2638
2639    #[test]
2640    fn test_mdx_comment_without_disable_heading_on_next_line() {
2641        let rule = MD041FirstLineHeading::default();
2642
2643        // MDX comment (not a disable directive) on line 1, heading on line 2
2644        let content = "{/* Some MDX comment */}\n# My Document\n\nContent.";
2645        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2646        let result = rule.check(&ctx).unwrap();
2647        assert!(
2648            result.is_empty(),
2649            "MDX comment is preamble; heading on next line should satisfy MD041"
2650        );
2651    }
2652
2653    #[test]
2654    fn test_mdx_comment_without_heading_triggers_warning() {
2655        let rule = MD041FirstLineHeading::default();
2656
2657        // MDX comment on line 1, non-heading content on line 2
2658        let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent.";
2659        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2660        let result = rule.check(&ctx).unwrap();
2661        assert_eq!(
2662            result.len(),
2663            1,
2664            "MDX comment followed by non-heading should trigger MD041"
2665        );
2666        assert_eq!(
2667            result[0].line, 2,
2668            "Warning should be on line 2 (the first content line after MDX comment)"
2669        );
2670    }
2671
2672    #[test]
2673    fn test_multiline_mdx_comment_followed_by_heading() {
2674        let rule = MD041FirstLineHeading::default();
2675
2676        // Multi-line MDX comment followed by heading
2677        let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent.";
2678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
2679        let result = rule.check(&ctx).unwrap();
2680        assert!(
2681            result.is_empty(),
2682            "Multi-line MDX comment should be preamble; heading after it satisfies MD041"
2683        );
2684    }
2685
2686    #[test]
2687    fn test_html_comment_still_works_as_preamble_regression() {
2688        let rule = MD041FirstLineHeading::default();
2689
2690        // Plain HTML comment on line 1, heading on line 2
2691        let content = "<!-- Some comment -->\n# My Document\n\nContent.";
2692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2693        let result = rule.check(&ctx).unwrap();
2694        assert!(
2695            result.is_empty(),
2696            "HTML comment should still be treated as preamble (regression test)"
2697        );
2698    }
2699}