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