Skip to main content

rumdl_lib/rules/
md001_heading_increment.rs

1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::RuleConfig;
4use crate::rules::front_matter_utils::FrontMatterUtils;
5use crate::rules::heading_utils::HeadingUtils;
6use crate::utils::range_utils::calculate_heading_range;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10/// Configuration for MD001 (Heading increment)
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "kebab-case")]
13pub(super) struct MD001Config {
14    /// Whether a title field in front matter counts as an implicit level-1 heading
15    #[serde(default = "default_front_matter_title", alias = "front_matter_title")]
16    pub front_matter_title: bool,
17
18    /// Regex matching the front matter line that holds the title. When set, it replaces
19    /// the default `title:` lookup, so a document keying its title differently still
20    /// gets the implicit level-1 heading.
21    #[serde(default, alias = "front_matter_title_pattern")]
22    pub front_matter_title_pattern: Option<String>,
23}
24
25fn default_front_matter_title() -> bool {
26    true
27}
28
29impl Default for MD001Config {
30    fn default() -> Self {
31        Self {
32            front_matter_title: default_front_matter_title(),
33            front_matter_title_pattern: None,
34        }
35    }
36}
37
38impl RuleConfig for MD001Config {
39    const RULE_NAME: &'static str = "MD001";
40}
41
42/// Rule MD001: Heading levels should only increment by one level at a time
43///
44/// See [docs/md001.md](../../docs/md001.md) for full documentation, configuration, and examples.
45///
46/// This rule enforces a fundamental principle of document structure: heading levels
47/// should increase by exactly one level at a time to maintain a proper document hierarchy.
48///
49/// ## Purpose
50///
51/// Proper heading structure creates a logical document outline and improves:
52/// - Readability for humans
53/// - Accessibility for screen readers
54/// - Navigation in rendered documents
55/// - Automatic generation of tables of contents
56///
57/// ## Examples
58///
59/// ### Correct Heading Structure
60/// ```markdown
61/// # Heading 1
62/// ## Heading 2
63/// ### Heading 3
64/// ## Another Heading 2
65/// ```
66///
67/// ### Incorrect Heading Structure
68/// ```markdown
69/// # Heading 1
70/// ### Heading 3 (skips level 2)
71/// #### Heading 4
72/// ```
73///
74/// ## Behavior
75///
76/// This rule:
77/// - Tracks the heading level throughout the document
78/// - Validates that each new heading is at most one level deeper than the previous heading
79/// - Allows heading levels to decrease by any amount (e.g., going from ### to #)
80/// - Works with both ATX (`#`) and Setext (underlined) heading styles
81///
82/// ## Fix Behavior
83///
84/// When applying automatic fixes, this rule:
85/// - Changes the level of non-compliant headings to be one level deeper than the previous heading
86/// - Preserves the original heading style (ATX or Setext)
87/// - Maintains indentation and other formatting
88///
89/// ## Rationale
90///
91/// Skipping heading levels (e.g., from `h1` to `h3`) can confuse readers and screen readers
92/// by creating gaps in the document structure. Consistent heading increments create a proper
93/// hierarchical outline essential for well-structured documents.
94///
95/// ## Front Matter Title Support
96///
97/// When `front_matter_title` is enabled (default: true), this rule recognizes a `title:` field
98/// in YAML/TOML frontmatter as an implicit level-1 heading. This allows documents like:
99///
100/// ```markdown
101/// ---
102/// title: My Document
103/// ---
104///
105/// ## First Section
106/// ```
107///
108/// Without triggering a warning about skipping from H1 to H2, since the frontmatter title
109/// counts as the H1.
110///
111#[derive(Debug, Clone)]
112pub struct MD001HeadingIncrement {
113    /// Whether to treat frontmatter title field as an implicit H1
114    pub front_matter_title: bool,
115    /// Optional regex pattern to match custom title fields in frontmatter
116    pub front_matter_title_pattern: Option<Regex>,
117}
118
119impl Default for MD001HeadingIncrement {
120    fn default() -> Self {
121        Self {
122            front_matter_title: true,
123            front_matter_title_pattern: None,
124        }
125    }
126}
127
128/// Result of computing the fix for a single heading
129struct HeadingFixInfo {
130    /// The level after fixing (may equal original if no fix needed)
131    fixed_level: usize,
132    /// The heading style to use for the replacement
133    style: HeadingStyle,
134    /// Whether this heading needs a fix
135    needs_fix: bool,
136}
137
138impl MD001HeadingIncrement {
139    /// Create a new instance with specified settings
140    pub fn new(front_matter_title: bool) -> Self {
141        Self {
142            front_matter_title,
143            front_matter_title_pattern: None,
144        }
145    }
146
147    /// Create a new instance with a custom pattern for matching title fields
148    pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
149        Self::with_pattern_from(front_matter_title, pattern, false)
150    }
151
152    /// [`Self::with_pattern`], told whether a message about the pattern may quote it.
153    /// See [`crate::rule_config_serde::compile_config_regex`].
154    fn with_pattern_from(front_matter_title: bool, pattern: Option<String>, values_withheld: bool) -> Self {
155        let front_matter_title_pattern = pattern.and_then(|p| {
156            crate::rule_config_serde::compile_config_regex(&p, "MD001", "front-matter-title-pattern", values_withheld)
157        });
158
159        Self {
160            front_matter_title,
161            front_matter_title_pattern,
162        }
163    }
164
165    /// An empty pattern is read as "no pattern": compiled, it would match every line
166    /// and turn any front matter at all into an implicit level-1 heading.
167    fn from_config_struct(config: MD001Config, values_withheld: bool) -> Self {
168        Self::with_pattern_from(
169            config.front_matter_title,
170            config.front_matter_title_pattern.filter(|p| !p.is_empty()),
171            values_withheld,
172        )
173    }
174
175    /// Check if the document has a front matter title field
176    fn has_front_matter_title(&self, content: &str) -> bool {
177        if !self.front_matter_title {
178            return false;
179        }
180
181        // If we have a custom pattern, use it to search front matter content
182        if let Some(ref pattern) = self.front_matter_title_pattern {
183            let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
184            for line in front_matter_lines {
185                if pattern.is_match(line) {
186                    return true;
187                }
188            }
189            return false;
190        }
191
192        // Default behavior: check for "title:" field
193        FrontMatterUtils::has_front_matter_field(content, "title:")
194    }
195
196    /// Single source of truth for heading level computation and style mapping.
197    ///
198    /// Returns `(HeadingFixInfo, new_prev_level)`. Both `check()` and `fix()` call
199    /// this, making it structurally impossible for them to diverge.
200    fn compute_heading_fix(
201        prev_level: Option<usize>,
202        heading: &crate::lint_context::HeadingInfo,
203    ) -> (HeadingFixInfo, Option<usize>) {
204        let level = heading.level as usize;
205
206        let (fixed_level, needs_fix) = if let Some(prev) = prev_level
207            && level > prev + 1
208        {
209            (prev + 1, true)
210        } else {
211            (level, false)
212        };
213
214        // Map heading style, adjusting Setext variant based on the fixed level
215        let style = match heading.style {
216            crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
217            crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
218                if fixed_level == 1 {
219                    HeadingStyle::Setext1
220                } else {
221                    HeadingStyle::Setext2
222                }
223            }
224        };
225
226        let info = HeadingFixInfo {
227            fixed_level,
228            style,
229            needs_fix,
230        };
231        (info, Some(fixed_level))
232    }
233}
234
235impl Rule for MD001HeadingIncrement {
236    fn name(&self) -> &'static str {
237        "MD001"
238    }
239
240    fn description(&self) -> &'static str {
241        "Heading levels should only increment by one level at a time"
242    }
243
244    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
245        let mut warnings = Vec::new();
246
247        let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
248            Some(1)
249        } else {
250            None
251        };
252
253        for valid_heading in ctx.valid_headings() {
254            let heading = valid_heading.heading;
255            let line_info = valid_heading.line_info;
256
257            let level = heading.level as usize;
258
259            // Headings disabled via inline config keep their original level for
260            // successor tracking (the user explicitly opted out of fixing them),
261            // and no warning is emitted.
262            if ctx
263                .inline_config()
264                .is_rule_disabled(self.name(), valid_heading.line_num)
265            {
266                prev_level = Some(level);
267                continue;
268            }
269
270            let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
271            prev_level = new_prev;
272
273            if fix_info.needs_fix {
274                let line_content = line_info.content(ctx.content);
275                let original_indent = &line_content[..line_info.indent];
276                let replacement =
277                    HeadingUtils::convert_heading_style(&heading.raw_text, fix_info.fixed_level as u32, fix_info.style);
278
279                let (start_line, start_col, end_line, end_col) =
280                    calculate_heading_range(valid_heading.line_num, line_content);
281
282                warnings.push(LintWarning {
283                    rule_name: Some(self.name().to_string()),
284                    line: start_line,
285                    column: start_col,
286                    end_line,
287                    end_column: end_col,
288                    message: format!(
289                        "Expected heading level {}, but found heading level {}",
290                        fix_info.fixed_level, level
291                    ),
292                    severity: Severity::Error,
293                    fix: Some(Fix::new(
294                        ctx.line_content_byte_range(valid_heading.line_num),
295                        format!("{original_indent}{replacement}"),
296                    )),
297                });
298            }
299        }
300
301        Ok(warnings)
302    }
303
304    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
305        if self.should_skip(ctx) {
306            return Ok(ctx.content.to_string());
307        }
308        let warnings = self.check(ctx)?;
309        if warnings.is_empty() {
310            return Ok(ctx.content.to_string());
311        }
312        let warnings =
313            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
314        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
315    }
316
317    fn category(&self) -> RuleCategory {
318        RuleCategory::Heading
319    }
320
321    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
322        // Fast path: check if document likely has headings
323        if ctx.content.is_empty() || !ctx.likely_has_headings() {
324            return true;
325        }
326        // Verify valid headings actually exist
327        !ctx.has_valid_headings()
328    }
329
330    fn as_any(&self) -> &dyn std::any::Any {
331        self
332    }
333
334    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
335    where
336        Self: Sized,
337    {
338        let rule_config = crate::rule_config_serde::load_rule_config::<MD001Config>(config);
339        Box::new(Self::from_config_struct(
340            rule_config,
341            config.withheld_rule_values.contains("MD001"),
342        ))
343    }
344
345    crate::impl_rule_config_sections!(MD001Config);
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::lint_context::LintContext;
352
353    #[test]
354    fn test_basic_functionality() {
355        let rule = MD001HeadingIncrement::default();
356
357        // Test with valid headings
358        let content = "# Heading 1\n## Heading 2\n### Heading 3";
359        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360        let result = rule.check(&ctx).unwrap();
361        assert!(result.is_empty());
362
363        // Test with invalid headings: H1 → H3 → H4
364        // H3 skips level 2, and H4 is > fixed(H3=H2) + 1, so both are flagged
365        let content = "# Heading 1\n### Heading 3\n#### Heading 4";
366        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367        let result = rule.check(&ctx).unwrap();
368        assert_eq!(result.len(), 2);
369        assert_eq!(result[0].line, 2);
370        assert_eq!(result[1].line, 3);
371    }
372
373    #[test]
374    fn test_frontmatter_title_counts_as_h1() {
375        let rule = MD001HeadingIncrement::default();
376
377        // Frontmatter with title, followed by H2 - should pass
378        let content = "---\ntitle: My Document\n---\n\n## First Section";
379        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380        let result = rule.check(&ctx).unwrap();
381        assert!(
382            result.is_empty(),
383            "H2 after frontmatter title should not trigger warning"
384        );
385
386        // Frontmatter with title, followed by H3 - should warn (skips H2)
387        let content = "---\ntitle: My Document\n---\n\n### Third Level";
388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389        let result = rule.check(&ctx).unwrap();
390        assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
391        assert!(result[0].message.contains("Expected heading level 2"));
392    }
393
394    #[test]
395    fn test_frontmatter_without_title() {
396        let rule = MD001HeadingIncrement::default();
397
398        // Frontmatter without title, followed by H2 - first heading has no predecessor
399        // so it should pass (no increment check for the first heading)
400        let content = "---\nauthor: John\n---\n\n## First Section";
401        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402        let result = rule.check(&ctx).unwrap();
403        assert!(
404            result.is_empty(),
405            "First heading after frontmatter without title has no predecessor"
406        );
407    }
408
409    #[test]
410    fn test_frontmatter_title_disabled() {
411        let rule = MD001HeadingIncrement::new(false);
412
413        // Frontmatter with title, but feature disabled - H2 has no predecessor
414        let content = "---\ntitle: My Document\n---\n\n## First Section";
415        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416        let result = rule.check(&ctx).unwrap();
417        assert!(
418            result.is_empty(),
419            "With front_matter_title disabled, first heading has no predecessor"
420        );
421    }
422
423    #[test]
424    fn test_frontmatter_title_with_subsequent_headings() {
425        let rule = MD001HeadingIncrement::default();
426
427        // Complete document with frontmatter title
428        let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
429        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430        let result = rule.check(&ctx).unwrap();
431        assert!(result.is_empty(), "Valid heading progression after frontmatter title");
432    }
433
434    #[test]
435    fn test_frontmatter_title_fix() {
436        let rule = MD001HeadingIncrement::default();
437
438        // Frontmatter with title, H3 should be fixed to H2
439        let content = "---\ntitle: My Document\n---\n\n### Third Level";
440        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441        let fixed = rule.fix(&ctx).unwrap();
442        assert!(
443            fixed.contains("## Third Level"),
444            "H3 should be fixed to H2 when frontmatter has title"
445        );
446    }
447
448    #[test]
449    fn test_toml_frontmatter_title() {
450        let rule = MD001HeadingIncrement::default();
451
452        // TOML frontmatter with title
453        let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
454        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455        let result = rule.check(&ctx).unwrap();
456        assert!(result.is_empty(), "TOML frontmatter title should count as H1");
457    }
458
459    #[test]
460    fn test_no_frontmatter_no_h1() {
461        let rule = MD001HeadingIncrement::default();
462
463        // No frontmatter, starts with H2 - first heading has no predecessor, so no warning
464        let content = "## First Section\n\n### Subsection";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let result = rule.check(&ctx).unwrap();
467        assert!(
468            result.is_empty(),
469            "First heading (even if H2) has no predecessor to compare against"
470        );
471    }
472
473    #[test]
474    fn test_fix_preserves_attribute_lists() {
475        let rule = MD001HeadingIncrement::default();
476
477        // H1 followed by H3 with attribute list - fix should preserve { #custom-id }
478        let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
479        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480
481        // Verify fix() preserves attribute list
482        let fixed = rule.fix(&ctx).unwrap();
483        assert!(
484            fixed.contains("## Heading 3 { #custom-id .special }"),
485            "fix() should preserve attribute list, got: {fixed}"
486        );
487
488        // Verify check() fix output also preserves attribute list
489        let warnings = rule.check(&ctx).unwrap();
490        assert_eq!(warnings.len(), 1);
491        let fix = warnings[0].fix.as_ref().expect("Should have a fix");
492        assert!(
493            fix.replacement.contains("{ #custom-id .special }"),
494            "check() fix should preserve attribute list, got: {}",
495            fix.replacement
496        );
497    }
498
499    #[test]
500    fn test_check_single_skip_with_repeated_level() {
501        let rule = MD001HeadingIncrement::default();
502
503        // H1 followed by two H3s: only the first H3 is flagged.
504        // After fixing H3a to H2 (prev+1), H3b at level 3 = 2+1 is valid.
505        let content = "# H1\n### H3a\n### H3b";
506        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507
508        let warnings = rule.check(&ctx).unwrap();
509        assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
510        assert!(warnings[0].message.contains("Expected heading level 2"));
511
512        // Verify check()+apply_all_fixes produces idempotent output
513        let fixed = rule.fix(&ctx).unwrap();
514        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
515        let warnings_after = rule.check(&ctx_fixed).unwrap();
516        assert!(
517            warnings_after.is_empty(),
518            "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
519        );
520    }
521
522    #[test]
523    fn test_check_cascading_skip_produces_idempotent_fix() {
524        let rule = MD001HeadingIncrement::default();
525
526        // H1 → H4 → H5: both are flagged.
527        // H4: prev=1, expected=2. Fixed level tracked as 2.
528        // H5: prev=2, expected=3.
529        // Both fixes applied in one pass produce clean output.
530        let content = "# Title\n#### Deep\n##### Deeper";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532
533        let warnings = rule.check(&ctx).unwrap();
534        assert_eq!(
535            warnings.len(),
536            2,
537            "Both deep headings should be flagged for idempotent fix"
538        );
539        assert!(warnings[0].message.contains("Expected heading level 2"));
540        assert!(warnings[1].message.contains("Expected heading level 3"));
541
542        // Verify single-pass idempotent fix
543        let fixed = rule.fix(&ctx).unwrap();
544        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
545        let warnings_after = rule.check(&ctx_fixed).unwrap();
546        assert!(
547            warnings_after.is_empty(),
548            "Fixed content should have no warnings: {fixed:?}"
549        );
550    }
551
552    #[test]
553    fn test_check_level_decrease_resets_tracking() {
554        let rule = MD001HeadingIncrement::default();
555
556        // H1 → H3 (flagged) → H1 (decrease, always allowed) → H3 (flagged again)
557        let content = "# Title\n### Sub\n# Another\n### Sub2";
558        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559
560        let warnings = rule.check(&ctx).unwrap();
561        assert_eq!(
562            warnings.len(),
563            2,
564            "Both H3 headings should be flagged (each follows an H1)"
565        );
566
567        // Verify single-pass idempotent fix
568        let fixed = rule.fix(&ctx).unwrap();
569        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
570        assert!(
571            rule.check(&ctx_fixed).unwrap().is_empty(),
572            "Fixed content should pass: {fixed:?}"
573        );
574    }
575
576    /// Core invariant: for every warning with a Fix, the replacement text must
577    /// match what fix() produces for that same line.
578    #[test]
579    fn test_check_and_fix_produce_identical_replacements() {
580        let rule = MD001HeadingIncrement::default();
581
582        let inputs = [
583            "# H1\n### H3\n",
584            "# H1\n#### H4\n##### H5\n",
585            "# H1\n### H3\n# H1b\n### H3b\n",
586            "# H1\n\n### H3 { #custom-id }\n",
587            "---\ntitle: Doc\n---\n\n### Deep\n",
588        ];
589
590        for input in &inputs {
591            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
592            let warnings = rule.check(&ctx).unwrap();
593            let fixed = rule.fix(&ctx).unwrap();
594            let fixed_lines: Vec<&str> = fixed.lines().collect();
595
596            for warning in &warnings {
597                if let Some(ref fix) = warning.fix {
598                    // Extract the fixed line from fix() output for the same line number
599                    let line_idx = warning.line - 1;
600                    assert!(
601                        line_idx < fixed_lines.len(),
602                        "Warning line {} out of range for fixed output (input: {input:?})",
603                        warning.line,
604                    );
605                    let fix_output_line = fixed_lines[line_idx];
606                    assert_eq!(
607                        fix.replacement, fix_output_line,
608                        "check() fix and fix() output diverge at line {} (input: {input:?})",
609                        warning.line,
610                    );
611                }
612            }
613        }
614    }
615
616    /// Setext H1 followed by deep ATX heading: Setext heading is untouched,
617    /// ATX heading is fixed to H2.
618    #[test]
619    fn test_setext_headings_mixed_with_atx_cascading() {
620        let rule = MD001HeadingIncrement::default();
621
622        let content = "Setext Title\n============\n\n#### Deep ATX\n";
623        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624
625        let warnings = rule.check(&ctx).unwrap();
626        assert_eq!(warnings.len(), 1);
627        assert!(warnings[0].message.contains("Expected heading level 2"));
628
629        let fixed = rule.fix(&ctx).unwrap();
630        assert!(
631            fixed.contains("## Deep ATX"),
632            "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
633        );
634
635        // Verify idempotency
636        let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
637        assert!(
638            rule.check(&ctx_fixed).unwrap().is_empty(),
639            "Fixed content should produce no warnings"
640        );
641    }
642
643    /// fix(fix(x)) == fix(x) for various inputs
644    #[test]
645    fn test_fix_idempotent_applied_twice() {
646        let rule = MD001HeadingIncrement::default();
647
648        let inputs = [
649            "# H1\n### H3\n#### H4\n",
650            "## H2\n##### H5\n###### H6\n",
651            "# A\n### B\n# C\n### D\n##### E\n",
652            "# H1\nH2\n--\n#### H4\n",
653            // Setext edge cases
654            "Title\n=====\n",
655            "Title\n=====\n\n#### Deep\n",
656            "Sub\n---\n\n#### Deep\n",
657            "T1\n==\nT2\n--\n#### Deep\n",
658        ];
659
660        for input in &inputs {
661            let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
662            let fixed_once = rule.fix(&ctx1).unwrap();
663
664            let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
665            let fixed_twice = rule.fix(&ctx2).unwrap();
666
667            assert_eq!(
668                fixed_once, fixed_twice,
669                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed_once:?}\nsecond: {fixed_twice:?}"
670            );
671        }
672    }
673
674    /// Setext underline must not be duplicated: fix() should produce the same
675    /// number of lines as the input for valid documents.
676    #[test]
677    fn test_setext_fix_no_underline_duplication() {
678        let rule = MD001HeadingIncrement::default();
679
680        // Setext H1 only — no fix needed, output must be identical
681        let content = "Title\n=====\n";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let fixed = rule.fix(&ctx).unwrap();
684        assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
685
686        // Setext H2 only — no fix needed
687        let content = "Sub\n---\n";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let fixed = rule.fix(&ctx).unwrap();
690        assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
691
692        // Two consecutive Setext headings — valid H1 then H2
693        let content = "Title\n=====\nSub\n---\n";
694        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695        let fixed = rule.fix(&ctx).unwrap();
696        assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
697
698        // Setext H1 at end of file without trailing newline
699        let content = "Title\n=====";
700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let fixed = rule.fix(&ctx).unwrap();
702        assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
703
704        // Setext H2 followed by deep ATX heading
705        let content = "Sub\n---\n\n#### Deep\n";
706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707        let fixed = rule.fix(&ctx).unwrap();
708        assert!(
709            fixed.contains("### Deep"),
710            "H4 after Setext H2 should become H3, got: {fixed}"
711        );
712        assert_eq!(
713            fixed.matches("---").count(),
714            1,
715            "Underline should not be duplicated, got: {fixed}"
716        );
717
718        // Underline longer than text must not be normalized for valid headings
719        let content = "Hi\n==========\n";
720        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721        let fixed = rule.fix(&ctx).unwrap();
722        assert_eq!(
723            fixed, content,
724            "Valid Setext with long underline must be preserved exactly, got: {fixed}"
725        );
726
727        // Underline shorter than text must not be normalized
728        let content = "Long Title Here\n===\n";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let fixed = rule.fix(&ctx).unwrap();
731        assert_eq!(
732            fixed, content,
733            "Valid Setext with short underline must be preserved exactly, got: {fixed}"
734        );
735    }
736
737    /// Roundtrip safety: after fix(), check() must produce no warnings
738    /// across a variety of inputs covering frontmatter, setext, attribute
739    /// lists, cascading skips, and level decreases.
740    #[test]
741    fn test_roundtrip_fix_produces_no_warnings() {
742        let rule = MD001HeadingIncrement::default();
743
744        let inputs = [
745            "# H1\n### H3\n",
746            "# H1\n#### H4\n##### H5\n",
747            "# H1\n### H3\n# H1b\n### H3b\n",
748            "# H1\n\n### H3 { #custom-id }\n",
749            "---\ntitle: Doc\n---\n\n### Deep\n",
750            "Title\n=====\n\n#### Deep\n",
751            "Sub\n---\n\n#### Deep\n",
752            "# A\n### B\n# C\n### D\n##### E\n",
753            "# Title\n#### Deep\n##### Deeper\n###### Deepest\n",
754        ];
755
756        for input in &inputs {
757            let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
758            let fixed = rule.fix(&ctx).unwrap();
759
760            let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
761            let warnings_after = rule.check(&ctx_fixed).unwrap();
762            assert!(
763                warnings_after.is_empty(),
764                "Fix should produce clean output for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
765            );
766
767            // Idempotency: fix(fix(x)) == fix(x)
768            let fixed_twice = rule.fix(&ctx_fixed).unwrap();
769            assert_eq!(
770                fixed, fixed_twice,
771                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
772            );
773        }
774    }
775
776    /// Disable-via-inline-config must still update prev_level tracking so that
777    /// subsequent headings are computed relative to the (unfixed) disabled heading.
778    #[test]
779    fn test_inline_disable_preserves_content() {
780        let rule = MD001HeadingIncrement::default();
781
782        // H1, then disabled H4 (kept as-is), then H5 (4+1=5 is valid after disabled H4)
783        let content = "# H1\n\n<!-- rumdl-disable-next-line MD001 -->\n#### H4\n\n##### H5\n";
784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785
786        let fixed = rule.fix(&ctx).unwrap();
787        // The disabled H4 must remain, and H5 must also remain (valid after prev=4)
788        assert!(fixed.contains("#### H4"), "Disabled heading should be preserved");
789        assert!(fixed.contains("##### H5"), "Heading after disabled should be preserved");
790    }
791}