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