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's first line
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    /// Remove `depth` blockquote markers, each with at most one following space,
386    /// leaving any further indentation in place.
387    fn strip_quote_markers(line: &str, depth: usize) -> &str {
388        let mut rest = line;
389        for _ in 0..depth {
390            let Some(after) = rest.trim_start().strip_prefix('>') else {
391                break;
392            };
393            rest = after.strip_prefix(' ').unwrap_or(after);
394        }
395        rest
396    }
397
398    /// Extract TOC entries from the detected region
399    fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
400        let mut entries = Vec::new();
401
402        for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
403            let line_info = &ctx.lines[idx];
404            // A TOC inside a blockquote is matched on the text after the quote
405            // markers, keeping the indentation of nested entries.
406            let content = line_info.blockquote.as_ref().map_or_else(
407                || line_info.content(ctx.content),
408                |quote| Self::strip_quote_markers(line_info.content(ctx.content), quote.nesting_level),
409            );
410
411            if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
412                let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
413                let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
414                let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
415
416                entries.push(TocEntry {
417                    text,
418                    anchor,
419                    indent_spaces,
420                });
421            }
422        }
423
424        entries
425    }
426
427    /// Build expected TOC entries from document headings
428    fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
429        let mut entries = Vec::new();
430        let mut fragment_counts: HashMap<String, usize> = HashMap::new();
431
432        // Every heading takes a number from the renderer, in document order, whether
433        // or not it appears in the TOC, so the duplicate numbering runs over all of
434        // them and only the entries are filtered.
435        for parsed in ctx.headings() {
436            let line_num = parsed.line_num;
437            let line_info = parsed.line_info;
438            let heading = parsed.heading;
439
440            // Skip code blocks, front matter, HTML blocks
441            if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
442                continue;
443            }
444
445            // A custom ID ({#id}) replaces the generated slug. An HTML anchor
446            // element beside the heading adds a target either way: the
447            // renderer keeps the element's own id, and without a custom ID it
448            // still generates the slug from the text, which still counts
449            // towards the numbering of later duplicates.
450            let (anchor, aliases) = match &heading.custom_id {
451                Some(custom_id) => (
452                    Self::deduplicate_fragment(&mut fragment_counts, custom_id),
453                    extract_html_anchor_ids(&heading.raw_text),
454                ),
455                None => {
456                    let slug = AnchorStyle::GitHub.generate_fragment(&heading.slug_text);
457                    let generated = Self::deduplicate_fragment(&mut fragment_counts, &slug);
458                    let mut explicit = extract_html_anchor_ids(&heading.raw_text);
459                    match explicit.first().cloned() {
460                        Some(preferred) => {
461                            explicit.remove(0);
462                            explicit.push(generated);
463                            (preferred, explicit)
464                        }
465                        // A heading whose text slugs to nothing (an image, an
466                        // emoji) gets no anchor from GitHub, only a number on a
467                        // repeat, so without an element of its own no TOC entry
468                        // can reach it. It still counts towards the numbering.
469                        None if slug.is_empty() => continue,
470                        None => (generated, Vec::new()),
471                    }
472                }
473            };
474
475            // Headings before/within the TOC region are not listed, and the
476            // configured level range limits the rest.
477            if line_num <= toc_region.end_line || heading.level < self.min_level || heading.level > self.max_level {
478                continue;
479            }
480
481            entries.push(ExpectedTocEntry {
482                // A Setext heading is recorded on the last line of its text,
483                // and a reader looks for it where it starts.
484                heading_line: parsed.first_line_num(),
485                level: heading.level,
486                text: heading.text.clone(),
487                anchor,
488                aliases,
489            });
490        }
491
492        entries
493    }
494
495    /// The fragment a renderer gives a heading whose base fragment has already
496    /// been seen `n` times: `base`, then `base-1`, `base-2`, ...
497    fn deduplicate_fragment(fragment_counts: &mut HashMap<String, usize>, base: &str) -> String {
498        match fragment_counts.get_mut(base) {
499            Some(count) => {
500                let suffix = *count;
501                *count += 1;
502                format!("{base}-{suffix}")
503            }
504            None => {
505                fragment_counts.insert(base.to_string(), 1);
506                base.to_string()
507            }
508        }
509    }
510
511    /// Pair each actual TOC entry with the first still-unclaimed heading its
512    /// anchor reaches. `None` marks an entry that reaches no heading.
513    fn match_entries(actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<Option<usize>> {
514        let mut claimed = vec![false; expected.len()];
515        actual
516            .iter()
517            .map(|entry| {
518                let anchor = decode_fragment(&entry.anchor);
519                let found = expected
520                    .iter()
521                    .enumerate()
522                    .position(|(idx, exp)| !claimed[idx] && exp.is_reached_by(&anchor));
523                if let Some(idx) = found {
524                    claimed[idx] = true;
525                }
526                found
527            })
528            .collect()
529    }
530
531    /// Compare actual TOC entries against expected and find mismatches.
532    ///
533    /// `matching` pairs each actual entry with the heading it reaches, as
534    /// computed by [`Self::match_entries`].
535    fn validate_toc(
536        &self,
537        actual: &[TocEntry],
538        expected: &[ExpectedTocEntry],
539        matching: &[Option<usize>],
540    ) -> Vec<TocMismatch> {
541        let mut mismatches = Vec::new();
542        let pairs = || {
543            actual
544                .iter()
545                .zip(matching)
546                .enumerate()
547                .filter_map(|(actual_idx, (entry, matched))| matched.map(|exp_idx| (actual_idx, entry, exp_idx)))
548        };
549
550        // Stale entries reach no heading.
551        for (entry, matched) in actual.iter().zip(matching) {
552            if matched.is_none() {
553                mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
554            }
555        }
556
557        // Missing entries are headings no TOC entry reaches.
558        for (exp_idx, exp) in expected.iter().enumerate() {
559            if !matching.contains(&Some(exp_idx)) {
560                mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
561            }
562        }
563
564        // Check for text mismatches. Compare with the same normalization used in
565        // generate_toc: strip only links and images, preserve code spans and emphasis.
566        // This ensures a correct user-written TOC entry like `` [`my header`](#anchor) ``
567        // is not flagged against a heading `` `my header` ``.
568        let mut text_mismatched = vec![false; actual.len()];
569        for (actual_idx, entry, exp_idx) in pairs() {
570            let exp = &expected[exp_idx];
571            let actual_normalized = strip_links_and_images(entry.text.trim());
572            let expected_normalized = strip_links_and_images(exp.text.trim());
573            if actual_normalized != expected_normalized {
574                text_mismatched[actual_idx] = true;
575                mismatches.push(TocMismatch::TextMismatch {
576                    entry: entry.clone(),
577                    expected: exp.clone(),
578                });
579            }
580        }
581
582        // Check for indentation mismatches
583        // Expected indentation is indent spaces per level difference from base level
584        if !expected.is_empty() {
585            let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
586
587            for (actual_idx, entry, exp_idx) in pairs() {
588                let level_diff = expected[exp_idx].level.saturating_sub(base_level) as usize;
589                let expected_indent = level_diff * self.indent;
590
591                // An entry already reported for its text is not reported again
592                if entry.indent_spaces != expected_indent && !text_mismatched[actual_idx] {
593                    mismatches.push(TocMismatch::IndentationMismatch {
594                        entry: entry.clone(),
595                        actual_indent: entry.indent_spaces,
596                        expected_indent,
597                    });
598                }
599            }
600        }
601
602        // Check order if enforce_order is enabled: walk the headings alongside the
603        // matched entries, and an entry whose heading lies behind the walk is out
604        // of order.
605        if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
606            let mut expected_idx = 0;
607            for (actual_idx, entry, exp_idx) in pairs() {
608                if exp_idx >= expected_idx {
609                    expected_idx = exp_idx + 1;
610                } else if !text_mismatched[actual_idx] {
611                    mismatches.push(TocMismatch::OrderMismatch {
612                        entry: entry.clone(),
613                        expected_position: exp_idx + 1,
614                    });
615                }
616            }
617        }
618
619        mismatches
620    }
621
622    /// Expected entries as they should be regenerated: a TOC entry that already
623    /// reaches its heading keeps the anchor it was written with. The anchor is
624    /// stored decoded, as every expected anchor is, and encoded again on output.
625    fn keep_reached_anchors(
626        actual: &[TocEntry],
627        expected: &[ExpectedTocEntry],
628        matching: &[Option<usize>],
629    ) -> Vec<ExpectedTocEntry> {
630        let mut regenerated = expected.to_vec();
631        for (entry, exp_idx) in actual
632            .iter()
633            .zip(matching)
634            .filter_map(|(entry, m)| m.map(|idx| (entry, idx)))
635        {
636            regenerated[exp_idx].anchor = decode_fragment(&entry.anchor).into_owned();
637        }
638        regenerated
639    }
640
641    /// Generate a new TOC from expected entries (always uses nested indentation)
642    fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
643        if expected.is_empty() {
644            return String::new();
645        }
646
647        let mut result = String::new();
648        let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
649        let indent_str = " ".repeat(self.indent);
650
651        for entry in expected {
652            let level_diff = entry.level.saturating_sub(base_level) as usize;
653            let indent = indent_str.repeat(level_diff);
654
655            // Build display text: strip only links and images (which would create invalid
656            // nested-link syntax inside `[...]`), but preserve code spans and emphasis so
657            // the TOC entry reflects the heading's visual appearance.
658            let display_text = strip_links_and_images(&entry.text);
659            let fragment = encode_fragment(&entry.anchor);
660            result.push_str(&format!("{indent}- [{display_text}](#{fragment})\n"));
661        }
662
663        result
664    }
665}
666
667impl Rule for MD073TocValidation {
668    fn name(&self) -> &'static str {
669        "MD073"
670    }
671
672    fn description(&self) -> &'static str {
673        "Table of Contents should match document headings"
674    }
675
676    fn should_skip(&self, ctx: &LintContext) -> bool {
677        // Quick check: skip if no TOC markers. detect_toc_region() is
678        // case-insensitive, so use a case-insensitive containment check here
679        // to avoid skipping fix() on documents with uppercase markers like
680        // `<!-- TOC -->`.
681        let lower = ctx.content.to_ascii_lowercase();
682        !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
683    }
684
685    fn check(&self, ctx: &LintContext) -> LintResult {
686        let mut warnings = Vec::new();
687
688        // Detect TOC region
689        let Some(region) = self.detect_toc_region(ctx) else {
690            // No TOC found - nothing to validate
691            return Ok(warnings);
692        };
693
694        // Extract actual TOC entries
695        let actual_entries = self.extract_toc_entries(ctx, &region);
696
697        // Build expected TOC from headings
698        let expected_entries = self.build_expected_toc(ctx, &region);
699
700        // If no expected entries and no actual entries, nothing to validate
701        if expected_entries.is_empty() && actual_entries.is_empty() {
702            return Ok(warnings);
703        }
704
705        // Validate
706        let matching = Self::match_entries(&actual_entries, &expected_entries);
707        let mismatches = self.validate_toc(&actual_entries, &expected_entries, &matching);
708
709        if !mismatches.is_empty() {
710            // Generate a single warning at the TOC region with details
711            let mut details = Vec::new();
712
713            for mismatch in &mismatches {
714                match mismatch {
715                    TocMismatch::StaleEntry { entry } => {
716                        details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
717                    }
718                    TocMismatch::MissingEntry { expected } => {
719                        details.push(format!(
720                            "Missing entry: '{}' (line {})",
721                            expected.text, expected.heading_line
722                        ));
723                    }
724                    TocMismatch::TextMismatch { entry, expected } => {
725                        details.push(format!(
726                            "Text mismatch: TOC has '{}', heading is '{}'",
727                            entry.text, expected.text
728                        ));
729                    }
730                    TocMismatch::OrderMismatch {
731                        entry,
732                        expected_position,
733                    } => {
734                        details.push(format!(
735                            "Order mismatch: '{}' should be at position {}",
736                            entry.text, expected_position
737                        ));
738                    }
739                    TocMismatch::IndentationMismatch {
740                        entry,
741                        actual_indent,
742                        expected_indent,
743                        ..
744                    } => {
745                        details.push(format!(
746                            "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
747                            entry.text, actual_indent, expected_indent
748                        ));
749                    }
750                }
751            }
752
753            let message = format!(
754                "Table of Contents does not match document headings: {}",
755                details.join("; ")
756            );
757
758            // Generate fix: replace entire TOC content
759            let regenerated = Self::keep_reached_anchors(&actual_entries, &expected_entries, &matching);
760            let new_toc = self.generate_toc(&regenerated);
761            let fix_range = region.content_start..region.content_end;
762            // Generated entries carry no container prefix, so a TOC whose markers
763            // sit in a blockquote is reported but not rewritten.
764            let (stop_marker_line, _) = ctx.offset_to_line_col(region.content_end);
765            let in_blockquote = [region.start_line - 1, stop_marker_line]
766                .into_iter()
767                .any(|line| ctx.line_info(line).is_some_and(|info| info.blockquote.is_some()));
768
769            warnings.push(LintWarning {
770                rule_name: Some(self.name().to_string()),
771                message,
772                line: region.start_line,
773                column: 1,
774                end_line: region.end_line,
775                end_column: 1,
776                severity: Severity::Warning,
777                fix: (!in_blockquote).then(|| Fix::new(fix_range, new_toc)),
778            });
779        }
780
781        Ok(warnings)
782    }
783
784    fn fixes_from_diagnostics(&self) -> bool {
785        true
786    }
787
788    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
789        if self.should_skip(ctx) {
790            return Ok(ctx.content.to_string());
791        }
792        let warnings = self.check(ctx)?;
793        if warnings.is_empty() {
794            return Ok(ctx.content.to_string());
795        }
796        let warnings =
797            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
798        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
799    }
800
801    fn category(&self) -> RuleCategory {
802        RuleCategory::Other
803    }
804
805    fn as_any(&self) -> &dyn std::any::Any {
806        self
807    }
808
809    fn default_config_section(&self) -> Option<(String, toml::Value)> {
810        let value: toml::Value = toml::from_str(
811            r#"
812# Whether this rule is enabled (opt-in, disabled by default)
813enabled = false
814# Minimum heading level to include
815min-level = 2
816# Maximum heading level to include
817max-level = 4
818# Whether TOC order must match document order
819enforce-order = true
820# Indentation per nesting level (defaults to MD007's indent value)
821indent = 2
822"#,
823        )
824        .ok()?;
825        Some(("MD073".to_string(), value))
826    }
827
828    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
829    where
830        Self: Sized,
831    {
832        let mut rule = MD073TocValidation::default();
833        let mut indent_from_md073 = false;
834
835        if let Some(rule_config) = config.rules.get("MD073") {
836            // Parse enabled (opt-in rule, defaults to false)
837            if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
838                rule.enabled = enabled;
839            }
840
841            // Parse min-level
842            if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
843                rule.min_level = (min_level.clamp(1, 6)) as u8;
844            }
845
846            // Parse max-level
847            if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
848                rule.max_level = (max_level.clamp(1, 6)) as u8;
849            }
850
851            // Parse enforce-order
852            if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
853                rule.enforce_order = enforce_order;
854            }
855
856            // Parse indent (MD073-specific override)
857            if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
858                rule.indent = (indent.clamp(1, 8)) as usize;
859                indent_from_md073 = true;
860            }
861        }
862
863        // If indent not explicitly set in MD073, read from MD007 config
864        if !indent_from_md073
865            && let Some(md007_config) = config.rules.get("MD007")
866            && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
867        {
868            rule.indent = (indent.clamp(1, 8)) as usize;
869        }
870
871        Box::new(rule)
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::config::MarkdownFlavor;
879    use regex::Regex;
880    use std::sync::LazyLock;
881
882    // ---- Test-only helpers for stripping all inline formatting ----
883    // These are not used in production code; they exist only to test
884    // the individual stripping primitives in isolation.
885
886    /// Strip code spans from text, handling multi-backtick spans per CommonMark spec.
887    fn strip_code_spans(text: &str) -> String {
888        let chars: Vec<char> = text.chars().collect();
889        let len = chars.len();
890        let mut result = String::with_capacity(text.len());
891        let mut i = 0;
892
893        while i < len {
894            if chars[i] == '`' {
895                let open_start = i;
896                while i < len && chars[i] == '`' {
897                    i += 1;
898                }
899                let backtick_count = i - open_start;
900
901                let content_start = i;
902                let mut found_close = false;
903                while i < len {
904                    if chars[i] == '`' {
905                        let close_start = i;
906                        while i < len && chars[i] == '`' {
907                            i += 1;
908                        }
909                        if i - close_start == backtick_count {
910                            let content: String = chars[content_start..close_start].iter().collect();
911                            let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
912                                content[1..content.len() - 1].to_string()
913                            } else {
914                                content
915                            };
916                            result.push_str(&stripped);
917                            found_close = true;
918                            break;
919                        }
920                    } else {
921                        i += 1;
922                    }
923                }
924                if !found_close {
925                    for _ in 0..backtick_count {
926                        result.push('`');
927                    }
928                    let remaining: String = chars[content_start..].iter().collect();
929                    result.push_str(&remaining);
930                    break;
931                }
932            } else {
933                result.push(chars[i]);
934                i += 1;
935            }
936        }
937
938        result
939    }
940
941    static TEST_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
942    static TEST_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
943    static TEST_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
944    static TEST_ITALIC_UNDERSCORE: LazyLock<Regex> =
945        LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
946
947    /// Strip all inline markdown formatting from text, reducing it to plain text.
948    /// Builds on `strip_links_and_images` and additionally removes code spans,
949    /// bold, and italic markers. Used in tests only.
950    fn strip_markdown_formatting(text: &str) -> String {
951        let mut result = strip_links_and_images(text);
952        result = strip_code_spans(&result);
953        result = TEST_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
954        result = TEST_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
955        result = TEST_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
956        result = TEST_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
957        result
958    }
959
960    fn create_ctx(content: &str) -> LintContext<'_> {
961        LintContext::new(content, MarkdownFlavor::Standard, None)
962    }
963
964    /// Create rule with enabled=true for tests that call check() directly
965    fn create_enabled_rule() -> MD073TocValidation {
966        MD073TocValidation {
967            enabled: true,
968            ..MD073TocValidation::default()
969        }
970    }
971
972    // ========== Detection Tests ==========
973
974    #[test]
975    fn test_detect_markers_basic() {
976        let rule = MD073TocValidation::new();
977        let content = r#"# Title
978
979<!-- toc -->
980
981- [Heading 1](#heading-1)
982
983<!-- tocstop -->
984
985## Heading 1
986
987Content here.
988"#;
989        let ctx = create_ctx(content);
990        let region = rule.detect_by_markers(&ctx);
991        assert!(region.is_some());
992        let region = region.unwrap();
993        // Verify region boundaries are detected correctly
994        assert_eq!(region.start_line, 4);
995        assert_eq!(region.end_line, 6);
996    }
997
998    #[test]
999    fn test_detect_markers_variations() {
1000        let rule = MD073TocValidation::new();
1001
1002        // Test <!--toc--> (no spaces)
1003        let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
1004        let ctx1 = create_ctx(content1);
1005        assert!(rule.detect_by_markers(&ctx1).is_some());
1006
1007        // Test <!-- TOC --> (uppercase)
1008        let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
1009        let ctx2 = create_ctx(content2);
1010        assert!(rule.detect_by_markers(&ctx2).is_some());
1011
1012        // Test <!-- /toc --> (alternative stop marker)
1013        let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
1014        let ctx3 = create_ctx(content3);
1015        assert!(rule.detect_by_markers(&ctx3).is_some());
1016    }
1017
1018    #[test]
1019    fn test_no_toc_region() {
1020        let rule = MD073TocValidation::new();
1021        let content = r#"# Title
1022
1023## Heading 1
1024
1025Content here.
1026
1027## Heading 2
1028
1029More content.
1030"#;
1031        let ctx = create_ctx(content);
1032        let region = rule.detect_toc_region(&ctx);
1033        assert!(region.is_none());
1034    }
1035
1036    // ========== Validation Tests ==========
1037
1038    #[test]
1039    fn test_toc_matches_headings() {
1040        let rule = create_enabled_rule();
1041        let content = r#"# Title
1042
1043<!-- toc -->
1044
1045- [Heading 1](#heading-1)
1046- [Heading 2](#heading-2)
1047
1048<!-- tocstop -->
1049
1050## Heading 1
1051
1052Content.
1053
1054## Heading 2
1055
1056More content.
1057"#;
1058        let ctx = create_ctx(content);
1059        let result = rule.check(&ctx).unwrap();
1060        assert!(result.is_empty(), "Expected no warnings for matching TOC");
1061    }
1062
1063    #[test]
1064    fn test_missing_entry() {
1065        let rule = create_enabled_rule();
1066        let content = r#"# Title
1067
1068<!-- toc -->
1069
1070- [Heading 1](#heading-1)
1071
1072<!-- tocstop -->
1073
1074## Heading 1
1075
1076Content.
1077
1078## Heading 2
1079
1080New heading not in TOC.
1081"#;
1082        let ctx = create_ctx(content);
1083        let result = rule.check(&ctx).unwrap();
1084        assert_eq!(result.len(), 1);
1085        assert!(result[0].message.contains("Missing entry"));
1086        assert!(result[0].message.contains("Heading 2"));
1087    }
1088
1089    #[test]
1090    fn test_stale_entry() {
1091        let rule = create_enabled_rule();
1092        let content = r#"# Title
1093
1094<!-- toc -->
1095
1096- [Heading 1](#heading-1)
1097- [Deleted Heading](#deleted-heading)
1098
1099<!-- tocstop -->
1100
1101## Heading 1
1102
1103Content.
1104"#;
1105        let ctx = create_ctx(content);
1106        let result = rule.check(&ctx).unwrap();
1107        assert_eq!(result.len(), 1);
1108        assert!(result[0].message.contains("Stale entry"));
1109        assert!(result[0].message.contains("Deleted Heading"));
1110    }
1111
1112    #[test]
1113    fn test_text_mismatch() {
1114        let rule = create_enabled_rule();
1115        let content = r#"# Title
1116
1117<!-- toc -->
1118
1119- [Old Name](#heading-1)
1120
1121<!-- tocstop -->
1122
1123## Heading 1
1124
1125Content.
1126"#;
1127        let ctx = create_ctx(content);
1128        let result = rule.check(&ctx).unwrap();
1129        assert_eq!(result.len(), 1);
1130        assert!(result[0].message.contains("Text mismatch"));
1131    }
1132
1133    // ========== Level Filtering Tests ==========
1134
1135    #[test]
1136    fn test_min_level_excludes_h1() {
1137        let mut rule = MD073TocValidation::new();
1138        rule.min_level = 2;
1139
1140        let content = r#"<!-- toc -->
1141
1142<!-- tocstop -->
1143
1144# Should Be Excluded
1145
1146## Should Be Included
1147
1148Content.
1149"#;
1150        let ctx = create_ctx(content);
1151        let region = rule.detect_toc_region(&ctx).unwrap();
1152        let expected = rule.build_expected_toc(&ctx, &region);
1153
1154        assert_eq!(expected.len(), 1);
1155        assert_eq!(expected[0].text, "Should Be Included");
1156    }
1157
1158    #[test]
1159    fn test_max_level_excludes_h5_h6() {
1160        let mut rule = MD073TocValidation::new();
1161        rule.max_level = 4;
1162
1163        let content = r#"<!-- toc -->
1164
1165<!-- tocstop -->
1166
1167## Level 2
1168
1169### Level 3
1170
1171#### Level 4
1172
1173##### Level 5 Should Be Excluded
1174
1175###### Level 6 Should Be Excluded
1176"#;
1177        let ctx = create_ctx(content);
1178        let region = rule.detect_toc_region(&ctx).unwrap();
1179        let expected = rule.build_expected_toc(&ctx, &region);
1180
1181        assert_eq!(expected.len(), 3);
1182        assert!(expected.iter().all(|e| e.level <= 4));
1183    }
1184
1185    // ========== Fix Tests ==========
1186
1187    #[test]
1188    fn test_fix_adds_missing_entry() {
1189        let rule = MD073TocValidation::new();
1190        let content = r#"# Title
1191
1192<!-- toc -->
1193
1194- [Heading 1](#heading-1)
1195
1196<!-- tocstop -->
1197
1198## Heading 1
1199
1200Content.
1201
1202## Heading 2
1203
1204New heading.
1205"#;
1206        let ctx = create_ctx(content);
1207        let fixed = rule.fix(&ctx).unwrap();
1208        assert!(fixed.contains("- [Heading 2](#heading-2)"));
1209    }
1210
1211    #[test]
1212    fn test_fix_removes_stale_entry() {
1213        let rule = MD073TocValidation::new();
1214        let content = r#"# Title
1215
1216<!-- toc -->
1217
1218- [Heading 1](#heading-1)
1219- [Deleted](#deleted)
1220
1221<!-- tocstop -->
1222
1223## Heading 1
1224
1225Content.
1226"#;
1227        let ctx = create_ctx(content);
1228        let fixed = rule.fix(&ctx).unwrap();
1229        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1230        assert!(!fixed.contains("Deleted"));
1231    }
1232
1233    #[test]
1234    fn test_fix_idempotent() {
1235        let rule = MD073TocValidation::new();
1236        let content = r#"# Title
1237
1238<!-- toc -->
1239
1240- [Heading 1](#heading-1)
1241- [Heading 2](#heading-2)
1242
1243<!-- tocstop -->
1244
1245## Heading 1
1246
1247Content.
1248
1249## Heading 2
1250
1251More.
1252"#;
1253        let ctx = create_ctx(content);
1254        let fixed1 = rule.fix(&ctx).unwrap();
1255        let ctx2 = create_ctx(&fixed1);
1256        let fixed2 = rule.fix(&ctx2).unwrap();
1257
1258        // Second fix should produce same output
1259        assert_eq!(fixed1, fixed2);
1260    }
1261
1262    #[test]
1263    fn test_fix_preserves_markers() {
1264        let rule = MD073TocValidation::new();
1265        let content = r#"# Title
1266
1267<!-- toc -->
1268
1269Old TOC content.
1270
1271<!-- tocstop -->
1272
1273## New Heading
1274
1275Content.
1276"#;
1277        let ctx = create_ctx(content);
1278        let fixed = rule.fix(&ctx).unwrap();
1279
1280        // Markers should still be present
1281        assert!(fixed.contains("<!-- toc -->"));
1282        assert!(fixed.contains("<!-- tocstop -->"));
1283        // New content should be generated
1284        assert!(fixed.contains("- [New Heading](#new-heading)"));
1285    }
1286
1287    #[test]
1288    fn test_fix_requires_markers() {
1289        let rule = create_enabled_rule();
1290
1291        // Document without markers - no TOC detected, no changes
1292        let content_no_markers = r#"# Title
1293
1294## Heading 1
1295
1296Content.
1297"#;
1298        let ctx = create_ctx(content_no_markers);
1299        let fixed = rule.fix(&ctx).unwrap();
1300        assert_eq!(fixed, content_no_markers);
1301
1302        // Document with markers - TOC detected and fixed
1303        let content_markers = r#"# Title
1304
1305<!-- toc -->
1306
1307- [Old Entry](#old-entry)
1308
1309<!-- tocstop -->
1310
1311## Heading 1
1312
1313Content.
1314"#;
1315        let ctx = create_ctx(content_markers);
1316        let fixed = rule.fix(&ctx).unwrap();
1317        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1318        assert!(!fixed.contains("Old Entry"));
1319    }
1320
1321    // ========== Anchor Tests ==========
1322
1323    #[test]
1324    fn test_duplicate_heading_anchors() {
1325        let rule = MD073TocValidation::new();
1326        let content = r#"# Title
1327
1328<!-- toc -->
1329
1330<!-- tocstop -->
1331
1332## Duplicate
1333
1334Content.
1335
1336## Duplicate
1337
1338More content.
1339
1340## Duplicate
1341
1342Even more.
1343"#;
1344        let ctx = create_ctx(content);
1345        let region = rule.detect_toc_region(&ctx).unwrap();
1346        let expected = rule.build_expected_toc(&ctx, &region);
1347
1348        assert_eq!(expected.len(), 3);
1349        assert_eq!(expected[0].anchor, "duplicate");
1350        assert_eq!(expected[1].anchor, "duplicate-1");
1351        assert_eq!(expected[2].anchor, "duplicate-2");
1352    }
1353
1354    // ========== Edge Cases ==========
1355
1356    #[test]
1357    fn test_headings_in_code_blocks_ignored() {
1358        let rule = create_enabled_rule();
1359        let content = r#"# Title
1360
1361<!-- toc -->
1362
1363- [Real Heading](#real-heading)
1364
1365<!-- tocstop -->
1366
1367## Real Heading
1368
1369```markdown
1370## Fake Heading In Code
1371```
1372
1373Content.
1374"#;
1375        let ctx = create_ctx(content);
1376        let result = rule.check(&ctx).unwrap();
1377        assert!(result.is_empty(), "Should not report fake heading in code block");
1378    }
1379
1380    #[test]
1381    fn test_empty_toc_region() {
1382        let rule = create_enabled_rule();
1383        let content = r#"# Title
1384
1385<!-- toc -->
1386<!-- tocstop -->
1387
1388## Heading 1
1389
1390Content.
1391"#;
1392        let ctx = create_ctx(content);
1393        let result = rule.check(&ctx).unwrap();
1394        assert_eq!(result.len(), 1);
1395        assert!(result[0].message.contains("Missing entry"));
1396    }
1397
1398    #[test]
1399    fn test_nested_indentation() {
1400        let rule = create_enabled_rule();
1401
1402        let content = r#"<!-- toc -->
1403
1404<!-- tocstop -->
1405
1406## Level 2
1407
1408### Level 3
1409
1410#### Level 4
1411
1412## Another Level 2
1413"#;
1414        let ctx = create_ctx(content);
1415        let region = rule.detect_toc_region(&ctx).unwrap();
1416        let expected = rule.build_expected_toc(&ctx, &region);
1417        let toc = rule.generate_toc(&expected);
1418
1419        // Check indentation (always nested)
1420        assert!(toc.contains("- [Level 2](#level-2)"));
1421        assert!(toc.contains("  - [Level 3](#level-3)"));
1422        assert!(toc.contains("    - [Level 4](#level-4)"));
1423        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1424    }
1425
1426    // ========== Indentation Mismatch Tests ==========
1427
1428    #[test]
1429    fn test_indentation_mismatch_detected() {
1430        let rule = create_enabled_rule();
1431        // TOC entries are all at same indentation level, but headings have different levels
1432        let content = r#"<!-- toc -->
1433- [Hello](#hello)
1434- [Another](#another)
1435- [Heading](#heading)
1436<!-- tocstop -->
1437
1438## Hello
1439
1440### Another
1441
1442## Heading
1443"#;
1444        let ctx = create_ctx(content);
1445        let result = rule.check(&ctx).unwrap();
1446        // Should detect indentation mismatch - "Another" is level 3 but has no indent
1447        assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1448        assert!(
1449            result[0].message.contains("Indentation mismatch"),
1450            "Message should mention indentation: {}",
1451            result[0].message
1452        );
1453        assert!(
1454            result[0].message.contains("Another"),
1455            "Message should mention the entry: {}",
1456            result[0].message
1457        );
1458    }
1459
1460    #[test]
1461    fn test_indentation_mismatch_fixed() {
1462        let rule = create_enabled_rule();
1463        // TOC entries are all at same indentation level, but headings have different levels
1464        let content = r#"<!-- toc -->
1465- [Hello](#hello)
1466- [Another](#another)
1467- [Heading](#heading)
1468<!-- tocstop -->
1469
1470## Hello
1471
1472### Another
1473
1474## Heading
1475"#;
1476        let ctx = create_ctx(content);
1477        let fixed = rule.fix(&ctx).unwrap();
1478        // After fix, "Another" should be indented
1479        assert!(fixed.contains("- [Hello](#hello)"));
1480        assert!(fixed.contains("  - [Another](#another)")); // Indented with 2 spaces
1481        assert!(fixed.contains("- [Heading](#heading)"));
1482    }
1483
1484    #[test]
1485    fn test_no_indentation_mismatch_when_correct() {
1486        let rule = create_enabled_rule();
1487        // TOC has correct indentation
1488        let content = r#"<!-- toc -->
1489- [Hello](#hello)
1490  - [Another](#another)
1491- [Heading](#heading)
1492<!-- tocstop -->
1493
1494## Hello
1495
1496### Another
1497
1498## Heading
1499"#;
1500        let ctx = create_ctx(content);
1501        let result = rule.check(&ctx).unwrap();
1502        // Should not report any issues - indentation is correct
1503        assert!(result.is_empty(), "Should not report issues: {result:?}");
1504    }
1505
1506    // ========== Order Mismatch Tests ==========
1507
1508    #[test]
1509    fn test_order_mismatch_detected() {
1510        let rule = create_enabled_rule();
1511        let content = r#"# Title
1512
1513<!-- toc -->
1514
1515- [Section B](#section-b)
1516- [Section A](#section-a)
1517
1518<!-- tocstop -->
1519
1520## Section A
1521
1522Content A.
1523
1524## Section B
1525
1526Content B.
1527"#;
1528        let ctx = create_ctx(content);
1529        let result = rule.check(&ctx).unwrap();
1530        // Should detect order mismatch - Section B appears before Section A in TOC
1531        // but Section A comes first in document
1532        assert!(!result.is_empty(), "Should detect order mismatch");
1533    }
1534
1535    #[test]
1536    fn test_order_mismatch_ignored_when_disabled() {
1537        let mut rule = create_enabled_rule();
1538        rule.enforce_order = false;
1539        let content = r#"# Title
1540
1541<!-- toc -->
1542
1543- [Section B](#section-b)
1544- [Section A](#section-a)
1545
1546<!-- tocstop -->
1547
1548## Section A
1549
1550Content A.
1551
1552## Section B
1553
1554Content B.
1555"#;
1556        let ctx = create_ctx(content);
1557        let result = rule.check(&ctx).unwrap();
1558        // With enforce_order=false, order mismatches should be ignored
1559        assert!(result.is_empty(), "Should not report order mismatch when disabled");
1560    }
1561
1562    // ========== Unicode and Special Characters Tests ==========
1563
1564    #[test]
1565    fn test_unicode_headings() {
1566        let rule = create_enabled_rule();
1567        let content = r#"# Title
1568
1569<!-- toc -->
1570
1571- [日本語の見出し](#日本語の見出し)
1572- [Émojis 🎉](#émojis-)
1573
1574<!-- tocstop -->
1575
1576## 日本語の見出し
1577
1578Japanese content.
1579
1580## Émojis 🎉
1581
1582Content with emojis.
1583"#;
1584        let ctx = create_ctx(content);
1585        let result = rule.check(&ctx).unwrap();
1586        // Should handle unicode correctly
1587        assert!(result.is_empty(), "Should handle unicode headings");
1588    }
1589
1590    #[test]
1591    fn test_special_characters_in_headings() {
1592        let rule = create_enabled_rule();
1593        let content = r#"# Title
1594
1595<!-- toc -->
1596
1597- [What's New?](#whats-new)
1598- [C++ Guide](#c-guide)
1599
1600<!-- tocstop -->
1601
1602## What's New?
1603
1604News content.
1605
1606## C++ Guide
1607
1608C++ content.
1609"#;
1610        let ctx = create_ctx(content);
1611        let result = rule.check(&ctx).unwrap();
1612        assert!(result.is_empty(), "Should handle special characters");
1613    }
1614
1615    #[test]
1616    fn test_code_spans_in_headings() {
1617        let rule = create_enabled_rule();
1618        let content = r#"# Title
1619
1620<!-- toc -->
1621
1622- [`check [PATHS...]`](#check-paths)
1623
1624<!-- tocstop -->
1625
1626## `check [PATHS...]`
1627
1628Command documentation.
1629"#;
1630        let ctx = create_ctx(content);
1631        let result = rule.check(&ctx).unwrap();
1632        assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1633    }
1634
1635    // ========== Config Tests ==========
1636
1637    #[test]
1638    fn test_from_config_defaults() {
1639        let config = crate::config::Config::default();
1640        let rule = MD073TocValidation::from_config(&config);
1641        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1642
1643        assert_eq!(rule.min_level, 2);
1644        assert_eq!(rule.max_level, 4);
1645        assert!(rule.enforce_order);
1646        assert_eq!(rule.indent, 2);
1647    }
1648
1649    #[test]
1650    fn test_indent_from_md007_config() {
1651        use crate::config::{Config, RuleConfig};
1652        use std::collections::BTreeMap;
1653
1654        let mut config = Config::default();
1655
1656        // Set MD007 indent to 4
1657        let mut md007_values = BTreeMap::new();
1658        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1659        config.rules.insert(
1660            "MD007".to_string(),
1661            RuleConfig {
1662                severity: None,
1663                values: md007_values,
1664            },
1665        );
1666
1667        let rule = MD073TocValidation::from_config(&config);
1668        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1669
1670        assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1671    }
1672
1673    #[test]
1674    fn test_indent_md073_overrides_md007() {
1675        use crate::config::{Config, RuleConfig};
1676        use std::collections::BTreeMap;
1677
1678        let mut config = Config::default();
1679
1680        // Set MD007 indent to 4
1681        let mut md007_values = BTreeMap::new();
1682        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1683        config.rules.insert(
1684            "MD007".to_string(),
1685            RuleConfig {
1686                severity: None,
1687                values: md007_values,
1688            },
1689        );
1690
1691        // Set MD073 indent to 3 (should override MD007)
1692        let mut md073_values = BTreeMap::new();
1693        md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1694        md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1695        config.rules.insert(
1696            "MD073".to_string(),
1697            RuleConfig {
1698                severity: None,
1699                values: md073_values,
1700            },
1701        );
1702
1703        let rule = MD073TocValidation::from_config(&config);
1704        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1705
1706        assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1707    }
1708
1709    #[test]
1710    fn test_generate_toc_with_4_space_indent() {
1711        let mut rule = create_enabled_rule();
1712        rule.indent = 4;
1713
1714        let content = r#"<!-- toc -->
1715
1716<!-- tocstop -->
1717
1718## Level 2
1719
1720### Level 3
1721
1722#### Level 4
1723
1724## Another Level 2
1725"#;
1726        let ctx = create_ctx(content);
1727        let region = rule.detect_toc_region(&ctx).unwrap();
1728        let expected = rule.build_expected_toc(&ctx, &region);
1729        let toc = rule.generate_toc(&expected);
1730
1731        // With 4-space indent:
1732        // Level 2 = 0 spaces (base level)
1733        // Level 3 = 4 spaces
1734        // Level 4 = 8 spaces
1735        assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1736        assert!(
1737            toc.contains("    - [Level 3](#level-3)"),
1738            "Level 3 should have 4-space indent"
1739        );
1740        assert!(
1741            toc.contains("        - [Level 4](#level-4)"),
1742            "Level 4 should have 8-space indent"
1743        );
1744        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1745    }
1746
1747    #[test]
1748    fn test_validate_toc_with_4_space_indent() {
1749        let mut rule = create_enabled_rule();
1750        rule.indent = 4;
1751
1752        // TOC with correct 4-space indentation
1753        let content = r#"<!-- toc -->
1754- [Hello](#hello)
1755    - [Another](#another)
1756- [Heading](#heading)
1757<!-- tocstop -->
1758
1759## Hello
1760
1761### Another
1762
1763## Heading
1764"#;
1765        let ctx = create_ctx(content);
1766        let result = rule.check(&ctx).unwrap();
1767        assert!(
1768            result.is_empty(),
1769            "Should accept 4-space indent when configured: {result:?}"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_validate_toc_wrong_indent_with_4_space_config() {
1775        let mut rule = create_enabled_rule();
1776        rule.indent = 4;
1777
1778        // TOC with 2-space indentation (wrong when 4-space is configured)
1779        let content = r#"<!-- toc -->
1780- [Hello](#hello)
1781  - [Another](#another)
1782- [Heading](#heading)
1783<!-- tocstop -->
1784
1785## Hello
1786
1787### Another
1788
1789## Heading
1790"#;
1791        let ctx = create_ctx(content);
1792        let result = rule.check(&ctx).unwrap();
1793        assert_eq!(result.len(), 1, "Should detect wrong indent");
1794        assert!(
1795            result[0].message.contains("Indentation mismatch"),
1796            "Should report indentation mismatch: {}",
1797            result[0].message
1798        );
1799        assert!(
1800            result[0].message.contains("expected 4 spaces"),
1801            "Should mention expected 4 spaces: {}",
1802            result[0].message
1803        );
1804    }
1805
1806    // ========== Markdown Stripping Tests ==========
1807
1808    #[test]
1809    fn test_strip_markdown_formatting_link() {
1810        let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1811        assert_eq!(result, "Tool: terminal");
1812    }
1813
1814    #[test]
1815    fn test_strip_markdown_formatting_bold() {
1816        let result = strip_markdown_formatting("This is **bold** text");
1817        assert_eq!(result, "This is bold text");
1818
1819        let result = strip_markdown_formatting("This is __bold__ text");
1820        assert_eq!(result, "This is bold text");
1821    }
1822
1823    #[test]
1824    fn test_strip_markdown_formatting_italic() {
1825        let result = strip_markdown_formatting("This is *italic* text");
1826        assert_eq!(result, "This is italic text");
1827
1828        let result = strip_markdown_formatting("This is _italic_ text");
1829        assert_eq!(result, "This is italic text");
1830    }
1831
1832    #[test]
1833    fn test_strip_markdown_formatting_code_span() {
1834        let result = strip_markdown_formatting("Use the `format` function");
1835        assert_eq!(result, "Use the format function");
1836    }
1837
1838    #[test]
1839    fn test_strip_markdown_formatting_image() {
1840        let result = strip_markdown_formatting("See ![logo](image.png) for details");
1841        assert_eq!(result, "See logo for details");
1842    }
1843
1844    #[test]
1845    fn test_strip_markdown_formatting_reference_link() {
1846        let result = strip_markdown_formatting("See [documentation][docs] for details");
1847        assert_eq!(result, "See documentation for details");
1848    }
1849
1850    #[test]
1851    fn test_strip_markdown_formatting_combined() {
1852        // Link is stripped first, leaving bold, then bold is stripped
1853        let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1854        assert_eq!(result, "Tool: terminal");
1855    }
1856
1857    #[test]
1858    fn test_toc_with_link_in_heading_matches_stripped_text() {
1859        let rule = create_enabled_rule();
1860
1861        // TOC entry text matches the stripped heading text
1862        let content = r#"# Title
1863
1864<!-- toc -->
1865
1866- [Tool: terminal](#tool-terminal)
1867
1868<!-- tocstop -->
1869
1870## Tool: [terminal](https://example.com)
1871
1872Content here.
1873"#;
1874        let ctx = create_ctx(content);
1875        let result = rule.check(&ctx).unwrap();
1876        assert!(
1877            result.is_empty(),
1878            "Stripped heading text should match TOC entry: {result:?}"
1879        );
1880    }
1881
1882    #[test]
1883    fn test_toc_with_simplified_text_still_mismatches() {
1884        let rule = create_enabled_rule();
1885
1886        // TOC entry "terminal" does NOT match stripped heading "Tool: terminal"
1887        let content = r#"# Title
1888
1889<!-- toc -->
1890
1891- [terminal](#tool-terminal)
1892
1893<!-- tocstop -->
1894
1895## Tool: [terminal](https://example.com)
1896
1897Content here.
1898"#;
1899        let ctx = create_ctx(content);
1900        let result = rule.check(&ctx).unwrap();
1901        assert_eq!(result.len(), 1, "Should report text mismatch");
1902        assert!(result[0].message.contains("Text mismatch"));
1903    }
1904
1905    #[test]
1906    fn test_fix_generates_stripped_toc_entries() {
1907        let rule = MD073TocValidation::new();
1908        let content = r#"# Title
1909
1910<!-- toc -->
1911
1912<!-- tocstop -->
1913
1914## Tool: [busybox](https://www.busybox.net/)
1915
1916Content.
1917
1918## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1919
1920More content.
1921"#;
1922        let ctx = create_ctx(content);
1923        let fixed = rule.fix(&ctx).unwrap();
1924
1925        // Generated TOC should have stripped text (links removed)
1926        assert!(
1927            fixed.contains("- [Tool: busybox](#tool-busybox)"),
1928            "TOC entry should have stripped link text"
1929        );
1930        assert!(
1931            fixed.contains("- [Tool: mount](#tool-mount)"),
1932            "TOC entry should have stripped link text"
1933        );
1934        // TOC entries should NOT contain the URL (the actual headings in the document still will)
1935        // Check only within the TOC region (between toc markers)
1936        let toc_start = fixed.find("<!-- toc -->").unwrap();
1937        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1938        let toc_content = &fixed[toc_start..toc_end];
1939        assert!(
1940            !toc_content.contains("busybox.net"),
1941            "TOC should not contain URLs: {toc_content}"
1942        );
1943        assert!(
1944            !toc_content.contains("wikipedia.org"),
1945            "TOC should not contain URLs: {toc_content}"
1946        );
1947    }
1948
1949    #[test]
1950    fn test_fix_with_bold_in_heading() {
1951        let rule = MD073TocValidation::new();
1952        let content = r#"# Title
1953
1954<!-- toc -->
1955
1956<!-- tocstop -->
1957
1958## **Important** Section
1959
1960Content.
1961"#;
1962        let ctx = create_ctx(content);
1963        let fixed = rule.fix(&ctx).unwrap();
1964
1965        // Generated TOC preserves bold markers in display text; anchor strips them.
1966        assert!(fixed.contains("- [**Important** Section](#important-section)"));
1967    }
1968
1969    #[test]
1970    fn test_fix_with_code_in_heading() {
1971        let rule = MD073TocValidation::new();
1972        let content = r#"# Title
1973
1974<!-- toc -->
1975
1976<!-- tocstop -->
1977
1978## Using `async` Functions
1979
1980Content.
1981"#;
1982        let ctx = create_ctx(content);
1983        let fixed = rule.fix(&ctx).unwrap();
1984
1985        // Generated TOC preserves code ticks in display text; anchor strips them.
1986        assert!(fixed.contains("- [Using `async` Functions](#using-async-functions)"));
1987    }
1988
1989    // ========== Custom Anchor Tests ==========
1990
1991    #[test]
1992    fn test_custom_anchor_id_respected() {
1993        let rule = create_enabled_rule();
1994        let content = r#"# Title
1995
1996<!-- toc -->
1997
1998- [My Section](#my-custom-anchor)
1999
2000<!-- tocstop -->
2001
2002## My Section {#my-custom-anchor}
2003
2004Content here.
2005"#;
2006        let ctx = create_ctx(content);
2007        let result = rule.check(&ctx).unwrap();
2008        assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
2009    }
2010
2011    #[test]
2012    fn test_custom_anchor_id_in_generated_toc() {
2013        let rule = create_enabled_rule();
2014        let content = r#"# Title
2015
2016<!-- toc -->
2017
2018<!-- tocstop -->
2019
2020## First Section {#custom-first}
2021
2022Content.
2023
2024## Second Section {#another-custom}
2025
2026More content.
2027"#;
2028        let ctx = create_ctx(content);
2029        let fixed = rule.fix(&ctx).unwrap();
2030        assert!(fixed.contains("- [First Section](#custom-first)"));
2031        assert!(fixed.contains("- [Second Section](#another-custom)"));
2032    }
2033
2034    #[test]
2035    fn test_mixed_custom_and_generated_anchors() {
2036        let rule = create_enabled_rule();
2037        let content = r#"# Title
2038
2039<!-- toc -->
2040
2041- [Custom Section](#my-id)
2042- [Normal Section](#normal-section)
2043
2044<!-- tocstop -->
2045
2046## Custom Section {#my-id}
2047
2048Content.
2049
2050## Normal Section
2051
2052More content.
2053"#;
2054        let ctx = create_ctx(content);
2055        let result = rule.check(&ctx).unwrap();
2056        assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
2057    }
2058
2059    // ========== Anchor Generation Tests ==========
2060
2061    #[test]
2062    fn test_github_anchor_style() {
2063        let rule = create_enabled_rule();
2064
2065        let content = r#"<!-- toc -->
2066
2067<!-- tocstop -->
2068
2069## Test_With_Underscores
2070
2071Content.
2072"#;
2073        let ctx = create_ctx(content);
2074        let region = rule.detect_toc_region(&ctx).unwrap();
2075        let expected = rule.build_expected_toc(&ctx, &region);
2076
2077        // GitHub-style anchors preserve underscores
2078        assert_eq!(expected[0].anchor, "test_with_underscores");
2079    }
2080
2081    // ========== Stress Tests ==========
2082
2083    #[test]
2084    fn test_stress_many_headings() {
2085        let rule = create_enabled_rule();
2086
2087        // Generate a document with 150 headings
2088        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2089
2090        for i in 1..=150 {
2091            content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
2092        }
2093
2094        let ctx = create_ctx(&content);
2095
2096        // Should not panic or timeout
2097        let result = rule.check(&ctx).unwrap();
2098
2099        // Should report missing entries for all 150 headings
2100        assert_eq!(result.len(), 1, "Should report single warning for TOC");
2101        assert!(result[0].message.contains("Missing entry"));
2102
2103        // Fix should generate TOC with 150 entries
2104        let fixed = rule.fix(&ctx).unwrap();
2105        assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
2106        assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
2107        assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
2108    }
2109
2110    #[test]
2111    fn test_stress_deeply_nested() {
2112        let rule = create_enabled_rule();
2113        let content = r#"# Title
2114
2115<!-- toc -->
2116
2117<!-- tocstop -->
2118
2119## Level 2 A
2120
2121### Level 3 A
2122
2123#### Level 4 A
2124
2125## Level 2 B
2126
2127### Level 3 B
2128
2129#### Level 4 B
2130
2131## Level 2 C
2132
2133### Level 3 C
2134
2135#### Level 4 C
2136
2137## Level 2 D
2138
2139### Level 3 D
2140
2141#### Level 4 D
2142"#;
2143        let ctx = create_ctx(content);
2144        let fixed = rule.fix(&ctx).unwrap();
2145
2146        // Check nested indentation is correct
2147        assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
2148        assert!(fixed.contains("  - [Level 3 A](#level-3-a)"));
2149        assert!(fixed.contains("    - [Level 4 A](#level-4-a)"));
2150        assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
2151        assert!(fixed.contains("  - [Level 3 D](#level-3-d)"));
2152        assert!(fixed.contains("    - [Level 4 D](#level-4-d)"));
2153    }
2154
2155    // ==================== Duplicate TOC anchors ====================
2156
2157    #[test]
2158    fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
2159        let rule = create_enabled_rule();
2160        // Document has headings "Example", "Another", "Example" which produce anchors:
2161        // "example", "another", "example-1"
2162        // TOC incorrectly uses #example twice instead of #example and #example-1
2163        let content = r#"# Document
2164
2165<!-- toc -->
2166
2167- [Example](#example)
2168- [Another](#another)
2169- [Example](#example)
2170
2171<!-- tocstop -->
2172
2173## Example
2174First.
2175
2176## Another
2177Middle.
2178
2179## Example
2180Second.
2181"#;
2182        let ctx = create_ctx(content);
2183        let result = rule.check(&ctx).unwrap();
2184
2185        // The TOC has #example twice but expected has #example and #example-1.
2186        // Should report that #example-1 is missing from the TOC.
2187        assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
2188        assert!(
2189            result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
2190            "Should report missing or stale entries for duplicate anchors. Got: {}",
2191            result[0].message
2192        );
2193    }
2194
2195    // ==================== Multi-backtick code spans ====================
2196
2197    #[test]
2198    fn test_strip_double_backtick_code_span() {
2199        // Double-backtick code spans should be stripped
2200        let result = strip_markdown_formatting("Using ``code with ` backtick``");
2201        assert_eq!(
2202            result, "Using code with ` backtick",
2203            "Should strip double-backtick code spans"
2204        );
2205    }
2206
2207    #[test]
2208    fn test_strip_triple_backtick_code_span() {
2209        // Triple-backtick code spans should be stripped
2210        let result = strip_markdown_formatting("Using ```code with `` backticks```");
2211        assert_eq!(
2212            result, "Using code with `` backticks",
2213            "Should strip triple-backtick code spans"
2214        );
2215    }
2216
2217    #[test]
2218    fn test_toc_with_double_backtick_heading() {
2219        let rule = create_enabled_rule();
2220        // Use fix() to generate the correct TOC (including anchor), then check()
2221        // should produce no warnings on the fixed output.
2222        let content = r#"# Title
2223
2224<!-- toc -->
2225
2226<!-- tocstop -->
2227
2228## Using ``code with ` backtick``
2229
2230Content here.
2231"#;
2232        let ctx = create_ctx(content);
2233        // The heading uses double-backtick code span: ``code with ` backtick``
2234        // TOC display text preserves the code span; anchor is derived from raw text.
2235        let fixed = rule.fix(&ctx).unwrap();
2236
2237        // Verify that the generated TOC entry preserves the double-backtick code span
2238        // in the display text.
2239        let toc_start = fixed.find("<!-- toc -->").unwrap();
2240        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2241        let toc_content = &fixed[toc_start..toc_end];
2242        assert!(
2243            toc_content.contains("``code with ` backtick``"),
2244            "Fix should preserve double-backtick code span in TOC display text. Got: {toc_content}"
2245        );
2246
2247        // After fix, check() must produce no warnings (idempotency check)
2248        let ctx2 = create_ctx(&fixed);
2249        let result = rule.check(&ctx2).unwrap();
2250        assert!(
2251            result.is_empty(),
2252            "check() should not warn on fixed output. Warnings: {result:?}"
2253        );
2254    }
2255
2256    #[test]
2257    fn test_stress_many_duplicates() {
2258        let rule = create_enabled_rule();
2259
2260        // Generate 50 headings with the same text
2261        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2262        for _ in 0..50 {
2263            content.push_str("## FAQ\n\nContent.\n\n");
2264        }
2265
2266        let ctx = create_ctx(&content);
2267        let region = rule.detect_toc_region(&ctx).unwrap();
2268        let expected = rule.build_expected_toc(&ctx, &region);
2269
2270        // Should generate unique anchors for all 50
2271        assert_eq!(expected.len(), 50);
2272        assert_eq!(expected[0].anchor, "faq");
2273        assert_eq!(expected[1].anchor, "faq-1");
2274        assert_eq!(expected[49].anchor, "faq-49");
2275    }
2276
2277    /// Core invariant: for every warning with a Fix, fix() must produce
2278    /// output consistent with applying that fix directly.
2279    #[test]
2280    fn test_roundtrip_check_and_fix_alignment() {
2281        let rule = create_enabled_rule();
2282
2283        let inputs = [
2284            // Stale entry
2285            "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2286            // Missing entry
2287            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2288            // Text mismatch
2289            "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2290            // Already correct (no warnings, no change)
2291            "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2292        ];
2293
2294        for input in &inputs {
2295            let ctx = create_ctx(input);
2296            let fixed = rule.fix(&ctx).unwrap();
2297
2298            // Idempotency: fix(fix(x)) == fix(x)
2299            let ctx2 = create_ctx(&fixed);
2300            let fixed_twice = rule.fix(&ctx2).unwrap();
2301            assert_eq!(
2302                fixed, fixed_twice,
2303                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
2304            );
2305
2306            // After fix, check() should produce no warnings
2307            let warnings_after = rule.check(&ctx2).unwrap();
2308            assert!(
2309                warnings_after.is_empty(),
2310                "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2311            );
2312        }
2313    }
2314
2315    /// If a TOC has no mismatches, check() emits no warnings and fix()
2316    /// returns content unchanged.
2317    #[test]
2318    fn test_no_mismatch_preserves_content() {
2319        let rule = create_enabled_rule();
2320
2321        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";
2322        let ctx = create_ctx(content);
2323
2324        let warnings = rule.check(&ctx).unwrap();
2325        assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2326
2327        let fixed = rule.fix(&ctx).unwrap();
2328        assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2329    }
2330
2331    /// Inline-disabled TOC should not be modified by fix().
2332    #[test]
2333    fn test_inline_disable_preserves_toc() {
2334        let rule = create_enabled_rule();
2335
2336        // TOC with a stale entry, but MD073 disabled for the TOC region
2337        let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2338        let ctx = create_ctx(content);
2339
2340        let fixed = rule.fix(&ctx).unwrap();
2341        assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2342    }
2343
2344    // ========== Inline Formatting Preservation Tests (#634) ==========
2345
2346    /// Backticks in a heading must be preserved in the TOC display text.
2347    /// The anchor is generated from the raw heading text (which includes backticks)
2348    /// and must still use the stripped form.
2349    #[test]
2350    fn test_fix_code_ticks_preserved_in_toc_display_text() {
2351        let rule = MD073TocValidation::new();
2352        let content = r#"# Title
2353
2354<!-- toc -->
2355
2356<!-- tocstop -->
2357
2358### `my header`
2359
2360Content.
2361"#;
2362        let ctx = create_ctx(content);
2363        let fixed = rule.fix(&ctx).unwrap();
2364
2365        assert!(
2366            fixed.contains("- [`my header`](#my-header)"),
2367            "Code ticks must be preserved in TOC display text. Got: {fixed}"
2368        );
2369    }
2370
2371    /// A correct user-written TOC entry with code ticks must not be re-flagged.
2372    #[test]
2373    fn test_validate_toc_with_code_ticks_is_valid() {
2374        let rule = create_enabled_rule();
2375        let content = r#"# Title
2376
2377<!-- toc -->
2378
2379- [`my header`](#my-header)
2380
2381<!-- tocstop -->
2382
2383## `my header`
2384
2385Content.
2386"#;
2387        let ctx = create_ctx(content);
2388        let result = rule.check(&ctx).unwrap();
2389        assert!(
2390            result.is_empty(),
2391            "A TOC entry with preserved code ticks should be accepted as valid: {result:?}"
2392        );
2393    }
2394
2395    /// A heading with bold/italic preserves emphasis markers in the TOC display text;
2396    /// the anchor is generated from the raw (formatted) heading text and still uses
2397    /// the stripped form.
2398    #[test]
2399    fn test_fix_emphasis_preserved_in_toc_display_text() {
2400        let rule = MD073TocValidation::new();
2401        let content = r#"# Title
2402
2403<!-- toc -->
2404
2405<!-- tocstop -->
2406
2407## **bold** and *italic*
2408
2409Content.
2410"#;
2411        let ctx = create_ctx(content);
2412        let fixed = rule.fix(&ctx).unwrap();
2413
2414        assert!(
2415            fixed.contains("- [**bold** and *italic*](#bold-and-italic)"),
2416            "Emphasis markers must be preserved in TOC display text. Got: {fixed}"
2417        );
2418    }
2419
2420    /// A heading containing a link must have the link stripped from the TOC display
2421    /// text (nested links are invalid in Markdown).
2422    #[test]
2423    fn test_fix_link_in_heading_is_stripped() {
2424        let rule = MD073TocValidation::new();
2425        let content = r#"# Title
2426
2427<!-- toc -->
2428
2429<!-- tocstop -->
2430
2431## See [docs](http://example.com) for details
2432
2433Content.
2434"#;
2435        let ctx = create_ctx(content);
2436        let fixed = rule.fix(&ctx).unwrap();
2437
2438        assert!(
2439            fixed.contains("- [See docs for details](#see-docs-for-details)"),
2440            "Link must be stripped from TOC display text. Got: {fixed}"
2441        );
2442        // Ensure no URL leaks into TOC entry
2443        let toc_start = fixed.find("<!-- toc -->").unwrap();
2444        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2445        let toc_content = &fixed[toc_start..toc_end];
2446        assert!(
2447            !toc_content.contains("http://example.com"),
2448            "TOC should not contain link URL: {toc_content}"
2449        );
2450    }
2451
2452    /// An image in a heading must still be stripped from the TOC display text.
2453    #[test]
2454    fn test_fix_image_in_heading_is_stripped() {
2455        let rule = MD073TocValidation::new();
2456        let content = r#"# Title
2457
2458<!-- toc -->
2459
2460<!-- tocstop -->
2461
2462## Section ![icon](icon.png) Title
2463
2464Content.
2465"#;
2466        let ctx = create_ctx(content);
2467        let fixed = rule.fix(&ctx).unwrap();
2468
2469        assert!(
2470            fixed.contains("- [Section icon Title](#section-icon-title)"),
2471            "Image must be stripped from TOC display text. Got: {fixed}"
2472        );
2473    }
2474
2475    /// Running fix() twice on a document with inline-formatted headings must
2476    /// produce stable output (idempotency).
2477    #[test]
2478    fn test_fix_idempotent_with_inline_formatting() {
2479        let rule = MD073TocValidation::new();
2480        let content = r#"# Title
2481
2482<!-- toc -->
2483
2484<!-- tocstop -->
2485
2486## `code` heading
2487
2488### **bold** heading
2489
2490## See [link](http://x.com)
2491
2492"#;
2493        let ctx = create_ctx(content);
2494        let fixed1 = rule.fix(&ctx).unwrap();
2495        let ctx2 = create_ctx(&fixed1);
2496        let fixed2 = rule.fix(&ctx2).unwrap();
2497
2498        assert_eq!(fixed1, fixed2, "fix() must be idempotent for inline-formatted headings");
2499
2500        // After fix, check() must produce no warnings
2501        let warnings = rule.check(&ctx2).unwrap();
2502        assert!(
2503            warnings.is_empty(),
2504            "check() must not warn after fix() for inline-formatted headings: {warnings:?}"
2505        );
2506    }
2507
2508    /// Link-like syntax inside a code span must not be stripped, because it is
2509    /// literal content of the code span and not a real Markdown link.
2510    #[test]
2511    fn test_link_inside_code_span_preserved_in_toc() {
2512        let rule = MD073TocValidation::new();
2513        let content = r#"# Title
2514
2515<!-- toc -->
2516
2517<!-- tocstop -->
2518
2519## Use `[foo](bar)` syntax
2520
2521Content.
2522"#;
2523        let ctx = create_ctx(content);
2524        let fixed = rule.fix(&ctx).unwrap();
2525
2526        // The code span `[foo](bar)` must survive intact in the TOC display text.
2527        // The anchor is generated from the raw heading text by the GitHub algorithm,
2528        // which strips backtick, bracket, and paren characters. Verify only the
2529        // display-text preservation, not the exact anchor (which depends on the anchor
2530        // generation algorithm's treatment of non-alphanumeric chars in code spans).
2531        let toc_start = fixed.find("<!-- toc -->").unwrap();
2532        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2533        let toc_content = &fixed[toc_start..toc_end];
2534        assert!(
2535            toc_content.contains("Use `[foo](bar)` syntax"),
2536            "Link-like text inside code span must be preserved in TOC display text. Got: {toc_content}"
2537        );
2538        // Also ensure the real link stripping (outside code spans) still works
2539        assert!(
2540            !toc_content.contains("http://"),
2541            "Real links (outside code spans) should be stripped: {toc_content}"
2542        );
2543    }
2544
2545    // ========== HTML anchor targets ==========
2546
2547    /// The TOC region after `fix`, without the markers and surrounding blank lines.
2548    fn generated_toc(content: &str) -> String {
2549        let rule = create_enabled_rule();
2550        let ctx = create_ctx(content);
2551        let fixed = rule.fix(&ctx).unwrap();
2552        let start = fixed.find("<!-- toc -->").unwrap() + "<!-- toc -->".len();
2553        let end = fixed.find("<!-- tocstop -->").unwrap();
2554        fixed[start..end].trim().to_string()
2555    }
2556
2557    fn check_toc(content: &str) -> Vec<LintWarning> {
2558        let rule = create_enabled_rule();
2559        let ctx = create_ctx(content);
2560        rule.check(&ctx).unwrap()
2561    }
2562
2563    #[test]
2564    fn test_toc_may_target_the_generated_slug_of_a_heading_with_an_html_anchor() {
2565        // GitHub gives the heading both `#cheat-sheets` (generated) and `#cheatsheets`
2566        // (the element's own id), so a TOC written against either reaches it.
2567        for anchor in ["cheat-sheets", "cheatsheets"] {
2568            let content = format!(
2569                "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\nContent.\n"
2570            );
2571            let result = check_toc(&content);
2572            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2573        }
2574    }
2575
2576    #[test]
2577    fn test_every_anchor_element_on_a_heading_is_a_valid_target() {
2578        for anchor in ["old", "older", "foo"] {
2579            let content = format!(
2580                "# Title\n\n<!-- toc -->\n\n- [Foo](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"old\"></a><a id=\"older\"></a>Foo\n"
2581            );
2582            let result = check_toc(&content);
2583            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2584        }
2585    }
2586
2587    #[test]
2588    fn test_generated_toc_prefers_the_explicit_html_anchor() {
2589        let toc =
2590            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n");
2591        assert_eq!(toc, "- [Cheat Sheets](#cheatsheets)");
2592    }
2593
2594    #[test]
2595    fn test_regeneration_keeps_an_existing_anchor_that_reaches_its_heading() {
2596        // The unrelated missing entry forces a rewrite; the entry already reaching its
2597        // heading is not switched to the other valid spelling on the way.
2598        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";
2599        assert_eq!(
2600            generated_toc(content),
2601            "- [Cheat Sheets](#cheat-sheets)\n- [Other](#other)"
2602        );
2603    }
2604
2605    #[test]
2606    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
2607        let toc = generated_toc(
2608            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\" data-name=\"pixel\"></a>\n",
2609        );
2610        assert_eq!(toc, "- [Foo](#foo)");
2611
2612        let result = check_toc(
2613            "# Title\n\n<!-- toc -->\n\n- [Foo](#tracking)\n\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\"></a>\n",
2614        );
2615        assert_eq!(result.len(), 1, "{result:?}");
2616        assert!(
2617            result[0].message.contains("Stale entry: 'Foo'"),
2618            "{}",
2619            result[0].message
2620        );
2621        assert!(
2622            result[0].message.contains("Missing entry: 'Foo'"),
2623            "{}",
2624            result[0].message
2625        );
2626    }
2627
2628    #[test]
2629    fn test_an_empty_id_is_not_an_anchor() {
2630        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"\"></a>\n");
2631        assert_eq!(toc, "- [Foo](#foo)");
2632    }
2633
2634    #[test]
2635    fn test_an_explicit_anchor_still_consumes_the_generated_slug() {
2636        // GitHub numbers duplicate generated slugs whether or not the first heading also
2637        // carries its own id, so the second `Same` is `same-1`, never `same`.
2638        let toc =
2639            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Same<a id=\"stable\"></a>\n\n## Same\n");
2640        assert_eq!(toc, "- [Same](#stable)\n- [Same](#same-1)");
2641    }
2642
2643    #[test]
2644    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
2645        let heading = "Showing `<a id=\"literal\"></a>` syntax";
2646        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2647        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2648        assert_ne!(slug, "literal");
2649        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2650    }
2651
2652    #[test]
2653    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
2654        let heading = "Foo <!-- <a id=\"hidden\"></a> -->";
2655        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2656        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2657        assert_ne!(slug, "hidden");
2658        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2659    }
2660
2661    #[test]
2662    fn test_an_explicit_anchor_is_written_as_a_valid_link_destination() {
2663        // A bare link destination cannot hold `)` or a space, so the anchor is
2664        // percent-encoded on the way out, and the encoded entry reaches the heading
2665        // again on the next check, so the generated TOC is stable.
2666        let headings = "## Alpha<a id=\"foo)bar\"></a>\n\n## Beta<a id=\"has space\"></a>\n";
2667        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{headings}"));
2668        assert_eq!(toc, "- [Alpha](#foo%29bar)\n- [Beta](#has%20space)");
2669
2670        let result = check_toc(&format!(
2671            "# Title\n\n<!-- toc -->\n\n{toc}\n\n<!-- tocstop -->\n\n{headings}"
2672        ));
2673        assert!(result.is_empty(), "{result:?}");
2674    }
2675
2676    #[test]
2677    fn test_regeneration_does_not_encode_an_encoded_entry_twice() {
2678        // The entry reaching `foo)bar` is kept as written; the stale sibling forces
2679        // the rewrite.
2680        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";
2681        assert_eq!(generated_toc(content), "- [Alpha](#foo%29bar)");
2682    }
2683
2684    #[test]
2685    fn test_a_backslash_escaped_anchor_element_defines_no_target() {
2686        // `\<a ...>` renders as literal text, so `#example` reaches nothing.
2687        let heading = "Show \\<a id=\"example\"></a> syntax";
2688        let result = check_toc(&format!(
2689            "# Title\n\n<!-- toc -->\n\n- [{heading}](#example)\n\n<!-- tocstop -->\n\n## {heading}\n"
2690        ));
2691        assert_eq!(result.len(), 1, "{result:?}");
2692        assert!(result[0].message.contains("Stale entry"), "{}", result[0].message);
2693
2694        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2695        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2696        assert_ne!(slug, "example");
2697        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2698    }
2699
2700    #[test]
2701    fn test_a_backslash_escaped_marker_is_text() {
2702        // `\<!-- toc -->` renders as the literal text `<!-- toc -->`, so the pair
2703        // below delimits no TOC region and the prose between them is left alone.
2704        let content = "# Title\n\nWrite these lines:\n\n\\<!-- toc -->\n\nProse that must survive.\n\n\\<!-- tocstop -->\n\n## Alpha\n";
2705        let result = check_toc(content);
2706        assert!(result.is_empty(), "{result:?}");
2707        assert_eq!(create_enabled_rule().fix(&create_ctx(content)).unwrap(), content);
2708
2709        // An even run of backslashes leaves the `<` itself unescaped, so the comment
2710        // is rendered and the marker is real.
2711        let toc = generated_toc("# Title\n\n\\\\<!-- toc -->\n<!-- tocstop -->\n\n## Alpha\n");
2712        assert_eq!(toc, "- [Alpha](#alpha)");
2713    }
2714
2715    #[test]
2716    fn test_the_whitespace_beside_an_anchor_element_stays_in_the_heading_text() {
2717        // `Foo<a id="alias"></a> Bar` renders as "Foo Bar", so an entry written
2718        // that way is up to date and a regenerated entry reads the same.
2719        let content =
2720            "# Title\n\n<!-- toc -->\n- [Foo Bar](#foo-bar)\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n";
2721        let warnings = check_toc(content);
2722        assert!(warnings.is_empty(), "{warnings:?}");
2723
2724        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n");
2725        assert_eq!(toc, "- [Foo Bar](#alias)");
2726    }
2727
2728    #[test]
2729    fn test_the_whitespace_an_anchor_element_leaves_at_either_end_is_part_of_the_slug() {
2730        // `## Alpha <a id="x"></a>` renders as "Alpha", but GitHub slugs the text
2731        // content with the space the element leaves, so the heading answers to
2732        // `#alpha-` and `#x` and not to `#alpha`. A leading element likewise
2733        // leaves `#-beta`.
2734        let document = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha-)\n- [Beta](#-beta)\n<!-- tocstop -->\n\n\
2735                        ## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n";
2736        let warnings = check_toc(document);
2737        assert!(warnings.is_empty(), "{warnings:?}");
2738
2739        let trimmed = document.replace("#alpha-", "#alpha").replace("#-beta", "#beta");
2740        assert_eq!(
2741            check_toc(&trimmed).len(),
2742            1,
2743            "a TOC written against trimmed slugs is stale"
2744        );
2745
2746        let toc = generated_toc(
2747            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n",
2748        );
2749        assert_eq!(toc, "- [Alpha](#x)\n- [Beta](#y)");
2750    }
2751
2752    #[test]
2753    fn test_a_heading_whose_text_slugs_to_nothing_gets_no_toc_entry() {
2754        // An image's description becomes its alt text, in which markup is
2755        // escaped, so `<a id="img">` inside it is no element and no target, and
2756        // the heading's text content is empty: GitHub gives it no anchor at all.
2757        // A heading no fragment can reach gets no entry, so the TOC converges
2758        // instead of regenerating an `[](#)` entry on every run.
2759        let body = "## ![<a id=\"img\"></a>](img.png)\n\n## ![](img.png)\n\n## Plain\n";
2760        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{body}"));
2761        assert_eq!(toc, "- [Plain](#plain)");
2762
2763        let warnings = check_toc(&format!(
2764            "# Title\n\n<!-- toc -->\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2765        ));
2766        assert!(warnings.is_empty(), "{warnings:?}");
2767
2768        let warnings = check_toc(&format!(
2769            "# Title\n\n<!-- toc -->\n- [img](#img)\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2770        ));
2771        assert_eq!(
2772            warnings.len(),
2773            1,
2774            "an entry for the image's anchor is stale: {warnings:?}"
2775        );
2776    }
2777
2778    #[test]
2779    fn test_an_anchor_inside_another_tags_attribute_value_is_heading_text() {
2780        // The `<a>` sits in the span's `title` value, so the browser makes no
2781        // element from it: the heading answers to `#foo` and keeps its bytes.
2782        let toc = generated_toc(
2783            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <span title='<a id=\"fake\"></a>'>Foo</span>\n",
2784        );
2785        assert_eq!(toc, "- [<span title='<a id=\"fake\"></a>'>Foo</span>](#foo)");
2786    }
2787
2788    #[test]
2789    fn test_a_heading_with_a_custom_id_keeps_its_html_anchor_as_a_target() {
2790        // `{#new}` replaces the generated slug, but the element's own `name` is still
2791        // an anchor in the rendered page, so a TOC entry may use either of the two.
2792        let heading = "<a name=\"old\"></a>My Section {#new}";
2793        let result = check_toc(&format!(
2794            "# Title\n\n<!-- toc -->\n\n- [My Section](#old)\n\n<!-- tocstop -->\n\n## {heading}\n"
2795        ));
2796        assert!(result.is_empty(), "{result:?}");
2797
2798        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2799        assert_eq!(toc, "- [My Section](#new)");
2800
2801        let result = check_toc(&format!(
2802            "# Title\n\n<!-- toc -->\n\n- [My Section](#my-section)\n\n<!-- tocstop -->\n\n## {heading}\n"
2803        ));
2804        assert_eq!(result.len(), 1, "the generated slug is replaced, got {result:?}");
2805    }
2806
2807    #[test]
2808    fn test_toc_entry_matches_a_multi_line_setext_heading() {
2809        // A setext underline makes a heading of the whole paragraph above it, so
2810        // the entry a TOC needs holds the joined text and the slug of that text.
2811        let content = "# Title\n\n<!-- toc -->\n\n- [First line second line](#first-line-second-line)\n\n<!-- tocstop -->\n\nFirst line\nsecond line\n---\n\nContent.\n";
2812        assert!(check_toc(content).is_empty(), "{:?}", check_toc(content));
2813        assert_eq!(
2814            generated_toc(content),
2815            "- [First line second line](#first-line-second-line)"
2816        );
2817    }
2818
2819    #[test]
2820    fn test_missing_entry_names_the_first_line_of_a_multi_line_setext_heading() {
2821        // The heading a missing entry points at starts on the first line of the
2822        // paragraph its underline ends, which is where a reader looks for it.
2823        let content = "# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\nFirst line\nsecond line\n---\n\nContent.\n";
2824        let result = check_toc(content);
2825        assert_eq!(result.len(), 1, "the TOC misses the heading, got {result:?}");
2826        assert!(
2827            result[0]
2828                .message
2829                .contains("Missing entry: 'First line second line' (line 7)"),
2830            "the heading starts on line 7: {}",
2831            result[0].message
2832        );
2833    }
2834    mod quoted_headings {
2835        use super::*;
2836
2837        fn warnings(content: &str) -> usize {
2838            let rule = create_enabled_rule();
2839            rule.check(&create_ctx(content)).unwrap().len()
2840        }
2841
2842        #[test]
2843        fn quoted_heading_belongs_in_the_toc() {
2844            let toc = |entries: &str| format!("# T\n\n<!-- toc -->\n\n{entries}\n<!-- tocstop -->\n\n## A\n\n> ## Q\n");
2845            assert_eq!(warnings(&toc("- [A](#a)\n- [Q](#q)\n")), 0);
2846            assert!(
2847                warnings(&toc("- [A](#a)\n")) > 0,
2848                "a quoted heading missing from the TOC is reported"
2849            );
2850        }
2851
2852        #[test]
2853        fn quoted_heading_in_every_container_shape_is_listed() {
2854            for body in ["> ## Q\n", "> > ## Q\n", "- x\n\n  > ## Q\n", "> - x\n>\n>   ## Q\n"] {
2855                let content = format!("# T\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n{body}");
2856                let rule = create_enabled_rule();
2857                let fixed = rule.fix(&create_ctx(&content)).unwrap();
2858                assert!(fixed.contains("[Q](#q)"), "{body:?}: {fixed:?}");
2859            }
2860        }
2861
2862        #[test]
2863        fn a_toc_inside_a_blockquote_is_reported_without_a_fix() {
2864            // Regenerated entries carry no quote prefix, so splicing them in
2865            // would end the quote.
2866            let content = "# T\n\n> <!-- toc -->\n>\n> <!-- tocstop -->\n\n## A\n";
2867            let rule = create_enabled_rule();
2868            let warnings = rule.check(&create_ctx(content)).unwrap();
2869            assert_eq!(warnings.len(), 1);
2870            assert!(warnings[0].fix.is_none());
2871            assert_eq!(rule.fix(&create_ctx(content)).unwrap(), content);
2872        }
2873
2874        #[test]
2875        fn a_correct_toc_inside_a_blockquote_is_valid() {
2876            let content =
2877                "# T\n\n> <!-- toc -->\n>\n> - [A](#a)\n> - [Q](#q)\n>\n> <!-- tocstop -->\n\n## A\n\n> ## Q\n";
2878            assert_eq!(warnings(content), 0);
2879            let stale = content.replace("> - [Q](#q)\n", "");
2880            assert_eq!(warnings(&stale), 1);
2881        }
2882
2883        #[test]
2884        fn a_quoted_toc_keeps_the_indentation_of_nested_entries() {
2885            for quote in [">", ">>"] {
2886                let mut toc = String::new();
2887                for line in ["<!-- toc -->", "", "- [A](#a)", "  - [B](#b)", "", "<!-- tocstop -->"] {
2888                    toc.push_str(quote);
2889                    if !line.is_empty() {
2890                        toc.push(' ');
2891                        toc.push_str(line);
2892                    }
2893                    toc.push('\n');
2894                }
2895                let content = format!("# T\n\n{toc}\n## A\n\nx\n\n### B\n");
2896                assert_eq!(warnings(&content), 0, "{content:?}");
2897                let flat = content.replace("  - [B]", "- [B]");
2898                assert_eq!(warnings(&flat), 1, "{flat:?}");
2899            }
2900        }
2901
2902        #[test]
2903        fn a_quote_next_to_an_unquoted_toc_does_not_withhold_its_fix() {
2904            let rule = create_enabled_rule();
2905            for content in [
2906                "# T\n\n<!-- toc -->\n<!-- tocstop -->\n> Intro\n\n## A\n",
2907                "> Intro\n<!-- toc -->\n<!-- tocstop -->\n\n## A\n",
2908                "> Intro\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n> Outro\n\n## A\n",
2909            ] {
2910                let warnings = rule.check(&create_ctx(content)).unwrap();
2911                assert_eq!(warnings.len(), 1, "{content:?}");
2912                assert!(warnings[0].fix.is_some(), "{content:?}");
2913            }
2914        }
2915
2916        #[test]
2917        fn duplicates_are_numbered_across_quoted_and_unquoted_headings() {
2918            let content = "# T\n\n<!-- toc -->\n\n- [Q](#q)\n- [Q](#q-1)\n\n<!-- tocstop -->\n\n> ## Q\n\n## Q\n";
2919            assert_eq!(warnings(content), 0);
2920        }
2921
2922        #[test]
2923        fn headings_outside_the_level_range_still_take_a_number() {
2924            // The title is level 1, outside the default range, yet GitHub numbers the
2925            // later `## Repeat` as `repeat-1`.
2926            let content = "# Repeat\n\n<!-- toc -->\n\n- [Repeat](#repeat-1)\n\n<!-- tocstop -->\n\n## Repeat\n";
2927            assert_eq!(warnings(content), 0);
2928            let quoted = "<!-- toc -->\n\n- [Repeat](#repeat-1)\n\n<!-- tocstop -->\n\n> # Repeat\n\n## Repeat\n";
2929            assert_eq!(warnings(quoted), 0);
2930        }
2931
2932        #[test]
2933        fn quoted_heading_in_a_quoted_code_block_is_not_listed() {
2934            let content = "# T\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n> ```\n> ## Q\n> ```\n";
2935            assert_eq!(warnings(content), 0);
2936        }
2937    }
2938}