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