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