Skip to main content

rumdl_lib/rules/
md007_ul_indent.rs

1/// Rule MD007: Unordered list indentation
2///
3/// See [docs/md007.md](../../docs/md007.md) for full documentation, configuration, and examples.
4use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::rule_config_serde::RuleConfig;
6
7mod md007_config;
8use md007_config::MD007Config;
9
10#[derive(Debug, Clone, Default)]
11pub struct MD007ULIndent {
12    config: MD007Config,
13}
14
15impl MD007ULIndent {
16    pub fn new(indent: usize) -> Self {
17        Self {
18            config: MD007Config {
19                indent: crate::types::IndentSize::from_const(indent as u8),
20                start_indented: false,
21                start_indent: crate::types::IndentSize::from_const(2),
22                style: md007_config::IndentStyle::TextAligned,
23                style_explicit: false,  // Allow auto-detection for programmatic construction
24                indent_explicit: false, // Programmatic construction uses default behavior
25            },
26        }
27    }
28
29    pub fn from_config_struct(config: MD007Config) -> Self {
30        Self { config }
31    }
32
33    /// Convert character position to visual column (accounting for tabs)
34    fn char_pos_to_visual_column(content: &str, char_pos: usize) -> usize {
35        let mut visual_col = 0;
36
37        for (current_pos, ch) in content.chars().enumerate() {
38            if current_pos >= char_pos {
39                break;
40            }
41            if ch == '\t' {
42                // Tab moves to next multiple of 4
43                visual_col = (visual_col / 4 + 1) * 4;
44            } else {
45                visual_col += 1;
46            }
47        }
48        visual_col
49    }
50
51    /// Calculate expected indentation for a nested list item.
52    ///
53    /// This uses per-parent logic rather than document-wide style selection:
54    /// - When parent is **ordered**: align with parent's text (handles variable-width markers)
55    /// - When parent is **unordered**: use configured indent (fixed-width markers)
56    ///
57    /// If user explicitly sets `style`, that choice is respected uniformly.
58    /// "Do What I Mean" behavior: if user sets `indent` but not `style`, use fixed style.
59    fn calculate_expected_indent(
60        &self,
61        nesting_level: usize,
62        parent_info: Option<(bool, usize)>, // (is_ordered, content_visual_col)
63    ) -> usize {
64        if nesting_level == 0 {
65            return 0;
66        }
67
68        // If user explicitly set style, respect their choice uniformly
69        if self.config.style_explicit {
70            return match self.config.style {
71                md007_config::IndentStyle::Fixed => nesting_level * self.config.indent.get() as usize,
72                md007_config::IndentStyle::TextAligned => {
73                    parent_info.map_or(nesting_level * 2, |(_, content_col)| content_col)
74                }
75            };
76        }
77
78        // "Do What I Mean": if indent is explicitly set (but style is not), use fixed style
79        // This is the expected behavior when users configure `indent = 4` - they want 4-space increments
80        // BUT: bullets under ordered lists still need text-aligned because ordered markers have variable width
81        if self.config.indent_explicit {
82            match parent_info {
83                Some((true, parent_content_col)) => {
84                    // Parent is ordered: even with explicit indent, use text-aligned
85                    // Ordered markers have variable width ("1." vs "10." vs "100.")
86                    return parent_content_col;
87                }
88                _ => {
89                    // Parent is unordered or no parent: use fixed indent
90                    return nesting_level * self.config.indent.get() as usize;
91                }
92            }
93        }
94
95        // Smart default: per-parent type decision
96        match parent_info {
97            Some((true, parent_content_col)) => {
98                // Parent is ordered: align with parent's text position
99                // This handles variable-width markers ("1." vs "10." vs "100.")
100                parent_content_col
101            }
102            Some((false, parent_content_col)) => {
103                // Parent is unordered: check if it's at the expected fixed position
104                // If yes, continue with fixed style (for pure unordered lists)
105                // If no, parent is offset (e.g., inside ordered list), use text-aligned
106                let parent_level = nesting_level.saturating_sub(1);
107                let expected_parent_marker = parent_level * self.config.indent.get() as usize;
108                // Parent's marker column is content column minus marker width (2 for "- ")
109                let parent_marker_col = parent_content_col.saturating_sub(2);
110
111                if parent_marker_col == expected_parent_marker {
112                    // Parent is at expected fixed position, continue with fixed style
113                    nesting_level * self.config.indent.get() as usize
114                } else {
115                    // Parent is offset, use text-aligned
116                    parent_content_col
117                }
118            }
119            None => {
120                // No parent found (shouldn't happen at nesting_level > 0)
121                nesting_level * self.config.indent.get() as usize
122            }
123        }
124    }
125}
126
127impl Rule for MD007ULIndent {
128    fn name(&self) -> &'static str {
129        "MD007"
130    }
131
132    fn description(&self) -> &'static str {
133        "Unordered list indentation"
134    }
135
136    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
137        let mut warnings = Vec::new();
138        let mut list_stack: Vec<(usize, usize, bool, usize)> = Vec::new(); // Stack of (marker_visual_col, line_num, is_ordered, content_visual_col) for tracking nesting
139
140        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
141            // Skip if this line is in a code block, front matter, or mkdocstrings
142            if line_info.in_code_block || line_info.in_front_matter || line_info.in_mkdocstrings {
143                continue;
144            }
145
146            // Check if this line has a list item
147            if let Some(list_item) = &line_info.list_item {
148                // For blockquoted lists, we need to calculate indentation relative to the blockquote content
149                // not the full line. This is because blockquoted lists follow the same indentation rules
150                // as regular lists, just within their blockquote context.
151                let (content_for_calculation, adjusted_marker_column) = if line_info.blockquote.is_some() {
152                    // Find the position after ALL blockquote prefixes (handles nested > > > etc)
153                    let line_content = line_info.content(ctx.content);
154                    let mut remaining = line_content;
155                    let mut content_start = 0;
156
157                    loop {
158                        let trimmed = remaining.trim_start();
159                        if !trimmed.starts_with('>') {
160                            break;
161                        }
162                        // Account for leading whitespace
163                        content_start += remaining.len() - trimmed.len();
164                        // Account for '>'
165                        content_start += 1;
166                        let after_gt = &trimmed[1..];
167                        // Handle optional whitespace after '>' (space or tab)
168                        if let Some(stripped) = after_gt.strip_prefix(' ') {
169                            content_start += 1;
170                            remaining = stripped;
171                        } else if let Some(stripped) = after_gt.strip_prefix('\t') {
172                            content_start += 1;
173                            remaining = stripped;
174                        } else {
175                            remaining = after_gt;
176                        }
177                    }
178
179                    // Extract the content after the blockquote prefix
180                    let content_after_prefix = &line_content[content_start..];
181                    // Adjust the marker column to be relative to the content after the prefix
182                    let adjusted_col = if list_item.marker_column >= content_start {
183                        list_item.marker_column - content_start
184                    } else {
185                        // This shouldn't happen, but handle it gracefully
186                        list_item.marker_column
187                    };
188                    (content_after_prefix.to_string(), adjusted_col)
189                } else {
190                    (line_info.content(ctx.content).to_string(), list_item.marker_column)
191                };
192
193                // Convert marker position to visual column
194                let visual_marker_column =
195                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_marker_column);
196
197                // Calculate content visual column for text-aligned style
198                let visual_content_column = if line_info.blockquote.is_some() {
199                    // For blockquoted content, we already have the adjusted content
200                    let adjusted_content_col =
201                        if list_item.content_column >= (line_info.byte_len - content_for_calculation.len()) {
202                            list_item.content_column - (line_info.byte_len - content_for_calculation.len())
203                        } else {
204                            list_item.content_column
205                        };
206                    Self::char_pos_to_visual_column(&content_for_calculation, adjusted_content_col)
207                } else {
208                    Self::char_pos_to_visual_column(line_info.content(ctx.content), list_item.content_column)
209                };
210
211                // For nesting detection, treat 1-space indent as if it's at column 0
212                // because 1 space is insufficient to establish a nesting relationship
213                // UNLESS the user has explicitly configured indent=1, in which case 1 space IS valid nesting
214                let visual_marker_for_nesting = if visual_marker_column == 1 && self.config.indent.get() != 1 {
215                    0
216                } else {
217                    visual_marker_column
218                };
219
220                // Clean up stack - remove items at same or deeper indentation
221                while let Some(&(indent, _, _, _)) = list_stack.last() {
222                    if indent >= visual_marker_for_nesting {
223                        list_stack.pop();
224                    } else {
225                        break;
226                    }
227                }
228
229                // For ordered list items, just track them in the stack
230                if list_item.is_ordered {
231                    // For ordered lists, we don't check indentation but we need to track for text-aligned children
232                    // Use the actual positions since we don't enforce indentation for ordered lists
233                    list_stack.push((visual_marker_column, line_idx, true, visual_content_column));
234                    continue;
235                }
236
237                // At this point, we know this is an unordered list item
238                // Now stack contains only parent items
239                let nesting_level = list_stack.len();
240
241                // Get parent info for per-parent calculation
242                let parent_info = list_stack
243                    .get(nesting_level.wrapping_sub(1))
244                    .map(|&(_, _, is_ordered, content_col)| (is_ordered, content_col));
245
246                // Calculate expected indent using per-parent logic
247                let mut expected_indent = if self.config.start_indented {
248                    self.config.start_indent.get() as usize + (nesting_level * self.config.indent.get() as usize)
249                } else {
250                    self.calculate_expected_indent(nesting_level, parent_info)
251                };
252
253                // MkDocs (Python-Markdown) uses 4-space-tab continuation for list items.
254                // Under an ordered list item, Python-Markdown requires at least
255                // marker_column + 4 spaces for continuation content to be recognized.
256                if ctx.flavor == crate::config::MarkdownFlavor::MkDocs
257                    && let Some(&(parent_marker_col, _, true, _)) = list_stack.get(nesting_level.wrapping_sub(1))
258                {
259                    expected_indent = expected_indent.max(parent_marker_col + 4);
260                }
261
262                // Add current item to stack
263                // Use actual marker position for cleanup logic
264                // For text-aligned children, store the EXPECTED content position after fix
265                // (not the actual position) to prevent error cascade
266                let expected_content_visual_col = expected_indent + 2; // where content SHOULD be after fix
267                list_stack.push((visual_marker_column, line_idx, false, expected_content_visual_col));
268
269                // Skip first level check if start_indented is false
270                // BUT always check items with 1 space indent (insufficient for nesting)
271                if !self.config.start_indented && nesting_level == 0 && visual_marker_column != 1 {
272                    continue;
273                }
274
275                if visual_marker_column != expected_indent {
276                    // Generate fix for this list item
277                    let fix = {
278                        let correct_indent = " ".repeat(expected_indent);
279
280                        // Build the replacement string - need to preserve everything before the list marker
281                        // For blockquoted lines, this includes the blockquote prefix
282                        let replacement = if line_info.blockquote.is_some() {
283                            // Count the blockquote markers
284                            let mut blockquote_count = 0;
285                            for ch in line_info.content(ctx.content).chars() {
286                                if ch == '>' {
287                                    blockquote_count += 1;
288                                } else if ch != ' ' && ch != '\t' {
289                                    break;
290                                }
291                            }
292                            // Build the blockquote prefix (one '>' per level, with spaces between for nested)
293                            let blockquote_prefix = if blockquote_count > 1 {
294                                (0..blockquote_count)
295                                    .map(|_| "> ")
296                                    .collect::<String>()
297                                    .trim_end()
298                                    .to_string()
299                            } else {
300                                ">".to_string()
301                            };
302                            // Add correct indentation after the blockquote prefix
303                            // Include one space after the blockquote marker(s) as part of the indent
304                            format!("{blockquote_prefix} {correct_indent}")
305                        } else {
306                            correct_indent
307                        };
308
309                        // Calculate the byte positions
310                        // The range should cover from start of line to the marker position
311                        let start_byte = line_info.byte_offset;
312                        let mut end_byte = line_info.byte_offset;
313
314                        // Calculate where the marker starts
315                        for (i, ch) in line_info.content(ctx.content).chars().enumerate() {
316                            if i >= list_item.marker_column {
317                                break;
318                            }
319                            end_byte += ch.len_utf8();
320                        }
321
322                        Some(crate::rule::Fix {
323                            range: start_byte..end_byte,
324                            replacement,
325                        })
326                    };
327
328                    warnings.push(LintWarning {
329                        rule_name: Some(self.name().to_string()),
330                        message: format!(
331                            "Expected {expected_indent} spaces for indent depth {nesting_level}, found {visual_marker_column}"
332                        ),
333                        line: line_idx + 1, // Convert to 1-indexed
334                        column: 1,          // Start of line
335                        end_line: line_idx + 1,
336                        end_column: visual_marker_column + 1, // End of visual indentation
337                        severity: Severity::Warning,
338                        fix,
339                    });
340                }
341            }
342        }
343        Ok(warnings)
344    }
345
346    /// Optimized check using document structure
347    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
348        // Get all warnings with their fixes
349        let warnings = self.check(ctx)?;
350
351        // If no warnings, return original content
352        if warnings.is_empty() {
353            return Ok(ctx.content.to_string());
354        }
355
356        // Collect all fixes and sort by range start (descending) to apply from end to beginning
357        let mut fixes: Vec<_> = warnings
358            .iter()
359            .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
360            .collect();
361        fixes.sort_by(|a, b| b.0.cmp(&a.0));
362
363        // Apply fixes from end to beginning to preserve byte offsets
364        let mut result = ctx.content.to_string();
365        for (start, end, replacement) in fixes {
366            if start < result.len() && end <= result.len() && start <= end {
367                result.replace_range(start..end, replacement);
368            }
369        }
370
371        Ok(result)
372    }
373
374    /// Get the category of this rule for selective processing
375    fn category(&self) -> RuleCategory {
376        RuleCategory::List
377    }
378
379    /// Check if this rule should be skipped
380    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
381        // Fast path: check if document likely has lists
382        if ctx.content.is_empty() || !ctx.likely_has_lists() {
383            return true;
384        }
385        // Verify unordered list items actually exist
386        !ctx.lines
387            .iter()
388            .any(|line| line.list_item.as_ref().is_some_and(|item| !item.is_ordered))
389    }
390
391    fn as_any(&self) -> &dyn std::any::Any {
392        self
393    }
394
395    fn default_config_section(&self) -> Option<(String, toml::Value)> {
396        let default_config = MD007Config::default();
397        let json_value = serde_json::to_value(&default_config).ok()?;
398        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
399
400        if let toml::Value::Table(table) = toml_value {
401            if !table.is_empty() {
402                Some((MD007Config::RULE_NAME.to_string(), toml::Value::Table(table)))
403            } else {
404                None
405            }
406        } else {
407            None
408        }
409    }
410
411    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
412    where
413        Self: Sized,
414    {
415        let mut rule_config = crate::rule_config_serde::load_rule_config::<MD007Config>(config);
416
417        // Check if style and/or indent were explicitly set in the config
418        if let Some(rule_cfg) = config.rules.get("MD007") {
419            rule_config.style_explicit = rule_cfg.values.contains_key("style");
420            rule_config.indent_explicit = rule_cfg.values.contains_key("indent");
421
422            // Warn if both indent and text-aligned style are explicitly set
423            // This combination is contradictory: indent implies fixed increments,
424            // but text-aligned ignores the indent value and aligns with parent text
425            if rule_config.indent_explicit
426                && rule_config.style_explicit
427                && rule_config.style == md007_config::IndentStyle::TextAligned
428            {
429                eprintln!(
430                    "\x1b[33m[config warning]\x1b[0m MD007: 'indent' has no effect when 'style = \"text-aligned\"'. \
431                     Text-aligned style ignores indent and aligns nested items with parent text. \
432                     To use fixed {} space increments, either remove 'style' or set 'style = \"fixed\"'.",
433                    rule_config.indent.get()
434                );
435            }
436        }
437
438        Box::new(Self::from_config_struct(rule_config))
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::lint_context::LintContext;
446    use crate::rule::Rule;
447
448    #[test]
449    fn test_valid_list_indent() {
450        let rule = MD007ULIndent::default();
451        let content = "* Item 1\n  * Item 2\n    * Item 3";
452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
453        let result = rule.check(&ctx).unwrap();
454        assert!(
455            result.is_empty(),
456            "Expected no warnings for valid indentation, but got {} warnings",
457            result.len()
458        );
459    }
460
461    #[test]
462    fn test_invalid_list_indent() {
463        let rule = MD007ULIndent::default();
464        let content = "* Item 1\n   * Item 2\n      * Item 3";
465        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466        let result = rule.check(&ctx).unwrap();
467        assert_eq!(result.len(), 2);
468        assert_eq!(result[0].line, 2);
469        assert_eq!(result[0].column, 1);
470        assert_eq!(result[1].line, 3);
471        assert_eq!(result[1].column, 1);
472    }
473
474    #[test]
475    fn test_mixed_indentation() {
476        let rule = MD007ULIndent::default();
477        let content = "* Item 1\n  * Item 2\n   * Item 3\n  * Item 4";
478        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
479        let result = rule.check(&ctx).unwrap();
480        assert_eq!(result.len(), 1);
481        assert_eq!(result[0].line, 3);
482        assert_eq!(result[0].column, 1);
483    }
484
485    #[test]
486    fn test_fix_indentation() {
487        let rule = MD007ULIndent::default();
488        let content = "* Item 1\n   * Item 2\n      * Item 3";
489        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490        let result = rule.fix(&ctx).unwrap();
491        // With text-aligned style and non-cascade:
492        // Item 2 aligns with Item 1's text (2 spaces)
493        // Item 3 aligns with Item 2's expected text position (4 spaces)
494        let expected = "* Item 1\n  * Item 2\n    * Item 3";
495        assert_eq!(result, expected);
496    }
497
498    #[test]
499    fn test_md007_in_yaml_code_block() {
500        let rule = MD007ULIndent::default();
501        let content = r#"```yaml
502repos:
503-   repo: https://github.com/rvben/rumdl
504    rev: v0.5.0
505    hooks:
506    -   id: rumdl-check
507```"#;
508        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509        let result = rule.check(&ctx).unwrap();
510        assert!(
511            result.is_empty(),
512            "MD007 should not trigger inside a code block, but got warnings: {result:?}"
513        );
514    }
515
516    #[test]
517    fn test_blockquoted_list_indent() {
518        let rule = MD007ULIndent::default();
519        let content = "> * Item 1\n>   * Item 2\n>     * Item 3";
520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521        let result = rule.check(&ctx).unwrap();
522        assert!(
523            result.is_empty(),
524            "Expected no warnings for valid blockquoted list indentation, but got {result:?}"
525        );
526    }
527
528    #[test]
529    fn test_blockquoted_list_invalid_indent() {
530        let rule = MD007ULIndent::default();
531        let content = "> * Item 1\n>    * Item 2\n>       * Item 3";
532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
533        let result = rule.check(&ctx).unwrap();
534        assert_eq!(
535            result.len(),
536            2,
537            "Expected 2 warnings for invalid blockquoted list indentation, got {result:?}"
538        );
539        assert_eq!(result[0].line, 2);
540        assert_eq!(result[1].line, 3);
541    }
542
543    #[test]
544    fn test_nested_blockquote_list_indent() {
545        let rule = MD007ULIndent::default();
546        let content = "> > * Item 1\n> >   * Item 2\n> >     * Item 3";
547        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548        let result = rule.check(&ctx).unwrap();
549        assert!(
550            result.is_empty(),
551            "Expected no warnings for valid nested blockquoted list indentation, but got {result:?}"
552        );
553    }
554
555    #[test]
556    fn test_blockquote_list_with_code_block() {
557        let rule = MD007ULIndent::default();
558        let content = "> * Item 1\n>   * Item 2\n>   ```\n>   code\n>   ```\n>   * Item 3";
559        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
560        let result = rule.check(&ctx).unwrap();
561        assert!(
562            result.is_empty(),
563            "MD007 should not trigger inside a code block within a blockquote, but got warnings: {result:?}"
564        );
565    }
566
567    #[test]
568    fn test_properly_indented_lists() {
569        let rule = MD007ULIndent::default();
570
571        // Test various properly indented lists
572        let test_cases = vec![
573            "* Item 1\n* Item 2",
574            "* Item 1\n  * Item 1.1\n    * Item 1.1.1",
575            "- Item 1\n  - Item 1.1",
576            "+ Item 1\n  + Item 1.1",
577            "* Item 1\n  * Item 1.1\n* Item 2\n  * Item 2.1",
578        ];
579
580        for content in test_cases {
581            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582            let result = rule.check(&ctx).unwrap();
583            assert!(
584                result.is_empty(),
585                "Expected no warnings for properly indented list:\n{}\nGot {} warnings",
586                content,
587                result.len()
588            );
589        }
590    }
591
592    #[test]
593    fn test_under_indented_lists() {
594        let rule = MD007ULIndent::default();
595
596        let test_cases = vec![
597            ("* Item 1\n * Item 1.1", 1, 2),                   // Expected 2 spaces, got 1
598            ("* Item 1\n  * Item 1.1\n   * Item 1.1.1", 1, 3), // Expected 4 spaces, got 3
599        ];
600
601        for (content, expected_warnings, line) in test_cases {
602            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603            let result = rule.check(&ctx).unwrap();
604            assert_eq!(
605                result.len(),
606                expected_warnings,
607                "Expected {expected_warnings} warnings for under-indented list:\n{content}"
608            );
609            if expected_warnings > 0 {
610                assert_eq!(result[0].line, line);
611            }
612        }
613    }
614
615    #[test]
616    fn test_over_indented_lists() {
617        let rule = MD007ULIndent::default();
618
619        let test_cases = vec![
620            ("* Item 1\n   * Item 1.1", 1, 2),                   // Expected 2 spaces, got 3
621            ("* Item 1\n    * Item 1.1", 1, 2),                  // Expected 2 spaces, got 4
622            ("* Item 1\n  * Item 1.1\n     * Item 1.1.1", 1, 3), // Expected 4 spaces, got 5
623        ];
624
625        for (content, expected_warnings, line) in test_cases {
626            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
627            let result = rule.check(&ctx).unwrap();
628            assert_eq!(
629                result.len(),
630                expected_warnings,
631                "Expected {expected_warnings} warnings for over-indented list:\n{content}"
632            );
633            if expected_warnings > 0 {
634                assert_eq!(result[0].line, line);
635            }
636        }
637    }
638
639    #[test]
640    fn test_custom_indent_2_spaces() {
641        let rule = MD007ULIndent::new(2); // Default
642        let content = "* Item 1\n  * Item 2\n    * Item 3";
643        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644        let result = rule.check(&ctx).unwrap();
645        assert!(result.is_empty());
646    }
647
648    #[test]
649    fn test_custom_indent_3_spaces() {
650        // With smart auto-detection, pure unordered lists with indent=3 use fixed style
651        // This provides markdownlint compatibility for the common case
652        let rule = MD007ULIndent::new(3);
653
654        // Fixed style with indent=3: level 0 = 0, level 1 = 3, level 2 = 6
655        let correct_content = "* Item 1\n   * Item 2\n      * Item 3";
656        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
657        let result = rule.check(&ctx).unwrap();
658        assert!(
659            result.is_empty(),
660            "Fixed style expects 0, 3, 6 spaces but got: {result:?}"
661        );
662
663        // Wrong indentation (text-aligned style spacing)
664        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
665        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
666        let result = rule.check(&ctx).unwrap();
667        assert!(!result.is_empty(), "Should warn: expected 3 spaces, found 2");
668    }
669
670    #[test]
671    fn test_custom_indent_4_spaces() {
672        // With smart auto-detection, pure unordered lists with indent=4 use fixed style
673        // This provides markdownlint compatibility (fixes issue #210)
674        let rule = MD007ULIndent::new(4);
675
676        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
677        let correct_content = "* Item 1\n    * Item 2\n        * Item 3";
678        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
679        let result = rule.check(&ctx).unwrap();
680        assert!(
681            result.is_empty(),
682            "Fixed style expects 0, 4, 8 spaces but got: {result:?}"
683        );
684
685        // Wrong indentation (text-aligned style spacing)
686        let wrong_content = "* Item 1\n  * Item 2\n    * Item 3";
687        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
688        let result = rule.check(&ctx).unwrap();
689        assert!(!result.is_empty(), "Should warn: expected 4 spaces, found 2");
690    }
691
692    #[test]
693    fn test_tab_indentation() {
694        let rule = MD007ULIndent::default();
695
696        // Note: Tab at line start = 4 spaces = indented code per CommonMark, not a list item
697        // MD007 checks list indentation, so this test now checks actual nested lists
698        // Hard tabs within lists should be caught by MD010, not MD007
699
700        // Single wrong indentation (3 spaces instead of 2)
701        let content = "* Item 1\n   * Item 2";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let result = rule.check(&ctx).unwrap();
704        assert_eq!(result.len(), 1, "Wrong indentation should trigger warning");
705
706        // Fix should correct to 2 spaces
707        let fixed = rule.fix(&ctx).unwrap();
708        assert_eq!(fixed, "* Item 1\n  * Item 2");
709
710        // Multiple indentation errors
711        let content_multi = "* Item 1\n   * Item 2\n      * Item 3";
712        let ctx = LintContext::new(content_multi, crate::config::MarkdownFlavor::Standard, None);
713        let fixed = rule.fix(&ctx).unwrap();
714        // With non-cascade: Item 2 at 2 spaces, content at 4
715        // Item 3 aligns with Item 2's expected content at 4 spaces
716        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
717
718        // Mixed wrong indentations
719        let content_mixed = "* Item 1\n   * Item 2\n     * Item 3";
720        let ctx = LintContext::new(content_mixed, crate::config::MarkdownFlavor::Standard, None);
721        let fixed = rule.fix(&ctx).unwrap();
722        // With non-cascade: Item 2 at 2 spaces, content at 4
723        // Item 3 aligns with Item 2's expected content at 4 spaces
724        assert_eq!(fixed, "* Item 1\n  * Item 2\n    * Item 3");
725    }
726
727    #[test]
728    fn test_mixed_ordered_unordered_lists() {
729        let rule = MD007ULIndent::default();
730
731        // MD007 only checks unordered lists, so ordered lists should be ignored
732        // Note: 3 spaces is now correct for bullets under ordered items
733        let content = r#"1. Ordered item
734   * Unordered sub-item (correct - 3 spaces under ordered)
735   2. Ordered sub-item
736* Unordered item
737  1. Ordered sub-item
738  * Unordered sub-item"#;
739
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742        assert_eq!(result.len(), 0, "All unordered list indentation should be correct");
743
744        // No fix needed as all indentation is correct
745        let fixed = rule.fix(&ctx).unwrap();
746        assert_eq!(fixed, content);
747    }
748
749    #[test]
750    fn test_list_markers_variety() {
751        let rule = MD007ULIndent::default();
752
753        // Test all three unordered list markers
754        let content = r#"* Asterisk
755  * Nested asterisk
756- Hyphen
757  - Nested hyphen
758+ Plus
759  + Nested plus"#;
760
761        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
762        let result = rule.check(&ctx).unwrap();
763        assert!(
764            result.is_empty(),
765            "All unordered list markers should work with proper indentation"
766        );
767
768        // Test with wrong indentation for each marker type
769        let wrong_content = r#"* Asterisk
770   * Wrong asterisk
771- Hyphen
772 - Wrong hyphen
773+ Plus
774    + Wrong plus"#;
775
776        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
777        let result = rule.check(&ctx).unwrap();
778        assert_eq!(result.len(), 3, "All marker types should be checked for indentation");
779    }
780
781    #[test]
782    fn test_empty_list_items() {
783        let rule = MD007ULIndent::default();
784        let content = "* Item 1\n* \n  * Item 2";
785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
786        let result = rule.check(&ctx).unwrap();
787        assert!(
788            result.is_empty(),
789            "Empty list items should not affect indentation checks"
790        );
791    }
792
793    #[test]
794    fn test_list_with_code_blocks() {
795        let rule = MD007ULIndent::default();
796        let content = r#"* Item 1
797  ```
798  code
799  ```
800  * Item 2
801    * Item 3"#;
802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let result = rule.check(&ctx).unwrap();
804        assert!(result.is_empty());
805    }
806
807    #[test]
808    fn test_list_in_front_matter() {
809        let rule = MD007ULIndent::default();
810        let content = r#"---
811tags:
812  - tag1
813  - tag2
814---
815* Item 1
816  * Item 2"#;
817        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818        let result = rule.check(&ctx).unwrap();
819        assert!(result.is_empty(), "Lists in YAML front matter should be ignored");
820    }
821
822    #[test]
823    fn test_fix_preserves_content() {
824        let rule = MD007ULIndent::default();
825        let content = "* Item 1 with **bold** and *italic*\n   * Item 2 with `code`\n     * Item 3 with [link](url)";
826        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827        let fixed = rule.fix(&ctx).unwrap();
828        // With non-cascade: Item 2 at 2 spaces, content at 4
829        // Item 3 aligns with Item 2's expected content at 4 spaces
830        let expected = "* Item 1 with **bold** and *italic*\n  * Item 2 with `code`\n    * Item 3 with [link](url)";
831        assert_eq!(fixed, expected, "Fix should only change indentation, not content");
832    }
833
834    #[test]
835    fn test_start_indented_config() {
836        let config = MD007Config {
837            start_indented: true,
838            start_indent: crate::types::IndentSize::from_const(4),
839            indent: crate::types::IndentSize::from_const(2),
840            style: md007_config::IndentStyle::TextAligned,
841            style_explicit: true, // Explicit style for this test
842            indent_explicit: false,
843        };
844        let rule = MD007ULIndent::from_config_struct(config);
845
846        // First level should be indented by start_indent (4 spaces)
847        // Level 0: 4 spaces (start_indent)
848        // Level 1: 6 spaces (start_indent + indent = 4 + 2)
849        // Level 2: 8 spaces (start_indent + 2*indent = 4 + 4)
850        let content = "    * Item 1\n      * Item 2\n        * Item 3";
851        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
852        let result = rule.check(&ctx).unwrap();
853        assert!(result.is_empty(), "Expected no warnings with start_indented config");
854
855        // Wrong first level indentation
856        let wrong_content = "  * Item 1\n    * Item 2";
857        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
858        let result = rule.check(&ctx).unwrap();
859        assert_eq!(result.len(), 2);
860        assert_eq!(result[0].line, 1);
861        assert_eq!(result[0].message, "Expected 4 spaces for indent depth 0, found 2");
862        assert_eq!(result[1].line, 2);
863        assert_eq!(result[1].message, "Expected 6 spaces for indent depth 1, found 4");
864
865        // Fix should correct to start_indent for first level
866        let fixed = rule.fix(&ctx).unwrap();
867        assert_eq!(fixed, "    * Item 1\n      * Item 2");
868    }
869
870    #[test]
871    fn test_start_indented_false_allows_any_first_level() {
872        let rule = MD007ULIndent::default(); // start_indented is false by default
873
874        // When start_indented is false, first level items at any indentation are allowed
875        let content = "   * Item 1"; // First level at 3 spaces
876        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877        let result = rule.check(&ctx).unwrap();
878        assert!(
879            result.is_empty(),
880            "First level at any indentation should be allowed when start_indented is false"
881        );
882
883        // Multiple first level items at different indentations should all be allowed
884        let content = "* Item 1\n  * Item 2\n    * Item 3"; // All at level 0 (different indents)
885        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886        let result = rule.check(&ctx).unwrap();
887        assert!(
888            result.is_empty(),
889            "All first-level items should be allowed at any indentation"
890        );
891    }
892
893    #[test]
894    fn test_deeply_nested_lists() {
895        let rule = MD007ULIndent::default();
896        let content = r#"* L1
897  * L2
898    * L3
899      * L4
900        * L5
901          * L6"#;
902        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903        let result = rule.check(&ctx).unwrap();
904        assert!(result.is_empty());
905
906        // Test with wrong deep nesting
907        let wrong_content = r#"* L1
908  * L2
909    * L3
910      * L4
911         * L5
912            * L6"#;
913        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
914        let result = rule.check(&ctx).unwrap();
915        assert_eq!(result.len(), 2, "Deep nesting errors should be detected");
916    }
917
918    #[test]
919    fn test_excessive_indentation_detected() {
920        let rule = MD007ULIndent::default();
921
922        // Test excessive indentation (5 spaces instead of 2)
923        let content = "- Item 1\n     - Item 2 with 5 spaces";
924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925        let result = rule.check(&ctx).unwrap();
926        assert_eq!(result.len(), 1, "Should detect excessive indentation (5 instead of 2)");
927        assert_eq!(result[0].line, 2);
928        assert!(result[0].message.contains("Expected 2 spaces"));
929        assert!(result[0].message.contains("found 5"));
930
931        // Test slightly excessive indentation (3 spaces instead of 2)
932        let content = "- Item 1\n   - Item 2 with 3 spaces";
933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934        let result = rule.check(&ctx).unwrap();
935        assert_eq!(
936            result.len(),
937            1,
938            "Should detect slightly excessive indentation (3 instead of 2)"
939        );
940        assert_eq!(result[0].line, 2);
941        assert!(result[0].message.contains("Expected 2 spaces"));
942        assert!(result[0].message.contains("found 3"));
943
944        // Test insufficient indentation (1 space is treated as level 0, should be 0)
945        let content = "- Item 1\n - Item 2 with 1 space";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let result = rule.check(&ctx).unwrap();
948        assert_eq!(
949            result.len(),
950            1,
951            "Should detect 1-space indent (insufficient for nesting, expected 0)"
952        );
953        assert_eq!(result[0].line, 2);
954        assert!(result[0].message.contains("Expected 0 spaces"));
955        assert!(result[0].message.contains("found 1"));
956    }
957
958    #[test]
959    fn test_excessive_indentation_with_4_space_config() {
960        // With smart auto-detection, pure unordered lists use fixed style
961        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
962        let rule = MD007ULIndent::new(4);
963
964        // Test excessive indentation (5 spaces instead of 4)
965        let content = "- Formatter:\n     - The stable style changed";
966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967        let result = rule.check(&ctx).unwrap();
968        assert!(
969            !result.is_empty(),
970            "Should detect 5 spaces when expecting 4 (fixed style)"
971        );
972
973        // Test with correct fixed style alignment (4 spaces for level 1)
974        let correct_content = "- Formatter:\n    - The stable style changed";
975        let ctx = LintContext::new(correct_content, crate::config::MarkdownFlavor::Standard, None);
976        let result = rule.check(&ctx).unwrap();
977        assert!(result.is_empty(), "Should accept correct fixed style indent (4 spaces)");
978    }
979
980    #[test]
981    fn test_bullets_nested_under_numbered_items() {
982        let rule = MD007ULIndent::default();
983        let content = "\
9841. **Active Directory/LDAP**
985   - User authentication and directory services
986   - LDAP for user information and validation
987
9882. **Oracle Unified Directory (OUD)**
989   - Extended user directory services";
990        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
991        let result = rule.check(&ctx).unwrap();
992        // Should have no warnings - 3 spaces is correct for bullets under numbered items
993        assert!(
994            result.is_empty(),
995            "Expected no warnings for bullets with 3 spaces under numbered items, got: {result:?}"
996        );
997    }
998
999    #[test]
1000    fn test_bullets_nested_under_numbered_items_wrong_indent() {
1001        let rule = MD007ULIndent::default();
1002        let content = "\
10031. **Active Directory/LDAP**
1004  - Wrong: only 2 spaces";
1005        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1006        let result = rule.check(&ctx).unwrap();
1007        // Should flag incorrect indentation
1008        assert_eq!(
1009            result.len(),
1010            1,
1011            "Expected warning for incorrect indentation under numbered items"
1012        );
1013        assert!(
1014            result
1015                .iter()
1016                .any(|w| w.line == 2 && w.message.contains("Expected 3 spaces"))
1017        );
1018    }
1019
1020    #[test]
1021    fn test_regular_bullet_nesting_still_works() {
1022        let rule = MD007ULIndent::default();
1023        let content = "\
1024* Top level
1025  * Nested bullet (2 spaces is correct)
1026    * Deeply nested (4 spaces)";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1028        let result = rule.check(&ctx).unwrap();
1029        // Should have no warnings - standard bullet nesting still uses 2-space increments
1030        assert!(
1031            result.is_empty(),
1032            "Expected no warnings for standard bullet nesting, got: {result:?}"
1033        );
1034    }
1035
1036    #[test]
1037    fn test_blockquote_with_tab_after_marker() {
1038        let rule = MD007ULIndent::default();
1039        let content = ">\t* List item\n>\t  * Nested\n";
1040        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041        let result = rule.check(&ctx).unwrap();
1042        assert!(
1043            result.is_empty(),
1044            "Tab after blockquote marker should be handled correctly, got: {result:?}"
1045        );
1046    }
1047
1048    #[test]
1049    fn test_blockquote_with_space_then_tab_after_marker() {
1050        let rule = MD007ULIndent::default();
1051        let content = "> \t* List item\n";
1052        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053        let result = rule.check(&ctx).unwrap();
1054        // First-level list item at any indentation is allowed when start_indented=false (default)
1055        assert!(
1056            result.is_empty(),
1057            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1058        );
1059    }
1060
1061    #[test]
1062    fn test_blockquote_with_multiple_tabs() {
1063        let rule = MD007ULIndent::default();
1064        let content = ">\t\t* List item\n";
1065        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066        let result = rule.check(&ctx).unwrap();
1067        // First-level list item at any indentation is allowed when start_indented=false (default)
1068        assert!(
1069            result.is_empty(),
1070            "First-level list item at any indentation is allowed when start_indented=false, got: {result:?}"
1071        );
1072    }
1073
1074    #[test]
1075    fn test_nested_blockquote_with_tab() {
1076        let rule = MD007ULIndent::default();
1077        let content = ">\t>\t* List item\n>\t>\t  * Nested\n";
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079        let result = rule.check(&ctx).unwrap();
1080        assert!(
1081            result.is_empty(),
1082            "Nested blockquotes with tabs should work correctly, got: {result:?}"
1083        );
1084    }
1085
1086    // Tests for smart style auto-detection (fixes issue #210 while preserving #209 fix)
1087
1088    #[test]
1089    fn test_smart_style_pure_unordered_uses_fixed() {
1090        // Issue #210: Pure unordered lists with custom indent should use fixed style
1091        let rule = MD007ULIndent::new(4);
1092
1093        // With fixed style (auto-detected), this should be valid
1094        let content = "* Level 0\n    * Level 1\n        * Level 2";
1095        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096        let result = rule.check(&ctx).unwrap();
1097        assert!(
1098            result.is_empty(),
1099            "Pure unordered with indent=4 should use fixed style (0, 4, 8), got: {result:?}"
1100        );
1101    }
1102
1103    #[test]
1104    fn test_smart_style_mixed_lists_uses_text_aligned() {
1105        // Issue #209: Mixed lists should use text-aligned to avoid oscillation
1106        let rule = MD007ULIndent::new(4);
1107
1108        // With text-aligned style (auto-detected for mixed), bullets align with parent text
1109        let content = "1. Ordered\n   * Bullet aligns with 'Ordered' text (3 spaces)";
1110        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111        let result = rule.check(&ctx).unwrap();
1112        assert!(
1113            result.is_empty(),
1114            "Mixed lists should use text-aligned style, got: {result:?}"
1115        );
1116    }
1117
1118    #[test]
1119    fn test_smart_style_explicit_fixed_overrides() {
1120        // When style is explicitly set to fixed, it should be respected even for mixed lists
1121        let config = MD007Config {
1122            indent: crate::types::IndentSize::from_const(4),
1123            start_indented: false,
1124            start_indent: crate::types::IndentSize::from_const(2),
1125            style: md007_config::IndentStyle::Fixed,
1126            style_explicit: true, // Explicit setting
1127            indent_explicit: false,
1128        };
1129        let rule = MD007ULIndent::from_config_struct(config);
1130
1131        // With explicit fixed style, expect fixed calculations even for mixed lists
1132        let content = "1. Ordered\n    * Should be at 4 spaces (fixed)";
1133        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134        let result = rule.check(&ctx).unwrap();
1135        // The bullet is at 4 spaces which matches fixed style level 1
1136        assert!(
1137            result.is_empty(),
1138            "Explicit fixed style should be respected, got: {result:?}"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_smart_style_explicit_text_aligned_overrides() {
1144        // When style is explicitly set to text-aligned, it should be respected
1145        let config = MD007Config {
1146            indent: crate::types::IndentSize::from_const(4),
1147            start_indented: false,
1148            start_indent: crate::types::IndentSize::from_const(2),
1149            style: md007_config::IndentStyle::TextAligned,
1150            style_explicit: true, // Explicit setting
1151            indent_explicit: false,
1152        };
1153        let rule = MD007ULIndent::from_config_struct(config);
1154
1155        // With explicit text-aligned, pure unordered should use text-aligned (not auto-switch to fixed)
1156        let content = "* Level 0\n  * Level 1 (aligned with 'Level 0' text)";
1157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158        let result = rule.check(&ctx).unwrap();
1159        assert!(
1160            result.is_empty(),
1161            "Explicit text-aligned should be respected, got: {result:?}"
1162        );
1163
1164        // This would be correct for fixed but wrong for text-aligned
1165        let fixed_style_content = "* Level 0\n    * Level 1 (4 spaces - fixed style)";
1166        let ctx = LintContext::new(fixed_style_content, crate::config::MarkdownFlavor::Standard, None);
1167        let result = rule.check(&ctx).unwrap();
1168        assert!(
1169            !result.is_empty(),
1170            "With explicit text-aligned, 4-space indent should be wrong (expected 2)"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_smart_style_default_indent_no_autoswitch() {
1176        // When indent is default (2), no auto-switch happens (both styles produce same result)
1177        let rule = MD007ULIndent::new(2);
1178
1179        let content = "* Level 0\n  * Level 1\n    * Level 2";
1180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181        let result = rule.check(&ctx).unwrap();
1182        assert!(
1183            result.is_empty(),
1184            "Default indent should work regardless of style, got: {result:?}"
1185        );
1186    }
1187
1188    #[test]
1189    fn test_has_mixed_list_nesting_detection() {
1190        // Test the mixed list detection function directly
1191
1192        // Pure unordered - no mixed nesting
1193        let content = "* Item 1\n  * Item 2\n    * Item 3";
1194        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195        assert!(
1196            !ctx.has_mixed_list_nesting(),
1197            "Pure unordered should not be detected as mixed"
1198        );
1199
1200        // Pure ordered - no mixed nesting
1201        let content = "1. Item 1\n   2. Item 2\n      3. Item 3";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203        assert!(
1204            !ctx.has_mixed_list_nesting(),
1205            "Pure ordered should not be detected as mixed"
1206        );
1207
1208        // Mixed: unordered under ordered
1209        let content = "1. Ordered\n   * Unordered child";
1210        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211        assert!(
1212            ctx.has_mixed_list_nesting(),
1213            "Unordered under ordered should be detected as mixed"
1214        );
1215
1216        // Mixed: ordered under unordered
1217        let content = "* Unordered\n  1. Ordered child";
1218        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1219        assert!(
1220            ctx.has_mixed_list_nesting(),
1221            "Ordered under unordered should be detected as mixed"
1222        );
1223
1224        // Separate lists (not nested) - not mixed
1225        let content = "* Unordered\n\n1. Ordered (separate list)";
1226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227        assert!(
1228            !ctx.has_mixed_list_nesting(),
1229            "Separate lists should not be detected as mixed"
1230        );
1231
1232        // Mixed lists inside blockquotes should be detected
1233        let content = "> 1. Ordered in blockquote\n>    * Unordered child";
1234        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235        assert!(
1236            ctx.has_mixed_list_nesting(),
1237            "Mixed lists in blockquotes should be detected"
1238        );
1239    }
1240
1241    #[test]
1242    fn test_issue_210_exact_reproduction() {
1243        // Exact reproduction from issue #210
1244        let config = MD007Config {
1245            indent: crate::types::IndentSize::from_const(4),
1246            start_indented: false,
1247            start_indent: crate::types::IndentSize::from_const(2),
1248            style: md007_config::IndentStyle::TextAligned, // Default
1249            style_explicit: false,                         // Not explicitly set - should auto-detect
1250            indent_explicit: false,                        // Not explicitly set
1251        };
1252        let rule = MD007ULIndent::from_config_struct(config);
1253
1254        let content = "# Title\n\n* some\n    * list\n    * items\n";
1255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256        let result = rule.check(&ctx).unwrap();
1257
1258        assert!(
1259            result.is_empty(),
1260            "Issue #210: indent=4 on pure unordered should work (auto-fixed style), got: {result:?}"
1261        );
1262    }
1263
1264    #[test]
1265    fn test_issue_209_still_fixed() {
1266        // Verify issue #209 (oscillation) is still fixed when style is explicitly set
1267        // With issue #236 fix, explicit style must be set to get pure text-aligned behavior
1268        let config = MD007Config {
1269            indent: crate::types::IndentSize::from_const(3),
1270            start_indented: false,
1271            start_indent: crate::types::IndentSize::from_const(2),
1272            style: md007_config::IndentStyle::TextAligned,
1273            style_explicit: true, // Explicit style to test text-aligned behavior
1274            indent_explicit: false,
1275        };
1276        let rule = MD007ULIndent::from_config_struct(config);
1277
1278        // Mixed list from issue #209 - with explicit text-aligned, no oscillation
1279        let content = r#"# Header 1
1280
1281- **Second item**:
1282  - **This is a nested list**:
1283    1. **First point**
1284       - First subpoint
1285"#;
1286        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287        let result = rule.check(&ctx).unwrap();
1288
1289        assert!(
1290            result.is_empty(),
1291            "Issue #209: With explicit text-aligned style, should have no issues, got: {result:?}"
1292        );
1293    }
1294
1295    // Edge case tests for review findings
1296
1297    #[test]
1298    fn test_multi_level_mixed_detection_grandparent() {
1299        // Test that multi-level mixed detection finds grandparent type differences
1300        // ordered → unordered → unordered should be detected as mixed
1301        // because the grandparent (ordered) is different from descendants (unordered)
1302        let content = "1. Ordered grandparent\n   * Unordered child\n     * Unordered grandchild";
1303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304        assert!(
1305            ctx.has_mixed_list_nesting(),
1306            "Should detect mixed nesting when grandparent differs in type"
1307        );
1308
1309        // unordered → ordered → ordered should also be detected as mixed
1310        let content = "* Unordered grandparent\n  1. Ordered child\n     2. Ordered grandchild";
1311        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312        assert!(
1313            ctx.has_mixed_list_nesting(),
1314            "Should detect mixed nesting for ordered descendants under unordered"
1315        );
1316    }
1317
1318    #[test]
1319    fn test_html_comments_skipped_in_detection() {
1320        // Lists inside HTML comments should not affect mixed detection
1321        let content = r#"* Unordered list
1322<!-- This is a comment
1323  1. This ordered list is inside a comment
1324     * This nested bullet is also inside
1325-->
1326  * Another unordered item"#;
1327        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1328        assert!(
1329            !ctx.has_mixed_list_nesting(),
1330            "Lists in HTML comments should be ignored in mixed detection"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_blank_lines_separate_lists() {
1336        // Blank lines at root level should separate lists, treating them as independent
1337        let content = "* First unordered list\n\n1. Second list is ordered (separate)";
1338        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339        assert!(
1340            !ctx.has_mixed_list_nesting(),
1341            "Blank line at root should separate lists"
1342        );
1343
1344        // But nested lists after blank should still be detected if mixed
1345        let content = "1. Ordered parent\n\n   * Still a child due to indentation";
1346        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        assert!(
1348            ctx.has_mixed_list_nesting(),
1349            "Indented list after blank is still nested"
1350        );
1351    }
1352
1353    #[test]
1354    fn test_column_1_normalization() {
1355        // 1-space indent should be treated as column 0 (root level)
1356        // This creates a sibling relationship, not nesting
1357        let content = "* First item\n * Second item with 1 space (sibling)";
1358        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1359        let rule = MD007ULIndent::default();
1360        let result = rule.check(&ctx).unwrap();
1361        // The second item should be flagged as wrong (1 space is not valid for nesting)
1362        assert!(
1363            result.iter().any(|w| w.line == 2),
1364            "1-space indent should be flagged as incorrect"
1365        );
1366    }
1367
1368    #[test]
1369    fn test_code_blocks_skipped_in_detection() {
1370        // Lists inside code blocks should not affect mixed detection
1371        let content = r#"* Unordered list
1372```
13731. This ordered list is inside a code block
1374   * This nested bullet is also inside
1375```
1376  * Another unordered item"#;
1377        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378        assert!(
1379            !ctx.has_mixed_list_nesting(),
1380            "Lists in code blocks should be ignored in mixed detection"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_front_matter_skipped_in_detection() {
1386        // Lists inside YAML front matter should not affect mixed detection
1387        let content = r#"---
1388items:
1389  - yaml list item
1390  - another item
1391---
1392* Unordered list after front matter"#;
1393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394        assert!(
1395            !ctx.has_mixed_list_nesting(),
1396            "Lists in front matter should be ignored in mixed detection"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_alternating_types_at_same_level() {
1402        // Alternating between ordered and unordered at the same nesting level
1403        // is NOT mixed nesting (they are siblings, not parent-child)
1404        let content = "* First bullet\n1. First number\n* Second bullet\n2. Second number";
1405        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1406        assert!(
1407            !ctx.has_mixed_list_nesting(),
1408            "Alternating types at same level should not be detected as mixed"
1409        );
1410    }
1411
1412    #[test]
1413    fn test_five_level_deep_mixed_nesting() {
1414        // Test detection at 5+ levels of nesting
1415        let content = "* L0\n  1. L1\n     * L2\n       1. L3\n          * L4\n            1. L5";
1416        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417        assert!(ctx.has_mixed_list_nesting(), "Should detect mixed nesting at 5+ levels");
1418    }
1419
1420    #[test]
1421    fn test_very_deep_pure_unordered_nesting() {
1422        // Test pure unordered list with 10+ levels of nesting
1423        let mut content = String::from("* L1");
1424        for level in 2..=12 {
1425            let indent = "  ".repeat(level - 1);
1426            content.push_str(&format!("\n{indent}* L{level}"));
1427        }
1428
1429        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1430
1431        // Should NOT be detected as mixed (all unordered)
1432        assert!(
1433            !ctx.has_mixed_list_nesting(),
1434            "Pure unordered deep nesting should not be detected as mixed"
1435        );
1436
1437        // Should use fixed style with custom indent
1438        let rule = MD007ULIndent::new(4);
1439        let result = rule.check(&ctx).unwrap();
1440        // With text-aligned default but auto-switch to fixed for pure unordered,
1441        // the first nested level should be flagged (2 spaces instead of 4)
1442        assert!(!result.is_empty(), "Should flag incorrect indentation for fixed style");
1443    }
1444
1445    #[test]
1446    fn test_interleaved_content_between_list_items() {
1447        // Paragraph continuation between list items should not break detection
1448        let content = "1. Ordered parent\n\n   Paragraph continuation\n\n   * Unordered child";
1449        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1450        assert!(
1451            ctx.has_mixed_list_nesting(),
1452            "Should detect mixed nesting even with interleaved paragraphs"
1453        );
1454    }
1455
1456    #[test]
1457    fn test_esm_blocks_skipped_in_detection() {
1458        // ESM import/export blocks in MDX should be skipped
1459        // Note: ESM detection depends on LintContext properly setting in_esm_block
1460        let content = "* Unordered list\n  * Nested unordered";
1461        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1462        assert!(
1463            !ctx.has_mixed_list_nesting(),
1464            "Pure unordered should not be detected as mixed"
1465        );
1466    }
1467
1468    #[test]
1469    fn test_multiple_list_blocks_pure_then_mixed() {
1470        // Document with pure unordered list followed by mixed list
1471        // Detection should find the mixed list and return true
1472        let content = r#"* Pure unordered
1473  * Nested unordered
1474
14751. Mixed section
1476   * Bullet under ordered"#;
1477        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478        assert!(
1479            ctx.has_mixed_list_nesting(),
1480            "Should detect mixed nesting in any part of document"
1481        );
1482    }
1483
1484    #[test]
1485    fn test_multiple_separate_pure_lists() {
1486        // Multiple pure unordered lists separated by blank lines
1487        // Should NOT be detected as mixed
1488        let content = r#"* First list
1489  * Nested
1490
1491* Second list
1492  * Also nested
1493
1494* Third list
1495  * Deeply
1496    * Nested"#;
1497        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1498        assert!(
1499            !ctx.has_mixed_list_nesting(),
1500            "Multiple separate pure unordered lists should not be mixed"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_code_block_between_list_items() {
1506        // Code block between list items should not affect detection
1507        let content = r#"1. Ordered
1508   ```
1509   code
1510   ```
1511   * Still a mixed child"#;
1512        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513        assert!(
1514            ctx.has_mixed_list_nesting(),
1515            "Code block between items should not prevent mixed detection"
1516        );
1517    }
1518
1519    #[test]
1520    fn test_blockquoted_mixed_detection() {
1521        // Mixed lists inside blockquotes should be detected
1522        let content = "> 1. Ordered in blockquote\n>    * Mixed child";
1523        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1524        // Note: Detection depends on correct marker_column calculation in blockquotes
1525        // This test verifies the detection logic works with blockquoted content
1526        assert!(
1527            ctx.has_mixed_list_nesting(),
1528            "Should detect mixed nesting in blockquotes"
1529        );
1530    }
1531
1532    // Tests for "Do What I Mean" behavior (issue #273)
1533
1534    #[test]
1535    fn test_indent_explicit_uses_fixed_style() {
1536        // When indent is explicitly set but style is not, use fixed style automatically
1537        // This is the "Do What I Mean" behavior for issue #273
1538        let config = MD007Config {
1539            indent: crate::types::IndentSize::from_const(4),
1540            start_indented: false,
1541            start_indent: crate::types::IndentSize::from_const(2),
1542            style: md007_config::IndentStyle::TextAligned, // Default
1543            style_explicit: false,                         // Style NOT explicitly set
1544            indent_explicit: true,                         // Indent explicitly set
1545        };
1546        let rule = MD007ULIndent::from_config_struct(config);
1547
1548        // With indent_explicit=true and style_explicit=false, should use fixed style
1549        // Fixed style with indent=4: level 0 = 0, level 1 = 4, level 2 = 8
1550        let content = "* Level 0\n    * Level 1\n        * Level 2";
1551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552        let result = rule.check(&ctx).unwrap();
1553        assert!(
1554            result.is_empty(),
1555            "With indent_explicit=true, should use fixed style (0, 4, 8), got: {result:?}"
1556        );
1557
1558        // Text-aligned spacing (2 spaces per level) should now be wrong
1559        let wrong_content = "* Level 0\n  * Level 1\n    * Level 2";
1560        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1561        let result = rule.check(&ctx).unwrap();
1562        assert!(
1563            !result.is_empty(),
1564            "Should flag text-aligned spacing when indent_explicit=true"
1565        );
1566    }
1567
1568    #[test]
1569    fn test_explicit_style_overrides_indent_explicit() {
1570        // When both indent and style are explicitly set, style wins
1571        // This ensures backwards compatibility and respects explicit user choice
1572        let config = MD007Config {
1573            indent: crate::types::IndentSize::from_const(4),
1574            start_indented: false,
1575            start_indent: crate::types::IndentSize::from_const(2),
1576            style: md007_config::IndentStyle::TextAligned,
1577            style_explicit: true,  // Style explicitly set
1578            indent_explicit: true, // Indent also explicitly set (user will see warning)
1579        };
1580        let rule = MD007ULIndent::from_config_struct(config);
1581
1582        // With explicit text-aligned style, should use text-aligned even with indent_explicit
1583        let content = "* Level 0\n  * Level 1\n    * Level 2";
1584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585        let result = rule.check(&ctx).unwrap();
1586        assert!(
1587            result.is_empty(),
1588            "Explicit text-aligned style should be respected, got: {result:?}"
1589        );
1590    }
1591
1592    #[test]
1593    fn test_no_indent_explicit_uses_smart_detection() {
1594        // When neither is explicitly set, use smart per-parent detection (original behavior)
1595        let config = MD007Config {
1596            indent: crate::types::IndentSize::from_const(4),
1597            start_indented: false,
1598            start_indent: crate::types::IndentSize::from_const(2),
1599            style: md007_config::IndentStyle::TextAligned,
1600            style_explicit: false,
1601            indent_explicit: false, // Neither explicitly set - use smart detection
1602        };
1603        let rule = MD007ULIndent::from_config_struct(config);
1604
1605        // Pure unordered with neither explicit: per-parent logic applies
1606        // For pure unordered at expected positions, fixed style is used
1607        let content = "* Level 0\n    * Level 1";
1608        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1609        let result = rule.check(&ctx).unwrap();
1610        // This should work with smart detection for pure unordered lists
1611        assert!(
1612            result.is_empty(),
1613            "Smart detection should accept 4-space indent, got: {result:?}"
1614        );
1615    }
1616
1617    #[test]
1618    fn test_issue_273_exact_reproduction() {
1619        // Exact reproduction from issue #273:
1620        // User sets `indent = 4` without setting style, expects 4-space increments
1621        let config = MD007Config {
1622            indent: crate::types::IndentSize::from_const(4),
1623            start_indented: false,
1624            start_indent: crate::types::IndentSize::from_const(2),
1625            style: md007_config::IndentStyle::TextAligned, // Default (would use text-aligned)
1626            style_explicit: false,
1627            indent_explicit: true, // User explicitly set indent
1628        };
1629        let rule = MD007ULIndent::from_config_struct(config);
1630
1631        let content = r#"* Item 1
1632    * Item 2
1633        * Item 3"#;
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635        let result = rule.check(&ctx).unwrap();
1636        assert!(
1637            result.is_empty(),
1638            "Issue #273: indent=4 should use 4-space increments, got: {result:?}"
1639        );
1640    }
1641
1642    #[test]
1643    fn test_indent_explicit_with_ordered_parent() {
1644        // When indent is explicitly set BUT the parent is ordered,
1645        // bullets must still use text-aligned because ordered markers have variable width.
1646        // This is the critical edge case that caused the regression.
1647        let config = MD007Config {
1648            indent: crate::types::IndentSize::from_const(4),
1649            start_indented: false,
1650            start_indent: crate::types::IndentSize::from_const(2),
1651            style: md007_config::IndentStyle::TextAligned,
1652            style_explicit: false,
1653            indent_explicit: true, // User set indent=4
1654        };
1655        let rule = MD007ULIndent::from_config_struct(config);
1656
1657        // Ordered list with bullet child - bullet MUST align with ordered text (3 spaces)
1658        // NOT use fixed indent (4 spaces) even though indent=4 is set
1659        let content = "1. Ordered\n   * Bullet aligned with ordered text";
1660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1661        let result = rule.check(&ctx).unwrap();
1662        assert!(
1663            result.is_empty(),
1664            "Bullet under ordered must use text-aligned (3 spaces) even with indent=4: {result:?}"
1665        );
1666
1667        // Fixed indent (4 spaces) under ordered list should be WRONG
1668        let wrong_content = "1. Ordered\n    * Bullet with 4-space fixed indent";
1669        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1670        let result = rule.check(&ctx).unwrap();
1671        assert!(
1672            !result.is_empty(),
1673            "4-space indent under ordered list should be flagged"
1674        );
1675    }
1676
1677    #[test]
1678    fn test_indent_explicit_mixed_list_deep_nesting() {
1679        // Deep nesting with alternating list types tests the edge case thoroughly:
1680        // - Bullets under bullets: use configured indent (4)
1681        // - Bullets under ordered: use text-aligned
1682        // - Ordered under bullets: N/A (MD007 only checks bullets)
1683        let config = MD007Config {
1684            indent: crate::types::IndentSize::from_const(4),
1685            start_indented: false,
1686            start_indent: crate::types::IndentSize::from_const(2),
1687            style: md007_config::IndentStyle::TextAligned,
1688            style_explicit: false,
1689            indent_explicit: true,
1690        };
1691        let rule = MD007ULIndent::from_config_struct(config);
1692
1693        // Level 0: bullet (col 0)
1694        // Level 1: bullet (col 4 - fixed, parent is bullet)
1695        // Level 2: ordered (col 8 - not checked by MD007)
1696        // Level 3: bullet (col 11 - text-aligned with "1. " = 3 chars from col 8)
1697        let content = r#"* Level 0
1698    * Level 1 (4-space indent from bullet parent)
1699        1. Level 2 ordered
1700           * Level 3 bullet (text-aligned under ordered)"#;
1701        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702        let result = rule.check(&ctx).unwrap();
1703        assert!(
1704            result.is_empty(),
1705            "Mixed nesting should handle each parent type correctly: {result:?}"
1706        );
1707    }
1708
1709    #[test]
1710    fn test_ordered_list_double_digit_markers() {
1711        // Ordered lists with 10+ items have wider markers ("10." vs "9.")
1712        // Bullets nested under these must text-align correctly
1713        let config = MD007Config {
1714            indent: crate::types::IndentSize::from_const(4),
1715            start_indented: false,
1716            start_indent: crate::types::IndentSize::from_const(2),
1717            style: md007_config::IndentStyle::TextAligned,
1718            style_explicit: false,
1719            indent_explicit: true,
1720        };
1721        let rule = MD007ULIndent::from_config_struct(config);
1722
1723        // "10. " = 4 chars, so bullet should be at column 4
1724        let content = "10. Double digit\n    * Bullet at col 4";
1725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726        let result = rule.check(&ctx).unwrap();
1727        assert!(
1728            result.is_empty(),
1729            "Bullet under '10.' should align at column 4: {result:?}"
1730        );
1731
1732        // Single digit "1. " = 3 chars, bullet at column 3
1733        let content_single = "1. Single digit\n   * Bullet at col 3";
1734        let ctx = LintContext::new(content_single, crate::config::MarkdownFlavor::Standard, None);
1735        let result = rule.check(&ctx).unwrap();
1736        assert!(
1737            result.is_empty(),
1738            "Bullet under '1.' should align at column 3: {result:?}"
1739        );
1740    }
1741
1742    #[test]
1743    fn test_indent_explicit_pure_unordered_uses_fixed() {
1744        // Regression test: pure unordered lists should use fixed indent
1745        // when indent is explicitly configured
1746        let config = MD007Config {
1747            indent: crate::types::IndentSize::from_const(4),
1748            start_indented: false,
1749            start_indent: crate::types::IndentSize::from_const(2),
1750            style: md007_config::IndentStyle::TextAligned,
1751            style_explicit: false,
1752            indent_explicit: true,
1753        };
1754        let rule = MD007ULIndent::from_config_struct(config);
1755
1756        // Pure unordered with 4-space indent should pass
1757        let content = "* Level 0\n    * Level 1\n        * Level 2";
1758        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1759        let result = rule.check(&ctx).unwrap();
1760        assert!(
1761            result.is_empty(),
1762            "Pure unordered with indent=4 should use 4-space increments: {result:?}"
1763        );
1764
1765        // Text-aligned (2-space) should fail with indent=4
1766        let wrong_content = "* Level 0\n  * Level 1\n    * Level 2";
1767        let ctx = LintContext::new(wrong_content, crate::config::MarkdownFlavor::Standard, None);
1768        let result = rule.check(&ctx).unwrap();
1769        assert!(
1770            !result.is_empty(),
1771            "2-space indent should be flagged when indent=4 is configured"
1772        );
1773    }
1774
1775    #[test]
1776    fn test_mkdocs_ordered_list_with_4_space_nested_unordered() {
1777        // MkDocs (Python-Markdown) requires 4-space continuation for ordered
1778        // list items. `1. text` has content at column 3, but Python-Markdown
1779        // needs marker_col + 4 = 4 spaces minimum.
1780        let rule = MD007ULIndent::default();
1781        let content = "1. text\n\n    - nested item";
1782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1783        let result = rule.check(&ctx).unwrap();
1784        assert!(
1785            result.is_empty(),
1786            "4-space indent under ordered list should be valid in MkDocs flavor, got: {result:?}"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_standard_flavor_ordered_list_with_3_space_nested_unordered() {
1792        // Without MkDocs, `1. text` has content at column 3,
1793        // so 3-space indent is correct (text-aligned).
1794        let rule = MD007ULIndent::default();
1795        let content = "1. text\n\n   - nested item";
1796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1797        let result = rule.check(&ctx).unwrap();
1798        assert!(
1799            result.is_empty(),
1800            "3-space indent under ordered list should be valid in Standard flavor, got: {result:?}"
1801        );
1802    }
1803
1804    #[test]
1805    fn test_standard_flavor_ordered_list_with_4_space_warns() {
1806        // In Standard flavor, `1. text` expects 3-space indent (text-aligned).
1807        // 4 spaces should trigger a warning.
1808        let rule = MD007ULIndent::default();
1809        let content = "1. text\n\n    - nested item";
1810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811        let result = rule.check(&ctx).unwrap();
1812        assert_eq!(
1813            result.len(),
1814            1,
1815            "4-space indent under ordered list should warn in Standard flavor"
1816        );
1817    }
1818
1819    #[test]
1820    fn test_mkdocs_multi_digit_ordered_list() {
1821        // `10. text` has content at column 4, which already meets
1822        // the 4-space minimum (marker_col 0 + 4 = 4). No adjustment needed.
1823        let rule = MD007ULIndent::default();
1824        let content = "10. text\n\n    - nested item";
1825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1826        let result = rule.check(&ctx).unwrap();
1827        assert!(
1828            result.is_empty(),
1829            "4-space indent under `10.` should be valid in MkDocs flavor, got: {result:?}"
1830        );
1831    }
1832
1833    #[test]
1834    fn test_mkdocs_triple_digit_ordered_list() {
1835        // `100. text` has content at column 5, which exceeds
1836        // the 4-space minimum (marker_col 0 + 4 = 4). No adjustment needed.
1837        let rule = MD007ULIndent::default();
1838        let content = "100. text\n\n     - nested item";
1839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1840        let result = rule.check(&ctx).unwrap();
1841        assert!(
1842            result.is_empty(),
1843            "5-space indent under `100.` should be valid in MkDocs flavor, got: {result:?}"
1844        );
1845    }
1846
1847    #[test]
1848    fn test_mkdocs_insufficient_indent_under_ordered() {
1849        // In MkDocs, 2-space indent under `1. text` is insufficient.
1850        // Expected: marker_col(0) + 4 = 4, got: 2.
1851        let rule = MD007ULIndent::default();
1852        let content = "1. text\n\n  - nested item";
1853        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1854        let result = rule.check(&ctx).unwrap();
1855        assert_eq!(
1856            result.len(),
1857            1,
1858            "2-space indent under ordered list should warn in MkDocs flavor"
1859        );
1860        assert!(
1861            result[0].message.contains("Expected 4"),
1862            "Warning should expect 4 spaces (MkDocs minimum), got: {}",
1863            result[0].message
1864        );
1865    }
1866
1867    #[test]
1868    fn test_mkdocs_deeper_nesting_under_ordered() {
1869        // `1. text` -> `    - sub` (4 spaces) -> `      - subsub` (6 spaces)
1870        // The sub-item at 4 spaces is correct for MkDocs.
1871        // The sub-sub-item at 6 spaces: parent is unordered at col 4 with content at col 6,
1872        // so 6-space indent is text-aligned (correct).
1873        let rule = MD007ULIndent::default();
1874        let content = "1. text\n\n    - sub\n      - subsub";
1875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1876        let result = rule.check(&ctx).unwrap();
1877        assert!(
1878            result.is_empty(),
1879            "Deeper nesting under ordered list should be valid in MkDocs flavor, got: {result:?}"
1880        );
1881    }
1882
1883    #[test]
1884    fn test_mkdocs_fix_adjusts_to_4_spaces() {
1885        // Verify that auto-fix corrects 3-space indent to 4-space in MkDocs
1886        let rule = MD007ULIndent::default();
1887        let content = "1. text\n\n   - nested item";
1888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1889        let result = rule.check(&ctx).unwrap();
1890        assert_eq!(result.len(), 1, "3-space indent should warn in MkDocs");
1891        let fixed = rule.fix(&ctx).unwrap();
1892        assert_eq!(
1893            fixed, "1. text\n\n    - nested item",
1894            "Fix should adjust indent to 4 spaces in MkDocs"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_mkdocs_start_indented_with_ordered_parent() {
1900        // start_indented mode with MkDocs: the MkDocs adjustment should still apply
1901        // as a floor on top of the start_indented calculation.
1902        let config = MD007Config {
1903            start_indented: true,
1904            ..Default::default()
1905        };
1906        let rule = MD007ULIndent::from_config_struct(config);
1907        let content = "1. text\n\n    - nested item";
1908        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1909        let result = rule.check(&ctx).unwrap();
1910        assert!(
1911            result.is_empty(),
1912            "4-space indent under ordered list with start_indented should be valid in MkDocs, got: {result:?}"
1913        );
1914    }
1915
1916    #[test]
1917    fn test_mkdocs_ordered_at_nonzero_indent() {
1918        // Ordered list nested inside an unordered list, with a further unordered child.
1919        // `- outer` at col 0, `  1. inner` at col 2, `      - deep` at col 6.
1920        // For `deep`: parent is ordered at marker_col=2, so MkDocs minimum = 2+4 = 6.
1921        // Text-aligned: content_col of `1. inner` = 5. max(5, 6) = 6.
1922        let rule = MD007ULIndent::default();
1923        let content = "- outer\n  1. inner\n      - deep";
1924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1925        let result = rule.check(&ctx).unwrap();
1926        assert!(
1927            result.is_empty(),
1928            "6-space indent under nested ordered list should be valid in MkDocs, got: {result:?}"
1929        );
1930    }
1931
1932    #[test]
1933    fn test_mkdocs_blockquoted_ordered_list() {
1934        // Blockquoted ordered list in MkDocs: the indent is relative to
1935        // the blockquote content, so `> 1. text` with `>     - nested`
1936        // has 4 spaces of indent within the blockquote context.
1937        let rule = MD007ULIndent::default();
1938        let content = "> 1. text\n>\n>     - nested item";
1939        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1940        let result = rule.check(&ctx).unwrap();
1941        assert!(
1942            result.is_empty(),
1943            "4-space indent under blockquoted ordered list should be valid in MkDocs, got: {result:?}"
1944        );
1945    }
1946
1947    #[test]
1948    fn test_mkdocs_ordered_at_nonzero_indent_insufficient() {
1949        // Same structure but with only 5 spaces for `deep`.
1950        // MkDocs minimum = marker_col(2) + 4 = 6, but got 5. Should warn.
1951        let rule = MD007ULIndent::default();
1952        let content = "- outer\n  1. inner\n     - deep";
1953        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
1954        let result = rule.check(&ctx).unwrap();
1955        assert_eq!(
1956            result.len(),
1957            1,
1958            "5-space indent under nested ordered at col 2 should warn in MkDocs (needs 6)"
1959        );
1960    }
1961}