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