1use crate::lint_context::LintContext;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::anchor_styles::AnchorStyle;
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
14
15static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
17
18static TOC_ENTRY_PATTERN: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
23
24#[derive(Debug, Clone)]
26struct TocRegion {
27 start_line: usize,
29 end_line: usize,
31 content_start: usize,
33 content_end: usize,
35}
36
37#[derive(Debug, Clone)]
39struct TocEntry {
40 text: String,
42 anchor: String,
44 indent_spaces: usize,
46}
47
48#[derive(Debug, Clone)]
50struct ExpectedTocEntry {
51 heading_line: usize,
53 level: u8,
55 text: String,
57 anchor: String,
59}
60
61#[derive(Debug)]
63enum TocMismatch {
64 StaleEntry { entry: TocEntry },
66 MissingEntry { expected: ExpectedTocEntry },
68 TextMismatch {
70 entry: TocEntry,
71 expected: ExpectedTocEntry,
72 },
73 OrderMismatch { entry: TocEntry, expected_position: usize },
75 IndentationMismatch {
77 entry: TocEntry,
78 actual_indent: usize,
79 expected_indent: usize,
80 },
81}
82
83static MARKDOWN_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
85static MARKDOWN_REF_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[[^\]]*\]").unwrap());
86static MARKDOWN_IMAGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\([^)]+\)").unwrap());
87fn strip_code_spans(text: &str) -> String {
90 let chars: Vec<char> = text.chars().collect();
91 let len = chars.len();
92 let mut result = String::with_capacity(text.len());
93 let mut i = 0;
94
95 while i < len {
96 if chars[i] == '`' {
97 let open_start = i;
99 while i < len && chars[i] == '`' {
100 i += 1;
101 }
102 let backtick_count = i - open_start;
103
104 let content_start = i;
106 let mut found_close = false;
107 while i < len {
108 if chars[i] == '`' {
109 let close_start = i;
110 while i < len && chars[i] == '`' {
111 i += 1;
112 }
113 if i - close_start == backtick_count {
114 let content: String = chars[content_start..close_start].iter().collect();
116 let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
118 &content[1..content.len() - 1]
119 } else {
120 &content
121 };
122 result.push_str(stripped);
123 found_close = true;
124 break;
125 }
126 } else {
127 i += 1;
128 }
129 }
130 if !found_close {
131 for _ in 0..backtick_count {
133 result.push('`');
134 }
135 let remaining: String = chars[content_start..].iter().collect();
136 result.push_str(&remaining);
137 break;
138 }
139 } else {
140 result.push(chars[i]);
141 i += 1;
142 }
143 }
144
145 result
146}
147static MARKDOWN_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
148static MARKDOWN_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
149static MARKDOWN_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
150static MARKDOWN_ITALIC_UNDERSCORE: LazyLock<Regex> =
153 LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
154
155fn strip_markdown_formatting(text: &str) -> String {
164 let mut result = text.to_string();
165
166 result = MARKDOWN_IMAGE.replace_all(&result, "$1").to_string();
168
169 result = MARKDOWN_LINK.replace_all(&result, "$1").to_string();
171
172 result = MARKDOWN_REF_LINK.replace_all(&result, "$1").to_string();
174
175 result = strip_code_spans(&result);
177
178 result = MARKDOWN_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
180 result = MARKDOWN_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
181
182 result = MARKDOWN_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
184 result = MARKDOWN_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
186
187 result
188}
189
190#[derive(Clone)]
218pub struct MD073TocValidation {
219 enabled: bool,
221 min_level: u8,
223 max_level: u8,
225 enforce_order: bool,
227 pub indent: usize,
229}
230
231impl Default for MD073TocValidation {
232 fn default() -> Self {
233 Self {
234 enabled: false, min_level: 2,
236 max_level: 4,
237 enforce_order: true,
238 indent: 2, }
240 }
241}
242
243impl std::fmt::Debug for MD073TocValidation {
244 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245 f.debug_struct("MD073TocValidation")
246 .field("enabled", &self.enabled)
247 .field("min_level", &self.min_level)
248 .field("max_level", &self.max_level)
249 .field("enforce_order", &self.enforce_order)
250 .field("indent", &self.indent)
251 .finish()
252 }
253}
254
255impl MD073TocValidation {
256 pub fn new() -> Self {
258 Self::default()
259 }
260
261 fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
263 let mut start_line = None;
264 let mut start_byte = None;
265
266 for (idx, line_info) in ctx.lines.iter().enumerate() {
267 let line_num = idx + 1;
268 let content = line_info.content(ctx.content);
269
270 if line_info.in_code_block || line_info.in_front_matter {
272 continue;
273 }
274
275 if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
277 if TOC_STOP_MARKER.is_match(content) {
279 let end_line = line_num - 1;
280 let content_end = line_info.byte_offset;
281
282 if end_line < s_line {
284 return Some(TocRegion {
285 start_line: s_line,
286 end_line: s_line,
287 content_start: s_byte,
288 content_end: s_byte,
289 });
290 }
291
292 return Some(TocRegion {
293 start_line: s_line,
294 end_line,
295 content_start: s_byte,
296 content_end,
297 });
298 }
299 } else if TOC_START_MARKER.is_match(content) {
300 if idx + 1 < ctx.lines.len() {
302 start_line = Some(line_num + 1);
303 start_byte = Some(ctx.lines[idx + 1].byte_offset);
304 }
305 }
306 }
307
308 None
309 }
310
311 fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
313 self.detect_by_markers(ctx)
314 }
315
316 fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
318 let mut entries = Vec::new();
319
320 for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
321 let line_info = &ctx.lines[idx];
322 let content = line_info.content(ctx.content);
323
324 if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
325 let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
326 let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
327 let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
328
329 entries.push(TocEntry {
330 text,
331 anchor,
332 indent_spaces,
333 });
334 }
335 }
336
337 entries
338 }
339
340 fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
342 let mut entries = Vec::new();
343 let mut fragment_counts: HashMap<String, usize> = HashMap::new();
344
345 for (idx, line_info) in ctx.lines.iter().enumerate() {
346 let line_num = idx + 1;
347
348 if line_num <= toc_region.end_line {
350 continue;
352 }
353
354 if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
356 continue;
357 }
358
359 if let Some(heading) = &line_info.heading {
360 if heading.level < self.min_level || heading.level > self.max_level {
362 continue;
363 }
364
365 let base_anchor = if let Some(custom_id) = &heading.custom_id {
367 custom_id.clone()
368 } else {
369 AnchorStyle::GitHub.generate_fragment(&heading.text)
370 };
371
372 let anchor = if let Some(count) = fragment_counts.get_mut(&base_anchor) {
374 let suffix = *count;
375 *count += 1;
376 format!("{base_anchor}-{suffix}")
377 } else {
378 fragment_counts.insert(base_anchor.clone(), 1);
379 base_anchor
380 };
381
382 entries.push(ExpectedTocEntry {
383 heading_line: line_num,
384 level: heading.level,
385 text: heading.text.clone(),
386 anchor,
387 });
388 }
389 }
390
391 entries
392 }
393
394 fn validate_toc(&self, actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<TocMismatch> {
396 let mut mismatches = Vec::new();
397
398 let expected_anchors: HashMap<&str, &ExpectedTocEntry> =
400 expected.iter().map(|e| (e.anchor.as_str(), e)).collect();
401
402 let mut actual_anchor_counts: HashMap<&str, usize> = HashMap::new();
404 for entry in actual {
405 *actual_anchor_counts.entry(entry.anchor.as_str()).or_insert(0) += 1;
406 }
407
408 let mut expected_anchor_counts: HashMap<&str, usize> = HashMap::new();
410 for exp in expected {
411 *expected_anchor_counts.entry(exp.anchor.as_str()).or_insert(0) += 1;
412 }
413
414 let mut stale_anchor_counts: HashMap<&str, usize> = HashMap::new();
416 for entry in actual {
417 let actual_count = actual_anchor_counts.get(entry.anchor.as_str()).copied().unwrap_or(0);
418 let expected_count = expected_anchor_counts.get(entry.anchor.as_str()).copied().unwrap_or(0);
419 if actual_count > expected_count {
420 let reported = stale_anchor_counts.entry(entry.anchor.as_str()).or_insert(0);
421 if *reported < actual_count - expected_count {
422 *reported += 1;
423 mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
424 }
425 }
426 }
427
428 let mut missing_anchor_counts: HashMap<&str, usize> = HashMap::new();
430 for exp in expected {
431 let actual_count = actual_anchor_counts.get(exp.anchor.as_str()).copied().unwrap_or(0);
432 let expected_count = expected_anchor_counts.get(exp.anchor.as_str()).copied().unwrap_or(0);
433 if expected_count > actual_count {
434 let reported = missing_anchor_counts.entry(exp.anchor.as_str()).or_insert(0);
435 if *reported < expected_count - actual_count {
436 *reported += 1;
437 mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
438 }
439 }
440 }
441
442 for entry in actual {
444 if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
445 let actual_stripped = strip_markdown_formatting(entry.text.trim());
447 let expected_stripped = strip_markdown_formatting(exp.text.trim());
448 if actual_stripped != expected_stripped {
449 mismatches.push(TocMismatch::TextMismatch {
450 entry: entry.clone(),
451 expected: (*exp).clone(),
452 });
453 }
454 }
455 }
456
457 if !expected.is_empty() {
460 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
461
462 for entry in actual {
463 if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
464 let level_diff = exp.level.saturating_sub(base_level) as usize;
465 let expected_indent = level_diff * self.indent;
466
467 if entry.indent_spaces != expected_indent {
468 let already_reported = mismatches.iter().any(|m| match m {
470 TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
471 TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
472 _ => false,
473 });
474 if !already_reported {
475 mismatches.push(TocMismatch::IndentationMismatch {
476 entry: entry.clone(),
477 actual_indent: entry.indent_spaces,
478 expected_indent,
479 });
480 }
481 }
482 }
483 }
484 }
485
486 if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
488 let expected_order: Vec<&str> = expected.iter().map(|e| e.anchor.as_str()).collect();
489
490 let mut expected_idx = 0;
492 for entry in actual {
493 if !expected_anchors.contains_key(entry.anchor.as_str()) {
495 continue;
496 }
497
498 while expected_idx < expected_order.len() && expected_order[expected_idx] != entry.anchor {
500 expected_idx += 1;
501 }
502
503 if expected_idx >= expected_order.len() {
504 let correct_pos = expected_order.iter().position(|a| *a == entry.anchor).unwrap_or(0);
506 let already_reported = mismatches.iter().any(|m| match m {
508 TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
509 TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
510 _ => false,
511 });
512 if !already_reported {
513 mismatches.push(TocMismatch::OrderMismatch {
514 entry: entry.clone(),
515 expected_position: correct_pos + 1,
516 });
517 }
518 } else {
519 expected_idx += 1;
520 }
521 }
522 }
523
524 mismatches
525 }
526
527 fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
529 if expected.is_empty() {
530 return String::new();
531 }
532
533 let mut result = String::new();
534 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
535 let indent_str = " ".repeat(self.indent);
536
537 for entry in expected {
538 let level_diff = entry.level.saturating_sub(base_level) as usize;
539 let indent = indent_str.repeat(level_diff);
540
541 let display_text = strip_markdown_formatting(&entry.text);
543 result.push_str(&format!("{indent}- [{display_text}](#{})\n", entry.anchor));
544 }
545
546 result
547 }
548}
549
550impl Rule for MD073TocValidation {
551 fn name(&self) -> &'static str {
552 "MD073"
553 }
554
555 fn description(&self) -> &'static str {
556 "Table of Contents should match document headings"
557 }
558
559 fn should_skip(&self, ctx: &LintContext) -> bool {
560 let lower = ctx.content.to_ascii_lowercase();
565 !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
566 }
567
568 fn check(&self, ctx: &LintContext) -> LintResult {
569 let mut warnings = Vec::new();
570
571 let Some(region) = self.detect_toc_region(ctx) else {
573 return Ok(warnings);
575 };
576
577 let actual_entries = self.extract_toc_entries(ctx, ®ion);
579
580 let expected_entries = self.build_expected_toc(ctx, ®ion);
582
583 if expected_entries.is_empty() && actual_entries.is_empty() {
585 return Ok(warnings);
586 }
587
588 let mismatches = self.validate_toc(&actual_entries, &expected_entries);
590
591 if !mismatches.is_empty() {
592 let mut details = Vec::new();
594
595 for mismatch in &mismatches {
596 match mismatch {
597 TocMismatch::StaleEntry { entry } => {
598 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
599 }
600 TocMismatch::MissingEntry { expected } => {
601 details.push(format!(
602 "Missing entry: '{}' (line {})",
603 expected.text, expected.heading_line
604 ));
605 }
606 TocMismatch::TextMismatch { entry, expected } => {
607 details.push(format!(
608 "Text mismatch: TOC has '{}', heading is '{}'",
609 entry.text, expected.text
610 ));
611 }
612 TocMismatch::OrderMismatch {
613 entry,
614 expected_position,
615 } => {
616 details.push(format!(
617 "Order mismatch: '{}' should be at position {}",
618 entry.text, expected_position
619 ));
620 }
621 TocMismatch::IndentationMismatch {
622 entry,
623 actual_indent,
624 expected_indent,
625 ..
626 } => {
627 details.push(format!(
628 "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
629 entry.text, actual_indent, expected_indent
630 ));
631 }
632 }
633 }
634
635 let message = format!(
636 "Table of Contents does not match document headings: {}",
637 details.join("; ")
638 );
639
640 let new_toc = self.generate_toc(&expected_entries);
642 let fix_range = region.content_start..region.content_end;
643
644 warnings.push(LintWarning {
645 rule_name: Some(self.name().to_string()),
646 message,
647 line: region.start_line,
648 column: 1,
649 end_line: region.end_line,
650 end_column: 1,
651 severity: Severity::Warning,
652 fix: Some(Fix::new(fix_range, new_toc)),
653 });
654 }
655
656 Ok(warnings)
657 }
658
659 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
660 if self.should_skip(ctx) {
661 return Ok(ctx.content.to_string());
662 }
663 let warnings = self.check(ctx)?;
664 if warnings.is_empty() {
665 return Ok(ctx.content.to_string());
666 }
667 let warnings =
668 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
669 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
670 }
671
672 fn category(&self) -> RuleCategory {
673 RuleCategory::Other
674 }
675
676 fn as_any(&self) -> &dyn std::any::Any {
677 self
678 }
679
680 fn default_config_section(&self) -> Option<(String, toml::Value)> {
681 let value: toml::Value = toml::from_str(
682 r#"
683# Whether this rule is enabled (opt-in, disabled by default)
684enabled = false
685# Minimum heading level to include
686min-level = 2
687# Maximum heading level to include
688max-level = 4
689# Whether TOC order must match document order
690enforce-order = true
691# Indentation per nesting level (defaults to MD007's indent value)
692indent = 2
693"#,
694 )
695 .ok()?;
696 Some(("MD073".to_string(), value))
697 }
698
699 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
700 where
701 Self: Sized,
702 {
703 let mut rule = MD073TocValidation::default();
704 let mut indent_from_md073 = false;
705
706 if let Some(rule_config) = config.rules.get("MD073") {
707 if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
709 rule.enabled = enabled;
710 }
711
712 if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
714 rule.min_level = (min_level.clamp(1, 6)) as u8;
715 }
716
717 if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
719 rule.max_level = (max_level.clamp(1, 6)) as u8;
720 }
721
722 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
724 rule.enforce_order = enforce_order;
725 }
726
727 if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
729 rule.indent = (indent.clamp(1, 8)) as usize;
730 indent_from_md073 = true;
731 }
732 }
733
734 if !indent_from_md073
736 && let Some(md007_config) = config.rules.get("MD007")
737 && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
738 {
739 rule.indent = (indent.clamp(1, 8)) as usize;
740 }
741
742 Box::new(rule)
743 }
744}
745
746#[cfg(test)]
747mod tests {
748 use super::*;
749 use crate::config::MarkdownFlavor;
750
751 fn create_ctx(content: &str) -> LintContext<'_> {
752 LintContext::new(content, MarkdownFlavor::Standard, None)
753 }
754
755 fn create_enabled_rule() -> MD073TocValidation {
757 MD073TocValidation {
758 enabled: true,
759 ..MD073TocValidation::default()
760 }
761 }
762
763 #[test]
766 fn test_detect_markers_basic() {
767 let rule = MD073TocValidation::new();
768 let content = r#"# Title
769
770<!-- toc -->
771
772- [Heading 1](#heading-1)
773
774<!-- tocstop -->
775
776## Heading 1
777
778Content here.
779"#;
780 let ctx = create_ctx(content);
781 let region = rule.detect_by_markers(&ctx);
782 assert!(region.is_some());
783 let region = region.unwrap();
784 assert_eq!(region.start_line, 4);
786 assert_eq!(region.end_line, 6);
787 }
788
789 #[test]
790 fn test_detect_markers_variations() {
791 let rule = MD073TocValidation::new();
792
793 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
795 let ctx1 = create_ctx(content1);
796 assert!(rule.detect_by_markers(&ctx1).is_some());
797
798 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
800 let ctx2 = create_ctx(content2);
801 assert!(rule.detect_by_markers(&ctx2).is_some());
802
803 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
805 let ctx3 = create_ctx(content3);
806 assert!(rule.detect_by_markers(&ctx3).is_some());
807 }
808
809 #[test]
810 fn test_no_toc_region() {
811 let rule = MD073TocValidation::new();
812 let content = r#"# Title
813
814## Heading 1
815
816Content here.
817
818## Heading 2
819
820More content.
821"#;
822 let ctx = create_ctx(content);
823 let region = rule.detect_toc_region(&ctx);
824 assert!(region.is_none());
825 }
826
827 #[test]
830 fn test_toc_matches_headings() {
831 let rule = create_enabled_rule();
832 let content = r#"# Title
833
834<!-- toc -->
835
836- [Heading 1](#heading-1)
837- [Heading 2](#heading-2)
838
839<!-- tocstop -->
840
841## Heading 1
842
843Content.
844
845## Heading 2
846
847More content.
848"#;
849 let ctx = create_ctx(content);
850 let result = rule.check(&ctx).unwrap();
851 assert!(result.is_empty(), "Expected no warnings for matching TOC");
852 }
853
854 #[test]
855 fn test_missing_entry() {
856 let rule = create_enabled_rule();
857 let content = r#"# Title
858
859<!-- toc -->
860
861- [Heading 1](#heading-1)
862
863<!-- tocstop -->
864
865## Heading 1
866
867Content.
868
869## Heading 2
870
871New heading not in TOC.
872"#;
873 let ctx = create_ctx(content);
874 let result = rule.check(&ctx).unwrap();
875 assert_eq!(result.len(), 1);
876 assert!(result[0].message.contains("Missing entry"));
877 assert!(result[0].message.contains("Heading 2"));
878 }
879
880 #[test]
881 fn test_stale_entry() {
882 let rule = create_enabled_rule();
883 let content = r#"# Title
884
885<!-- toc -->
886
887- [Heading 1](#heading-1)
888- [Deleted Heading](#deleted-heading)
889
890<!-- tocstop -->
891
892## Heading 1
893
894Content.
895"#;
896 let ctx = create_ctx(content);
897 let result = rule.check(&ctx).unwrap();
898 assert_eq!(result.len(), 1);
899 assert!(result[0].message.contains("Stale entry"));
900 assert!(result[0].message.contains("Deleted Heading"));
901 }
902
903 #[test]
904 fn test_text_mismatch() {
905 let rule = create_enabled_rule();
906 let content = r#"# Title
907
908<!-- toc -->
909
910- [Old Name](#heading-1)
911
912<!-- tocstop -->
913
914## Heading 1
915
916Content.
917"#;
918 let ctx = create_ctx(content);
919 let result = rule.check(&ctx).unwrap();
920 assert_eq!(result.len(), 1);
921 assert!(result[0].message.contains("Text mismatch"));
922 }
923
924 #[test]
927 fn test_min_level_excludes_h1() {
928 let mut rule = MD073TocValidation::new();
929 rule.min_level = 2;
930
931 let content = r#"<!-- toc -->
932
933<!-- tocstop -->
934
935# Should Be Excluded
936
937## Should Be Included
938
939Content.
940"#;
941 let ctx = create_ctx(content);
942 let region = rule.detect_toc_region(&ctx).unwrap();
943 let expected = rule.build_expected_toc(&ctx, ®ion);
944
945 assert_eq!(expected.len(), 1);
946 assert_eq!(expected[0].text, "Should Be Included");
947 }
948
949 #[test]
950 fn test_max_level_excludes_h5_h6() {
951 let mut rule = MD073TocValidation::new();
952 rule.max_level = 4;
953
954 let content = r#"<!-- toc -->
955
956<!-- tocstop -->
957
958## Level 2
959
960### Level 3
961
962#### Level 4
963
964##### Level 5 Should Be Excluded
965
966###### Level 6 Should Be Excluded
967"#;
968 let ctx = create_ctx(content);
969 let region = rule.detect_toc_region(&ctx).unwrap();
970 let expected = rule.build_expected_toc(&ctx, ®ion);
971
972 assert_eq!(expected.len(), 3);
973 assert!(expected.iter().all(|e| e.level <= 4));
974 }
975
976 #[test]
979 fn test_fix_adds_missing_entry() {
980 let rule = MD073TocValidation::new();
981 let content = r#"# Title
982
983<!-- toc -->
984
985- [Heading 1](#heading-1)
986
987<!-- tocstop -->
988
989## Heading 1
990
991Content.
992
993## Heading 2
994
995New heading.
996"#;
997 let ctx = create_ctx(content);
998 let fixed = rule.fix(&ctx).unwrap();
999 assert!(fixed.contains("- [Heading 2](#heading-2)"));
1000 }
1001
1002 #[test]
1003 fn test_fix_removes_stale_entry() {
1004 let rule = MD073TocValidation::new();
1005 let content = r#"# Title
1006
1007<!-- toc -->
1008
1009- [Heading 1](#heading-1)
1010- [Deleted](#deleted)
1011
1012<!-- tocstop -->
1013
1014## Heading 1
1015
1016Content.
1017"#;
1018 let ctx = create_ctx(content);
1019 let fixed = rule.fix(&ctx).unwrap();
1020 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1021 assert!(!fixed.contains("Deleted"));
1022 }
1023
1024 #[test]
1025 fn test_fix_idempotent() {
1026 let rule = MD073TocValidation::new();
1027 let content = r#"# Title
1028
1029<!-- toc -->
1030
1031- [Heading 1](#heading-1)
1032- [Heading 2](#heading-2)
1033
1034<!-- tocstop -->
1035
1036## Heading 1
1037
1038Content.
1039
1040## Heading 2
1041
1042More.
1043"#;
1044 let ctx = create_ctx(content);
1045 let fixed1 = rule.fix(&ctx).unwrap();
1046 let ctx2 = create_ctx(&fixed1);
1047 let fixed2 = rule.fix(&ctx2).unwrap();
1048
1049 assert_eq!(fixed1, fixed2);
1051 }
1052
1053 #[test]
1054 fn test_fix_preserves_markers() {
1055 let rule = MD073TocValidation::new();
1056 let content = r#"# Title
1057
1058<!-- toc -->
1059
1060Old TOC content.
1061
1062<!-- tocstop -->
1063
1064## New Heading
1065
1066Content.
1067"#;
1068 let ctx = create_ctx(content);
1069 let fixed = rule.fix(&ctx).unwrap();
1070
1071 assert!(fixed.contains("<!-- toc -->"));
1073 assert!(fixed.contains("<!-- tocstop -->"));
1074 assert!(fixed.contains("- [New Heading](#new-heading)"));
1076 }
1077
1078 #[test]
1079 fn test_fix_requires_markers() {
1080 let rule = create_enabled_rule();
1081
1082 let content_no_markers = r#"# Title
1084
1085## Heading 1
1086
1087Content.
1088"#;
1089 let ctx = create_ctx(content_no_markers);
1090 let fixed = rule.fix(&ctx).unwrap();
1091 assert_eq!(fixed, content_no_markers);
1092
1093 let content_markers = r#"# Title
1095
1096<!-- toc -->
1097
1098- [Old Entry](#old-entry)
1099
1100<!-- tocstop -->
1101
1102## Heading 1
1103
1104Content.
1105"#;
1106 let ctx = create_ctx(content_markers);
1107 let fixed = rule.fix(&ctx).unwrap();
1108 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1109 assert!(!fixed.contains("Old Entry"));
1110 }
1111
1112 #[test]
1115 fn test_duplicate_heading_anchors() {
1116 let rule = MD073TocValidation::new();
1117 let content = r#"# Title
1118
1119<!-- toc -->
1120
1121<!-- tocstop -->
1122
1123## Duplicate
1124
1125Content.
1126
1127## Duplicate
1128
1129More content.
1130
1131## Duplicate
1132
1133Even more.
1134"#;
1135 let ctx = create_ctx(content);
1136 let region = rule.detect_toc_region(&ctx).unwrap();
1137 let expected = rule.build_expected_toc(&ctx, ®ion);
1138
1139 assert_eq!(expected.len(), 3);
1140 assert_eq!(expected[0].anchor, "duplicate");
1141 assert_eq!(expected[1].anchor, "duplicate-1");
1142 assert_eq!(expected[2].anchor, "duplicate-2");
1143 }
1144
1145 #[test]
1148 fn test_headings_in_code_blocks_ignored() {
1149 let rule = create_enabled_rule();
1150 let content = r#"# Title
1151
1152<!-- toc -->
1153
1154- [Real Heading](#real-heading)
1155
1156<!-- tocstop -->
1157
1158## Real Heading
1159
1160```markdown
1161## Fake Heading In Code
1162```
1163
1164Content.
1165"#;
1166 let ctx = create_ctx(content);
1167 let result = rule.check(&ctx).unwrap();
1168 assert!(result.is_empty(), "Should not report fake heading in code block");
1169 }
1170
1171 #[test]
1172 fn test_empty_toc_region() {
1173 let rule = create_enabled_rule();
1174 let content = r#"# Title
1175
1176<!-- toc -->
1177<!-- tocstop -->
1178
1179## Heading 1
1180
1181Content.
1182"#;
1183 let ctx = create_ctx(content);
1184 let result = rule.check(&ctx).unwrap();
1185 assert_eq!(result.len(), 1);
1186 assert!(result[0].message.contains("Missing entry"));
1187 }
1188
1189 #[test]
1190 fn test_nested_indentation() {
1191 let rule = create_enabled_rule();
1192
1193 let content = r#"<!-- toc -->
1194
1195<!-- tocstop -->
1196
1197## Level 2
1198
1199### Level 3
1200
1201#### Level 4
1202
1203## Another Level 2
1204"#;
1205 let ctx = create_ctx(content);
1206 let region = rule.detect_toc_region(&ctx).unwrap();
1207 let expected = rule.build_expected_toc(&ctx, ®ion);
1208 let toc = rule.generate_toc(&expected);
1209
1210 assert!(toc.contains("- [Level 2](#level-2)"));
1212 assert!(toc.contains(" - [Level 3](#level-3)"));
1213 assert!(toc.contains(" - [Level 4](#level-4)"));
1214 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1215 }
1216
1217 #[test]
1220 fn test_indentation_mismatch_detected() {
1221 let rule = create_enabled_rule();
1222 let content = r#"<!-- toc -->
1224- [Hello](#hello)
1225- [Another](#another)
1226- [Heading](#heading)
1227<!-- tocstop -->
1228
1229## Hello
1230
1231### Another
1232
1233## Heading
1234"#;
1235 let ctx = create_ctx(content);
1236 let result = rule.check(&ctx).unwrap();
1237 assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1239 assert!(
1240 result[0].message.contains("Indentation mismatch"),
1241 "Message should mention indentation: {}",
1242 result[0].message
1243 );
1244 assert!(
1245 result[0].message.contains("Another"),
1246 "Message should mention the entry: {}",
1247 result[0].message
1248 );
1249 }
1250
1251 #[test]
1252 fn test_indentation_mismatch_fixed() {
1253 let rule = create_enabled_rule();
1254 let content = r#"<!-- toc -->
1256- [Hello](#hello)
1257- [Another](#another)
1258- [Heading](#heading)
1259<!-- tocstop -->
1260
1261## Hello
1262
1263### Another
1264
1265## Heading
1266"#;
1267 let ctx = create_ctx(content);
1268 let fixed = rule.fix(&ctx).unwrap();
1269 assert!(fixed.contains("- [Hello](#hello)"));
1271 assert!(fixed.contains(" - [Another](#another)")); assert!(fixed.contains("- [Heading](#heading)"));
1273 }
1274
1275 #[test]
1276 fn test_no_indentation_mismatch_when_correct() {
1277 let rule = create_enabled_rule();
1278 let content = r#"<!-- toc -->
1280- [Hello](#hello)
1281 - [Another](#another)
1282- [Heading](#heading)
1283<!-- tocstop -->
1284
1285## Hello
1286
1287### Another
1288
1289## Heading
1290"#;
1291 let ctx = create_ctx(content);
1292 let result = rule.check(&ctx).unwrap();
1293 assert!(result.is_empty(), "Should not report issues: {result:?}");
1295 }
1296
1297 #[test]
1300 fn test_order_mismatch_detected() {
1301 let rule = create_enabled_rule();
1302 let content = r#"# Title
1303
1304<!-- toc -->
1305
1306- [Section B](#section-b)
1307- [Section A](#section-a)
1308
1309<!-- tocstop -->
1310
1311## Section A
1312
1313Content A.
1314
1315## Section B
1316
1317Content B.
1318"#;
1319 let ctx = create_ctx(content);
1320 let result = rule.check(&ctx).unwrap();
1321 assert!(!result.is_empty(), "Should detect order mismatch");
1324 }
1325
1326 #[test]
1327 fn test_order_mismatch_ignored_when_disabled() {
1328 let mut rule = create_enabled_rule();
1329 rule.enforce_order = false;
1330 let content = r#"# Title
1331
1332<!-- toc -->
1333
1334- [Section B](#section-b)
1335- [Section A](#section-a)
1336
1337<!-- tocstop -->
1338
1339## Section A
1340
1341Content A.
1342
1343## Section B
1344
1345Content B.
1346"#;
1347 let ctx = create_ctx(content);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1351 }
1352
1353 #[test]
1356 fn test_unicode_headings() {
1357 let rule = create_enabled_rule();
1358 let content = r#"# Title
1359
1360<!-- toc -->
1361
1362- [日本語の見出し](#日本語の見出し)
1363- [Émojis 🎉](#émojis-)
1364
1365<!-- tocstop -->
1366
1367## 日本語の見出し
1368
1369Japanese content.
1370
1371## Émojis 🎉
1372
1373Content with emojis.
1374"#;
1375 let ctx = create_ctx(content);
1376 let result = rule.check(&ctx).unwrap();
1377 assert!(result.is_empty(), "Should handle unicode headings");
1379 }
1380
1381 #[test]
1382 fn test_special_characters_in_headings() {
1383 let rule = create_enabled_rule();
1384 let content = r#"# Title
1385
1386<!-- toc -->
1387
1388- [What's New?](#whats-new)
1389- [C++ Guide](#c-guide)
1390
1391<!-- tocstop -->
1392
1393## What's New?
1394
1395News content.
1396
1397## C++ Guide
1398
1399C++ content.
1400"#;
1401 let ctx = create_ctx(content);
1402 let result = rule.check(&ctx).unwrap();
1403 assert!(result.is_empty(), "Should handle special characters");
1404 }
1405
1406 #[test]
1407 fn test_code_spans_in_headings() {
1408 let rule = create_enabled_rule();
1409 let content = r#"# Title
1410
1411<!-- toc -->
1412
1413- [`check [PATHS...]`](#check-paths)
1414
1415<!-- tocstop -->
1416
1417## `check [PATHS...]`
1418
1419Command documentation.
1420"#;
1421 let ctx = create_ctx(content);
1422 let result = rule.check(&ctx).unwrap();
1423 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1424 }
1425
1426 #[test]
1429 fn test_from_config_defaults() {
1430 let config = crate::config::Config::default();
1431 let rule = MD073TocValidation::from_config(&config);
1432 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1433
1434 assert_eq!(rule.min_level, 2);
1435 assert_eq!(rule.max_level, 4);
1436 assert!(rule.enforce_order);
1437 assert_eq!(rule.indent, 2);
1438 }
1439
1440 #[test]
1441 fn test_indent_from_md007_config() {
1442 use crate::config::{Config, RuleConfig};
1443 use std::collections::BTreeMap;
1444
1445 let mut config = Config::default();
1446
1447 let mut md007_values = BTreeMap::new();
1449 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1450 config.rules.insert(
1451 "MD007".to_string(),
1452 RuleConfig {
1453 severity: None,
1454 values: md007_values,
1455 },
1456 );
1457
1458 let rule = MD073TocValidation::from_config(&config);
1459 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1460
1461 assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1462 }
1463
1464 #[test]
1465 fn test_indent_md073_overrides_md007() {
1466 use crate::config::{Config, RuleConfig};
1467 use std::collections::BTreeMap;
1468
1469 let mut config = Config::default();
1470
1471 let mut md007_values = BTreeMap::new();
1473 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1474 config.rules.insert(
1475 "MD007".to_string(),
1476 RuleConfig {
1477 severity: None,
1478 values: md007_values,
1479 },
1480 );
1481
1482 let mut md073_values = BTreeMap::new();
1484 md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1485 md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1486 config.rules.insert(
1487 "MD073".to_string(),
1488 RuleConfig {
1489 severity: None,
1490 values: md073_values,
1491 },
1492 );
1493
1494 let rule = MD073TocValidation::from_config(&config);
1495 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1496
1497 assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1498 }
1499
1500 #[test]
1501 fn test_generate_toc_with_4_space_indent() {
1502 let mut rule = create_enabled_rule();
1503 rule.indent = 4;
1504
1505 let content = r#"<!-- toc -->
1506
1507<!-- tocstop -->
1508
1509## Level 2
1510
1511### Level 3
1512
1513#### Level 4
1514
1515## Another Level 2
1516"#;
1517 let ctx = create_ctx(content);
1518 let region = rule.detect_toc_region(&ctx).unwrap();
1519 let expected = rule.build_expected_toc(&ctx, ®ion);
1520 let toc = rule.generate_toc(&expected);
1521
1522 assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1527 assert!(
1528 toc.contains(" - [Level 3](#level-3)"),
1529 "Level 3 should have 4-space indent"
1530 );
1531 assert!(
1532 toc.contains(" - [Level 4](#level-4)"),
1533 "Level 4 should have 8-space indent"
1534 );
1535 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1536 }
1537
1538 #[test]
1539 fn test_validate_toc_with_4_space_indent() {
1540 let mut rule = create_enabled_rule();
1541 rule.indent = 4;
1542
1543 let content = r#"<!-- toc -->
1545- [Hello](#hello)
1546 - [Another](#another)
1547- [Heading](#heading)
1548<!-- tocstop -->
1549
1550## Hello
1551
1552### Another
1553
1554## Heading
1555"#;
1556 let ctx = create_ctx(content);
1557 let result = rule.check(&ctx).unwrap();
1558 assert!(
1559 result.is_empty(),
1560 "Should accept 4-space indent when configured: {result:?}"
1561 );
1562 }
1563
1564 #[test]
1565 fn test_validate_toc_wrong_indent_with_4_space_config() {
1566 let mut rule = create_enabled_rule();
1567 rule.indent = 4;
1568
1569 let content = r#"<!-- toc -->
1571- [Hello](#hello)
1572 - [Another](#another)
1573- [Heading](#heading)
1574<!-- tocstop -->
1575
1576## Hello
1577
1578### Another
1579
1580## Heading
1581"#;
1582 let ctx = create_ctx(content);
1583 let result = rule.check(&ctx).unwrap();
1584 assert_eq!(result.len(), 1, "Should detect wrong indent");
1585 assert!(
1586 result[0].message.contains("Indentation mismatch"),
1587 "Should report indentation mismatch: {}",
1588 result[0].message
1589 );
1590 assert!(
1591 result[0].message.contains("expected 4 spaces"),
1592 "Should mention expected 4 spaces: {}",
1593 result[0].message
1594 );
1595 }
1596
1597 #[test]
1600 fn test_strip_markdown_formatting_link() {
1601 let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1602 assert_eq!(result, "Tool: terminal");
1603 }
1604
1605 #[test]
1606 fn test_strip_markdown_formatting_bold() {
1607 let result = strip_markdown_formatting("This is **bold** text");
1608 assert_eq!(result, "This is bold text");
1609
1610 let result = strip_markdown_formatting("This is __bold__ text");
1611 assert_eq!(result, "This is bold text");
1612 }
1613
1614 #[test]
1615 fn test_strip_markdown_formatting_italic() {
1616 let result = strip_markdown_formatting("This is *italic* text");
1617 assert_eq!(result, "This is italic text");
1618
1619 let result = strip_markdown_formatting("This is _italic_ text");
1620 assert_eq!(result, "This is italic text");
1621 }
1622
1623 #[test]
1624 fn test_strip_markdown_formatting_code_span() {
1625 let result = strip_markdown_formatting("Use the `format` function");
1626 assert_eq!(result, "Use the format function");
1627 }
1628
1629 #[test]
1630 fn test_strip_markdown_formatting_image() {
1631 let result = strip_markdown_formatting("See  for details");
1632 assert_eq!(result, "See logo for details");
1633 }
1634
1635 #[test]
1636 fn test_strip_markdown_formatting_reference_link() {
1637 let result = strip_markdown_formatting("See [documentation][docs] for details");
1638 assert_eq!(result, "See documentation for details");
1639 }
1640
1641 #[test]
1642 fn test_strip_markdown_formatting_combined() {
1643 let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1645 assert_eq!(result, "Tool: terminal");
1646 }
1647
1648 #[test]
1649 fn test_toc_with_link_in_heading_matches_stripped_text() {
1650 let rule = create_enabled_rule();
1651
1652 let content = r#"# Title
1654
1655<!-- toc -->
1656
1657- [Tool: terminal](#tool-terminal)
1658
1659<!-- tocstop -->
1660
1661## Tool: [terminal](https://example.com)
1662
1663Content here.
1664"#;
1665 let ctx = create_ctx(content);
1666 let result = rule.check(&ctx).unwrap();
1667 assert!(
1668 result.is_empty(),
1669 "Stripped heading text should match TOC entry: {result:?}"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_toc_with_simplified_text_still_mismatches() {
1675 let rule = create_enabled_rule();
1676
1677 let content = r#"# Title
1679
1680<!-- toc -->
1681
1682- [terminal](#tool-terminal)
1683
1684<!-- tocstop -->
1685
1686## Tool: [terminal](https://example.com)
1687
1688Content here.
1689"#;
1690 let ctx = create_ctx(content);
1691 let result = rule.check(&ctx).unwrap();
1692 assert_eq!(result.len(), 1, "Should report text mismatch");
1693 assert!(result[0].message.contains("Text mismatch"));
1694 }
1695
1696 #[test]
1697 fn test_fix_generates_stripped_toc_entries() {
1698 let rule = MD073TocValidation::new();
1699 let content = r#"# Title
1700
1701<!-- toc -->
1702
1703<!-- tocstop -->
1704
1705## Tool: [busybox](https://www.busybox.net/)
1706
1707Content.
1708
1709## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1710
1711More content.
1712"#;
1713 let ctx = create_ctx(content);
1714 let fixed = rule.fix(&ctx).unwrap();
1715
1716 assert!(
1718 fixed.contains("- [Tool: busybox](#tool-busybox)"),
1719 "TOC entry should have stripped link text"
1720 );
1721 assert!(
1722 fixed.contains("- [Tool: mount](#tool-mount)"),
1723 "TOC entry should have stripped link text"
1724 );
1725 let toc_start = fixed.find("<!-- toc -->").unwrap();
1728 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1729 let toc_content = &fixed[toc_start..toc_end];
1730 assert!(
1731 !toc_content.contains("busybox.net"),
1732 "TOC should not contain URLs: {toc_content}"
1733 );
1734 assert!(
1735 !toc_content.contains("wikipedia.org"),
1736 "TOC should not contain URLs: {toc_content}"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_fix_with_bold_in_heading() {
1742 let rule = MD073TocValidation::new();
1743 let content = r#"# Title
1744
1745<!-- toc -->
1746
1747<!-- tocstop -->
1748
1749## **Important** Section
1750
1751Content.
1752"#;
1753 let ctx = create_ctx(content);
1754 let fixed = rule.fix(&ctx).unwrap();
1755
1756 assert!(fixed.contains("- [Important Section](#important-section)"));
1758 }
1759
1760 #[test]
1761 fn test_fix_with_code_in_heading() {
1762 let rule = MD073TocValidation::new();
1763 let content = r#"# Title
1764
1765<!-- toc -->
1766
1767<!-- tocstop -->
1768
1769## Using `async` Functions
1770
1771Content.
1772"#;
1773 let ctx = create_ctx(content);
1774 let fixed = rule.fix(&ctx).unwrap();
1775
1776 assert!(fixed.contains("- [Using async Functions](#using-async-functions)"));
1778 }
1779
1780 #[test]
1783 fn test_custom_anchor_id_respected() {
1784 let rule = create_enabled_rule();
1785 let content = r#"# Title
1786
1787<!-- toc -->
1788
1789- [My Section](#my-custom-anchor)
1790
1791<!-- tocstop -->
1792
1793## My Section {#my-custom-anchor}
1794
1795Content here.
1796"#;
1797 let ctx = create_ctx(content);
1798 let result = rule.check(&ctx).unwrap();
1799 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1800 }
1801
1802 #[test]
1803 fn test_custom_anchor_id_in_generated_toc() {
1804 let rule = create_enabled_rule();
1805 let content = r#"# Title
1806
1807<!-- toc -->
1808
1809<!-- tocstop -->
1810
1811## First Section {#custom-first}
1812
1813Content.
1814
1815## Second Section {#another-custom}
1816
1817More content.
1818"#;
1819 let ctx = create_ctx(content);
1820 let fixed = rule.fix(&ctx).unwrap();
1821 assert!(fixed.contains("- [First Section](#custom-first)"));
1822 assert!(fixed.contains("- [Second Section](#another-custom)"));
1823 }
1824
1825 #[test]
1826 fn test_mixed_custom_and_generated_anchors() {
1827 let rule = create_enabled_rule();
1828 let content = r#"# Title
1829
1830<!-- toc -->
1831
1832- [Custom Section](#my-id)
1833- [Normal Section](#normal-section)
1834
1835<!-- tocstop -->
1836
1837## Custom Section {#my-id}
1838
1839Content.
1840
1841## Normal Section
1842
1843More content.
1844"#;
1845 let ctx = create_ctx(content);
1846 let result = rule.check(&ctx).unwrap();
1847 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
1848 }
1849
1850 #[test]
1853 fn test_github_anchor_style() {
1854 let rule = create_enabled_rule();
1855
1856 let content = r#"<!-- toc -->
1857
1858<!-- tocstop -->
1859
1860## Test_With_Underscores
1861
1862Content.
1863"#;
1864 let ctx = create_ctx(content);
1865 let region = rule.detect_toc_region(&ctx).unwrap();
1866 let expected = rule.build_expected_toc(&ctx, ®ion);
1867
1868 assert_eq!(expected[0].anchor, "test_with_underscores");
1870 }
1871
1872 #[test]
1875 fn test_stress_many_headings() {
1876 let rule = create_enabled_rule();
1877
1878 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1880
1881 for i in 1..=150 {
1882 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
1883 }
1884
1885 let ctx = create_ctx(&content);
1886
1887 let result = rule.check(&ctx).unwrap();
1889
1890 assert_eq!(result.len(), 1, "Should report single warning for TOC");
1892 assert!(result[0].message.contains("Missing entry"));
1893
1894 let fixed = rule.fix(&ctx).unwrap();
1896 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
1897 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
1898 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
1899 }
1900
1901 #[test]
1902 fn test_stress_deeply_nested() {
1903 let rule = create_enabled_rule();
1904 let content = r#"# Title
1905
1906<!-- toc -->
1907
1908<!-- tocstop -->
1909
1910## Level 2 A
1911
1912### Level 3 A
1913
1914#### Level 4 A
1915
1916## Level 2 B
1917
1918### Level 3 B
1919
1920#### Level 4 B
1921
1922## Level 2 C
1923
1924### Level 3 C
1925
1926#### Level 4 C
1927
1928## Level 2 D
1929
1930### Level 3 D
1931
1932#### Level 4 D
1933"#;
1934 let ctx = create_ctx(content);
1935 let fixed = rule.fix(&ctx).unwrap();
1936
1937 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
1939 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
1940 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
1941 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
1942 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
1943 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
1944 }
1945
1946 #[test]
1949 fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
1950 let rule = create_enabled_rule();
1951 let content = r#"# Document
1955
1956<!-- toc -->
1957
1958- [Example](#example)
1959- [Another](#another)
1960- [Example](#example)
1961
1962<!-- tocstop -->
1963
1964## Example
1965First.
1966
1967## Another
1968Middle.
1969
1970## Example
1971Second.
1972"#;
1973 let ctx = create_ctx(content);
1974 let result = rule.check(&ctx).unwrap();
1975
1976 assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
1979 assert!(
1980 result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
1981 "Should report missing or stale entries for duplicate anchors. Got: {}",
1982 result[0].message
1983 );
1984 }
1985
1986 #[test]
1989 fn test_strip_double_backtick_code_span() {
1990 let result = strip_markdown_formatting("Using ``code with ` backtick``");
1992 assert_eq!(
1993 result, "Using code with ` backtick",
1994 "Should strip double-backtick code spans"
1995 );
1996 }
1997
1998 #[test]
1999 fn test_strip_triple_backtick_code_span() {
2000 let result = strip_markdown_formatting("Using ```code with `` backticks```");
2002 assert_eq!(
2003 result, "Using code with `` backticks",
2004 "Should strip triple-backtick code spans"
2005 );
2006 }
2007
2008 #[test]
2009 fn test_toc_with_double_backtick_heading() {
2010 let rule = create_enabled_rule();
2011 let content = r#"# Title
2012
2013<!-- toc -->
2014
2015- [Using code with backtick](#using-code-with-backtick)
2016
2017<!-- tocstop -->
2018
2019## Using ``code with ` backtick``
2020
2021Content here.
2022"#;
2023 let ctx = create_ctx(content);
2024 let fixed = rule.fix(&ctx).unwrap();
2028 assert!(
2030 fixed.contains("code with ` backtick") || fixed.contains("code with backtick"),
2031 "Fix should strip double-backtick code span from heading. Got TOC: {}",
2032 &fixed[fixed.find("<!-- toc -->").unwrap()..fixed.find("<!-- tocstop -->").unwrap()]
2033 );
2034 }
2035
2036 #[test]
2037 fn test_stress_many_duplicates() {
2038 let rule = create_enabled_rule();
2039
2040 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2042 for _ in 0..50 {
2043 content.push_str("## FAQ\n\nContent.\n\n");
2044 }
2045
2046 let ctx = create_ctx(&content);
2047 let region = rule.detect_toc_region(&ctx).unwrap();
2048 let expected = rule.build_expected_toc(&ctx, ®ion);
2049
2050 assert_eq!(expected.len(), 50);
2052 assert_eq!(expected[0].anchor, "faq");
2053 assert_eq!(expected[1].anchor, "faq-1");
2054 assert_eq!(expected[49].anchor, "faq-49");
2055 }
2056
2057 #[test]
2060 fn test_roundtrip_check_and_fix_alignment() {
2061 let rule = create_enabled_rule();
2062
2063 let inputs = [
2064 "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2066 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2068 "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2070 "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2072 ];
2073
2074 for input in &inputs {
2075 let ctx = create_ctx(input);
2076 let fixed = rule.fix(&ctx).unwrap();
2077
2078 let ctx2 = create_ctx(&fixed);
2080 let fixed_twice = rule.fix(&ctx2).unwrap();
2081 assert_eq!(
2082 fixed, fixed_twice,
2083 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
2084 );
2085
2086 let warnings_after = rule.check(&ctx2).unwrap();
2088 assert!(
2089 warnings_after.is_empty(),
2090 "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2091 );
2092 }
2093 }
2094
2095 #[test]
2098 fn test_no_mismatch_preserves_content() {
2099 let rule = create_enabled_rule();
2100
2101 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";
2102 let ctx = create_ctx(content);
2103
2104 let warnings = rule.check(&ctx).unwrap();
2105 assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2106
2107 let fixed = rule.fix(&ctx).unwrap();
2108 assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2109 }
2110
2111 #[test]
2113 fn test_inline_disable_preserves_toc() {
2114 let rule = create_enabled_rule();
2115
2116 let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2118 let ctx = create_ctx(content);
2119
2120 let fixed = rule.fix(&ctx).unwrap();
2121 assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2122 }
2123}