Skip to main content

rumdl_lib/rules/
md041_first_line_heading.rs

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