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