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