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