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());
87static MARKDOWN_CODE_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`([^`]+)`").unwrap());
88static MARKDOWN_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
89static MARKDOWN_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
90static MARKDOWN_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
91static MARKDOWN_ITALIC_UNDERSCORE: LazyLock<Regex> =
94 LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
95
96fn strip_markdown_formatting(text: &str) -> String {
105 let mut result = text.to_string();
106
107 result = MARKDOWN_IMAGE.replace_all(&result, "$1").to_string();
109
110 result = MARKDOWN_LINK.replace_all(&result, "$1").to_string();
112
113 result = MARKDOWN_REF_LINK.replace_all(&result, "$1").to_string();
115
116 result = MARKDOWN_CODE_SPAN.replace_all(&result, "$1").to_string();
118
119 result = MARKDOWN_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
121 result = MARKDOWN_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
122
123 result = MARKDOWN_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
125 result = MARKDOWN_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
127
128 result
129}
130
131#[derive(Clone)]
159pub struct MD073TocValidation {
160 enabled: bool,
162 min_level: u8,
164 max_level: u8,
166 enforce_order: bool,
168 pub indent: usize,
170}
171
172impl Default for MD073TocValidation {
173 fn default() -> Self {
174 Self {
175 enabled: false, min_level: 2,
177 max_level: 4,
178 enforce_order: true,
179 indent: 2, }
181 }
182}
183
184impl std::fmt::Debug for MD073TocValidation {
185 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186 f.debug_struct("MD073TocValidation")
187 .field("enabled", &self.enabled)
188 .field("min_level", &self.min_level)
189 .field("max_level", &self.max_level)
190 .field("enforce_order", &self.enforce_order)
191 .field("indent", &self.indent)
192 .finish()
193 }
194}
195
196impl MD073TocValidation {
197 pub fn new() -> Self {
199 Self::default()
200 }
201
202 fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
204 let mut start_line = None;
205 let mut start_byte = None;
206
207 for (idx, line_info) in ctx.lines.iter().enumerate() {
208 let line_num = idx + 1;
209 let content = line_info.content(ctx.content);
210
211 if line_info.in_code_block || line_info.in_front_matter {
213 continue;
214 }
215
216 if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
218 if TOC_STOP_MARKER.is_match(content) {
220 let end_line = line_num - 1;
221 let content_end = line_info.byte_offset;
222
223 if end_line < s_line {
225 return Some(TocRegion {
226 start_line: s_line,
227 end_line: s_line,
228 content_start: s_byte,
229 content_end: s_byte,
230 });
231 }
232
233 return Some(TocRegion {
234 start_line: s_line,
235 end_line,
236 content_start: s_byte,
237 content_end,
238 });
239 }
240 } else if TOC_START_MARKER.is_match(content) {
241 if idx + 1 < ctx.lines.len() {
243 start_line = Some(line_num + 1);
244 start_byte = Some(ctx.lines[idx + 1].byte_offset);
245 }
246 }
247 }
248
249 None
250 }
251
252 fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
254 self.detect_by_markers(ctx)
255 }
256
257 fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
259 let mut entries = Vec::new();
260
261 for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
262 let line_info = &ctx.lines[idx];
263 let content = line_info.content(ctx.content);
264
265 if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
266 let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
267 let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
268 let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
269
270 entries.push(TocEntry {
271 text,
272 anchor,
273 indent_spaces,
274 });
275 }
276 }
277
278 entries
279 }
280
281 fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
283 let mut entries = Vec::new();
284 let mut fragment_counts: HashMap<String, usize> = HashMap::new();
285
286 for (idx, line_info) in ctx.lines.iter().enumerate() {
287 let line_num = idx + 1;
288
289 if line_num <= toc_region.end_line {
291 continue;
293 }
294
295 if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
297 continue;
298 }
299
300 if let Some(heading) = &line_info.heading {
301 if heading.level < self.min_level || heading.level > self.max_level {
303 continue;
304 }
305
306 let base_anchor = if let Some(custom_id) = &heading.custom_id {
308 custom_id.clone()
309 } else {
310 AnchorStyle::GitHub.generate_fragment(&heading.text)
311 };
312
313 let anchor = if let Some(count) = fragment_counts.get_mut(&base_anchor) {
315 let suffix = *count;
316 *count += 1;
317 format!("{base_anchor}-{suffix}")
318 } else {
319 fragment_counts.insert(base_anchor.clone(), 1);
320 base_anchor
321 };
322
323 entries.push(ExpectedTocEntry {
324 heading_line: line_num,
325 level: heading.level,
326 text: heading.text.clone(),
327 anchor,
328 });
329 }
330 }
331
332 entries
333 }
334
335 fn validate_toc(&self, actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<TocMismatch> {
337 let mut mismatches = Vec::new();
338
339 let expected_anchors: HashMap<&str, &ExpectedTocEntry> =
341 expected.iter().map(|e| (e.anchor.as_str(), e)).collect();
342
343 let actual_anchors: HashMap<&str, &TocEntry> = actual.iter().map(|e| (e.anchor.as_str(), e)).collect();
345
346 for entry in actual {
348 if !expected_anchors.contains_key(entry.anchor.as_str()) {
349 mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
350 }
351 }
352
353 for exp in expected {
355 if !actual_anchors.contains_key(exp.anchor.as_str()) {
356 mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
357 }
358 }
359
360 for entry in actual {
362 if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
363 let actual_stripped = strip_markdown_formatting(entry.text.trim());
365 let expected_stripped = strip_markdown_formatting(exp.text.trim());
366 if actual_stripped != expected_stripped {
367 mismatches.push(TocMismatch::TextMismatch {
368 entry: entry.clone(),
369 expected: (*exp).clone(),
370 });
371 }
372 }
373 }
374
375 if !expected.is_empty() {
378 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
379
380 for entry in actual {
381 if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
382 let level_diff = exp.level.saturating_sub(base_level) as usize;
383 let expected_indent = level_diff * self.indent;
384
385 if entry.indent_spaces != expected_indent {
386 let already_reported = mismatches.iter().any(|m| match m {
388 TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
389 TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
390 _ => false,
391 });
392 if !already_reported {
393 mismatches.push(TocMismatch::IndentationMismatch {
394 entry: entry.clone(),
395 actual_indent: entry.indent_spaces,
396 expected_indent,
397 });
398 }
399 }
400 }
401 }
402 }
403
404 if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
406 let expected_order: Vec<&str> = expected.iter().map(|e| e.anchor.as_str()).collect();
407
408 let mut expected_idx = 0;
410 for entry in actual {
411 if !expected_anchors.contains_key(entry.anchor.as_str()) {
413 continue;
414 }
415
416 while expected_idx < expected_order.len() && expected_order[expected_idx] != entry.anchor {
418 expected_idx += 1;
419 }
420
421 if expected_idx >= expected_order.len() {
422 let correct_pos = expected_order.iter().position(|a| *a == entry.anchor).unwrap_or(0);
424 let already_reported = mismatches.iter().any(|m| match m {
426 TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
427 TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
428 _ => false,
429 });
430 if !already_reported {
431 mismatches.push(TocMismatch::OrderMismatch {
432 entry: entry.clone(),
433 expected_position: correct_pos + 1,
434 });
435 }
436 } else {
437 expected_idx += 1;
438 }
439 }
440 }
441
442 mismatches
443 }
444
445 fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
447 if expected.is_empty() {
448 return String::new();
449 }
450
451 let mut result = String::new();
452 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
453 let indent_str = " ".repeat(self.indent);
454
455 for entry in expected {
456 let level_diff = entry.level.saturating_sub(base_level) as usize;
457 let indent = indent_str.repeat(level_diff);
458
459 let display_text = strip_markdown_formatting(&entry.text);
461 result.push_str(&format!("{indent}- [{display_text}](#{})\n", entry.anchor));
462 }
463
464 result
465 }
466}
467
468impl Rule for MD073TocValidation {
469 fn name(&self) -> &'static str {
470 "MD073"
471 }
472
473 fn description(&self) -> &'static str {
474 "Table of Contents should match document headings"
475 }
476
477 fn should_skip(&self, ctx: &LintContext) -> bool {
478 if !self.enabled {
480 return true;
481 }
482
483 let has_toc_marker = ctx.content.contains("<!-- toc") || ctx.content.contains("<!--toc");
485 !has_toc_marker
486 }
487
488 fn check(&self, ctx: &LintContext) -> LintResult {
489 let mut warnings = Vec::new();
490
491 let Some(region) = self.detect_toc_region(ctx) else {
493 return Ok(warnings);
495 };
496
497 let actual_entries = self.extract_toc_entries(ctx, ®ion);
499
500 let expected_entries = self.build_expected_toc(ctx, ®ion);
502
503 if expected_entries.is_empty() && actual_entries.is_empty() {
505 return Ok(warnings);
506 }
507
508 let mismatches = self.validate_toc(&actual_entries, &expected_entries);
510
511 if !mismatches.is_empty() {
512 let mut details = Vec::new();
514
515 for mismatch in &mismatches {
516 match mismatch {
517 TocMismatch::StaleEntry { entry } => {
518 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
519 }
520 TocMismatch::MissingEntry { expected } => {
521 details.push(format!(
522 "Missing entry: '{}' (line {})",
523 expected.text, expected.heading_line
524 ));
525 }
526 TocMismatch::TextMismatch { entry, expected } => {
527 details.push(format!(
528 "Text mismatch: TOC has '{}', heading is '{}'",
529 entry.text, expected.text
530 ));
531 }
532 TocMismatch::OrderMismatch {
533 entry,
534 expected_position,
535 } => {
536 details.push(format!(
537 "Order mismatch: '{}' should be at position {}",
538 entry.text, expected_position
539 ));
540 }
541 TocMismatch::IndentationMismatch {
542 entry,
543 actual_indent,
544 expected_indent,
545 ..
546 } => {
547 details.push(format!(
548 "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
549 entry.text, actual_indent, expected_indent
550 ));
551 }
552 }
553 }
554
555 let message = format!(
556 "Table of Contents does not match document headings: {}",
557 details.join("; ")
558 );
559
560 let new_toc = self.generate_toc(&expected_entries);
562 let fix_range = region.content_start..region.content_end;
563
564 warnings.push(LintWarning {
565 rule_name: Some(self.name().to_string()),
566 message,
567 line: region.start_line,
568 column: 1,
569 end_line: region.end_line,
570 end_column: 1,
571 severity: Severity::Warning,
572 fix: Some(Fix {
573 range: fix_range,
574 replacement: new_toc,
575 }),
576 });
577 }
578
579 Ok(warnings)
580 }
581
582 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
583 let Some(region) = self.detect_toc_region(ctx) else {
585 return Ok(ctx.content.to_string());
587 };
588
589 let expected_entries = self.build_expected_toc(ctx, ®ion);
591
592 let new_toc = self.generate_toc(&expected_entries);
594
595 let mut result = String::with_capacity(ctx.content.len());
597 result.push_str(&ctx.content[..region.content_start]);
598 result.push_str(&new_toc);
599 result.push_str(&ctx.content[region.content_end..]);
600
601 Ok(result)
602 }
603
604 fn category(&self) -> RuleCategory {
605 RuleCategory::Other
606 }
607
608 fn as_any(&self) -> &dyn std::any::Any {
609 self
610 }
611
612 fn default_config_section(&self) -> Option<(String, toml::Value)> {
613 let value: toml::Value = toml::from_str(
614 r#"
615# Whether this rule is enabled (opt-in, disabled by default)
616enabled = false
617# Minimum heading level to include
618min-level = 2
619# Maximum heading level to include
620max-level = 4
621# Whether TOC order must match document order
622enforce-order = true
623# Indentation per nesting level (defaults to MD007's indent value)
624indent = 2
625"#,
626 )
627 .ok()?;
628 Some(("MD073".to_string(), value))
629 }
630
631 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
632 where
633 Self: Sized,
634 {
635 let mut rule = MD073TocValidation::default();
636 let mut indent_from_md073 = false;
637
638 if let Some(rule_config) = config.rules.get("MD073") {
639 if let Some(enabled) = rule_config.values.get("enabled").and_then(|v| v.as_bool()) {
641 rule.enabled = enabled;
642 }
643
644 if let Some(min_level) = rule_config.values.get("min-level").and_then(|v| v.as_integer()) {
646 rule.min_level = (min_level.clamp(1, 6)) as u8;
647 }
648
649 if let Some(max_level) = rule_config.values.get("max-level").and_then(|v| v.as_integer()) {
651 rule.max_level = (max_level.clamp(1, 6)) as u8;
652 }
653
654 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(|v| v.as_bool()) {
656 rule.enforce_order = enforce_order;
657 }
658
659 if let Some(indent) = rule_config.values.get("indent").and_then(|v| v.as_integer()) {
661 rule.indent = (indent.clamp(1, 8)) as usize;
662 indent_from_md073 = true;
663 }
664 }
665
666 if !indent_from_md073
668 && let Some(md007_config) = config.rules.get("MD007")
669 && let Some(indent) = md007_config.values.get("indent").and_then(|v| v.as_integer())
670 {
671 rule.indent = (indent.clamp(1, 8)) as usize;
672 }
673
674 Box::new(rule)
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 use crate::config::MarkdownFlavor;
682
683 fn create_ctx(content: &str) -> LintContext<'_> {
684 LintContext::new(content, MarkdownFlavor::Standard, None)
685 }
686
687 fn create_enabled_rule() -> MD073TocValidation {
689 MD073TocValidation {
690 enabled: true,
691 ..MD073TocValidation::default()
692 }
693 }
694
695 #[test]
698 fn test_detect_markers_basic() {
699 let rule = MD073TocValidation::new();
700 let content = r#"# Title
701
702<!-- toc -->
703
704- [Heading 1](#heading-1)
705
706<!-- tocstop -->
707
708## Heading 1
709
710Content here.
711"#;
712 let ctx = create_ctx(content);
713 let region = rule.detect_by_markers(&ctx);
714 assert!(region.is_some());
715 let region = region.unwrap();
716 assert_eq!(region.start_line, 4);
718 assert_eq!(region.end_line, 6);
719 }
720
721 #[test]
722 fn test_detect_markers_variations() {
723 let rule = MD073TocValidation::new();
724
725 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
727 let ctx1 = create_ctx(content1);
728 assert!(rule.detect_by_markers(&ctx1).is_some());
729
730 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
732 let ctx2 = create_ctx(content2);
733 assert!(rule.detect_by_markers(&ctx2).is_some());
734
735 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
737 let ctx3 = create_ctx(content3);
738 assert!(rule.detect_by_markers(&ctx3).is_some());
739 }
740
741 #[test]
742 fn test_no_toc_region() {
743 let rule = MD073TocValidation::new();
744 let content = r#"# Title
745
746## Heading 1
747
748Content here.
749
750## Heading 2
751
752More content.
753"#;
754 let ctx = create_ctx(content);
755 let region = rule.detect_toc_region(&ctx);
756 assert!(region.is_none());
757 }
758
759 #[test]
762 fn test_toc_matches_headings() {
763 let rule = create_enabled_rule();
764 let content = r#"# Title
765
766<!-- toc -->
767
768- [Heading 1](#heading-1)
769- [Heading 2](#heading-2)
770
771<!-- tocstop -->
772
773## Heading 1
774
775Content.
776
777## Heading 2
778
779More content.
780"#;
781 let ctx = create_ctx(content);
782 let result = rule.check(&ctx).unwrap();
783 assert!(result.is_empty(), "Expected no warnings for matching TOC");
784 }
785
786 #[test]
787 fn test_missing_entry() {
788 let rule = create_enabled_rule();
789 let content = r#"# Title
790
791<!-- toc -->
792
793- [Heading 1](#heading-1)
794
795<!-- tocstop -->
796
797## Heading 1
798
799Content.
800
801## Heading 2
802
803New heading not in TOC.
804"#;
805 let ctx = create_ctx(content);
806 let result = rule.check(&ctx).unwrap();
807 assert_eq!(result.len(), 1);
808 assert!(result[0].message.contains("Missing entry"));
809 assert!(result[0].message.contains("Heading 2"));
810 }
811
812 #[test]
813 fn test_stale_entry() {
814 let rule = create_enabled_rule();
815 let content = r#"# Title
816
817<!-- toc -->
818
819- [Heading 1](#heading-1)
820- [Deleted Heading](#deleted-heading)
821
822<!-- tocstop -->
823
824## Heading 1
825
826Content.
827"#;
828 let ctx = create_ctx(content);
829 let result = rule.check(&ctx).unwrap();
830 assert_eq!(result.len(), 1);
831 assert!(result[0].message.contains("Stale entry"));
832 assert!(result[0].message.contains("Deleted Heading"));
833 }
834
835 #[test]
836 fn test_text_mismatch() {
837 let rule = create_enabled_rule();
838 let content = r#"# Title
839
840<!-- toc -->
841
842- [Old Name](#heading-1)
843
844<!-- tocstop -->
845
846## Heading 1
847
848Content.
849"#;
850 let ctx = create_ctx(content);
851 let result = rule.check(&ctx).unwrap();
852 assert_eq!(result.len(), 1);
853 assert!(result[0].message.contains("Text mismatch"));
854 }
855
856 #[test]
859 fn test_min_level_excludes_h1() {
860 let mut rule = MD073TocValidation::new();
861 rule.min_level = 2;
862
863 let content = r#"<!-- toc -->
864
865<!-- tocstop -->
866
867# Should Be Excluded
868
869## Should Be Included
870
871Content.
872"#;
873 let ctx = create_ctx(content);
874 let region = rule.detect_toc_region(&ctx).unwrap();
875 let expected = rule.build_expected_toc(&ctx, ®ion);
876
877 assert_eq!(expected.len(), 1);
878 assert_eq!(expected[0].text, "Should Be Included");
879 }
880
881 #[test]
882 fn test_max_level_excludes_h5_h6() {
883 let mut rule = MD073TocValidation::new();
884 rule.max_level = 4;
885
886 let content = r#"<!-- toc -->
887
888<!-- tocstop -->
889
890## Level 2
891
892### Level 3
893
894#### Level 4
895
896##### Level 5 Should Be Excluded
897
898###### Level 6 Should Be Excluded
899"#;
900 let ctx = create_ctx(content);
901 let region = rule.detect_toc_region(&ctx).unwrap();
902 let expected = rule.build_expected_toc(&ctx, ®ion);
903
904 assert_eq!(expected.len(), 3);
905 assert!(expected.iter().all(|e| e.level <= 4));
906 }
907
908 #[test]
911 fn test_fix_adds_missing_entry() {
912 let rule = MD073TocValidation::new();
913 let content = r#"# Title
914
915<!-- toc -->
916
917- [Heading 1](#heading-1)
918
919<!-- tocstop -->
920
921## Heading 1
922
923Content.
924
925## Heading 2
926
927New heading.
928"#;
929 let ctx = create_ctx(content);
930 let fixed = rule.fix(&ctx).unwrap();
931 assert!(fixed.contains("- [Heading 2](#heading-2)"));
932 }
933
934 #[test]
935 fn test_fix_removes_stale_entry() {
936 let rule = MD073TocValidation::new();
937 let content = r#"# Title
938
939<!-- toc -->
940
941- [Heading 1](#heading-1)
942- [Deleted](#deleted)
943
944<!-- tocstop -->
945
946## Heading 1
947
948Content.
949"#;
950 let ctx = create_ctx(content);
951 let fixed = rule.fix(&ctx).unwrap();
952 assert!(fixed.contains("- [Heading 1](#heading-1)"));
953 assert!(!fixed.contains("Deleted"));
954 }
955
956 #[test]
957 fn test_fix_idempotent() {
958 let rule = MD073TocValidation::new();
959 let content = r#"# Title
960
961<!-- toc -->
962
963- [Heading 1](#heading-1)
964- [Heading 2](#heading-2)
965
966<!-- tocstop -->
967
968## Heading 1
969
970Content.
971
972## Heading 2
973
974More.
975"#;
976 let ctx = create_ctx(content);
977 let fixed1 = rule.fix(&ctx).unwrap();
978 let ctx2 = create_ctx(&fixed1);
979 let fixed2 = rule.fix(&ctx2).unwrap();
980
981 assert_eq!(fixed1, fixed2);
983 }
984
985 #[test]
986 fn test_fix_preserves_markers() {
987 let rule = MD073TocValidation::new();
988 let content = r#"# Title
989
990<!-- toc -->
991
992Old TOC content.
993
994<!-- tocstop -->
995
996## New Heading
997
998Content.
999"#;
1000 let ctx = create_ctx(content);
1001 let fixed = rule.fix(&ctx).unwrap();
1002
1003 assert!(fixed.contains("<!-- toc -->"));
1005 assert!(fixed.contains("<!-- tocstop -->"));
1006 assert!(fixed.contains("- [New Heading](#new-heading)"));
1008 }
1009
1010 #[test]
1011 fn test_fix_requires_markers() {
1012 let rule = create_enabled_rule();
1013
1014 let content_no_markers = r#"# Title
1016
1017## Heading 1
1018
1019Content.
1020"#;
1021 let ctx = create_ctx(content_no_markers);
1022 let fixed = rule.fix(&ctx).unwrap();
1023 assert_eq!(fixed, content_no_markers);
1024
1025 let content_markers = r#"# Title
1027
1028<!-- toc -->
1029
1030- [Old Entry](#old-entry)
1031
1032<!-- tocstop -->
1033
1034## Heading 1
1035
1036Content.
1037"#;
1038 let ctx = create_ctx(content_markers);
1039 let fixed = rule.fix(&ctx).unwrap();
1040 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1041 assert!(!fixed.contains("Old Entry"));
1042 }
1043
1044 #[test]
1047 fn test_duplicate_heading_anchors() {
1048 let rule = MD073TocValidation::new();
1049 let content = r#"# Title
1050
1051<!-- toc -->
1052
1053<!-- tocstop -->
1054
1055## Duplicate
1056
1057Content.
1058
1059## Duplicate
1060
1061More content.
1062
1063## Duplicate
1064
1065Even more.
1066"#;
1067 let ctx = create_ctx(content);
1068 let region = rule.detect_toc_region(&ctx).unwrap();
1069 let expected = rule.build_expected_toc(&ctx, ®ion);
1070
1071 assert_eq!(expected.len(), 3);
1072 assert_eq!(expected[0].anchor, "duplicate");
1073 assert_eq!(expected[1].anchor, "duplicate-1");
1074 assert_eq!(expected[2].anchor, "duplicate-2");
1075 }
1076
1077 #[test]
1080 fn test_headings_in_code_blocks_ignored() {
1081 let rule = create_enabled_rule();
1082 let content = r#"# Title
1083
1084<!-- toc -->
1085
1086- [Real Heading](#real-heading)
1087
1088<!-- tocstop -->
1089
1090## Real Heading
1091
1092```markdown
1093## Fake Heading In Code
1094```
1095
1096Content.
1097"#;
1098 let ctx = create_ctx(content);
1099 let result = rule.check(&ctx).unwrap();
1100 assert!(result.is_empty(), "Should not report fake heading in code block");
1101 }
1102
1103 #[test]
1104 fn test_empty_toc_region() {
1105 let rule = create_enabled_rule();
1106 let content = r#"# Title
1107
1108<!-- toc -->
1109<!-- tocstop -->
1110
1111## Heading 1
1112
1113Content.
1114"#;
1115 let ctx = create_ctx(content);
1116 let result = rule.check(&ctx).unwrap();
1117 assert_eq!(result.len(), 1);
1118 assert!(result[0].message.contains("Missing entry"));
1119 }
1120
1121 #[test]
1122 fn test_nested_indentation() {
1123 let rule = create_enabled_rule();
1124
1125 let content = r#"<!-- toc -->
1126
1127<!-- tocstop -->
1128
1129## Level 2
1130
1131### Level 3
1132
1133#### Level 4
1134
1135## Another Level 2
1136"#;
1137 let ctx = create_ctx(content);
1138 let region = rule.detect_toc_region(&ctx).unwrap();
1139 let expected = rule.build_expected_toc(&ctx, ®ion);
1140 let toc = rule.generate_toc(&expected);
1141
1142 assert!(toc.contains("- [Level 2](#level-2)"));
1144 assert!(toc.contains(" - [Level 3](#level-3)"));
1145 assert!(toc.contains(" - [Level 4](#level-4)"));
1146 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1147 }
1148
1149 #[test]
1152 fn test_indentation_mismatch_detected() {
1153 let rule = create_enabled_rule();
1154 let content = r#"<!-- toc -->
1156- [Hello](#hello)
1157- [Another](#another)
1158- [Heading](#heading)
1159<!-- tocstop -->
1160
1161## Hello
1162
1163### Another
1164
1165## Heading
1166"#;
1167 let ctx = create_ctx(content);
1168 let result = rule.check(&ctx).unwrap();
1169 assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1171 assert!(
1172 result[0].message.contains("Indentation mismatch"),
1173 "Message should mention indentation: {}",
1174 result[0].message
1175 );
1176 assert!(
1177 result[0].message.contains("Another"),
1178 "Message should mention the entry: {}",
1179 result[0].message
1180 );
1181 }
1182
1183 #[test]
1184 fn test_indentation_mismatch_fixed() {
1185 let rule = create_enabled_rule();
1186 let content = r#"<!-- toc -->
1188- [Hello](#hello)
1189- [Another](#another)
1190- [Heading](#heading)
1191<!-- tocstop -->
1192
1193## Hello
1194
1195### Another
1196
1197## Heading
1198"#;
1199 let ctx = create_ctx(content);
1200 let fixed = rule.fix(&ctx).unwrap();
1201 assert!(fixed.contains("- [Hello](#hello)"));
1203 assert!(fixed.contains(" - [Another](#another)")); assert!(fixed.contains("- [Heading](#heading)"));
1205 }
1206
1207 #[test]
1208 fn test_no_indentation_mismatch_when_correct() {
1209 let rule = create_enabled_rule();
1210 let content = r#"<!-- toc -->
1212- [Hello](#hello)
1213 - [Another](#another)
1214- [Heading](#heading)
1215<!-- tocstop -->
1216
1217## Hello
1218
1219### Another
1220
1221## Heading
1222"#;
1223 let ctx = create_ctx(content);
1224 let result = rule.check(&ctx).unwrap();
1225 assert!(result.is_empty(), "Should not report issues: {result:?}");
1227 }
1228
1229 #[test]
1232 fn test_order_mismatch_detected() {
1233 let rule = create_enabled_rule();
1234 let content = r#"# Title
1235
1236<!-- toc -->
1237
1238- [Section B](#section-b)
1239- [Section A](#section-a)
1240
1241<!-- tocstop -->
1242
1243## Section A
1244
1245Content A.
1246
1247## Section B
1248
1249Content B.
1250"#;
1251 let ctx = create_ctx(content);
1252 let result = rule.check(&ctx).unwrap();
1253 assert!(!result.is_empty(), "Should detect order mismatch");
1256 }
1257
1258 #[test]
1259 fn test_order_mismatch_ignored_when_disabled() {
1260 let mut rule = create_enabled_rule();
1261 rule.enforce_order = false;
1262 let content = r#"# Title
1263
1264<!-- toc -->
1265
1266- [Section B](#section-b)
1267- [Section A](#section-a)
1268
1269<!-- tocstop -->
1270
1271## Section A
1272
1273Content A.
1274
1275## Section B
1276
1277Content B.
1278"#;
1279 let ctx = create_ctx(content);
1280 let result = rule.check(&ctx).unwrap();
1281 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1283 }
1284
1285 #[test]
1288 fn test_unicode_headings() {
1289 let rule = create_enabled_rule();
1290 let content = r#"# Title
1291
1292<!-- toc -->
1293
1294- [日本語の見出し](#日本語の見出し)
1295- [Émojis 🎉](#émojis-)
1296
1297<!-- tocstop -->
1298
1299## 日本語の見出し
1300
1301Japanese content.
1302
1303## Émojis 🎉
1304
1305Content with emojis.
1306"#;
1307 let ctx = create_ctx(content);
1308 let result = rule.check(&ctx).unwrap();
1309 assert!(result.is_empty(), "Should handle unicode headings");
1311 }
1312
1313 #[test]
1314 fn test_special_characters_in_headings() {
1315 let rule = create_enabled_rule();
1316 let content = r#"# Title
1317
1318<!-- toc -->
1319
1320- [What's New?](#whats-new)
1321- [C++ Guide](#c-guide)
1322
1323<!-- tocstop -->
1324
1325## What's New?
1326
1327News content.
1328
1329## C++ Guide
1330
1331C++ content.
1332"#;
1333 let ctx = create_ctx(content);
1334 let result = rule.check(&ctx).unwrap();
1335 assert!(result.is_empty(), "Should handle special characters");
1336 }
1337
1338 #[test]
1339 fn test_code_spans_in_headings() {
1340 let rule = create_enabled_rule();
1341 let content = r#"# Title
1342
1343<!-- toc -->
1344
1345- [`check [PATHS...]`](#check-paths)
1346
1347<!-- tocstop -->
1348
1349## `check [PATHS...]`
1350
1351Command documentation.
1352"#;
1353 let ctx = create_ctx(content);
1354 let result = rule.check(&ctx).unwrap();
1355 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1356 }
1357
1358 #[test]
1361 fn test_from_config_defaults() {
1362 let config = crate::config::Config::default();
1363 let rule = MD073TocValidation::from_config(&config);
1364 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1365
1366 assert_eq!(rule.min_level, 2);
1367 assert_eq!(rule.max_level, 4);
1368 assert!(rule.enforce_order);
1369 assert_eq!(rule.indent, 2);
1370 }
1371
1372 #[test]
1373 fn test_indent_from_md007_config() {
1374 use crate::config::{Config, RuleConfig};
1375 use std::collections::BTreeMap;
1376
1377 let mut config = Config::default();
1378
1379 let mut md007_values = BTreeMap::new();
1381 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1382 config.rules.insert(
1383 "MD007".to_string(),
1384 RuleConfig {
1385 severity: None,
1386 values: md007_values,
1387 },
1388 );
1389
1390 let rule = MD073TocValidation::from_config(&config);
1391 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1392
1393 assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1394 }
1395
1396 #[test]
1397 fn test_indent_md073_overrides_md007() {
1398 use crate::config::{Config, RuleConfig};
1399 use std::collections::BTreeMap;
1400
1401 let mut config = Config::default();
1402
1403 let mut md007_values = BTreeMap::new();
1405 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1406 config.rules.insert(
1407 "MD007".to_string(),
1408 RuleConfig {
1409 severity: None,
1410 values: md007_values,
1411 },
1412 );
1413
1414 let mut md073_values = BTreeMap::new();
1416 md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1417 md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1418 config.rules.insert(
1419 "MD073".to_string(),
1420 RuleConfig {
1421 severity: None,
1422 values: md073_values,
1423 },
1424 );
1425
1426 let rule = MD073TocValidation::from_config(&config);
1427 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1428
1429 assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1430 }
1431
1432 #[test]
1433 fn test_generate_toc_with_4_space_indent() {
1434 let mut rule = create_enabled_rule();
1435 rule.indent = 4;
1436
1437 let content = r#"<!-- toc -->
1438
1439<!-- tocstop -->
1440
1441## Level 2
1442
1443### Level 3
1444
1445#### Level 4
1446
1447## Another Level 2
1448"#;
1449 let ctx = create_ctx(content);
1450 let region = rule.detect_toc_region(&ctx).unwrap();
1451 let expected = rule.build_expected_toc(&ctx, ®ion);
1452 let toc = rule.generate_toc(&expected);
1453
1454 assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1459 assert!(
1460 toc.contains(" - [Level 3](#level-3)"),
1461 "Level 3 should have 4-space indent"
1462 );
1463 assert!(
1464 toc.contains(" - [Level 4](#level-4)"),
1465 "Level 4 should have 8-space indent"
1466 );
1467 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1468 }
1469
1470 #[test]
1471 fn test_validate_toc_with_4_space_indent() {
1472 let mut rule = create_enabled_rule();
1473 rule.indent = 4;
1474
1475 let content = r#"<!-- toc -->
1477- [Hello](#hello)
1478 - [Another](#another)
1479- [Heading](#heading)
1480<!-- tocstop -->
1481
1482## Hello
1483
1484### Another
1485
1486## Heading
1487"#;
1488 let ctx = create_ctx(content);
1489 let result = rule.check(&ctx).unwrap();
1490 assert!(
1491 result.is_empty(),
1492 "Should accept 4-space indent when configured: {result:?}"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_validate_toc_wrong_indent_with_4_space_config() {
1498 let mut rule = create_enabled_rule();
1499 rule.indent = 4;
1500
1501 let content = r#"<!-- toc -->
1503- [Hello](#hello)
1504 - [Another](#another)
1505- [Heading](#heading)
1506<!-- tocstop -->
1507
1508## Hello
1509
1510### Another
1511
1512## Heading
1513"#;
1514 let ctx = create_ctx(content);
1515 let result = rule.check(&ctx).unwrap();
1516 assert_eq!(result.len(), 1, "Should detect wrong indent");
1517 assert!(
1518 result[0].message.contains("Indentation mismatch"),
1519 "Should report indentation mismatch: {}",
1520 result[0].message
1521 );
1522 assert!(
1523 result[0].message.contains("expected 4 spaces"),
1524 "Should mention expected 4 spaces: {}",
1525 result[0].message
1526 );
1527 }
1528
1529 #[test]
1532 fn test_strip_markdown_formatting_link() {
1533 let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1534 assert_eq!(result, "Tool: terminal");
1535 }
1536
1537 #[test]
1538 fn test_strip_markdown_formatting_bold() {
1539 let result = strip_markdown_formatting("This is **bold** text");
1540 assert_eq!(result, "This is bold text");
1541
1542 let result = strip_markdown_formatting("This is __bold__ text");
1543 assert_eq!(result, "This is bold text");
1544 }
1545
1546 #[test]
1547 fn test_strip_markdown_formatting_italic() {
1548 let result = strip_markdown_formatting("This is *italic* text");
1549 assert_eq!(result, "This is italic text");
1550
1551 let result = strip_markdown_formatting("This is _italic_ text");
1552 assert_eq!(result, "This is italic text");
1553 }
1554
1555 #[test]
1556 fn test_strip_markdown_formatting_code_span() {
1557 let result = strip_markdown_formatting("Use the `format` function");
1558 assert_eq!(result, "Use the format function");
1559 }
1560
1561 #[test]
1562 fn test_strip_markdown_formatting_image() {
1563 let result = strip_markdown_formatting("See  for details");
1564 assert_eq!(result, "See logo for details");
1565 }
1566
1567 #[test]
1568 fn test_strip_markdown_formatting_reference_link() {
1569 let result = strip_markdown_formatting("See [documentation][docs] for details");
1570 assert_eq!(result, "See documentation for details");
1571 }
1572
1573 #[test]
1574 fn test_strip_markdown_formatting_combined() {
1575 let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1577 assert_eq!(result, "Tool: terminal");
1578 }
1579
1580 #[test]
1581 fn test_toc_with_link_in_heading_matches_stripped_text() {
1582 let rule = create_enabled_rule();
1583
1584 let content = r#"# Title
1586
1587<!-- toc -->
1588
1589- [Tool: terminal](#tool-terminal)
1590
1591<!-- tocstop -->
1592
1593## Tool: [terminal](https://example.com)
1594
1595Content here.
1596"#;
1597 let ctx = create_ctx(content);
1598 let result = rule.check(&ctx).unwrap();
1599 assert!(
1600 result.is_empty(),
1601 "Stripped heading text should match TOC entry: {result:?}"
1602 );
1603 }
1604
1605 #[test]
1606 fn test_toc_with_simplified_text_still_mismatches() {
1607 let rule = create_enabled_rule();
1608
1609 let content = r#"# Title
1611
1612<!-- toc -->
1613
1614- [terminal](#tool-terminal)
1615
1616<!-- tocstop -->
1617
1618## Tool: [terminal](https://example.com)
1619
1620Content here.
1621"#;
1622 let ctx = create_ctx(content);
1623 let result = rule.check(&ctx).unwrap();
1624 assert_eq!(result.len(), 1, "Should report text mismatch");
1625 assert!(result[0].message.contains("Text mismatch"));
1626 }
1627
1628 #[test]
1629 fn test_fix_generates_stripped_toc_entries() {
1630 let rule = MD073TocValidation::new();
1631 let content = r#"# Title
1632
1633<!-- toc -->
1634
1635<!-- tocstop -->
1636
1637## Tool: [busybox](https://www.busybox.net/)
1638
1639Content.
1640
1641## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1642
1643More content.
1644"#;
1645 let ctx = create_ctx(content);
1646 let fixed = rule.fix(&ctx).unwrap();
1647
1648 assert!(
1650 fixed.contains("- [Tool: busybox](#tool-busybox)"),
1651 "TOC entry should have stripped link text"
1652 );
1653 assert!(
1654 fixed.contains("- [Tool: mount](#tool-mount)"),
1655 "TOC entry should have stripped link text"
1656 );
1657 let toc_start = fixed.find("<!-- toc -->").unwrap();
1660 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1661 let toc_content = &fixed[toc_start..toc_end];
1662 assert!(
1663 !toc_content.contains("busybox.net"),
1664 "TOC should not contain URLs: {toc_content}"
1665 );
1666 assert!(
1667 !toc_content.contains("wikipedia.org"),
1668 "TOC should not contain URLs: {toc_content}"
1669 );
1670 }
1671
1672 #[test]
1673 fn test_fix_with_bold_in_heading() {
1674 let rule = MD073TocValidation::new();
1675 let content = r#"# Title
1676
1677<!-- toc -->
1678
1679<!-- tocstop -->
1680
1681## **Important** Section
1682
1683Content.
1684"#;
1685 let ctx = create_ctx(content);
1686 let fixed = rule.fix(&ctx).unwrap();
1687
1688 assert!(fixed.contains("- [Important Section](#important-section)"));
1690 }
1691
1692 #[test]
1693 fn test_fix_with_code_in_heading() {
1694 let rule = MD073TocValidation::new();
1695 let content = r#"# Title
1696
1697<!-- toc -->
1698
1699<!-- tocstop -->
1700
1701## Using `async` Functions
1702
1703Content.
1704"#;
1705 let ctx = create_ctx(content);
1706 let fixed = rule.fix(&ctx).unwrap();
1707
1708 assert!(fixed.contains("- [Using async Functions](#using-async-functions)"));
1710 }
1711
1712 #[test]
1715 fn test_custom_anchor_id_respected() {
1716 let rule = create_enabled_rule();
1717 let content = r#"# Title
1718
1719<!-- toc -->
1720
1721- [My Section](#my-custom-anchor)
1722
1723<!-- tocstop -->
1724
1725## My Section {#my-custom-anchor}
1726
1727Content here.
1728"#;
1729 let ctx = create_ctx(content);
1730 let result = rule.check(&ctx).unwrap();
1731 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1732 }
1733
1734 #[test]
1735 fn test_custom_anchor_id_in_generated_toc() {
1736 let rule = create_enabled_rule();
1737 let content = r#"# Title
1738
1739<!-- toc -->
1740
1741<!-- tocstop -->
1742
1743## First Section {#custom-first}
1744
1745Content.
1746
1747## Second Section {#another-custom}
1748
1749More content.
1750"#;
1751 let ctx = create_ctx(content);
1752 let fixed = rule.fix(&ctx).unwrap();
1753 assert!(fixed.contains("- [First Section](#custom-first)"));
1754 assert!(fixed.contains("- [Second Section](#another-custom)"));
1755 }
1756
1757 #[test]
1758 fn test_mixed_custom_and_generated_anchors() {
1759 let rule = create_enabled_rule();
1760 let content = r#"# Title
1761
1762<!-- toc -->
1763
1764- [Custom Section](#my-id)
1765- [Normal Section](#normal-section)
1766
1767<!-- tocstop -->
1768
1769## Custom Section {#my-id}
1770
1771Content.
1772
1773## Normal Section
1774
1775More content.
1776"#;
1777 let ctx = create_ctx(content);
1778 let result = rule.check(&ctx).unwrap();
1779 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
1780 }
1781
1782 #[test]
1785 fn test_github_anchor_style() {
1786 let rule = create_enabled_rule();
1787
1788 let content = r#"<!-- toc -->
1789
1790<!-- tocstop -->
1791
1792## Test_With_Underscores
1793
1794Content.
1795"#;
1796 let ctx = create_ctx(content);
1797 let region = rule.detect_toc_region(&ctx).unwrap();
1798 let expected = rule.build_expected_toc(&ctx, ®ion);
1799
1800 assert_eq!(expected[0].anchor, "test_with_underscores");
1802 }
1803
1804 #[test]
1807 fn test_stress_many_headings() {
1808 let rule = create_enabled_rule();
1809
1810 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1812
1813 for i in 1..=150 {
1814 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
1815 }
1816
1817 let ctx = create_ctx(&content);
1818
1819 let result = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(result.len(), 1, "Should report single warning for TOC");
1824 assert!(result[0].message.contains("Missing entry"));
1825
1826 let fixed = rule.fix(&ctx).unwrap();
1828 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
1829 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
1830 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
1831 }
1832
1833 #[test]
1834 fn test_stress_deeply_nested() {
1835 let rule = create_enabled_rule();
1836 let content = r#"# Title
1837
1838<!-- toc -->
1839
1840<!-- tocstop -->
1841
1842## Level 2 A
1843
1844### Level 3 A
1845
1846#### Level 4 A
1847
1848## Level 2 B
1849
1850### Level 3 B
1851
1852#### Level 4 B
1853
1854## Level 2 C
1855
1856### Level 3 C
1857
1858#### Level 4 C
1859
1860## Level 2 D
1861
1862### Level 3 D
1863
1864#### Level 4 D
1865"#;
1866 let ctx = create_ctx(content);
1867 let fixed = rule.fix(&ctx).unwrap();
1868
1869 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
1871 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
1872 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
1873 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
1874 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
1875 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
1876 }
1877
1878 #[test]
1879 fn test_stress_many_duplicates() {
1880 let rule = create_enabled_rule();
1881
1882 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1884 for _ in 0..50 {
1885 content.push_str("## FAQ\n\nContent.\n\n");
1886 }
1887
1888 let ctx = create_ctx(&content);
1889 let region = rule.detect_toc_region(&ctx).unwrap();
1890 let expected = rule.build_expected_toc(&ctx, ®ion);
1891
1892 assert_eq!(expected.len(), 50);
1894 assert_eq!(expected[0].anchor, "faq");
1895 assert_eq!(expected[1].anchor, "faq-1");
1896 assert_eq!(expected[49].anchor, "faq-49");
1897 }
1898}