Skip to main content

rumdl_lib/rules/
md029_ordered_list_prefix.rs

1/// Rule MD029: Ordered list item prefix
2///
3/// See [docs/md029.md](../../docs/md029.md) for full documentation, configuration, and examples.
4use crate::lint_context::ParsedListItem;
5use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
6use crate::utils::list_fix_guard::{
7    Allowed, drop_structure_changing_fixes, is_inside_literal_code, protect_literal_code,
8};
9use crate::utils::list_indent_shift::{Nesting, move_owned_lines};
10use crate::utils::range_utils::byte_to_char_count;
11use crate::utils::regex_cache::ORDERED_LIST_MARKER_REGEX;
12use std::collections::HashMap;
13use toml;
14
15mod md029_config;
16pub use md029_config::ListStyle;
17pub(super) use md029_config::MD029Config;
18
19#[derive(Debug, Clone, Default)]
20pub struct MD029OrderedListPrefix {
21    config: MD029Config,
22}
23
24impl MD029OrderedListPrefix {
25    pub fn new(style: ListStyle) -> Self {
26        Self {
27            config: MD029Config { style },
28        }
29    }
30
31    pub fn from_config_struct(config: MD029Config) -> Self {
32        Self { config }
33    }
34
35    /// The number of an ordered list marker, whichever delimiter closes it:
36    /// `1.` and `1)` both number the item 1.
37    #[inline]
38    fn parse_marker_number(marker: &str) -> Option<usize> {
39        marker.strip_suffix(['.', ')']).unwrap_or(marker).parse::<usize>().ok()
40    }
41
42    /// Calculate the expected number for a list item.
43    /// The `start_value` is the CommonMark-provided start value for the list.
44    /// For style `Ordered`, items should be `start_value, start_value+1, start_value+2, ...`
45    #[inline]
46    fn get_expected_number(&self, index: usize, detected_style: Option<ListStyle>, start_value: u64) -> usize {
47        // Use detected_style when the configuration is auto-detect mode (OneOrOrdered or Consistent)
48        // For explicit style configurations, always use the configured style
49        let style = match self.config.style {
50            ListStyle::OneOrOrdered | ListStyle::Consistent => detected_style.unwrap_or(ListStyle::OneOne),
51            _ => self.config.style,
52        };
53
54        match style {
55            ListStyle::One | ListStyle::OneOne => 1,
56            ListStyle::Ordered => (start_value as usize) + index,
57            ListStyle::Ordered0 => index,
58            ListStyle::OneOrOrdered | ListStyle::Consistent => {
59                // This shouldn't be reached since we handle these above
60                1
61            }
62        }
63    }
64
65    /// Detect the style being used in a list by checking all items for prevalence.
66    /// The `start_value` parameter is the CommonMark-provided list start value.
67    fn detect_list_style(items: &[ParsedListItem<'_>], start_value: u64) -> ListStyle {
68        if items.len() < 2 {
69            // With only one item, check if it matches the start value
70            // If so, treat as Ordered (respects CommonMark start value)
71            // Otherwise, check if it's 1 (OneOne style)
72            let first_num = Self::parse_marker_number(items[0].marker());
73            if first_num == Some(start_value as usize) {
74                return ListStyle::Ordered;
75            }
76            return ListStyle::OneOne;
77        }
78
79        let first_num = Self::parse_marker_number(items[0].marker());
80        let second_num = Self::parse_marker_number(items[1].marker());
81
82        // Fast path: Check for Ordered0 special case (starts with 0, 1)
83        if matches!((first_num, second_num), (Some(0), Some(1))) {
84            return ListStyle::Ordered0;
85        }
86
87        // Fast path: If first 2 items aren't both "1", it must be Ordered (O(1))
88        // This handles ~95% of lists instantly: "1. 2. 3...", "2. 3. 4...", etc.
89        if first_num != Some(1) || second_num != Some(1) {
90            return ListStyle::Ordered;
91        }
92
93        // Slow path: Both first items are "1", check if ALL are "1" (O(n))
94        // This is necessary for lists like "1. 1. 1..." vs "1. 1. 2. 3..."
95        let all_ones = items
96            .iter()
97            .all(|item| Self::parse_marker_number(item.marker()) == Some(1));
98
99        if all_ones {
100            ListStyle::OneOne
101        } else {
102            ListStyle::Ordered
103        }
104    }
105
106    /// Check a CommonMark-grouped list for correct ordering.
107    /// Uses the CommonMark start value to validate items (e.g., a list starting at 11
108    /// expects items 11, 12, 13... - no violation there).
109    fn check_commonmark_list_group(
110        &self,
111        ctx: &crate::lint_context::LintContext,
112        group: &[ParsedListItem<'_>],
113        warnings: &mut Vec<LintWarning>,
114        document_wide_style: Option<ListStyle>,
115        start_value: u64,
116    ) {
117        if group.is_empty() {
118            return;
119        }
120
121        // Group items by indentation level (marker_column) to handle nested lists
122        type LevelGroups<'a> = HashMap<usize, Vec<ParsedListItem<'a>>>;
123        let mut level_groups: LevelGroups = HashMap::new();
124
125        for &list_item in group {
126            level_groups
127                .entry(list_item.marker_column())
128                .or_default()
129                .push(list_item);
130        }
131
132        // Process each indentation level in sorted order for deterministic output
133        let mut sorted_levels: Vec<_> = level_groups.into_iter().collect();
134        sorted_levels.sort_by_key(|(indent, _)| *indent);
135
136        for (_indent, mut items) in sorted_levels {
137            // Sort by line number
138            items.sort_by_key(|item| item.line_num());
139
140            if items.is_empty() {
141                continue;
142            }
143
144            // Determine style for this group
145            let detected_style = if let Some(doc_style) = document_wide_style {
146                Some(doc_style)
147            } else if self.config.style == ListStyle::OneOrOrdered {
148                Some(Self::detect_list_style(&items, start_value))
149            } else {
150                None
151            };
152
153            // Check each item using the CommonMark start value
154            for (idx, list_item) in items.iter().copied().enumerate() {
155                if let Some(actual_num) = Self::parse_marker_number(list_item.marker()) {
156                    let expected_num = self.get_expected_number(idx, detected_style, start_value);
157
158                    if actual_num != expected_num {
159                        let line_num = list_item.line_num();
160                        let line_info = list_item.line_info();
161                        let marker_start = list_item.marker_byte_offset();
162                        let number_len = if let Some(dot_pos) = list_item.marker().find('.') {
163                            dot_pos
164                        } else if let Some(paren_pos) = list_item.marker().find(')') {
165                            paren_pos
166                        } else {
167                            list_item.marker().len()
168                        };
169
170                        let style_name = match detected_style.as_ref().unwrap_or(&ListStyle::Ordered) {
171                            ListStyle::OneOne => "one",
172                            ListStyle::Ordered => "ordered",
173                            ListStyle::Ordered0 => "ordered0",
174                            _ => "ordered",
175                        };
176
177                        let style_context = match self.config.style {
178                            ListStyle::Consistent => format!("document style '{style_name}'"),
179                            ListStyle::OneOrOrdered => format!("list style '{style_name}'"),
180                            ListStyle::One | ListStyle::OneOne => "configured style 'one'".to_string(),
181                            ListStyle::Ordered => "configured style 'ordered'".to_string(),
182                            ListStyle::Ordered0 => "configured style 'ordered0'".to_string(),
183                        };
184
185                        // Only provide auto-fix when:
186                        // 1. The list starts at 1 (default numbering), OR
187                        // 2. We're using explicit 'one' style (numbers are meaningless)
188                        // When start_value > 1, the user explicitly chose that number,
189                        // so auto-fixing would destroy their intent.
190                        let should_provide_fix =
191                            start_value == 1 || matches!(self.config.style, ListStyle::One | ListStyle::OneOne);
192
193                        // marker_column is a byte offset within the line; convert to a
194                        // character column for the diagnostic.
195                        let line_text = line_info.content(ctx.content);
196
197                        warnings.push(LintWarning {
198                            rule_name: Some(self.name().to_string()),
199                            message: format!(
200                                "Ordered list item number {actual_num} does not match {style_context} (expected {expected_num})"
201                            ),
202                            line: line_num,
203                            column: byte_to_char_count(line_text, list_item.marker_column()),
204                            end_line: line_num,
205                            end_column: byte_to_char_count(line_text, list_item.marker_column() + number_len),
206                            severity: Severity::Warning,
207                            fix: if should_provide_fix {
208                                Some(Fix::new(marker_start..marker_start + number_len, expected_num.to_string()))
209                            } else {
210                                None
211                            },
212                        });
213                    }
214                }
215            }
216        }
217    }
218}
219
220impl Rule for MD029OrderedListPrefix {
221    fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
222        (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(29))
223    }
224
225    fn name(&self) -> &'static str {
226        "MD029"
227    }
228
229    fn description(&self) -> &'static str {
230        "Ordered list marker value"
231    }
232
233    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234        // Early returns for performance
235        if ctx.content.is_empty() {
236            return Ok(Vec::new());
237        }
238
239        // Quick check for any ordered list markers before processing
240        if (!ctx.content.contains('.') && !ctx.content.contains(')'))
241            || !ctx.content.lines().any(|line| ORDERED_LIST_MARKER_REGEX.is_match(line))
242        {
243            return Ok(Vec::new());
244        }
245
246        let mut warnings = Vec::new();
247
248        // Use pulldown-cmark's AST for authoritative list membership and start values.
249        // This respects CommonMark's list start values (e.g., a list starting at 11
250        // expects items 11, 12, 13... - no violation there).
251        let list_groups = ctx.commonmark_ordered_lists();
252
253        if list_groups.is_empty() {
254            return Ok(Vec::new());
255        }
256
257        // For Consistent style, detect document-wide prevalent style
258        let document_wide_style = if self.config.style == ListStyle::Consistent {
259            // Collect ALL ordered items from ALL groups
260            let mut all_document_items = Vec::new();
261            for list in list_groups {
262                all_document_items.extend(
263                    list.items()
264                        .filter(|item| !is_inside_literal_code(ctx, item.marker_byte_offset())),
265                );
266            }
267            // Detect style across entire document (use 1 as default for pattern detection)
268            if !all_document_items.is_empty() {
269                Some(Self::detect_list_style(&all_document_items, 1))
270            } else {
271                None
272            }
273        } else {
274            None
275        };
276
277        // Process each CommonMark-defined list group with its start value
278        for list in list_groups {
279            let mut items: Vec<_> = list.items().collect();
280            let starts_in_literal = items
281                .first()
282                .is_some_and(|item| is_inside_literal_code(ctx, item.marker_byte_offset()));
283            items.retain(|item| !is_inside_literal_code(ctx, item.marker_byte_offset()));
284            // A parser start owned by an invisible string is not the visible
285            // list's explicit start. Later literal items only lose their index;
286            // they do not split the visible siblings into a new sequence.
287            let start_value = if starts_in_literal {
288                items
289                    .first()
290                    .and_then(|item| Self::parse_marker_number(item.marker()))
291                    .map_or(list.start_value(), |number| number as u64)
292            } else {
293                list.start_value()
294            };
295            self.check_commonmark_list_group(ctx, &items, &mut warnings, document_wide_style, start_value);
296        }
297
298        // Sort warnings by line number for deterministic output
299        warnings.sort_by_key(|w| (w.line, w.column));
300
301        // A renumbering that changes the number's width (`9.` -> `10.`) moves the
302        // item's content column, so everything the item owns, nested lists
303        // included, moves with it.
304        let moves = warnings
305            .iter()
306            .enumerate()
307            .filter_map(|(index, warning)| {
308                let fix = warning.fix.as_ref()?;
309                let delta = fix.replacement.len() as isize - fix.range.len() as isize;
310                (delta != 0).then_some((warning.line - 1, (index, delta)))
311            })
312            .collect();
313        move_owned_lines(ctx, &moves, Nesting::Relative, &mut warnings);
314        protect_literal_code(ctx, &mut warnings);
315        drop_structure_changing_fixes(ctx, &mut warnings, Allowed::ListStart);
316
317        Ok(warnings)
318    }
319
320    fn fixes_from_diagnostics(&self) -> bool {
321        true
322    }
323
324    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
325        // Note: do not call self.should_skip() here — MD029's should_skip only covers
326        // unordered list markers (*, -, +), not ordered list markers (digits + . or )).
327        // check() has its own fast-path early-return for documents without ordered markers.
328        let warnings = self.check(ctx)?;
329        if warnings.is_empty() {
330            return Ok(ctx.content.to_string());
331        }
332        let warnings =
333            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
334        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
335    }
336
337    /// Get the category of this rule for selective processing
338    fn category(&self) -> RuleCategory {
339        RuleCategory::List
340    }
341
342    /// Skip a document with no ordered list. `likely_has_lists` counts bullet
343    /// characters only, so it says nothing about ordered lists; the parsed list
344    /// set does, and `check` reads the same cached value.
345    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
346        ctx.content.is_empty() || ctx.commonmark_ordered_lists().is_empty()
347    }
348
349    fn as_any(&self) -> &dyn std::any::Any {
350        self
351    }
352
353    crate::impl_rule_config_methods!(MD029Config);
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn test_fix_keeps_a_parent_paragraph_out_of_a_renumbered_child() {
362        // `10.` becoming `1.` pulls the child's content column from 7 to 6, where
363        // the parent's paragraph sits. It must stay with the parent.
364        let rule = MD029OrderedListPrefix::new(ListStyle::One);
365        let content = "1. one\n\n   1. a\n   10. child\n\n      Parent paragraph.\n";
366        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367        assert_eq!(
368            rule.fix(&ctx).unwrap(),
369            "1. one\n\n   1. a\n   1. child\n\n   Parent paragraph.\n"
370        );
371    }
372
373    #[test]
374    fn test_basic_functionality() {
375        // Test with default style (ordered)
376        let rule = MD029OrderedListPrefix::default();
377
378        // Test with correctly ordered list
379        let content = "1. First item\n2. Second item\n3. Third item";
380        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
381        let result = rule.check(&ctx).unwrap();
382        assert!(result.is_empty());
383
384        // Test with incorrectly ordered list
385        let content = "1. First item\n3. Third item\n5. Fifth item";
386        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387        let result = rule.check(&ctx).unwrap();
388        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 5
389
390        // Test with one-one style
391        let rule = MD029OrderedListPrefix::new(ListStyle::OneOne);
392        let content = "1. First item\n2. Second item\n3. Third item";
393        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
394        let result = rule.check(&ctx).unwrap();
395        assert_eq!(result.len(), 2); // Should have warnings for items 2 and 3
396
397        // Test with ordered0 style
398        let rule = MD029OrderedListPrefix::new(ListStyle::Ordered0);
399        let content = "0. First item\n1. Second item\n2. Third item";
400        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401        let result = rule.check(&ctx).unwrap();
402        assert!(result.is_empty());
403    }
404
405    #[test]
406    fn test_redundant_computation_fix() {
407        // This test confirms that the redundant computation bug is fixed
408        // Previously: get_list_number() was called twice (once for is_some(), once for unwrap())
409        // Now: get_list_number() is called once with if let pattern
410
411        let rule = MD029OrderedListPrefix::default();
412
413        // Test with mixed valid and edge case content
414        let content = "1. First item\n3. Wrong number\n2. Another wrong number";
415        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416
417        // This should not panic and should produce warnings for incorrect numbering
418        let result = rule.check(&ctx).unwrap();
419        assert_eq!(result.len(), 2); // Should have warnings for items 3 and 2
420
421        // Verify the warnings have correct content
422        assert!(result[0].message.contains('3') && result[0].message.contains("expected 2"));
423        assert!(result[1].message.contains('2') && result[1].message.contains("expected 3"));
424    }
425
426    #[test]
427    fn test_performance_improvement() {
428        // This test verifies the rule handles large lists without performance issues
429        let rule = MD029OrderedListPrefix::default();
430
431        // Create a larger list with WRONG numbers: 1, 5, 10, 15, ...
432        // Starting at 1, CommonMark expects 1, 2, 3, 4, ...
433        // So items 2-100 are all wrong (expected 2, got 5; expected 3, got 10; etc.)
434        let mut content = String::from("1. Item 1\n"); // First item correct
435        for i in 2..=100 {
436            content.push_str(&format!("{}. Item {}\n", i * 5 - 5, i)); // Wrong numbers
437        }
438
439        let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
440
441        // This should complete without issues and produce warnings for items 2-100
442        let result = rule.check(&ctx).unwrap();
443        assert_eq!(result.len(), 99, "Should have warnings for items 2-100 (99 items)");
444
445        // First wrong item: "5. Item 2" (expected 2)
446        assert!(result[0].message.contains('5') && result[0].message.contains("expected 2"));
447    }
448
449    #[test]
450    fn test_one_or_ordered_with_all_ones() {
451        // Test OneOrOrdered style with all 1s (should pass)
452        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
453
454        let content = "1. First item\n1. Second item\n1. Third item";
455        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456        let result = rule.check(&ctx).unwrap();
457        assert!(result.is_empty(), "All ones should be valid in OneOrOrdered mode");
458    }
459
460    #[test]
461    fn test_one_or_ordered_with_sequential() {
462        // Test OneOrOrdered style with sequential numbering (should pass)
463        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
464
465        let content = "1. First item\n2. Second item\n3. Third item";
466        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467        let result = rule.check(&ctx).unwrap();
468        assert!(
469            result.is_empty(),
470            "Sequential numbering should be valid in OneOrOrdered mode"
471        );
472    }
473
474    #[test]
475    fn test_one_or_ordered_with_mixed_style() {
476        // Test OneOrOrdered style with mixed numbering (should fail)
477        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
478
479        let content = "1. First item\n2. Second item\n1. Third item";
480        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
481        let result = rule.check(&ctx).unwrap();
482        assert_eq!(result.len(), 1, "Mixed style should produce one warning");
483        assert!(result[0].message.contains('1') && result[0].message.contains("expected 3"));
484    }
485
486    #[test]
487    fn test_one_or_ordered_separate_lists() {
488        // Test OneOrOrdered with separate lists using different styles (should pass)
489        let rule = MD029OrderedListPrefix::new(ListStyle::OneOrOrdered);
490
491        let content = "# First list\n\n1. Item A\n1. Item B\n\n# Second list\n\n1. Item X\n2. Item Y\n3. Item Z";
492        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
493        let result = rule.check(&ctx).unwrap();
494        assert!(
495            result.is_empty(),
496            "Separate lists can use different styles in OneOrOrdered mode"
497        );
498    }
499
500    /// Core invariant: for every warning with a Fix, the replacement text must
501    /// match what fix() produces for the same byte range in the output.
502    #[test]
503    fn test_check_and_fix_produce_identical_replacements() {
504        let rule = MD029OrderedListPrefix::default();
505
506        let inputs = [
507            "1. First\n3. Skip\n5. Skip\n",
508            "1. First\n3. Third\n2. Second\n",
509            "1. A\n\n3. B\n",
510            "- Unordered\n\n1. A\n3. B\n",
511            "1. A\n   1. Nested wrong\n   3. Nested\n2. B\n",
512        ];
513
514        for input in &inputs {
515            let ctx = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
516            let warnings = rule.check(&ctx).unwrap();
517            let fixed = rule.fix(&ctx).unwrap();
518
519            // fix() must be idempotent: applying it again produces the same output
520            let ctx2 = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
521            let fixed_twice = rule.fix(&ctx2).unwrap();
522            assert_eq!(
523                fixed, fixed_twice,
524                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
525            );
526
527            // After fixing, check() should produce no warnings
528            let warnings_after = rule.check(&ctx2).unwrap();
529            assert!(
530                warnings_after.is_empty(),
531                "check() should produce no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nremaining: {warnings_after:?}"
532            );
533
534            // For every warning with a Fix, applying the fix alone should match
535            // the content at the same range in the final fixed output
536            for warning in &warnings {
537                if let Some(ref fix) = warning.fix {
538                    assert!(
539                        fix.range.end <= input.len(),
540                        "Fix range exceeds input length for {input:?}"
541                    );
542                }
543            }
544        }
545    }
546
547    /// fix(fix(x)) == fix(x)
548    #[test]
549    fn test_fix_idempotent() {
550        let rule = MD029OrderedListPrefix::default();
551
552        let inputs = [
553            "1. A\n3. B\n5. C\n",
554            "# Intro\n\n1. First\n3. Third\n",
555            "1. A\n1. B\n1. C\n",
556        ];
557
558        for input in &inputs {
559            let ctx1 = crate::lint_context::LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
560            let fixed_once = rule.fix(&ctx1).unwrap();
561            let ctx2 =
562                crate::lint_context::LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
563            let fixed_twice = rule.fix(&ctx2).unwrap();
564            assert_eq!(fixed_once, fixed_twice, "fix() is not idempotent for input: {input:?}");
565        }
566    }
567
568    /// Example list markers `(@)` and `(@label)` must not be reported under
569    /// the Pandoc flavor — they are not ordered list items.
570    #[test]
571    fn test_pandoc_skips_example_list_markers() {
572        use crate::config::MarkdownFlavor;
573        use crate::lint_context::LintContext;
574        let rule = MD029OrderedListPrefix::default();
575        let content = "(@) First.\n(@good) Second.\n(@) Third.\n";
576        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
577        let result = rule.check(&ctx).unwrap();
578        assert!(
579            result.is_empty(),
580            "MD029 should not flag (@)/(@label) example markers under Pandoc: {result:?}"
581        );
582    }
583
584    /// A real ordered list interleaved with example markers should validate
585    /// only the digit-prefixed items, ignoring the example markers.
586    #[test]
587    fn test_pandoc_example_markers_do_not_break_real_ordered_list() {
588        use crate::config::MarkdownFlavor;
589        use crate::lint_context::LintContext;
590        let rule = MD029OrderedListPrefix::default();
591        let content = "1. Real first.\n\n(@) Example.\n\n2. Real second.\n";
592        let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
593        let result = rule.check(&ctx).unwrap();
594        assert!(
595            result.is_empty(),
596            "MD029 should validate the digit-prefixed sequence and skip the example marker: {result:?}"
597        );
598    }
599
600    /// Lists with explicit non-1 start values should not be auto-fixed
601    /// (to preserve user intent).
602    #[test]
603    fn test_fix_preserves_non_default_start_value() {
604        let rule = MD029OrderedListPrefix::default();
605
606        // List starts at 11 — CommonMark expects 11, 12, 13... Item "14" is wrong
607        // but user explicitly chose 11 so no auto-fix should be offered.
608        let content = "11. First\n14. Fourth\n";
609        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
610        let warnings = rule.check(&ctx).unwrap();
611        // Warning present but no fix
612        assert!(!warnings.is_empty(), "Should produce warnings for misnumbered list");
613        assert!(
614            warnings.iter().all(|w| w.fix.is_none()),
615            "Should not provide auto-fix for lists starting at non-1 values"
616        );
617        // fix() should leave content unchanged
618        let fixed = rule.fix(&ctx).unwrap();
619        assert_eq!(
620            fixed, content,
621            "Content should be unchanged when no fixes are available"
622        );
623    }
624
625    #[test]
626    fn test_md029_front_matter() {
627        let rule = MD029OrderedListPrefix::default();
628        let content = "---\n1. key: value\n3. key2: value2\n---\n1. Item 1\n2. Item 2\n";
629        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631        assert!(
632            result.is_empty(),
633            "Should not flag list-like items in front-matter: {result:?}"
634        );
635    }
636}