Skip to main content

rumdl_lib/rules/
md073_toc_validation.rs

1//! MD073: Table of Contents validation rule
2//!
3//! Validates that TOC sections match the actual document headings.
4
5use crate::lint_context::LintContext;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::anchor_styles::AnchorStyle;
8use crate::utils::header_id_utils::{extract_html_anchor_ids, is_backslash_escaped};
9use percent_encoding::percent_decode_str;
10use regex::Regex;
11use std::borrow::Cow;
12use std::collections::HashMap;
13use std::sync::LazyLock;
14
15/// Regex for TOC start marker: `<!-- toc -->` with optional whitespace variations
16static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
17
18/// Regex for TOC stop marker: `<!-- tocstop -->` or `<!-- /toc -->`
19static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
20
21/// Regex for extracting TOC entries: `- [text](#anchor)` or `* [text](#anchor)`
22/// with optional leading whitespace for nested items
23/// Handles nested brackets like `[`check [PATHS...]`](#check-paths)`
24static TOC_ENTRY_PATTERN: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
26
27/// Represents a detected TOC region in the document
28#[derive(Debug, Clone)]
29struct TocRegion {
30    /// 1-indexed start line of the TOC content (after the marker)
31    start_line: usize,
32    /// 1-indexed end line of the TOC content (before the stop marker)
33    end_line: usize,
34    /// Byte offset where TOC content starts
35    content_start: usize,
36    /// Byte offset where TOC content ends
37    content_end: usize,
38}
39
40/// A parsed TOC entry from the existing TOC
41#[derive(Debug, Clone)]
42struct TocEntry {
43    /// Display text of the link
44    text: String,
45    /// Anchor/fragment (without #)
46    anchor: String,
47    /// Number of leading whitespace characters (for indentation checking)
48    indent_spaces: usize,
49}
50
51/// An expected TOC entry generated from document headings
52#[derive(Debug, Clone)]
53struct ExpectedTocEntry {
54    /// 1-indexed line number of the heading
55    heading_line: usize,
56    /// Heading level (1-6)
57    level: u8,
58    /// Heading text (for display)
59    text: String,
60    /// The fragment a generated entry links to
61    anchor: String,
62    /// Other fragments that also reach the heading. A heading with its own `<a id>`
63    /// keeps the slug generated from its text, so a TOC written against either is
64    /// right.
65    aliases: Vec<String>,
66}
67
68impl ExpectedTocEntry {
69    fn is_reached_by(&self, anchor: &str) -> bool {
70        self.anchor == anchor || self.aliases.iter().any(|alias| alias == anchor)
71    }
72}
73
74/// Types of mismatches between actual and expected TOC
75#[derive(Debug)]
76enum TocMismatch {
77    /// Entry exists in TOC but heading doesn't exist
78    StaleEntry { entry: TocEntry },
79    /// Heading exists but no TOC entry for it
80    MissingEntry { expected: ExpectedTocEntry },
81    /// TOC entry text doesn't match heading text
82    TextMismatch {
83        entry: TocEntry,
84        expected: ExpectedTocEntry,
85    },
86    /// TOC entries are in wrong order
87    OrderMismatch { entry: TocEntry, expected_position: usize },
88    /// TOC entry has wrong indentation level
89    IndentationMismatch {
90        entry: TocEntry,
91        actual_indent: usize,
92        expected_indent: usize,
93    },
94}
95
96/// Regex patterns used by `strip_links_and_images`.
97static MARKDOWN_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
98static MARKDOWN_REF_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[[^\]]*\]").unwrap());
99static MARKDOWN_IMAGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\([^)]+\)").unwrap());
100
101/// `anchor` written as the destination of a TOC link.
102///
103/// A bare link destination cannot hold whitespace, control characters or an
104/// unbalanced parenthesis, and a `%` would read as the start of an escape, so
105/// those ASCII characters are percent-encoded. Everything else is written as it
106/// is, so a slug generated from heading text, non-ASCII letters included, is
107/// unchanged.
108fn encode_fragment(anchor: &str) -> Cow<'_, str> {
109    fn needs_encoding(c: char) -> bool {
110        c.is_ascii() && (c <= ' ' || c == '\x7f' || matches!(c, '(' | ')' | '%'))
111    }
112
113    if !anchor.contains(needs_encoding) {
114        return Cow::Borrowed(anchor);
115    }
116    let mut encoded = String::with_capacity(anchor.len() + 6);
117    for c in anchor.chars() {
118        if needs_encoding(c) {
119            encoded.push_str(&format!("%{:02X}", c as u32));
120        } else {
121            encoded.push(c);
122        }
123    }
124    Cow::Owned(encoded)
125}
126
127/// The fragment a written TOC entry reaches, its percent-encoding undone, as a
128/// browser decodes a fragment before matching it against ids. An escape sequence
129/// that does not decode to UTF-8 leaves the fragment as written.
130fn decode_fragment(anchor: &str) -> Cow<'_, str> {
131    if !anchor.contains('%') {
132        return Cow::Borrowed(anchor);
133    }
134    percent_decode_str(anchor)
135        .decode_utf8()
136        .unwrap_or(Cow::Borrowed(anchor))
137}
138
139/// Extract code-span byte ranges from `text` using the CommonMark rule:
140/// a run of N backticks opens a span closed by exactly N backticks.
141/// Returns a sorted list of `(start, end)` byte offsets that are inside code spans
142/// (including the backtick delimiters themselves).
143fn code_span_ranges(text: &str) -> Vec<(usize, usize)> {
144    let chars: Vec<char> = text.chars().collect();
145    let len = chars.len();
146    let mut ranges = Vec::new();
147    let mut i = 0;
148
149    while i < len {
150        if chars[i] == '`' {
151            let span_start = i;
152            while i < len && chars[i] == '`' {
153                i += 1;
154            }
155            let n = i - span_start;
156
157            // Search for the matching closing sequence of exactly n backticks
158            let mut j = i;
159            let mut found = false;
160            while j < len {
161                if chars[j] == '`' {
162                    let close_start = j;
163                    while j < len && chars[j] == '`' {
164                        j += 1;
165                    }
166                    if j - close_start == n {
167                        // Convert char indices to byte offsets
168                        let byte_start: usize = text.char_indices().nth(span_start).map_or(0, |(b, _)| b);
169                        let byte_end: usize = text.char_indices().nth(j).map_or(text.len(), |(b, _)| b);
170                        ranges.push((byte_start, byte_end));
171                        i = j;
172                        found = true;
173                        break;
174                    }
175                } else {
176                    j += 1;
177                }
178            }
179            if !found {
180                // No matching close; skip past the opening backticks
181                i = span_start + n;
182            }
183        } else {
184            i += 1;
185        }
186    }
187
188    ranges
189}
190
191/// Strip only links and images from `text`, preserving all other inline
192/// formatting (code spans, bold, italic, etc.).
193///
194/// Links and images cannot appear inside a Markdown link label `[...]`, so they
195/// must be removed when building TOC display text. Code spans and emphasis are
196/// valid inside link labels and should be kept so the TOC entry faithfully
197/// reflects the heading's visual appearance.
198///
199/// Code-span contents are protected: link-like syntax such as `[foo](bar)` that
200/// appears inside backticks is left untouched.
201///
202/// Examples:
203/// - `` `my header` `` → `` `my header` `` (code ticks preserved)
204/// - `[terminal](url)` → `terminal` (link stripped)
205/// - `![alt](img.png)` → `alt` (image stripped)
206/// - `**bold**` → `**bold**` (emphasis preserved)
207/// - `` `[foo](bar)` `` → `` `[foo](bar)` `` (link inside code span preserved)
208/// - `Tool: [terminal](url)` → `Tool: terminal`
209fn strip_links_and_images(text: &str) -> String {
210    // Collect code-span byte ranges so we can protect their contents from
211    // the link/image regex substitutions.
212    let protected = code_span_ranges(text);
213
214    // If there are no code spans the fast path avoids all the extra work.
215    if protected.is_empty() {
216        let mut result = text.to_string();
217        result = MARKDOWN_IMAGE.replace_all(&result, "$1").to_string();
218        result = MARKDOWN_LINK.replace_all(&result, "$1").to_string();
219        result = MARKDOWN_REF_LINK.replace_all(&result, "$1").to_string();
220        return result;
221    }
222
223    // Replace each code span with a unique placeholder that cannot be matched
224    // by the link/image regexes, apply the regexes, then restore the originals.
225    let mut placeholders: Vec<(&str, String)> = Vec::with_capacity(protected.len());
226    let mut masked = text.to_string();
227    // Process spans in reverse order so byte offsets remain valid after each replacement.
228    for (i, &(start, end)) in protected.iter().enumerate().rev() {
229        // Placeholder: a string containing no `[`, `]`, `(`, `)`, `!` characters.
230        let placeholder = format!("\x00CODESPAN{i}\x00");
231        let original = &text[start..end];
232        placeholders.push((original, placeholder.clone()));
233        masked.replace_range(start..end, &placeholder);
234    }
235
236    // Apply link/image stripping to the masked string
237    masked = MARKDOWN_IMAGE.replace_all(&masked, "$1").to_string();
238    masked = MARKDOWN_LINK.replace_all(&masked, "$1").to_string();
239    masked = MARKDOWN_REF_LINK.replace_all(&masked, "$1").to_string();
240
241    // Restore the original code-span text
242    for (original, placeholder) in &placeholders {
243        masked = masked.replace(placeholder.as_str(), original);
244    }
245
246    masked
247}
248
249/// MD073: Table of Contents Validation
250///
251/// This rule validates that TOC sections match the actual document headings.
252/// It detects TOC regions via markers (`<!-- toc -->...<!-- tocstop -->`).
253///
254/// To opt into TOC validation, add markers to your document:
255/// ```markdown
256/// <!-- toc -->
257/// - [Section](#section)
258/// <!-- tocstop -->
259/// ```
260///
261/// ## Configuration
262///
263/// ```toml
264/// [MD073]
265/// # Enable the rule (opt-in, disabled by default)
266/// enabled = true
267/// # Minimum heading level to include (default: 2)
268/// min-level = 2
269/// # Maximum heading level to include (default: 4)
270/// max-level = 4
271/// # Whether TOC order must match document order (default: true)
272/// enforce-order = true
273/// # Indent size per nesting level (default: from MD007 config, or 2)
274/// indent = 2
275/// ```
276#[derive(Clone)]
277pub struct MD073TocValidation {
278    /// Whether this rule is enabled (default: false - opt-in rule)
279    enabled: bool,
280    /// Minimum heading level to include
281    min_level: u8,
282    /// Maximum heading level to include
283    max_level: u8,
284    /// Whether to enforce order matching
285    enforce_order: bool,
286    /// Indent size per nesting level (reads from MD007 config by default)
287    pub indent: usize,
288}
289
290impl Default for MD073TocValidation {
291    fn default() -> Self {
292        Self {
293            enabled: false, // Disabled by default - opt-in rule
294            min_level: 2,
295            max_level: 4,
296            enforce_order: true,
297            indent: 2, // Default indent, can be overridden by MD007 config
298        }
299    }
300}
301
302impl std::fmt::Debug for MD073TocValidation {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        f.debug_struct("MD073TocValidation")
305            .field("enabled", &self.enabled)
306            .field("min_level", &self.min_level)
307            .field("max_level", &self.max_level)
308            .field("enforce_order", &self.enforce_order)
309            .field("indent", &self.indent)
310            .finish()
311    }
312}
313
314impl MD073TocValidation {
315    /// Create a new rule with default settings
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    /// Whether `content` contains a TOC marker that is Markdown syntax rather
321    /// than literal text. A marker inside an inline code span is code, and one
322    /// whose `<` is backslash-escaped renders as the characters themselves.
323    fn has_active_marker(ctx: &LintContext, content: &str, line_byte_offset: usize, marker: &Regex) -> bool {
324        marker.find_iter(content).any(|matched| {
325            !ctx.is_in_code_span_byte(line_byte_offset + matched.start())
326                && !is_backslash_escaped(content, matched.start())
327        })
328    }
329
330    /// Detect TOC region using markers
331    fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
332        let mut start_line = None;
333        let mut start_byte = None;
334
335        for (idx, line_info) in ctx.lines.iter().enumerate() {
336            let line_num = idx + 1;
337            let content = line_info.content(ctx.content);
338
339            // Skip if in code block or front matter
340            if line_info.in_code_block || line_info.in_front_matter {
341                continue;
342            }
343
344            // Look for start marker or stop marker
345            if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
346                // We have a start, now look for stop marker
347                if Self::has_active_marker(ctx, content, line_info.byte_offset, &TOC_STOP_MARKER) {
348                    let end_line = line_num - 1;
349                    let content_end = line_info.byte_offset;
350
351                    // Handle case where there's no content between markers
352                    if end_line < s_line {
353                        return Some(TocRegion {
354                            start_line: s_line,
355                            end_line: s_line,
356                            content_start: s_byte,
357                            content_end: s_byte,
358                        });
359                    }
360
361                    return Some(TocRegion {
362                        start_line: s_line,
363                        end_line,
364                        content_start: s_byte,
365                        content_end,
366                    });
367                }
368            } else if Self::has_active_marker(ctx, content, line_info.byte_offset, &TOC_START_MARKER) {
369                // TOC content starts on the next line
370                if idx + 1 < ctx.lines.len() {
371                    start_line = Some(line_num + 1);
372                    start_byte = Some(ctx.lines[idx + 1].byte_offset);
373                }
374            }
375        }
376
377        None
378    }
379
380    /// Detect TOC region using markers
381    fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
382        self.detect_by_markers(ctx)
383    }
384
385    /// Extract TOC entries from the detected region
386    fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
387        let mut entries = Vec::new();
388
389        for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
390            let line_info = &ctx.lines[idx];
391            let content = line_info.content(ctx.content);
392
393            if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
394                let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
395                let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
396                let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
397
398                entries.push(TocEntry {
399                    text,
400                    anchor,
401                    indent_spaces,
402                });
403            }
404        }
405
406        entries
407    }
408
409    /// Build expected TOC entries from document headings
410    fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
411        let mut entries = Vec::new();
412        let mut fragment_counts: HashMap<String, usize> = HashMap::new();
413
414        for (idx, line_info) in ctx.lines.iter().enumerate() {
415            let line_num = idx + 1;
416
417            // Skip headings before/within the TOC region
418            if line_num <= toc_region.end_line {
419                // Also skip the TOC heading itself for heading-based detection
420                continue;
421            }
422
423            // Skip code blocks, front matter, HTML blocks
424            if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
425                continue;
426            }
427
428            if let Some(heading) = &line_info.heading {
429                // Filter by min/max level
430                if heading.level < self.min_level || heading.level > self.max_level {
431                    continue;
432                }
433
434                // A custom ID ({#id}) replaces the generated slug. An HTML anchor
435                // element beside the heading adds a target either way: the
436                // renderer keeps the element's own id, and without a custom ID it
437                // still generates the slug from the text, which still counts
438                // towards the numbering of later duplicates.
439                let (anchor, aliases) = match &heading.custom_id {
440                    Some(custom_id) => (
441                        Self::deduplicate_fragment(&mut fragment_counts, custom_id),
442                        extract_html_anchor_ids(&heading.raw_text),
443                    ),
444                    None => {
445                        let generated = Self::deduplicate_fragment(
446                            &mut fragment_counts,
447                            &AnchorStyle::GitHub.generate_fragment(&heading.text),
448                        );
449                        let mut explicit = extract_html_anchor_ids(&heading.raw_text);
450                        match explicit.first().cloned() {
451                            Some(preferred) => {
452                                explicit.remove(0);
453                                explicit.push(generated);
454                                (preferred, explicit)
455                            }
456                            None => (generated, Vec::new()),
457                        }
458                    }
459                };
460
461                entries.push(ExpectedTocEntry {
462                    heading_line: line_num,
463                    level: heading.level,
464                    text: heading.text.clone(),
465                    anchor,
466                    aliases,
467                });
468            }
469        }
470
471        entries
472    }
473
474    /// The fragment a renderer gives a heading whose base fragment has already
475    /// been seen `n` times: `base`, then `base-1`, `base-2`, ...
476    fn deduplicate_fragment(fragment_counts: &mut HashMap<String, usize>, base: &str) -> String {
477        match fragment_counts.get_mut(base) {
478            Some(count) => {
479                let suffix = *count;
480                *count += 1;
481                format!("{base}-{suffix}")
482            }
483            None => {
484                fragment_counts.insert(base.to_string(), 1);
485                base.to_string()
486            }
487        }
488    }
489
490    /// Pair each actual TOC entry with the first still-unclaimed heading its
491    /// anchor reaches. `None` marks an entry that reaches no heading.
492    fn match_entries(actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<Option<usize>> {
493        let mut claimed = vec![false; expected.len()];
494        actual
495            .iter()
496            .map(|entry| {
497                let anchor = decode_fragment(&entry.anchor);
498                let found = expected
499                    .iter()
500                    .enumerate()
501                    .position(|(idx, exp)| !claimed[idx] && exp.is_reached_by(&anchor));
502                if let Some(idx) = found {
503                    claimed[idx] = true;
504                }
505                found
506            })
507            .collect()
508    }
509
510    /// Compare actual TOC entries against expected and find mismatches.
511    ///
512    /// `matching` pairs each actual entry with the heading it reaches, as
513    /// computed by [`Self::match_entries`].
514    fn validate_toc(
515        &self,
516        actual: &[TocEntry],
517        expected: &[ExpectedTocEntry],
518        matching: &[Option<usize>],
519    ) -> Vec<TocMismatch> {
520        let mut mismatches = Vec::new();
521        let pairs = || {
522            actual
523                .iter()
524                .zip(matching)
525                .enumerate()
526                .filter_map(|(actual_idx, (entry, matched))| matched.map(|exp_idx| (actual_idx, entry, exp_idx)))
527        };
528
529        // Stale entries reach no heading.
530        for (entry, matched) in actual.iter().zip(matching) {
531            if matched.is_none() {
532                mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
533            }
534        }
535
536        // Missing entries are headings no TOC entry reaches.
537        for (exp_idx, exp) in expected.iter().enumerate() {
538            if !matching.contains(&Some(exp_idx)) {
539                mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
540            }
541        }
542
543        // Check for text mismatches. Compare with the same normalization used in
544        // generate_toc: strip only links and images, preserve code spans and emphasis.
545        // This ensures a correct user-written TOC entry like `` [`my header`](#anchor) ``
546        // is not flagged against a heading `` `my header` ``.
547        let mut text_mismatched = vec![false; actual.len()];
548        for (actual_idx, entry, exp_idx) in pairs() {
549            let exp = &expected[exp_idx];
550            let actual_normalized = strip_links_and_images(entry.text.trim());
551            let expected_normalized = strip_links_and_images(exp.text.trim());
552            if actual_normalized != expected_normalized {
553                text_mismatched[actual_idx] = true;
554                mismatches.push(TocMismatch::TextMismatch {
555                    entry: entry.clone(),
556                    expected: exp.clone(),
557                });
558            }
559        }
560
561        // Check for indentation mismatches
562        // Expected indentation is indent spaces per level difference from base level
563        if !expected.is_empty() {
564            let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
565
566            for (actual_idx, entry, exp_idx) in pairs() {
567                let level_diff = expected[exp_idx].level.saturating_sub(base_level) as usize;
568                let expected_indent = level_diff * self.indent;
569
570                // An entry already reported for its text is not reported again
571                if entry.indent_spaces != expected_indent && !text_mismatched[actual_idx] {
572                    mismatches.push(TocMismatch::IndentationMismatch {
573                        entry: entry.clone(),
574                        actual_indent: entry.indent_spaces,
575                        expected_indent,
576                    });
577                }
578            }
579        }
580
581        // Check order if enforce_order is enabled: walk the headings alongside the
582        // matched entries, and an entry whose heading lies behind the walk is out
583        // of order.
584        if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
585            let mut expected_idx = 0;
586            for (actual_idx, entry, exp_idx) in pairs() {
587                if exp_idx >= expected_idx {
588                    expected_idx = exp_idx + 1;
589                } else if !text_mismatched[actual_idx] {
590                    mismatches.push(TocMismatch::OrderMismatch {
591                        entry: entry.clone(),
592                        expected_position: exp_idx + 1,
593                    });
594                }
595            }
596        }
597
598        mismatches
599    }
600
601    /// Expected entries as they should be regenerated: a TOC entry that already
602    /// reaches its heading keeps the anchor it was written with. The anchor is
603    /// stored decoded, as every expected anchor is, and encoded again on output.
604    fn keep_reached_anchors(
605        actual: &[TocEntry],
606        expected: &[ExpectedTocEntry],
607        matching: &[Option<usize>],
608    ) -> Vec<ExpectedTocEntry> {
609        let mut regenerated = expected.to_vec();
610        for (entry, exp_idx) in actual
611            .iter()
612            .zip(matching)
613            .filter_map(|(entry, m)| m.map(|idx| (entry, idx)))
614        {
615            regenerated[exp_idx].anchor = decode_fragment(&entry.anchor).into_owned();
616        }
617        regenerated
618    }
619
620    /// Generate a new TOC from expected entries (always uses nested indentation)
621    fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
622        if expected.is_empty() {
623            return String::new();
624        }
625
626        let mut result = String::new();
627        let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
628        let indent_str = " ".repeat(self.indent);
629
630        for entry in expected {
631            let level_diff = entry.level.saturating_sub(base_level) as usize;
632            let indent = indent_str.repeat(level_diff);
633
634            // Build display text: strip only links and images (which would create invalid
635            // nested-link syntax inside `[...]`), but preserve code spans and emphasis so
636            // the TOC entry reflects the heading's visual appearance.
637            let display_text = strip_links_and_images(&entry.text);
638            let fragment = encode_fragment(&entry.anchor);
639            result.push_str(&format!("{indent}- [{display_text}](#{fragment})\n"));
640        }
641
642        result
643    }
644}
645
646impl Rule for MD073TocValidation {
647    fn name(&self) -> &'static str {
648        "MD073"
649    }
650
651    fn description(&self) -> &'static str {
652        "Table of Contents should match document headings"
653    }
654
655    fn should_skip(&self, ctx: &LintContext) -> bool {
656        // Quick check: skip if no TOC markers. detect_toc_region() is
657        // case-insensitive, so use a case-insensitive containment check here
658        // to avoid skipping fix() on documents with uppercase markers like
659        // `<!-- TOC -->`.
660        let lower = ctx.content.to_ascii_lowercase();
661        !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
662    }
663
664    fn check(&self, ctx: &LintContext) -> LintResult {
665        let mut warnings = Vec::new();
666
667        // Detect TOC region
668        let Some(region) = self.detect_toc_region(ctx) else {
669            // No TOC found - nothing to validate
670            return Ok(warnings);
671        };
672
673        // Extract actual TOC entries
674        let actual_entries = self.extract_toc_entries(ctx, &region);
675
676        // Build expected TOC from headings
677        let expected_entries = self.build_expected_toc(ctx, &region);
678
679        // If no expected entries and no actual entries, nothing to validate
680        if expected_entries.is_empty() && actual_entries.is_empty() {
681            return Ok(warnings);
682        }
683
684        // Validate
685        let matching = Self::match_entries(&actual_entries, &expected_entries);
686        let mismatches = self.validate_toc(&actual_entries, &expected_entries, &matching);
687
688        if !mismatches.is_empty() {
689            // Generate a single warning at the TOC region with details
690            let mut details = Vec::new();
691
692            for mismatch in &mismatches {
693                match mismatch {
694                    TocMismatch::StaleEntry { entry } => {
695                        details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
696                    }
697                    TocMismatch::MissingEntry { expected } => {
698                        details.push(format!(
699                            "Missing entry: '{}' (line {})",
700                            expected.text, expected.heading_line
701                        ));
702                    }
703                    TocMismatch::TextMismatch { entry, expected } => {
704                        details.push(format!(
705                            "Text mismatch: TOC has '{}', heading is '{}'",
706                            entry.text, expected.text
707                        ));
708                    }
709                    TocMismatch::OrderMismatch {
710                        entry,
711                        expected_position,
712                    } => {
713                        details.push(format!(
714                            "Order mismatch: '{}' should be at position {}",
715                            entry.text, expected_position
716                        ));
717                    }
718                    TocMismatch::IndentationMismatch {
719                        entry,
720                        actual_indent,
721                        expected_indent,
722                        ..
723                    } => {
724                        details.push(format!(
725                            "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
726                            entry.text, actual_indent, expected_indent
727                        ));
728                    }
729                }
730            }
731
732            let message = format!(
733                "Table of Contents does not match document headings: {}",
734                details.join("; ")
735            );
736
737            // Generate fix: replace entire TOC content
738            let regenerated = Self::keep_reached_anchors(&actual_entries, &expected_entries, &matching);
739            let new_toc = self.generate_toc(&regenerated);
740            let fix_range = region.content_start..region.content_end;
741
742            warnings.push(LintWarning {
743                rule_name: Some(self.name().to_string()),
744                message,
745                line: region.start_line,
746                column: 1,
747                end_line: region.end_line,
748                end_column: 1,
749                severity: Severity::Warning,
750                fix: Some(Fix::new(fix_range, new_toc)),
751            });
752        }
753
754        Ok(warnings)
755    }
756
757    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
758        if self.should_skip(ctx) {
759            return Ok(ctx.content.to_string());
760        }
761        let warnings = self.check(ctx)?;
762        if warnings.is_empty() {
763            return Ok(ctx.content.to_string());
764        }
765        let warnings =
766            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
767        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
768    }
769
770    fn category(&self) -> RuleCategory {
771        RuleCategory::Other
772    }
773
774    fn as_any(&self) -> &dyn std::any::Any {
775        self
776    }
777
778    fn default_config_section(&self) -> Option<(String, toml::Value)> {
779        let value: toml::Value = toml::from_str(
780            r#"
781# Whether this rule is enabled (opt-in, disabled by default)
782enabled = false
783# Minimum heading level to include
784min-level = 2
785# Maximum heading level to include
786max-level = 4
787# Whether TOC order must match document order
788enforce-order = true
789# Indentation per nesting level (defaults to MD007's indent value)
790indent = 2
791"#,
792        )
793        .ok()?;
794        Some(("MD073".to_string(), value))
795    }
796
797    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
798    where
799        Self: Sized,
800    {
801        let mut rule = MD073TocValidation::default();
802        let mut indent_from_md073 = false;
803
804        if let Some(rule_config) = config.rules.get("MD073") {
805            // Parse enabled (opt-in rule, defaults to false)
806            if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
807                rule.enabled = enabled;
808            }
809
810            // Parse min-level
811            if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
812                rule.min_level = (min_level.clamp(1, 6)) as u8;
813            }
814
815            // Parse max-level
816            if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
817                rule.max_level = (max_level.clamp(1, 6)) as u8;
818            }
819
820            // Parse enforce-order
821            if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
822                rule.enforce_order = enforce_order;
823            }
824
825            // Parse indent (MD073-specific override)
826            if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
827                rule.indent = (indent.clamp(1, 8)) as usize;
828                indent_from_md073 = true;
829            }
830        }
831
832        // If indent not explicitly set in MD073, read from MD007 config
833        if !indent_from_md073
834            && let Some(md007_config) = config.rules.get("MD007")
835            && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
836        {
837            rule.indent = (indent.clamp(1, 8)) as usize;
838        }
839
840        Box::new(rule)
841    }
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use crate::config::MarkdownFlavor;
848    use regex::Regex;
849    use std::sync::LazyLock;
850
851    // ---- Test-only helpers for stripping all inline formatting ----
852    // These are not used in production code; they exist only to test
853    // the individual stripping primitives in isolation.
854
855    /// Strip code spans from text, handling multi-backtick spans per CommonMark spec.
856    fn strip_code_spans(text: &str) -> String {
857        let chars: Vec<char> = text.chars().collect();
858        let len = chars.len();
859        let mut result = String::with_capacity(text.len());
860        let mut i = 0;
861
862        while i < len {
863            if chars[i] == '`' {
864                let open_start = i;
865                while i < len && chars[i] == '`' {
866                    i += 1;
867                }
868                let backtick_count = i - open_start;
869
870                let content_start = i;
871                let mut found_close = false;
872                while i < len {
873                    if chars[i] == '`' {
874                        let close_start = i;
875                        while i < len && chars[i] == '`' {
876                            i += 1;
877                        }
878                        if i - close_start == backtick_count {
879                            let content: String = chars[content_start..close_start].iter().collect();
880                            let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
881                                content[1..content.len() - 1].to_string()
882                            } else {
883                                content
884                            };
885                            result.push_str(&stripped);
886                            found_close = true;
887                            break;
888                        }
889                    } else {
890                        i += 1;
891                    }
892                }
893                if !found_close {
894                    for _ in 0..backtick_count {
895                        result.push('`');
896                    }
897                    let remaining: String = chars[content_start..].iter().collect();
898                    result.push_str(&remaining);
899                    break;
900                }
901            } else {
902                result.push(chars[i]);
903                i += 1;
904            }
905        }
906
907        result
908    }
909
910    static TEST_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
911    static TEST_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
912    static TEST_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
913    static TEST_ITALIC_UNDERSCORE: LazyLock<Regex> =
914        LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
915
916    /// Strip all inline markdown formatting from text, reducing it to plain text.
917    /// Builds on `strip_links_and_images` and additionally removes code spans,
918    /// bold, and italic markers. Used in tests only.
919    fn strip_markdown_formatting(text: &str) -> String {
920        let mut result = strip_links_and_images(text);
921        result = strip_code_spans(&result);
922        result = TEST_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
923        result = TEST_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
924        result = TEST_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
925        result = TEST_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
926        result
927    }
928
929    fn create_ctx(content: &str) -> LintContext<'_> {
930        LintContext::new(content, MarkdownFlavor::Standard, None)
931    }
932
933    /// Create rule with enabled=true for tests that call check() directly
934    fn create_enabled_rule() -> MD073TocValidation {
935        MD073TocValidation {
936            enabled: true,
937            ..MD073TocValidation::default()
938        }
939    }
940
941    // ========== Detection Tests ==========
942
943    #[test]
944    fn test_detect_markers_basic() {
945        let rule = MD073TocValidation::new();
946        let content = r#"# Title
947
948<!-- toc -->
949
950- [Heading 1](#heading-1)
951
952<!-- tocstop -->
953
954## Heading 1
955
956Content here.
957"#;
958        let ctx = create_ctx(content);
959        let region = rule.detect_by_markers(&ctx);
960        assert!(region.is_some());
961        let region = region.unwrap();
962        // Verify region boundaries are detected correctly
963        assert_eq!(region.start_line, 4);
964        assert_eq!(region.end_line, 6);
965    }
966
967    #[test]
968    fn test_detect_markers_variations() {
969        let rule = MD073TocValidation::new();
970
971        // Test <!--toc--> (no spaces)
972        let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
973        let ctx1 = create_ctx(content1);
974        assert!(rule.detect_by_markers(&ctx1).is_some());
975
976        // Test <!-- TOC --> (uppercase)
977        let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
978        let ctx2 = create_ctx(content2);
979        assert!(rule.detect_by_markers(&ctx2).is_some());
980
981        // Test <!-- /toc --> (alternative stop marker)
982        let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
983        let ctx3 = create_ctx(content3);
984        assert!(rule.detect_by_markers(&ctx3).is_some());
985    }
986
987    #[test]
988    fn test_no_toc_region() {
989        let rule = MD073TocValidation::new();
990        let content = r#"# Title
991
992## Heading 1
993
994Content here.
995
996## Heading 2
997
998More content.
999"#;
1000        let ctx = create_ctx(content);
1001        let region = rule.detect_toc_region(&ctx);
1002        assert!(region.is_none());
1003    }
1004
1005    // ========== Validation Tests ==========
1006
1007    #[test]
1008    fn test_toc_matches_headings() {
1009        let rule = create_enabled_rule();
1010        let content = r#"# Title
1011
1012<!-- toc -->
1013
1014- [Heading 1](#heading-1)
1015- [Heading 2](#heading-2)
1016
1017<!-- tocstop -->
1018
1019## Heading 1
1020
1021Content.
1022
1023## Heading 2
1024
1025More content.
1026"#;
1027        let ctx = create_ctx(content);
1028        let result = rule.check(&ctx).unwrap();
1029        assert!(result.is_empty(), "Expected no warnings for matching TOC");
1030    }
1031
1032    #[test]
1033    fn test_missing_entry() {
1034        let rule = create_enabled_rule();
1035        let content = r#"# Title
1036
1037<!-- toc -->
1038
1039- [Heading 1](#heading-1)
1040
1041<!-- tocstop -->
1042
1043## Heading 1
1044
1045Content.
1046
1047## Heading 2
1048
1049New heading not in TOC.
1050"#;
1051        let ctx = create_ctx(content);
1052        let result = rule.check(&ctx).unwrap();
1053        assert_eq!(result.len(), 1);
1054        assert!(result[0].message.contains("Missing entry"));
1055        assert!(result[0].message.contains("Heading 2"));
1056    }
1057
1058    #[test]
1059    fn test_stale_entry() {
1060        let rule = create_enabled_rule();
1061        let content = r#"# Title
1062
1063<!-- toc -->
1064
1065- [Heading 1](#heading-1)
1066- [Deleted Heading](#deleted-heading)
1067
1068<!-- tocstop -->
1069
1070## Heading 1
1071
1072Content.
1073"#;
1074        let ctx = create_ctx(content);
1075        let result = rule.check(&ctx).unwrap();
1076        assert_eq!(result.len(), 1);
1077        assert!(result[0].message.contains("Stale entry"));
1078        assert!(result[0].message.contains("Deleted Heading"));
1079    }
1080
1081    #[test]
1082    fn test_text_mismatch() {
1083        let rule = create_enabled_rule();
1084        let content = r#"# Title
1085
1086<!-- toc -->
1087
1088- [Old Name](#heading-1)
1089
1090<!-- tocstop -->
1091
1092## Heading 1
1093
1094Content.
1095"#;
1096        let ctx = create_ctx(content);
1097        let result = rule.check(&ctx).unwrap();
1098        assert_eq!(result.len(), 1);
1099        assert!(result[0].message.contains("Text mismatch"));
1100    }
1101
1102    // ========== Level Filtering Tests ==========
1103
1104    #[test]
1105    fn test_min_level_excludes_h1() {
1106        let mut rule = MD073TocValidation::new();
1107        rule.min_level = 2;
1108
1109        let content = r#"<!-- toc -->
1110
1111<!-- tocstop -->
1112
1113# Should Be Excluded
1114
1115## Should Be Included
1116
1117Content.
1118"#;
1119        let ctx = create_ctx(content);
1120        let region = rule.detect_toc_region(&ctx).unwrap();
1121        let expected = rule.build_expected_toc(&ctx, &region);
1122
1123        assert_eq!(expected.len(), 1);
1124        assert_eq!(expected[0].text, "Should Be Included");
1125    }
1126
1127    #[test]
1128    fn test_max_level_excludes_h5_h6() {
1129        let mut rule = MD073TocValidation::new();
1130        rule.max_level = 4;
1131
1132        let content = r#"<!-- toc -->
1133
1134<!-- tocstop -->
1135
1136## Level 2
1137
1138### Level 3
1139
1140#### Level 4
1141
1142##### Level 5 Should Be Excluded
1143
1144###### Level 6 Should Be Excluded
1145"#;
1146        let ctx = create_ctx(content);
1147        let region = rule.detect_toc_region(&ctx).unwrap();
1148        let expected = rule.build_expected_toc(&ctx, &region);
1149
1150        assert_eq!(expected.len(), 3);
1151        assert!(expected.iter().all(|e| e.level <= 4));
1152    }
1153
1154    // ========== Fix Tests ==========
1155
1156    #[test]
1157    fn test_fix_adds_missing_entry() {
1158        let rule = MD073TocValidation::new();
1159        let content = r#"# Title
1160
1161<!-- toc -->
1162
1163- [Heading 1](#heading-1)
1164
1165<!-- tocstop -->
1166
1167## Heading 1
1168
1169Content.
1170
1171## Heading 2
1172
1173New heading.
1174"#;
1175        let ctx = create_ctx(content);
1176        let fixed = rule.fix(&ctx).unwrap();
1177        assert!(fixed.contains("- [Heading 2](#heading-2)"));
1178    }
1179
1180    #[test]
1181    fn test_fix_removes_stale_entry() {
1182        let rule = MD073TocValidation::new();
1183        let content = r#"# Title
1184
1185<!-- toc -->
1186
1187- [Heading 1](#heading-1)
1188- [Deleted](#deleted)
1189
1190<!-- tocstop -->
1191
1192## Heading 1
1193
1194Content.
1195"#;
1196        let ctx = create_ctx(content);
1197        let fixed = rule.fix(&ctx).unwrap();
1198        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1199        assert!(!fixed.contains("Deleted"));
1200    }
1201
1202    #[test]
1203    fn test_fix_idempotent() {
1204        let rule = MD073TocValidation::new();
1205        let content = r#"# Title
1206
1207<!-- toc -->
1208
1209- [Heading 1](#heading-1)
1210- [Heading 2](#heading-2)
1211
1212<!-- tocstop -->
1213
1214## Heading 1
1215
1216Content.
1217
1218## Heading 2
1219
1220More.
1221"#;
1222        let ctx = create_ctx(content);
1223        let fixed1 = rule.fix(&ctx).unwrap();
1224        let ctx2 = create_ctx(&fixed1);
1225        let fixed2 = rule.fix(&ctx2).unwrap();
1226
1227        // Second fix should produce same output
1228        assert_eq!(fixed1, fixed2);
1229    }
1230
1231    #[test]
1232    fn test_fix_preserves_markers() {
1233        let rule = MD073TocValidation::new();
1234        let content = r#"# Title
1235
1236<!-- toc -->
1237
1238Old TOC content.
1239
1240<!-- tocstop -->
1241
1242## New Heading
1243
1244Content.
1245"#;
1246        let ctx = create_ctx(content);
1247        let fixed = rule.fix(&ctx).unwrap();
1248
1249        // Markers should still be present
1250        assert!(fixed.contains("<!-- toc -->"));
1251        assert!(fixed.contains("<!-- tocstop -->"));
1252        // New content should be generated
1253        assert!(fixed.contains("- [New Heading](#new-heading)"));
1254    }
1255
1256    #[test]
1257    fn test_fix_requires_markers() {
1258        let rule = create_enabled_rule();
1259
1260        // Document without markers - no TOC detected, no changes
1261        let content_no_markers = r#"# Title
1262
1263## Heading 1
1264
1265Content.
1266"#;
1267        let ctx = create_ctx(content_no_markers);
1268        let fixed = rule.fix(&ctx).unwrap();
1269        assert_eq!(fixed, content_no_markers);
1270
1271        // Document with markers - TOC detected and fixed
1272        let content_markers = r#"# Title
1273
1274<!-- toc -->
1275
1276- [Old Entry](#old-entry)
1277
1278<!-- tocstop -->
1279
1280## Heading 1
1281
1282Content.
1283"#;
1284        let ctx = create_ctx(content_markers);
1285        let fixed = rule.fix(&ctx).unwrap();
1286        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1287        assert!(!fixed.contains("Old Entry"));
1288    }
1289
1290    // ========== Anchor Tests ==========
1291
1292    #[test]
1293    fn test_duplicate_heading_anchors() {
1294        let rule = MD073TocValidation::new();
1295        let content = r#"# Title
1296
1297<!-- toc -->
1298
1299<!-- tocstop -->
1300
1301## Duplicate
1302
1303Content.
1304
1305## Duplicate
1306
1307More content.
1308
1309## Duplicate
1310
1311Even more.
1312"#;
1313        let ctx = create_ctx(content);
1314        let region = rule.detect_toc_region(&ctx).unwrap();
1315        let expected = rule.build_expected_toc(&ctx, &region);
1316
1317        assert_eq!(expected.len(), 3);
1318        assert_eq!(expected[0].anchor, "duplicate");
1319        assert_eq!(expected[1].anchor, "duplicate-1");
1320        assert_eq!(expected[2].anchor, "duplicate-2");
1321    }
1322
1323    // ========== Edge Cases ==========
1324
1325    #[test]
1326    fn test_headings_in_code_blocks_ignored() {
1327        let rule = create_enabled_rule();
1328        let content = r#"# Title
1329
1330<!-- toc -->
1331
1332- [Real Heading](#real-heading)
1333
1334<!-- tocstop -->
1335
1336## Real Heading
1337
1338```markdown
1339## Fake Heading In Code
1340```
1341
1342Content.
1343"#;
1344        let ctx = create_ctx(content);
1345        let result = rule.check(&ctx).unwrap();
1346        assert!(result.is_empty(), "Should not report fake heading in code block");
1347    }
1348
1349    #[test]
1350    fn test_empty_toc_region() {
1351        let rule = create_enabled_rule();
1352        let content = r#"# Title
1353
1354<!-- toc -->
1355<!-- tocstop -->
1356
1357## Heading 1
1358
1359Content.
1360"#;
1361        let ctx = create_ctx(content);
1362        let result = rule.check(&ctx).unwrap();
1363        assert_eq!(result.len(), 1);
1364        assert!(result[0].message.contains("Missing entry"));
1365    }
1366
1367    #[test]
1368    fn test_nested_indentation() {
1369        let rule = create_enabled_rule();
1370
1371        let content = r#"<!-- toc -->
1372
1373<!-- tocstop -->
1374
1375## Level 2
1376
1377### Level 3
1378
1379#### Level 4
1380
1381## Another Level 2
1382"#;
1383        let ctx = create_ctx(content);
1384        let region = rule.detect_toc_region(&ctx).unwrap();
1385        let expected = rule.build_expected_toc(&ctx, &region);
1386        let toc = rule.generate_toc(&expected);
1387
1388        // Check indentation (always nested)
1389        assert!(toc.contains("- [Level 2](#level-2)"));
1390        assert!(toc.contains("  - [Level 3](#level-3)"));
1391        assert!(toc.contains("    - [Level 4](#level-4)"));
1392        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1393    }
1394
1395    // ========== Indentation Mismatch Tests ==========
1396
1397    #[test]
1398    fn test_indentation_mismatch_detected() {
1399        let rule = create_enabled_rule();
1400        // TOC entries are all at same indentation level, but headings have different levels
1401        let content = r#"<!-- toc -->
1402- [Hello](#hello)
1403- [Another](#another)
1404- [Heading](#heading)
1405<!-- tocstop -->
1406
1407## Hello
1408
1409### Another
1410
1411## Heading
1412"#;
1413        let ctx = create_ctx(content);
1414        let result = rule.check(&ctx).unwrap();
1415        // Should detect indentation mismatch - "Another" is level 3 but has no indent
1416        assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1417        assert!(
1418            result[0].message.contains("Indentation mismatch"),
1419            "Message should mention indentation: {}",
1420            result[0].message
1421        );
1422        assert!(
1423            result[0].message.contains("Another"),
1424            "Message should mention the entry: {}",
1425            result[0].message
1426        );
1427    }
1428
1429    #[test]
1430    fn test_indentation_mismatch_fixed() {
1431        let rule = create_enabled_rule();
1432        // TOC entries are all at same indentation level, but headings have different levels
1433        let content = r#"<!-- toc -->
1434- [Hello](#hello)
1435- [Another](#another)
1436- [Heading](#heading)
1437<!-- tocstop -->
1438
1439## Hello
1440
1441### Another
1442
1443## Heading
1444"#;
1445        let ctx = create_ctx(content);
1446        let fixed = rule.fix(&ctx).unwrap();
1447        // After fix, "Another" should be indented
1448        assert!(fixed.contains("- [Hello](#hello)"));
1449        assert!(fixed.contains("  - [Another](#another)")); // Indented with 2 spaces
1450        assert!(fixed.contains("- [Heading](#heading)"));
1451    }
1452
1453    #[test]
1454    fn test_no_indentation_mismatch_when_correct() {
1455        let rule = create_enabled_rule();
1456        // TOC has correct indentation
1457        let content = r#"<!-- toc -->
1458- [Hello](#hello)
1459  - [Another](#another)
1460- [Heading](#heading)
1461<!-- tocstop -->
1462
1463## Hello
1464
1465### Another
1466
1467## Heading
1468"#;
1469        let ctx = create_ctx(content);
1470        let result = rule.check(&ctx).unwrap();
1471        // Should not report any issues - indentation is correct
1472        assert!(result.is_empty(), "Should not report issues: {result:?}");
1473    }
1474
1475    // ========== Order Mismatch Tests ==========
1476
1477    #[test]
1478    fn test_order_mismatch_detected() {
1479        let rule = create_enabled_rule();
1480        let content = r#"# Title
1481
1482<!-- toc -->
1483
1484- [Section B](#section-b)
1485- [Section A](#section-a)
1486
1487<!-- tocstop -->
1488
1489## Section A
1490
1491Content A.
1492
1493## Section B
1494
1495Content B.
1496"#;
1497        let ctx = create_ctx(content);
1498        let result = rule.check(&ctx).unwrap();
1499        // Should detect order mismatch - Section B appears before Section A in TOC
1500        // but Section A comes first in document
1501        assert!(!result.is_empty(), "Should detect order mismatch");
1502    }
1503
1504    #[test]
1505    fn test_order_mismatch_ignored_when_disabled() {
1506        let mut rule = create_enabled_rule();
1507        rule.enforce_order = false;
1508        let content = r#"# Title
1509
1510<!-- toc -->
1511
1512- [Section B](#section-b)
1513- [Section A](#section-a)
1514
1515<!-- tocstop -->
1516
1517## Section A
1518
1519Content A.
1520
1521## Section B
1522
1523Content B.
1524"#;
1525        let ctx = create_ctx(content);
1526        let result = rule.check(&ctx).unwrap();
1527        // With enforce_order=false, order mismatches should be ignored
1528        assert!(result.is_empty(), "Should not report order mismatch when disabled");
1529    }
1530
1531    // ========== Unicode and Special Characters Tests ==========
1532
1533    #[test]
1534    fn test_unicode_headings() {
1535        let rule = create_enabled_rule();
1536        let content = r#"# Title
1537
1538<!-- toc -->
1539
1540- [日本語の見出し](#日本語の見出し)
1541- [Émojis 🎉](#émojis-)
1542
1543<!-- tocstop -->
1544
1545## 日本語の見出し
1546
1547Japanese content.
1548
1549## Émojis 🎉
1550
1551Content with emojis.
1552"#;
1553        let ctx = create_ctx(content);
1554        let result = rule.check(&ctx).unwrap();
1555        // Should handle unicode correctly
1556        assert!(result.is_empty(), "Should handle unicode headings");
1557    }
1558
1559    #[test]
1560    fn test_special_characters_in_headings() {
1561        let rule = create_enabled_rule();
1562        let content = r#"# Title
1563
1564<!-- toc -->
1565
1566- [What's New?](#whats-new)
1567- [C++ Guide](#c-guide)
1568
1569<!-- tocstop -->
1570
1571## What's New?
1572
1573News content.
1574
1575## C++ Guide
1576
1577C++ content.
1578"#;
1579        let ctx = create_ctx(content);
1580        let result = rule.check(&ctx).unwrap();
1581        assert!(result.is_empty(), "Should handle special characters");
1582    }
1583
1584    #[test]
1585    fn test_code_spans_in_headings() {
1586        let rule = create_enabled_rule();
1587        let content = r#"# Title
1588
1589<!-- toc -->
1590
1591- [`check [PATHS...]`](#check-paths)
1592
1593<!-- tocstop -->
1594
1595## `check [PATHS...]`
1596
1597Command documentation.
1598"#;
1599        let ctx = create_ctx(content);
1600        let result = rule.check(&ctx).unwrap();
1601        assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1602    }
1603
1604    // ========== Config Tests ==========
1605
1606    #[test]
1607    fn test_from_config_defaults() {
1608        let config = crate::config::Config::default();
1609        let rule = MD073TocValidation::from_config(&config);
1610        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1611
1612        assert_eq!(rule.min_level, 2);
1613        assert_eq!(rule.max_level, 4);
1614        assert!(rule.enforce_order);
1615        assert_eq!(rule.indent, 2);
1616    }
1617
1618    #[test]
1619    fn test_indent_from_md007_config() {
1620        use crate::config::{Config, RuleConfig};
1621        use std::collections::BTreeMap;
1622
1623        let mut config = Config::default();
1624
1625        // Set MD007 indent to 4
1626        let mut md007_values = BTreeMap::new();
1627        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1628        config.rules.insert(
1629            "MD007".to_string(),
1630            RuleConfig {
1631                severity: None,
1632                values: md007_values,
1633            },
1634        );
1635
1636        let rule = MD073TocValidation::from_config(&config);
1637        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1638
1639        assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1640    }
1641
1642    #[test]
1643    fn test_indent_md073_overrides_md007() {
1644        use crate::config::{Config, RuleConfig};
1645        use std::collections::BTreeMap;
1646
1647        let mut config = Config::default();
1648
1649        // Set MD007 indent to 4
1650        let mut md007_values = BTreeMap::new();
1651        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1652        config.rules.insert(
1653            "MD007".to_string(),
1654            RuleConfig {
1655                severity: None,
1656                values: md007_values,
1657            },
1658        );
1659
1660        // Set MD073 indent to 3 (should override MD007)
1661        let mut md073_values = BTreeMap::new();
1662        md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1663        md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1664        config.rules.insert(
1665            "MD073".to_string(),
1666            RuleConfig {
1667                severity: None,
1668                values: md073_values,
1669            },
1670        );
1671
1672        let rule = MD073TocValidation::from_config(&config);
1673        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1674
1675        assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1676    }
1677
1678    #[test]
1679    fn test_generate_toc_with_4_space_indent() {
1680        let mut rule = create_enabled_rule();
1681        rule.indent = 4;
1682
1683        let content = r#"<!-- toc -->
1684
1685<!-- tocstop -->
1686
1687## Level 2
1688
1689### Level 3
1690
1691#### Level 4
1692
1693## Another Level 2
1694"#;
1695        let ctx = create_ctx(content);
1696        let region = rule.detect_toc_region(&ctx).unwrap();
1697        let expected = rule.build_expected_toc(&ctx, &region);
1698        let toc = rule.generate_toc(&expected);
1699
1700        // With 4-space indent:
1701        // Level 2 = 0 spaces (base level)
1702        // Level 3 = 4 spaces
1703        // Level 4 = 8 spaces
1704        assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1705        assert!(
1706            toc.contains("    - [Level 3](#level-3)"),
1707            "Level 3 should have 4-space indent"
1708        );
1709        assert!(
1710            toc.contains("        - [Level 4](#level-4)"),
1711            "Level 4 should have 8-space indent"
1712        );
1713        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1714    }
1715
1716    #[test]
1717    fn test_validate_toc_with_4_space_indent() {
1718        let mut rule = create_enabled_rule();
1719        rule.indent = 4;
1720
1721        // TOC with correct 4-space indentation
1722        let content = r#"<!-- toc -->
1723- [Hello](#hello)
1724    - [Another](#another)
1725- [Heading](#heading)
1726<!-- tocstop -->
1727
1728## Hello
1729
1730### Another
1731
1732## Heading
1733"#;
1734        let ctx = create_ctx(content);
1735        let result = rule.check(&ctx).unwrap();
1736        assert!(
1737            result.is_empty(),
1738            "Should accept 4-space indent when configured: {result:?}"
1739        );
1740    }
1741
1742    #[test]
1743    fn test_validate_toc_wrong_indent_with_4_space_config() {
1744        let mut rule = create_enabled_rule();
1745        rule.indent = 4;
1746
1747        // TOC with 2-space indentation (wrong when 4-space is configured)
1748        let content = r#"<!-- toc -->
1749- [Hello](#hello)
1750  - [Another](#another)
1751- [Heading](#heading)
1752<!-- tocstop -->
1753
1754## Hello
1755
1756### Another
1757
1758## Heading
1759"#;
1760        let ctx = create_ctx(content);
1761        let result = rule.check(&ctx).unwrap();
1762        assert_eq!(result.len(), 1, "Should detect wrong indent");
1763        assert!(
1764            result[0].message.contains("Indentation mismatch"),
1765            "Should report indentation mismatch: {}",
1766            result[0].message
1767        );
1768        assert!(
1769            result[0].message.contains("expected 4 spaces"),
1770            "Should mention expected 4 spaces: {}",
1771            result[0].message
1772        );
1773    }
1774
1775    // ========== Markdown Stripping Tests ==========
1776
1777    #[test]
1778    fn test_strip_markdown_formatting_link() {
1779        let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1780        assert_eq!(result, "Tool: terminal");
1781    }
1782
1783    #[test]
1784    fn test_strip_markdown_formatting_bold() {
1785        let result = strip_markdown_formatting("This is **bold** text");
1786        assert_eq!(result, "This is bold text");
1787
1788        let result = strip_markdown_formatting("This is __bold__ text");
1789        assert_eq!(result, "This is bold text");
1790    }
1791
1792    #[test]
1793    fn test_strip_markdown_formatting_italic() {
1794        let result = strip_markdown_formatting("This is *italic* text");
1795        assert_eq!(result, "This is italic text");
1796
1797        let result = strip_markdown_formatting("This is _italic_ text");
1798        assert_eq!(result, "This is italic text");
1799    }
1800
1801    #[test]
1802    fn test_strip_markdown_formatting_code_span() {
1803        let result = strip_markdown_formatting("Use the `format` function");
1804        assert_eq!(result, "Use the format function");
1805    }
1806
1807    #[test]
1808    fn test_strip_markdown_formatting_image() {
1809        let result = strip_markdown_formatting("See ![logo](image.png) for details");
1810        assert_eq!(result, "See logo for details");
1811    }
1812
1813    #[test]
1814    fn test_strip_markdown_formatting_reference_link() {
1815        let result = strip_markdown_formatting("See [documentation][docs] for details");
1816        assert_eq!(result, "See documentation for details");
1817    }
1818
1819    #[test]
1820    fn test_strip_markdown_formatting_combined() {
1821        // Link is stripped first, leaving bold, then bold is stripped
1822        let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1823        assert_eq!(result, "Tool: terminal");
1824    }
1825
1826    #[test]
1827    fn test_toc_with_link_in_heading_matches_stripped_text() {
1828        let rule = create_enabled_rule();
1829
1830        // TOC entry text matches the stripped heading text
1831        let content = r#"# Title
1832
1833<!-- toc -->
1834
1835- [Tool: terminal](#tool-terminal)
1836
1837<!-- tocstop -->
1838
1839## Tool: [terminal](https://example.com)
1840
1841Content here.
1842"#;
1843        let ctx = create_ctx(content);
1844        let result = rule.check(&ctx).unwrap();
1845        assert!(
1846            result.is_empty(),
1847            "Stripped heading text should match TOC entry: {result:?}"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_toc_with_simplified_text_still_mismatches() {
1853        let rule = create_enabled_rule();
1854
1855        // TOC entry "terminal" does NOT match stripped heading "Tool: terminal"
1856        let content = r#"# Title
1857
1858<!-- toc -->
1859
1860- [terminal](#tool-terminal)
1861
1862<!-- tocstop -->
1863
1864## Tool: [terminal](https://example.com)
1865
1866Content here.
1867"#;
1868        let ctx = create_ctx(content);
1869        let result = rule.check(&ctx).unwrap();
1870        assert_eq!(result.len(), 1, "Should report text mismatch");
1871        assert!(result[0].message.contains("Text mismatch"));
1872    }
1873
1874    #[test]
1875    fn test_fix_generates_stripped_toc_entries() {
1876        let rule = MD073TocValidation::new();
1877        let content = r#"# Title
1878
1879<!-- toc -->
1880
1881<!-- tocstop -->
1882
1883## Tool: [busybox](https://www.busybox.net/)
1884
1885Content.
1886
1887## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1888
1889More content.
1890"#;
1891        let ctx = create_ctx(content);
1892        let fixed = rule.fix(&ctx).unwrap();
1893
1894        // Generated TOC should have stripped text (links removed)
1895        assert!(
1896            fixed.contains("- [Tool: busybox](#tool-busybox)"),
1897            "TOC entry should have stripped link text"
1898        );
1899        assert!(
1900            fixed.contains("- [Tool: mount](#tool-mount)"),
1901            "TOC entry should have stripped link text"
1902        );
1903        // TOC entries should NOT contain the URL (the actual headings in the document still will)
1904        // Check only within the TOC region (between toc markers)
1905        let toc_start = fixed.find("<!-- toc -->").unwrap();
1906        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1907        let toc_content = &fixed[toc_start..toc_end];
1908        assert!(
1909            !toc_content.contains("busybox.net"),
1910            "TOC should not contain URLs: {toc_content}"
1911        );
1912        assert!(
1913            !toc_content.contains("wikipedia.org"),
1914            "TOC should not contain URLs: {toc_content}"
1915        );
1916    }
1917
1918    #[test]
1919    fn test_fix_with_bold_in_heading() {
1920        let rule = MD073TocValidation::new();
1921        let content = r#"# Title
1922
1923<!-- toc -->
1924
1925<!-- tocstop -->
1926
1927## **Important** Section
1928
1929Content.
1930"#;
1931        let ctx = create_ctx(content);
1932        let fixed = rule.fix(&ctx).unwrap();
1933
1934        // Generated TOC preserves bold markers in display text; anchor strips them.
1935        assert!(fixed.contains("- [**Important** Section](#important-section)"));
1936    }
1937
1938    #[test]
1939    fn test_fix_with_code_in_heading() {
1940        let rule = MD073TocValidation::new();
1941        let content = r#"# Title
1942
1943<!-- toc -->
1944
1945<!-- tocstop -->
1946
1947## Using `async` Functions
1948
1949Content.
1950"#;
1951        let ctx = create_ctx(content);
1952        let fixed = rule.fix(&ctx).unwrap();
1953
1954        // Generated TOC preserves code ticks in display text; anchor strips them.
1955        assert!(fixed.contains("- [Using `async` Functions](#using-async-functions)"));
1956    }
1957
1958    // ========== Custom Anchor Tests ==========
1959
1960    #[test]
1961    fn test_custom_anchor_id_respected() {
1962        let rule = create_enabled_rule();
1963        let content = r#"# Title
1964
1965<!-- toc -->
1966
1967- [My Section](#my-custom-anchor)
1968
1969<!-- tocstop -->
1970
1971## My Section {#my-custom-anchor}
1972
1973Content here.
1974"#;
1975        let ctx = create_ctx(content);
1976        let result = rule.check(&ctx).unwrap();
1977        assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1978    }
1979
1980    #[test]
1981    fn test_custom_anchor_id_in_generated_toc() {
1982        let rule = create_enabled_rule();
1983        let content = r#"# Title
1984
1985<!-- toc -->
1986
1987<!-- tocstop -->
1988
1989## First Section {#custom-first}
1990
1991Content.
1992
1993## Second Section {#another-custom}
1994
1995More content.
1996"#;
1997        let ctx = create_ctx(content);
1998        let fixed = rule.fix(&ctx).unwrap();
1999        assert!(fixed.contains("- [First Section](#custom-first)"));
2000        assert!(fixed.contains("- [Second Section](#another-custom)"));
2001    }
2002
2003    #[test]
2004    fn test_mixed_custom_and_generated_anchors() {
2005        let rule = create_enabled_rule();
2006        let content = r#"# Title
2007
2008<!-- toc -->
2009
2010- [Custom Section](#my-id)
2011- [Normal Section](#normal-section)
2012
2013<!-- tocstop -->
2014
2015## Custom Section {#my-id}
2016
2017Content.
2018
2019## Normal Section
2020
2021More content.
2022"#;
2023        let ctx = create_ctx(content);
2024        let result = rule.check(&ctx).unwrap();
2025        assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
2026    }
2027
2028    // ========== Anchor Generation Tests ==========
2029
2030    #[test]
2031    fn test_github_anchor_style() {
2032        let rule = create_enabled_rule();
2033
2034        let content = r#"<!-- toc -->
2035
2036<!-- tocstop -->
2037
2038## Test_With_Underscores
2039
2040Content.
2041"#;
2042        let ctx = create_ctx(content);
2043        let region = rule.detect_toc_region(&ctx).unwrap();
2044        let expected = rule.build_expected_toc(&ctx, &region);
2045
2046        // GitHub-style anchors preserve underscores
2047        assert_eq!(expected[0].anchor, "test_with_underscores");
2048    }
2049
2050    // ========== Stress Tests ==========
2051
2052    #[test]
2053    fn test_stress_many_headings() {
2054        let rule = create_enabled_rule();
2055
2056        // Generate a document with 150 headings
2057        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2058
2059        for i in 1..=150 {
2060            content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
2061        }
2062
2063        let ctx = create_ctx(&content);
2064
2065        // Should not panic or timeout
2066        let result = rule.check(&ctx).unwrap();
2067
2068        // Should report missing entries for all 150 headings
2069        assert_eq!(result.len(), 1, "Should report single warning for TOC");
2070        assert!(result[0].message.contains("Missing entry"));
2071
2072        // Fix should generate TOC with 150 entries
2073        let fixed = rule.fix(&ctx).unwrap();
2074        assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
2075        assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
2076        assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
2077    }
2078
2079    #[test]
2080    fn test_stress_deeply_nested() {
2081        let rule = create_enabled_rule();
2082        let content = r#"# Title
2083
2084<!-- toc -->
2085
2086<!-- tocstop -->
2087
2088## Level 2 A
2089
2090### Level 3 A
2091
2092#### Level 4 A
2093
2094## Level 2 B
2095
2096### Level 3 B
2097
2098#### Level 4 B
2099
2100## Level 2 C
2101
2102### Level 3 C
2103
2104#### Level 4 C
2105
2106## Level 2 D
2107
2108### Level 3 D
2109
2110#### Level 4 D
2111"#;
2112        let ctx = create_ctx(content);
2113        let fixed = rule.fix(&ctx).unwrap();
2114
2115        // Check nested indentation is correct
2116        assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
2117        assert!(fixed.contains("  - [Level 3 A](#level-3-a)"));
2118        assert!(fixed.contains("    - [Level 4 A](#level-4-a)"));
2119        assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
2120        assert!(fixed.contains("  - [Level 3 D](#level-3-d)"));
2121        assert!(fixed.contains("    - [Level 4 D](#level-4-d)"));
2122    }
2123
2124    // ==================== Duplicate TOC anchors ====================
2125
2126    #[test]
2127    fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
2128        let rule = create_enabled_rule();
2129        // Document has headings "Example", "Another", "Example" which produce anchors:
2130        // "example", "another", "example-1"
2131        // TOC incorrectly uses #example twice instead of #example and #example-1
2132        let content = r#"# Document
2133
2134<!-- toc -->
2135
2136- [Example](#example)
2137- [Another](#another)
2138- [Example](#example)
2139
2140<!-- tocstop -->
2141
2142## Example
2143First.
2144
2145## Another
2146Middle.
2147
2148## Example
2149Second.
2150"#;
2151        let ctx = create_ctx(content);
2152        let result = rule.check(&ctx).unwrap();
2153
2154        // The TOC has #example twice but expected has #example and #example-1.
2155        // Should report that #example-1 is missing from the TOC.
2156        assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
2157        assert!(
2158            result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
2159            "Should report missing or stale entries for duplicate anchors. Got: {}",
2160            result[0].message
2161        );
2162    }
2163
2164    // ==================== Multi-backtick code spans ====================
2165
2166    #[test]
2167    fn test_strip_double_backtick_code_span() {
2168        // Double-backtick code spans should be stripped
2169        let result = strip_markdown_formatting("Using ``code with ` backtick``");
2170        assert_eq!(
2171            result, "Using code with ` backtick",
2172            "Should strip double-backtick code spans"
2173        );
2174    }
2175
2176    #[test]
2177    fn test_strip_triple_backtick_code_span() {
2178        // Triple-backtick code spans should be stripped
2179        let result = strip_markdown_formatting("Using ```code with `` backticks```");
2180        assert_eq!(
2181            result, "Using code with `` backticks",
2182            "Should strip triple-backtick code spans"
2183        );
2184    }
2185
2186    #[test]
2187    fn test_toc_with_double_backtick_heading() {
2188        let rule = create_enabled_rule();
2189        // Use fix() to generate the correct TOC (including anchor), then check()
2190        // should produce no warnings on the fixed output.
2191        let content = r#"# Title
2192
2193<!-- toc -->
2194
2195<!-- tocstop -->
2196
2197## Using ``code with ` backtick``
2198
2199Content here.
2200"#;
2201        let ctx = create_ctx(content);
2202        // The heading uses double-backtick code span: ``code with ` backtick``
2203        // TOC display text preserves the code span; anchor is derived from raw text.
2204        let fixed = rule.fix(&ctx).unwrap();
2205
2206        // Verify that the generated TOC entry preserves the double-backtick code span
2207        // in the display text.
2208        let toc_start = fixed.find("<!-- toc -->").unwrap();
2209        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2210        let toc_content = &fixed[toc_start..toc_end];
2211        assert!(
2212            toc_content.contains("``code with ` backtick``"),
2213            "Fix should preserve double-backtick code span in TOC display text. Got: {toc_content}"
2214        );
2215
2216        // After fix, check() must produce no warnings (idempotency check)
2217        let ctx2 = create_ctx(&fixed);
2218        let result = rule.check(&ctx2).unwrap();
2219        assert!(
2220            result.is_empty(),
2221            "check() should not warn on fixed output. Warnings: {result:?}"
2222        );
2223    }
2224
2225    #[test]
2226    fn test_stress_many_duplicates() {
2227        let rule = create_enabled_rule();
2228
2229        // Generate 50 headings with the same text
2230        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2231        for _ in 0..50 {
2232            content.push_str("## FAQ\n\nContent.\n\n");
2233        }
2234
2235        let ctx = create_ctx(&content);
2236        let region = rule.detect_toc_region(&ctx).unwrap();
2237        let expected = rule.build_expected_toc(&ctx, &region);
2238
2239        // Should generate unique anchors for all 50
2240        assert_eq!(expected.len(), 50);
2241        assert_eq!(expected[0].anchor, "faq");
2242        assert_eq!(expected[1].anchor, "faq-1");
2243        assert_eq!(expected[49].anchor, "faq-49");
2244    }
2245
2246    /// Core invariant: for every warning with a Fix, fix() must produce
2247    /// output consistent with applying that fix directly.
2248    #[test]
2249    fn test_roundtrip_check_and_fix_alignment() {
2250        let rule = create_enabled_rule();
2251
2252        let inputs = [
2253            // Stale entry
2254            "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2255            // Missing entry
2256            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2257            // Text mismatch
2258            "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2259            // Already correct (no warnings, no change)
2260            "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2261        ];
2262
2263        for input in &inputs {
2264            let ctx = create_ctx(input);
2265            let fixed = rule.fix(&ctx).unwrap();
2266
2267            // Idempotency: fix(fix(x)) == fix(x)
2268            let ctx2 = create_ctx(&fixed);
2269            let fixed_twice = rule.fix(&ctx2).unwrap();
2270            assert_eq!(
2271                fixed, fixed_twice,
2272                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
2273            );
2274
2275            // After fix, check() should produce no warnings
2276            let warnings_after = rule.check(&ctx2).unwrap();
2277            assert!(
2278                warnings_after.is_empty(),
2279                "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2280            );
2281        }
2282    }
2283
2284    /// If a TOC has no mismatches, check() emits no warnings and fix()
2285    /// returns content unchanged.
2286    #[test]
2287    fn test_no_mismatch_preserves_content() {
2288        let rule = create_enabled_rule();
2289
2290        let content = "# Title\n\n<!-- toc -->\n- [First Section](#first-section)\n- [Second Section](#second-section)\n<!-- tocstop -->\n\n## First Section\n\ntext\n\n## Second Section\n\ntext\n";
2291        let ctx = create_ctx(content);
2292
2293        let warnings = rule.check(&ctx).unwrap();
2294        assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2295
2296        let fixed = rule.fix(&ctx).unwrap();
2297        assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2298    }
2299
2300    /// Inline-disabled TOC should not be modified by fix().
2301    #[test]
2302    fn test_inline_disable_preserves_toc() {
2303        let rule = create_enabled_rule();
2304
2305        // TOC with a stale entry, but MD073 disabled for the TOC region
2306        let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2307        let ctx = create_ctx(content);
2308
2309        let fixed = rule.fix(&ctx).unwrap();
2310        assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2311    }
2312
2313    // ========== Inline Formatting Preservation Tests (#634) ==========
2314
2315    /// Backticks in a heading must be preserved in the TOC display text.
2316    /// The anchor is generated from the raw heading text (which includes backticks)
2317    /// and must still use the stripped form.
2318    #[test]
2319    fn test_fix_code_ticks_preserved_in_toc_display_text() {
2320        let rule = MD073TocValidation::new();
2321        let content = r#"# Title
2322
2323<!-- toc -->
2324
2325<!-- tocstop -->
2326
2327### `my header`
2328
2329Content.
2330"#;
2331        let ctx = create_ctx(content);
2332        let fixed = rule.fix(&ctx).unwrap();
2333
2334        assert!(
2335            fixed.contains("- [`my header`](#my-header)"),
2336            "Code ticks must be preserved in TOC display text. Got: {fixed}"
2337        );
2338    }
2339
2340    /// A correct user-written TOC entry with code ticks must not be re-flagged.
2341    #[test]
2342    fn test_validate_toc_with_code_ticks_is_valid() {
2343        let rule = create_enabled_rule();
2344        let content = r#"# Title
2345
2346<!-- toc -->
2347
2348- [`my header`](#my-header)
2349
2350<!-- tocstop -->
2351
2352## `my header`
2353
2354Content.
2355"#;
2356        let ctx = create_ctx(content);
2357        let result = rule.check(&ctx).unwrap();
2358        assert!(
2359            result.is_empty(),
2360            "A TOC entry with preserved code ticks should be accepted as valid: {result:?}"
2361        );
2362    }
2363
2364    /// A heading with bold/italic preserves emphasis markers in the TOC display text;
2365    /// the anchor is generated from the raw (formatted) heading text and still uses
2366    /// the stripped form.
2367    #[test]
2368    fn test_fix_emphasis_preserved_in_toc_display_text() {
2369        let rule = MD073TocValidation::new();
2370        let content = r#"# Title
2371
2372<!-- toc -->
2373
2374<!-- tocstop -->
2375
2376## **bold** and *italic*
2377
2378Content.
2379"#;
2380        let ctx = create_ctx(content);
2381        let fixed = rule.fix(&ctx).unwrap();
2382
2383        assert!(
2384            fixed.contains("- [**bold** and *italic*](#bold-and-italic)"),
2385            "Emphasis markers must be preserved in TOC display text. Got: {fixed}"
2386        );
2387    }
2388
2389    /// A heading containing a link must have the link stripped from the TOC display
2390    /// text (nested links are invalid in Markdown).
2391    #[test]
2392    fn test_fix_link_in_heading_is_stripped() {
2393        let rule = MD073TocValidation::new();
2394        let content = r#"# Title
2395
2396<!-- toc -->
2397
2398<!-- tocstop -->
2399
2400## See [docs](http://example.com) for details
2401
2402Content.
2403"#;
2404        let ctx = create_ctx(content);
2405        let fixed = rule.fix(&ctx).unwrap();
2406
2407        assert!(
2408            fixed.contains("- [See docs for details](#see-docs-for-details)"),
2409            "Link must be stripped from TOC display text. Got: {fixed}"
2410        );
2411        // Ensure no URL leaks into TOC entry
2412        let toc_start = fixed.find("<!-- toc -->").unwrap();
2413        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2414        let toc_content = &fixed[toc_start..toc_end];
2415        assert!(
2416            !toc_content.contains("http://example.com"),
2417            "TOC should not contain link URL: {toc_content}"
2418        );
2419    }
2420
2421    /// An image in a heading must still be stripped from the TOC display text.
2422    #[test]
2423    fn test_fix_image_in_heading_is_stripped() {
2424        let rule = MD073TocValidation::new();
2425        let content = r#"# Title
2426
2427<!-- toc -->
2428
2429<!-- tocstop -->
2430
2431## Section ![icon](icon.png) Title
2432
2433Content.
2434"#;
2435        let ctx = create_ctx(content);
2436        let fixed = rule.fix(&ctx).unwrap();
2437
2438        assert!(
2439            fixed.contains("- [Section icon Title](#section-icon-title)"),
2440            "Image must be stripped from TOC display text. Got: {fixed}"
2441        );
2442    }
2443
2444    /// Running fix() twice on a document with inline-formatted headings must
2445    /// produce stable output (idempotency).
2446    #[test]
2447    fn test_fix_idempotent_with_inline_formatting() {
2448        let rule = MD073TocValidation::new();
2449        let content = r#"# Title
2450
2451<!-- toc -->
2452
2453<!-- tocstop -->
2454
2455## `code` heading
2456
2457### **bold** heading
2458
2459## See [link](http://x.com)
2460
2461"#;
2462        let ctx = create_ctx(content);
2463        let fixed1 = rule.fix(&ctx).unwrap();
2464        let ctx2 = create_ctx(&fixed1);
2465        let fixed2 = rule.fix(&ctx2).unwrap();
2466
2467        assert_eq!(fixed1, fixed2, "fix() must be idempotent for inline-formatted headings");
2468
2469        // After fix, check() must produce no warnings
2470        let warnings = rule.check(&ctx2).unwrap();
2471        assert!(
2472            warnings.is_empty(),
2473            "check() must not warn after fix() for inline-formatted headings: {warnings:?}"
2474        );
2475    }
2476
2477    /// Link-like syntax inside a code span must not be stripped, because it is
2478    /// literal content of the code span and not a real Markdown link.
2479    #[test]
2480    fn test_link_inside_code_span_preserved_in_toc() {
2481        let rule = MD073TocValidation::new();
2482        let content = r#"# Title
2483
2484<!-- toc -->
2485
2486<!-- tocstop -->
2487
2488## Use `[foo](bar)` syntax
2489
2490Content.
2491"#;
2492        let ctx = create_ctx(content);
2493        let fixed = rule.fix(&ctx).unwrap();
2494
2495        // The code span `[foo](bar)` must survive intact in the TOC display text.
2496        // The anchor is generated from the raw heading text by the GitHub algorithm,
2497        // which strips backtick, bracket, and paren characters. Verify only the
2498        // display-text preservation, not the exact anchor (which depends on the anchor
2499        // generation algorithm's treatment of non-alphanumeric chars in code spans).
2500        let toc_start = fixed.find("<!-- toc -->").unwrap();
2501        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2502        let toc_content = &fixed[toc_start..toc_end];
2503        assert!(
2504            toc_content.contains("Use `[foo](bar)` syntax"),
2505            "Link-like text inside code span must be preserved in TOC display text. Got: {toc_content}"
2506        );
2507        // Also ensure the real link stripping (outside code spans) still works
2508        assert!(
2509            !toc_content.contains("http://"),
2510            "Real links (outside code spans) should be stripped: {toc_content}"
2511        );
2512    }
2513
2514    // ========== HTML anchor targets ==========
2515
2516    /// The TOC region after `fix`, without the markers and surrounding blank lines.
2517    fn generated_toc(content: &str) -> String {
2518        let rule = create_enabled_rule();
2519        let ctx = create_ctx(content);
2520        let fixed = rule.fix(&ctx).unwrap();
2521        let start = fixed.find("<!-- toc -->").unwrap() + "<!-- toc -->".len();
2522        let end = fixed.find("<!-- tocstop -->").unwrap();
2523        fixed[start..end].trim().to_string()
2524    }
2525
2526    fn check_toc(content: &str) -> Vec<LintWarning> {
2527        let rule = create_enabled_rule();
2528        let ctx = create_ctx(content);
2529        rule.check(&ctx).unwrap()
2530    }
2531
2532    #[test]
2533    fn test_toc_may_target_the_generated_slug_of_a_heading_with_an_html_anchor() {
2534        // GitHub gives the heading both `#cheat-sheets` (generated) and `#cheatsheets`
2535        // (the element's own id), so a TOC written against either reaches it.
2536        for anchor in ["cheat-sheets", "cheatsheets"] {
2537            let content = format!(
2538                "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\nContent.\n"
2539            );
2540            let result = check_toc(&content);
2541            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2542        }
2543    }
2544
2545    #[test]
2546    fn test_every_anchor_element_on_a_heading_is_a_valid_target() {
2547        for anchor in ["old", "older", "foo"] {
2548            let content = format!(
2549                "# Title\n\n<!-- toc -->\n\n- [Foo](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"old\"></a><a id=\"older\"></a>Foo\n"
2550            );
2551            let result = check_toc(&content);
2552            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2553        }
2554    }
2555
2556    #[test]
2557    fn test_generated_toc_prefers_the_explicit_html_anchor() {
2558        let toc =
2559            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n");
2560        assert_eq!(toc, "- [Cheat Sheets](#cheatsheets)");
2561    }
2562
2563    #[test]
2564    fn test_regeneration_keeps_an_existing_anchor_that_reaches_its_heading() {
2565        // The unrelated missing entry forces a rewrite; the entry already reaching its
2566        // heading is not switched to the other valid spelling on the way.
2567        let content = "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#cheat-sheets)\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\n## Other\n";
2568        assert_eq!(
2569            generated_toc(content),
2570            "- [Cheat Sheets](#cheat-sheets)\n- [Other](#other)"
2571        );
2572    }
2573
2574    #[test]
2575    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
2576        let toc = generated_toc(
2577            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\" data-name=\"pixel\"></a>\n",
2578        );
2579        assert_eq!(toc, "- [Foo](#foo)");
2580
2581        let result = check_toc(
2582            "# Title\n\n<!-- toc -->\n\n- [Foo](#tracking)\n\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\"></a>\n",
2583        );
2584        assert_eq!(result.len(), 1, "{result:?}");
2585        assert!(
2586            result[0].message.contains("Stale entry: 'Foo'"),
2587            "{}",
2588            result[0].message
2589        );
2590        assert!(
2591            result[0].message.contains("Missing entry: 'Foo'"),
2592            "{}",
2593            result[0].message
2594        );
2595    }
2596
2597    #[test]
2598    fn test_an_empty_id_is_not_an_anchor() {
2599        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"\"></a>\n");
2600        assert_eq!(toc, "- [Foo](#foo)");
2601    }
2602
2603    #[test]
2604    fn test_an_explicit_anchor_still_consumes_the_generated_slug() {
2605        // GitHub numbers duplicate generated slugs whether or not the first heading also
2606        // carries its own id, so the second `Same` is `same-1`, never `same`.
2607        let toc =
2608            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Same<a id=\"stable\"></a>\n\n## Same\n");
2609        assert_eq!(toc, "- [Same](#stable)\n- [Same](#same-1)");
2610    }
2611
2612    #[test]
2613    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
2614        let heading = "Showing `<a id=\"literal\"></a>` syntax";
2615        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2616        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2617        assert_ne!(slug, "literal");
2618        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2619    }
2620
2621    #[test]
2622    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
2623        let heading = "Foo <!-- <a id=\"hidden\"></a> -->";
2624        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2625        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2626        assert_ne!(slug, "hidden");
2627        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2628    }
2629
2630    #[test]
2631    fn test_an_explicit_anchor_is_written_as_a_valid_link_destination() {
2632        // A bare link destination cannot hold `)` or a space, so the anchor is
2633        // percent-encoded on the way out, and the encoded entry reaches the heading
2634        // again on the next check, so the generated TOC is stable.
2635        let headings = "## Alpha<a id=\"foo)bar\"></a>\n\n## Beta<a id=\"has space\"></a>\n";
2636        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{headings}"));
2637        assert_eq!(toc, "- [Alpha](#foo%29bar)\n- [Beta](#has%20space)");
2638
2639        let result = check_toc(&format!(
2640            "# Title\n\n<!-- toc -->\n\n{toc}\n\n<!-- tocstop -->\n\n{headings}"
2641        ));
2642        assert!(result.is_empty(), "{result:?}");
2643    }
2644
2645    #[test]
2646    fn test_regeneration_does_not_encode_an_encoded_entry_twice() {
2647        // The entry reaching `foo)bar` is kept as written; the stale sibling forces
2648        // the rewrite.
2649        let content = "# Title\n\n<!-- toc -->\n\n- [Alpha](#foo%29bar)\n- [Gone](#gone)\n\n<!-- tocstop -->\n\n## Alpha<a id=\"foo)bar\"></a>\n";
2650        assert_eq!(generated_toc(content), "- [Alpha](#foo%29bar)");
2651    }
2652
2653    #[test]
2654    fn test_a_backslash_escaped_anchor_element_defines_no_target() {
2655        // `\<a ...>` renders as literal text, so `#example` reaches nothing.
2656        let heading = "Show \\<a id=\"example\"></a> syntax";
2657        let result = check_toc(&format!(
2658            "# Title\n\n<!-- toc -->\n\n- [{heading}](#example)\n\n<!-- tocstop -->\n\n## {heading}\n"
2659        ));
2660        assert_eq!(result.len(), 1, "{result:?}");
2661        assert!(result[0].message.contains("Stale entry"), "{}", result[0].message);
2662
2663        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2664        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2665        assert_ne!(slug, "example");
2666        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2667    }
2668
2669    #[test]
2670    fn test_a_backslash_escaped_marker_is_text() {
2671        // `\<!-- toc -->` renders as the literal text `<!-- toc -->`, so the pair
2672        // below delimits no TOC region and the prose between them is left alone.
2673        let content = "# Title\n\nWrite these lines:\n\n\\<!-- toc -->\n\nProse that must survive.\n\n\\<!-- tocstop -->\n\n## Alpha\n";
2674        let result = check_toc(content);
2675        assert!(result.is_empty(), "{result:?}");
2676        assert_eq!(create_enabled_rule().fix(&create_ctx(content)).unwrap(), content);
2677
2678        // An even run of backslashes leaves the `<` itself unescaped, so the comment
2679        // is rendered and the marker is real.
2680        let toc = generated_toc("# Title\n\n\\\\<!-- toc -->\n<!-- tocstop -->\n\n## Alpha\n");
2681        assert_eq!(toc, "- [Alpha](#alpha)");
2682    }
2683
2684    #[test]
2685    fn test_the_whitespace_beside_an_anchor_element_stays_in_the_heading_text() {
2686        // `Foo<a id="alias"></a> Bar` renders as "Foo Bar", so an entry written
2687        // that way is up to date and a regenerated entry reads the same.
2688        let content =
2689            "# Title\n\n<!-- toc -->\n- [Foo Bar](#foo-bar)\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n";
2690        let warnings = check_toc(content);
2691        assert!(warnings.is_empty(), "{warnings:?}");
2692
2693        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n");
2694        assert_eq!(toc, "- [Foo Bar](#alias)");
2695    }
2696
2697    #[test]
2698    fn test_an_anchor_inside_another_tags_attribute_value_is_heading_text() {
2699        // The `<a>` sits in the span's `title` value, so the browser makes no
2700        // element from it: the heading answers to `#foo` and keeps its bytes.
2701        let toc = generated_toc(
2702            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <span title='<a id=\"fake\"></a>'>Foo</span>\n",
2703        );
2704        assert_eq!(toc, "- [<span title='<a id=\"fake\"></a>'>Foo</span>](#foo)");
2705    }
2706
2707    #[test]
2708    fn test_a_heading_with_a_custom_id_keeps_its_html_anchor_as_a_target() {
2709        // `{#new}` replaces the generated slug, but the element's own `name` is still
2710        // an anchor in the rendered page, so a TOC entry may use either of the two.
2711        let heading = "<a name=\"old\"></a>My Section {#new}";
2712        let result = check_toc(&format!(
2713            "# Title\n\n<!-- toc -->\n\n- [My Section](#old)\n\n<!-- tocstop -->\n\n## {heading}\n"
2714        ));
2715        assert!(result.is_empty(), "{result:?}");
2716
2717        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2718        assert_eq!(toc, "- [My Section](#new)");
2719
2720        let result = check_toc(&format!(
2721            "# Title\n\n<!-- toc -->\n\n- [My Section](#my-section)\n\n<!-- tocstop -->\n\n## {heading}\n"
2722        ));
2723        assert_eq!(result.len(), 1, "the generated slug is replaced, got {result:?}");
2724    }
2725}