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 has_toc_marker = ctx.content.contains("<!-- toc") || ctx.content.contains("<!--toc");
562 !has_toc_marker
563 }
564
565 fn check(&self, ctx: &LintContext) -> LintResult {
566 let mut warnings = Vec::new();
567
568 let Some(region) = self.detect_toc_region(ctx) else {
570 return Ok(warnings);
572 };
573
574 let actual_entries = self.extract_toc_entries(ctx, ®ion);
576
577 let expected_entries = self.build_expected_toc(ctx, ®ion);
579
580 if expected_entries.is_empty() && actual_entries.is_empty() {
582 return Ok(warnings);
583 }
584
585 let mismatches = self.validate_toc(&actual_entries, &expected_entries);
587
588 if !mismatches.is_empty() {
589 let mut details = Vec::new();
591
592 for mismatch in &mismatches {
593 match mismatch {
594 TocMismatch::StaleEntry { entry } => {
595 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
596 }
597 TocMismatch::MissingEntry { expected } => {
598 details.push(format!(
599 "Missing entry: '{}' (line {})",
600 expected.text, expected.heading_line
601 ));
602 }
603 TocMismatch::TextMismatch { entry, expected } => {
604 details.push(format!(
605 "Text mismatch: TOC has '{}', heading is '{}'",
606 entry.text, expected.text
607 ));
608 }
609 TocMismatch::OrderMismatch {
610 entry,
611 expected_position,
612 } => {
613 details.push(format!(
614 "Order mismatch: '{}' should be at position {}",
615 entry.text, expected_position
616 ));
617 }
618 TocMismatch::IndentationMismatch {
619 entry,
620 actual_indent,
621 expected_indent,
622 ..
623 } => {
624 details.push(format!(
625 "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
626 entry.text, actual_indent, expected_indent
627 ));
628 }
629 }
630 }
631
632 let message = format!(
633 "Table of Contents does not match document headings: {}",
634 details.join("; ")
635 );
636
637 let new_toc = self.generate_toc(&expected_entries);
639 let fix_range = region.content_start..region.content_end;
640
641 warnings.push(LintWarning {
642 rule_name: Some(self.name().to_string()),
643 message,
644 line: region.start_line,
645 column: 1,
646 end_line: region.end_line,
647 end_column: 1,
648 severity: Severity::Warning,
649 fix: Some(Fix {
650 range: fix_range,
651 replacement: new_toc,
652 }),
653 });
654 }
655
656 Ok(warnings)
657 }
658
659 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
660 let Some(region) = self.detect_toc_region(ctx) else {
662 return Ok(ctx.content.to_string());
664 };
665
666 let expected_entries = self.build_expected_toc(ctx, ®ion);
668
669 let new_toc = self.generate_toc(&expected_entries);
671
672 let mut result = String::with_capacity(ctx.content.len());
674 result.push_str(&ctx.content[..region.content_start]);
675 result.push_str(&new_toc);
676 result.push_str(&ctx.content[region.content_end..]);
677
678 Ok(result)
679 }
680
681 fn category(&self) -> RuleCategory {
682 RuleCategory::Other
683 }
684
685 fn as_any(&self) -> &dyn std::any::Any {
686 self
687 }
688
689 fn default_config_section(&self) -> Option<(String, toml::Value)> {
690 let value: toml::Value = toml::from_str(
691 r#"
692# Whether this rule is enabled (opt-in, disabled by default)
693enabled = false
694# Minimum heading level to include
695min-level = 2
696# Maximum heading level to include
697max-level = 4
698# Whether TOC order must match document order
699enforce-order = true
700# Indentation per nesting level (defaults to MD007's indent value)
701indent = 2
702"#,
703 )
704 .ok()?;
705 Some(("MD073".to_string(), value))
706 }
707
708 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
709 where
710 Self: Sized,
711 {
712 let mut rule = MD073TocValidation::default();
713 let mut indent_from_md073 = false;
714
715 if let Some(rule_config) = config.rules.get("MD073") {
716 if let Some(enabled) = rule_config.values.get("enabled").and_then(|v| v.as_bool()) {
718 rule.enabled = enabled;
719 }
720
721 if let Some(min_level) = rule_config.values.get("min-level").and_then(|v| v.as_integer()) {
723 rule.min_level = (min_level.clamp(1, 6)) as u8;
724 }
725
726 if let Some(max_level) = rule_config.values.get("max-level").and_then(|v| v.as_integer()) {
728 rule.max_level = (max_level.clamp(1, 6)) as u8;
729 }
730
731 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(|v| v.as_bool()) {
733 rule.enforce_order = enforce_order;
734 }
735
736 if let Some(indent) = rule_config.values.get("indent").and_then(|v| v.as_integer()) {
738 rule.indent = (indent.clamp(1, 8)) as usize;
739 indent_from_md073 = true;
740 }
741 }
742
743 if !indent_from_md073
745 && let Some(md007_config) = config.rules.get("MD007")
746 && let Some(indent) = md007_config.values.get("indent").and_then(|v| v.as_integer())
747 {
748 rule.indent = (indent.clamp(1, 8)) as usize;
749 }
750
751 Box::new(rule)
752 }
753}
754
755#[cfg(test)]
756mod tests {
757 use super::*;
758 use crate::config::MarkdownFlavor;
759
760 fn create_ctx(content: &str) -> LintContext<'_> {
761 LintContext::new(content, MarkdownFlavor::Standard, None)
762 }
763
764 fn create_enabled_rule() -> MD073TocValidation {
766 MD073TocValidation {
767 enabled: true,
768 ..MD073TocValidation::default()
769 }
770 }
771
772 #[test]
775 fn test_detect_markers_basic() {
776 let rule = MD073TocValidation::new();
777 let content = r#"# Title
778
779<!-- toc -->
780
781- [Heading 1](#heading-1)
782
783<!-- tocstop -->
784
785## Heading 1
786
787Content here.
788"#;
789 let ctx = create_ctx(content);
790 let region = rule.detect_by_markers(&ctx);
791 assert!(region.is_some());
792 let region = region.unwrap();
793 assert_eq!(region.start_line, 4);
795 assert_eq!(region.end_line, 6);
796 }
797
798 #[test]
799 fn test_detect_markers_variations() {
800 let rule = MD073TocValidation::new();
801
802 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
804 let ctx1 = create_ctx(content1);
805 assert!(rule.detect_by_markers(&ctx1).is_some());
806
807 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
809 let ctx2 = create_ctx(content2);
810 assert!(rule.detect_by_markers(&ctx2).is_some());
811
812 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
814 let ctx3 = create_ctx(content3);
815 assert!(rule.detect_by_markers(&ctx3).is_some());
816 }
817
818 #[test]
819 fn test_no_toc_region() {
820 let rule = MD073TocValidation::new();
821 let content = r#"# Title
822
823## Heading 1
824
825Content here.
826
827## Heading 2
828
829More content.
830"#;
831 let ctx = create_ctx(content);
832 let region = rule.detect_toc_region(&ctx);
833 assert!(region.is_none());
834 }
835
836 #[test]
839 fn test_toc_matches_headings() {
840 let rule = create_enabled_rule();
841 let content = r#"# Title
842
843<!-- toc -->
844
845- [Heading 1](#heading-1)
846- [Heading 2](#heading-2)
847
848<!-- tocstop -->
849
850## Heading 1
851
852Content.
853
854## Heading 2
855
856More content.
857"#;
858 let ctx = create_ctx(content);
859 let result = rule.check(&ctx).unwrap();
860 assert!(result.is_empty(), "Expected no warnings for matching TOC");
861 }
862
863 #[test]
864 fn test_missing_entry() {
865 let rule = create_enabled_rule();
866 let content = r#"# Title
867
868<!-- toc -->
869
870- [Heading 1](#heading-1)
871
872<!-- tocstop -->
873
874## Heading 1
875
876Content.
877
878## Heading 2
879
880New heading not in TOC.
881"#;
882 let ctx = create_ctx(content);
883 let result = rule.check(&ctx).unwrap();
884 assert_eq!(result.len(), 1);
885 assert!(result[0].message.contains("Missing entry"));
886 assert!(result[0].message.contains("Heading 2"));
887 }
888
889 #[test]
890 fn test_stale_entry() {
891 let rule = create_enabled_rule();
892 let content = r#"# Title
893
894<!-- toc -->
895
896- [Heading 1](#heading-1)
897- [Deleted Heading](#deleted-heading)
898
899<!-- tocstop -->
900
901## Heading 1
902
903Content.
904"#;
905 let ctx = create_ctx(content);
906 let result = rule.check(&ctx).unwrap();
907 assert_eq!(result.len(), 1);
908 assert!(result[0].message.contains("Stale entry"));
909 assert!(result[0].message.contains("Deleted Heading"));
910 }
911
912 #[test]
913 fn test_text_mismatch() {
914 let rule = create_enabled_rule();
915 let content = r#"# Title
916
917<!-- toc -->
918
919- [Old Name](#heading-1)
920
921<!-- tocstop -->
922
923## Heading 1
924
925Content.
926"#;
927 let ctx = create_ctx(content);
928 let result = rule.check(&ctx).unwrap();
929 assert_eq!(result.len(), 1);
930 assert!(result[0].message.contains("Text mismatch"));
931 }
932
933 #[test]
936 fn test_min_level_excludes_h1() {
937 let mut rule = MD073TocValidation::new();
938 rule.min_level = 2;
939
940 let content = r#"<!-- toc -->
941
942<!-- tocstop -->
943
944# Should Be Excluded
945
946## Should Be Included
947
948Content.
949"#;
950 let ctx = create_ctx(content);
951 let region = rule.detect_toc_region(&ctx).unwrap();
952 let expected = rule.build_expected_toc(&ctx, ®ion);
953
954 assert_eq!(expected.len(), 1);
955 assert_eq!(expected[0].text, "Should Be Included");
956 }
957
958 #[test]
959 fn test_max_level_excludes_h5_h6() {
960 let mut rule = MD073TocValidation::new();
961 rule.max_level = 4;
962
963 let content = r#"<!-- toc -->
964
965<!-- tocstop -->
966
967## Level 2
968
969### Level 3
970
971#### Level 4
972
973##### Level 5 Should Be Excluded
974
975###### Level 6 Should Be Excluded
976"#;
977 let ctx = create_ctx(content);
978 let region = rule.detect_toc_region(&ctx).unwrap();
979 let expected = rule.build_expected_toc(&ctx, ®ion);
980
981 assert_eq!(expected.len(), 3);
982 assert!(expected.iter().all(|e| e.level <= 4));
983 }
984
985 #[test]
988 fn test_fix_adds_missing_entry() {
989 let rule = MD073TocValidation::new();
990 let content = r#"# Title
991
992<!-- toc -->
993
994- [Heading 1](#heading-1)
995
996<!-- tocstop -->
997
998## Heading 1
999
1000Content.
1001
1002## Heading 2
1003
1004New heading.
1005"#;
1006 let ctx = create_ctx(content);
1007 let fixed = rule.fix(&ctx).unwrap();
1008 assert!(fixed.contains("- [Heading 2](#heading-2)"));
1009 }
1010
1011 #[test]
1012 fn test_fix_removes_stale_entry() {
1013 let rule = MD073TocValidation::new();
1014 let content = r#"# Title
1015
1016<!-- toc -->
1017
1018- [Heading 1](#heading-1)
1019- [Deleted](#deleted)
1020
1021<!-- tocstop -->
1022
1023## Heading 1
1024
1025Content.
1026"#;
1027 let ctx = create_ctx(content);
1028 let fixed = rule.fix(&ctx).unwrap();
1029 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1030 assert!(!fixed.contains("Deleted"));
1031 }
1032
1033 #[test]
1034 fn test_fix_idempotent() {
1035 let rule = MD073TocValidation::new();
1036 let content = r#"# Title
1037
1038<!-- toc -->
1039
1040- [Heading 1](#heading-1)
1041- [Heading 2](#heading-2)
1042
1043<!-- tocstop -->
1044
1045## Heading 1
1046
1047Content.
1048
1049## Heading 2
1050
1051More.
1052"#;
1053 let ctx = create_ctx(content);
1054 let fixed1 = rule.fix(&ctx).unwrap();
1055 let ctx2 = create_ctx(&fixed1);
1056 let fixed2 = rule.fix(&ctx2).unwrap();
1057
1058 assert_eq!(fixed1, fixed2);
1060 }
1061
1062 #[test]
1063 fn test_fix_preserves_markers() {
1064 let rule = MD073TocValidation::new();
1065 let content = r#"# Title
1066
1067<!-- toc -->
1068
1069Old TOC content.
1070
1071<!-- tocstop -->
1072
1073## New Heading
1074
1075Content.
1076"#;
1077 let ctx = create_ctx(content);
1078 let fixed = rule.fix(&ctx).unwrap();
1079
1080 assert!(fixed.contains("<!-- toc -->"));
1082 assert!(fixed.contains("<!-- tocstop -->"));
1083 assert!(fixed.contains("- [New Heading](#new-heading)"));
1085 }
1086
1087 #[test]
1088 fn test_fix_requires_markers() {
1089 let rule = create_enabled_rule();
1090
1091 let content_no_markers = r#"# Title
1093
1094## Heading 1
1095
1096Content.
1097"#;
1098 let ctx = create_ctx(content_no_markers);
1099 let fixed = rule.fix(&ctx).unwrap();
1100 assert_eq!(fixed, content_no_markers);
1101
1102 let content_markers = r#"# Title
1104
1105<!-- toc -->
1106
1107- [Old Entry](#old-entry)
1108
1109<!-- tocstop -->
1110
1111## Heading 1
1112
1113Content.
1114"#;
1115 let ctx = create_ctx(content_markers);
1116 let fixed = rule.fix(&ctx).unwrap();
1117 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1118 assert!(!fixed.contains("Old Entry"));
1119 }
1120
1121 #[test]
1124 fn test_duplicate_heading_anchors() {
1125 let rule = MD073TocValidation::new();
1126 let content = r#"# Title
1127
1128<!-- toc -->
1129
1130<!-- tocstop -->
1131
1132## Duplicate
1133
1134Content.
1135
1136## Duplicate
1137
1138More content.
1139
1140## Duplicate
1141
1142Even more.
1143"#;
1144 let ctx = create_ctx(content);
1145 let region = rule.detect_toc_region(&ctx).unwrap();
1146 let expected = rule.build_expected_toc(&ctx, ®ion);
1147
1148 assert_eq!(expected.len(), 3);
1149 assert_eq!(expected[0].anchor, "duplicate");
1150 assert_eq!(expected[1].anchor, "duplicate-1");
1151 assert_eq!(expected[2].anchor, "duplicate-2");
1152 }
1153
1154 #[test]
1157 fn test_headings_in_code_blocks_ignored() {
1158 let rule = create_enabled_rule();
1159 let content = r#"# Title
1160
1161<!-- toc -->
1162
1163- [Real Heading](#real-heading)
1164
1165<!-- tocstop -->
1166
1167## Real Heading
1168
1169```markdown
1170## Fake Heading In Code
1171```
1172
1173Content.
1174"#;
1175 let ctx = create_ctx(content);
1176 let result = rule.check(&ctx).unwrap();
1177 assert!(result.is_empty(), "Should not report fake heading in code block");
1178 }
1179
1180 #[test]
1181 fn test_empty_toc_region() {
1182 let rule = create_enabled_rule();
1183 let content = r#"# Title
1184
1185<!-- toc -->
1186<!-- tocstop -->
1187
1188## Heading 1
1189
1190Content.
1191"#;
1192 let ctx = create_ctx(content);
1193 let result = rule.check(&ctx).unwrap();
1194 assert_eq!(result.len(), 1);
1195 assert!(result[0].message.contains("Missing entry"));
1196 }
1197
1198 #[test]
1199 fn test_nested_indentation() {
1200 let rule = create_enabled_rule();
1201
1202 let content = r#"<!-- toc -->
1203
1204<!-- tocstop -->
1205
1206## Level 2
1207
1208### Level 3
1209
1210#### Level 4
1211
1212## Another Level 2
1213"#;
1214 let ctx = create_ctx(content);
1215 let region = rule.detect_toc_region(&ctx).unwrap();
1216 let expected = rule.build_expected_toc(&ctx, ®ion);
1217 let toc = rule.generate_toc(&expected);
1218
1219 assert!(toc.contains("- [Level 2](#level-2)"));
1221 assert!(toc.contains(" - [Level 3](#level-3)"));
1222 assert!(toc.contains(" - [Level 4](#level-4)"));
1223 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1224 }
1225
1226 #[test]
1229 fn test_indentation_mismatch_detected() {
1230 let rule = create_enabled_rule();
1231 let content = r#"<!-- toc -->
1233- [Hello](#hello)
1234- [Another](#another)
1235- [Heading](#heading)
1236<!-- tocstop -->
1237
1238## Hello
1239
1240### Another
1241
1242## Heading
1243"#;
1244 let ctx = create_ctx(content);
1245 let result = rule.check(&ctx).unwrap();
1246 assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1248 assert!(
1249 result[0].message.contains("Indentation mismatch"),
1250 "Message should mention indentation: {}",
1251 result[0].message
1252 );
1253 assert!(
1254 result[0].message.contains("Another"),
1255 "Message should mention the entry: {}",
1256 result[0].message
1257 );
1258 }
1259
1260 #[test]
1261 fn test_indentation_mismatch_fixed() {
1262 let rule = create_enabled_rule();
1263 let content = r#"<!-- toc -->
1265- [Hello](#hello)
1266- [Another](#another)
1267- [Heading](#heading)
1268<!-- tocstop -->
1269
1270## Hello
1271
1272### Another
1273
1274## Heading
1275"#;
1276 let ctx = create_ctx(content);
1277 let fixed = rule.fix(&ctx).unwrap();
1278 assert!(fixed.contains("- [Hello](#hello)"));
1280 assert!(fixed.contains(" - [Another](#another)")); assert!(fixed.contains("- [Heading](#heading)"));
1282 }
1283
1284 #[test]
1285 fn test_no_indentation_mismatch_when_correct() {
1286 let rule = create_enabled_rule();
1287 let content = r#"<!-- toc -->
1289- [Hello](#hello)
1290 - [Another](#another)
1291- [Heading](#heading)
1292<!-- tocstop -->
1293
1294## Hello
1295
1296### Another
1297
1298## Heading
1299"#;
1300 let ctx = create_ctx(content);
1301 let result = rule.check(&ctx).unwrap();
1302 assert!(result.is_empty(), "Should not report issues: {result:?}");
1304 }
1305
1306 #[test]
1309 fn test_order_mismatch_detected() {
1310 let rule = create_enabled_rule();
1311 let content = r#"# Title
1312
1313<!-- toc -->
1314
1315- [Section B](#section-b)
1316- [Section A](#section-a)
1317
1318<!-- tocstop -->
1319
1320## Section A
1321
1322Content A.
1323
1324## Section B
1325
1326Content B.
1327"#;
1328 let ctx = create_ctx(content);
1329 let result = rule.check(&ctx).unwrap();
1330 assert!(!result.is_empty(), "Should detect order mismatch");
1333 }
1334
1335 #[test]
1336 fn test_order_mismatch_ignored_when_disabled() {
1337 let mut rule = create_enabled_rule();
1338 rule.enforce_order = false;
1339 let content = r#"# Title
1340
1341<!-- toc -->
1342
1343- [Section B](#section-b)
1344- [Section A](#section-a)
1345
1346<!-- tocstop -->
1347
1348## Section A
1349
1350Content A.
1351
1352## Section B
1353
1354Content B.
1355"#;
1356 let ctx = create_ctx(content);
1357 let result = rule.check(&ctx).unwrap();
1358 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1360 }
1361
1362 #[test]
1365 fn test_unicode_headings() {
1366 let rule = create_enabled_rule();
1367 let content = r#"# Title
1368
1369<!-- toc -->
1370
1371- [日本語の見出し](#日本語の見出し)
1372- [Émojis 🎉](#émojis-)
1373
1374<!-- tocstop -->
1375
1376## 日本語の見出し
1377
1378Japanese content.
1379
1380## Émojis 🎉
1381
1382Content with emojis.
1383"#;
1384 let ctx = create_ctx(content);
1385 let result = rule.check(&ctx).unwrap();
1386 assert!(result.is_empty(), "Should handle unicode headings");
1388 }
1389
1390 #[test]
1391 fn test_special_characters_in_headings() {
1392 let rule = create_enabled_rule();
1393 let content = r#"# Title
1394
1395<!-- toc -->
1396
1397- [What's New?](#whats-new)
1398- [C++ Guide](#c-guide)
1399
1400<!-- tocstop -->
1401
1402## What's New?
1403
1404News content.
1405
1406## C++ Guide
1407
1408C++ content.
1409"#;
1410 let ctx = create_ctx(content);
1411 let result = rule.check(&ctx).unwrap();
1412 assert!(result.is_empty(), "Should handle special characters");
1413 }
1414
1415 #[test]
1416 fn test_code_spans_in_headings() {
1417 let rule = create_enabled_rule();
1418 let content = r#"# Title
1419
1420<!-- toc -->
1421
1422- [`check [PATHS...]`](#check-paths)
1423
1424<!-- tocstop -->
1425
1426## `check [PATHS...]`
1427
1428Command documentation.
1429"#;
1430 let ctx = create_ctx(content);
1431 let result = rule.check(&ctx).unwrap();
1432 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1433 }
1434
1435 #[test]
1438 fn test_from_config_defaults() {
1439 let config = crate::config::Config::default();
1440 let rule = MD073TocValidation::from_config(&config);
1441 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1442
1443 assert_eq!(rule.min_level, 2);
1444 assert_eq!(rule.max_level, 4);
1445 assert!(rule.enforce_order);
1446 assert_eq!(rule.indent, 2);
1447 }
1448
1449 #[test]
1450 fn test_indent_from_md007_config() {
1451 use crate::config::{Config, RuleConfig};
1452 use std::collections::BTreeMap;
1453
1454 let mut config = Config::default();
1455
1456 let mut md007_values = BTreeMap::new();
1458 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1459 config.rules.insert(
1460 "MD007".to_string(),
1461 RuleConfig {
1462 severity: None,
1463 values: md007_values,
1464 },
1465 );
1466
1467 let rule = MD073TocValidation::from_config(&config);
1468 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1469
1470 assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1471 }
1472
1473 #[test]
1474 fn test_indent_md073_overrides_md007() {
1475 use crate::config::{Config, RuleConfig};
1476 use std::collections::BTreeMap;
1477
1478 let mut config = Config::default();
1479
1480 let mut md007_values = BTreeMap::new();
1482 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1483 config.rules.insert(
1484 "MD007".to_string(),
1485 RuleConfig {
1486 severity: None,
1487 values: md007_values,
1488 },
1489 );
1490
1491 let mut md073_values = BTreeMap::new();
1493 md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1494 md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1495 config.rules.insert(
1496 "MD073".to_string(),
1497 RuleConfig {
1498 severity: None,
1499 values: md073_values,
1500 },
1501 );
1502
1503 let rule = MD073TocValidation::from_config(&config);
1504 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1505
1506 assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1507 }
1508
1509 #[test]
1510 fn test_generate_toc_with_4_space_indent() {
1511 let mut rule = create_enabled_rule();
1512 rule.indent = 4;
1513
1514 let content = r#"<!-- toc -->
1515
1516<!-- tocstop -->
1517
1518## Level 2
1519
1520### Level 3
1521
1522#### Level 4
1523
1524## Another Level 2
1525"#;
1526 let ctx = create_ctx(content);
1527 let region = rule.detect_toc_region(&ctx).unwrap();
1528 let expected = rule.build_expected_toc(&ctx, ®ion);
1529 let toc = rule.generate_toc(&expected);
1530
1531 assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1536 assert!(
1537 toc.contains(" - [Level 3](#level-3)"),
1538 "Level 3 should have 4-space indent"
1539 );
1540 assert!(
1541 toc.contains(" - [Level 4](#level-4)"),
1542 "Level 4 should have 8-space indent"
1543 );
1544 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1545 }
1546
1547 #[test]
1548 fn test_validate_toc_with_4_space_indent() {
1549 let mut rule = create_enabled_rule();
1550 rule.indent = 4;
1551
1552 let content = r#"<!-- toc -->
1554- [Hello](#hello)
1555 - [Another](#another)
1556- [Heading](#heading)
1557<!-- tocstop -->
1558
1559## Hello
1560
1561### Another
1562
1563## Heading
1564"#;
1565 let ctx = create_ctx(content);
1566 let result = rule.check(&ctx).unwrap();
1567 assert!(
1568 result.is_empty(),
1569 "Should accept 4-space indent when configured: {result:?}"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_validate_toc_wrong_indent_with_4_space_config() {
1575 let mut rule = create_enabled_rule();
1576 rule.indent = 4;
1577
1578 let content = r#"<!-- toc -->
1580- [Hello](#hello)
1581 - [Another](#another)
1582- [Heading](#heading)
1583<!-- tocstop -->
1584
1585## Hello
1586
1587### Another
1588
1589## Heading
1590"#;
1591 let ctx = create_ctx(content);
1592 let result = rule.check(&ctx).unwrap();
1593 assert_eq!(result.len(), 1, "Should detect wrong indent");
1594 assert!(
1595 result[0].message.contains("Indentation mismatch"),
1596 "Should report indentation mismatch: {}",
1597 result[0].message
1598 );
1599 assert!(
1600 result[0].message.contains("expected 4 spaces"),
1601 "Should mention expected 4 spaces: {}",
1602 result[0].message
1603 );
1604 }
1605
1606 #[test]
1609 fn test_strip_markdown_formatting_link() {
1610 let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1611 assert_eq!(result, "Tool: terminal");
1612 }
1613
1614 #[test]
1615 fn test_strip_markdown_formatting_bold() {
1616 let result = strip_markdown_formatting("This is **bold** text");
1617 assert_eq!(result, "This is bold text");
1618
1619 let result = strip_markdown_formatting("This is __bold__ text");
1620 assert_eq!(result, "This is bold text");
1621 }
1622
1623 #[test]
1624 fn test_strip_markdown_formatting_italic() {
1625 let result = strip_markdown_formatting("This is *italic* text");
1626 assert_eq!(result, "This is italic text");
1627
1628 let result = strip_markdown_formatting("This is _italic_ text");
1629 assert_eq!(result, "This is italic text");
1630 }
1631
1632 #[test]
1633 fn test_strip_markdown_formatting_code_span() {
1634 let result = strip_markdown_formatting("Use the `format` function");
1635 assert_eq!(result, "Use the format function");
1636 }
1637
1638 #[test]
1639 fn test_strip_markdown_formatting_image() {
1640 let result = strip_markdown_formatting("See  for details");
1641 assert_eq!(result, "See logo for details");
1642 }
1643
1644 #[test]
1645 fn test_strip_markdown_formatting_reference_link() {
1646 let result = strip_markdown_formatting("See [documentation][docs] for details");
1647 assert_eq!(result, "See documentation for details");
1648 }
1649
1650 #[test]
1651 fn test_strip_markdown_formatting_combined() {
1652 let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1654 assert_eq!(result, "Tool: terminal");
1655 }
1656
1657 #[test]
1658 fn test_toc_with_link_in_heading_matches_stripped_text() {
1659 let rule = create_enabled_rule();
1660
1661 let content = r#"# Title
1663
1664<!-- toc -->
1665
1666- [Tool: terminal](#tool-terminal)
1667
1668<!-- tocstop -->
1669
1670## Tool: [terminal](https://example.com)
1671
1672Content here.
1673"#;
1674 let ctx = create_ctx(content);
1675 let result = rule.check(&ctx).unwrap();
1676 assert!(
1677 result.is_empty(),
1678 "Stripped heading text should match TOC entry: {result:?}"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_toc_with_simplified_text_still_mismatches() {
1684 let rule = create_enabled_rule();
1685
1686 let content = r#"# Title
1688
1689<!-- toc -->
1690
1691- [terminal](#tool-terminal)
1692
1693<!-- tocstop -->
1694
1695## Tool: [terminal](https://example.com)
1696
1697Content here.
1698"#;
1699 let ctx = create_ctx(content);
1700 let result = rule.check(&ctx).unwrap();
1701 assert_eq!(result.len(), 1, "Should report text mismatch");
1702 assert!(result[0].message.contains("Text mismatch"));
1703 }
1704
1705 #[test]
1706 fn test_fix_generates_stripped_toc_entries() {
1707 let rule = MD073TocValidation::new();
1708 let content = r#"# Title
1709
1710<!-- toc -->
1711
1712<!-- tocstop -->
1713
1714## Tool: [busybox](https://www.busybox.net/)
1715
1716Content.
1717
1718## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1719
1720More content.
1721"#;
1722 let ctx = create_ctx(content);
1723 let fixed = rule.fix(&ctx).unwrap();
1724
1725 assert!(
1727 fixed.contains("- [Tool: busybox](#tool-busybox)"),
1728 "TOC entry should have stripped link text"
1729 );
1730 assert!(
1731 fixed.contains("- [Tool: mount](#tool-mount)"),
1732 "TOC entry should have stripped link text"
1733 );
1734 let toc_start = fixed.find("<!-- toc -->").unwrap();
1737 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1738 let toc_content = &fixed[toc_start..toc_end];
1739 assert!(
1740 !toc_content.contains("busybox.net"),
1741 "TOC should not contain URLs: {toc_content}"
1742 );
1743 assert!(
1744 !toc_content.contains("wikipedia.org"),
1745 "TOC should not contain URLs: {toc_content}"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_fix_with_bold_in_heading() {
1751 let rule = MD073TocValidation::new();
1752 let content = r#"# Title
1753
1754<!-- toc -->
1755
1756<!-- tocstop -->
1757
1758## **Important** Section
1759
1760Content.
1761"#;
1762 let ctx = create_ctx(content);
1763 let fixed = rule.fix(&ctx).unwrap();
1764
1765 assert!(fixed.contains("- [Important Section](#important-section)"));
1767 }
1768
1769 #[test]
1770 fn test_fix_with_code_in_heading() {
1771 let rule = MD073TocValidation::new();
1772 let content = r#"# Title
1773
1774<!-- toc -->
1775
1776<!-- tocstop -->
1777
1778## Using `async` Functions
1779
1780Content.
1781"#;
1782 let ctx = create_ctx(content);
1783 let fixed = rule.fix(&ctx).unwrap();
1784
1785 assert!(fixed.contains("- [Using async Functions](#using-async-functions)"));
1787 }
1788
1789 #[test]
1792 fn test_custom_anchor_id_respected() {
1793 let rule = create_enabled_rule();
1794 let content = r#"# Title
1795
1796<!-- toc -->
1797
1798- [My Section](#my-custom-anchor)
1799
1800<!-- tocstop -->
1801
1802## My Section {#my-custom-anchor}
1803
1804Content here.
1805"#;
1806 let ctx = create_ctx(content);
1807 let result = rule.check(&ctx).unwrap();
1808 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1809 }
1810
1811 #[test]
1812 fn test_custom_anchor_id_in_generated_toc() {
1813 let rule = create_enabled_rule();
1814 let content = r#"# Title
1815
1816<!-- toc -->
1817
1818<!-- tocstop -->
1819
1820## First Section {#custom-first}
1821
1822Content.
1823
1824## Second Section {#another-custom}
1825
1826More content.
1827"#;
1828 let ctx = create_ctx(content);
1829 let fixed = rule.fix(&ctx).unwrap();
1830 assert!(fixed.contains("- [First Section](#custom-first)"));
1831 assert!(fixed.contains("- [Second Section](#another-custom)"));
1832 }
1833
1834 #[test]
1835 fn test_mixed_custom_and_generated_anchors() {
1836 let rule = create_enabled_rule();
1837 let content = r#"# Title
1838
1839<!-- toc -->
1840
1841- [Custom Section](#my-id)
1842- [Normal Section](#normal-section)
1843
1844<!-- tocstop -->
1845
1846## Custom Section {#my-id}
1847
1848Content.
1849
1850## Normal Section
1851
1852More content.
1853"#;
1854 let ctx = create_ctx(content);
1855 let result = rule.check(&ctx).unwrap();
1856 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
1857 }
1858
1859 #[test]
1862 fn test_github_anchor_style() {
1863 let rule = create_enabled_rule();
1864
1865 let content = r#"<!-- toc -->
1866
1867<!-- tocstop -->
1868
1869## Test_With_Underscores
1870
1871Content.
1872"#;
1873 let ctx = create_ctx(content);
1874 let region = rule.detect_toc_region(&ctx).unwrap();
1875 let expected = rule.build_expected_toc(&ctx, ®ion);
1876
1877 assert_eq!(expected[0].anchor, "test_with_underscores");
1879 }
1880
1881 #[test]
1884 fn test_stress_many_headings() {
1885 let rule = create_enabled_rule();
1886
1887 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1889
1890 for i in 1..=150 {
1891 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
1892 }
1893
1894 let ctx = create_ctx(&content);
1895
1896 let result = rule.check(&ctx).unwrap();
1898
1899 assert_eq!(result.len(), 1, "Should report single warning for TOC");
1901 assert!(result[0].message.contains("Missing entry"));
1902
1903 let fixed = rule.fix(&ctx).unwrap();
1905 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
1906 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
1907 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
1908 }
1909
1910 #[test]
1911 fn test_stress_deeply_nested() {
1912 let rule = create_enabled_rule();
1913 let content = r#"# Title
1914
1915<!-- toc -->
1916
1917<!-- tocstop -->
1918
1919## Level 2 A
1920
1921### Level 3 A
1922
1923#### Level 4 A
1924
1925## Level 2 B
1926
1927### Level 3 B
1928
1929#### Level 4 B
1930
1931## Level 2 C
1932
1933### Level 3 C
1934
1935#### Level 4 C
1936
1937## Level 2 D
1938
1939### Level 3 D
1940
1941#### Level 4 D
1942"#;
1943 let ctx = create_ctx(content);
1944 let fixed = rule.fix(&ctx).unwrap();
1945
1946 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
1948 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
1949 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
1950 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
1951 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
1952 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
1953 }
1954
1955 #[test]
1958 fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
1959 let rule = create_enabled_rule();
1960 let content = r#"# Document
1964
1965<!-- toc -->
1966
1967- [Example](#example)
1968- [Another](#another)
1969- [Example](#example)
1970
1971<!-- tocstop -->
1972
1973## Example
1974First.
1975
1976## Another
1977Middle.
1978
1979## Example
1980Second.
1981"#;
1982 let ctx = create_ctx(content);
1983 let result = rule.check(&ctx).unwrap();
1984
1985 assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
1988 assert!(
1989 result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
1990 "Should report missing or stale entries for duplicate anchors. Got: {}",
1991 result[0].message
1992 );
1993 }
1994
1995 #[test]
1998 fn test_strip_double_backtick_code_span() {
1999 let result = strip_markdown_formatting("Using ``code with ` backtick``");
2001 assert_eq!(
2002 result, "Using code with ` backtick",
2003 "Should strip double-backtick code spans"
2004 );
2005 }
2006
2007 #[test]
2008 fn test_strip_triple_backtick_code_span() {
2009 let result = strip_markdown_formatting("Using ```code with `` backticks```");
2011 assert_eq!(
2012 result, "Using code with `` backticks",
2013 "Should strip triple-backtick code spans"
2014 );
2015 }
2016
2017 #[test]
2018 fn test_toc_with_double_backtick_heading() {
2019 let rule = create_enabled_rule();
2020 let content = r#"# Title
2021
2022<!-- toc -->
2023
2024- [Using code with backtick](#using-code-with-backtick)
2025
2026<!-- tocstop -->
2027
2028## Using ``code with ` backtick``
2029
2030Content here.
2031"#;
2032 let ctx = create_ctx(content);
2033 let fixed = rule.fix(&ctx).unwrap();
2037 assert!(
2039 fixed.contains("code with ` backtick") || fixed.contains("code with backtick"),
2040 "Fix should strip double-backtick code span from heading. Got TOC: {}",
2041 &fixed[fixed.find("<!-- toc -->").unwrap()..fixed.find("<!-- tocstop -->").unwrap()]
2042 );
2043 }
2044
2045 #[test]
2046 fn test_stress_many_duplicates() {
2047 let rule = create_enabled_rule();
2048
2049 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2051 for _ in 0..50 {
2052 content.push_str("## FAQ\n\nContent.\n\n");
2053 }
2054
2055 let ctx = create_ctx(&content);
2056 let region = rule.detect_toc_region(&ctx).unwrap();
2057 let expected = rule.build_expected_toc(&ctx, ®ion);
2058
2059 assert_eq!(expected.len(), 50);
2061 assert_eq!(expected[0].anchor, "faq");
2062 assert_eq!(expected[1].anchor, "faq-1");
2063 assert_eq!(expected[49].anchor, "faq-49");
2064 }
2065}