Skip to main content

rumdl_lib/rules/md041_first_line_heading/
mod.rs

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