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