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