1use toml;
2
3use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
4use crate::utils::range_utils::calculate_match_range;
5use std::collections::{HashMap, HashSet};
6
7mod md024_config;
8use md024_config::MD024Config;
9
10#[derive(Clone, Debug, Default)]
11pub struct MD024NoDuplicateHeading {
12 config: MD024Config,
13}
14
15impl MD024NoDuplicateHeading {
16 pub fn new(allow_different_nesting: bool, siblings_only: bool) -> Self {
17 Self {
18 config: MD024Config {
19 allow_different_nesting,
20 siblings_only,
21 allow_different_link_anchors: true,
22 },
23 }
24 }
25
26 pub fn from_config_struct(config: MD024Config) -> Self {
27 Self { config }
28 }
29}
30
31impl Rule for MD024NoDuplicateHeading {
32 fn name(&self) -> &'static str {
33 "MD024"
34 }
35
36 fn description(&self) -> &'static str {
37 "Multiple headings with the same content"
38 }
39
40 fn fix_capability(&self) -> FixCapability {
41 FixCapability::Unfixable
42 }
43
44 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
45 if ctx.lines.is_empty() {
47 return Ok(Vec::new());
48 }
49
50 type HeadingKey = (String, Option<String>);
53
54 let mut warnings = Vec::new();
55 let mut seen_headings: HashSet<HeadingKey> = HashSet::new();
56 let mut seen_headings_per_level: HashMap<u8, HashSet<HeadingKey>> = HashMap::new();
57
58 let mut current_section_path: Vec<(u8, HeadingKey)> = Vec::new();
60 let mut seen_siblings: HashMap<Vec<HeadingKey>, HashSet<HeadingKey>> = HashMap::new();
61
62 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
64 let mut in_snippet_section = false;
65
66 for (line_num, line_info) in ctx.lines.iter().enumerate() {
68 if is_mkdocs {
70 if crate::utils::mkdocs_snippets::is_snippet_section_start(line_info.content(ctx.content)) {
71 in_snippet_section = true;
72 continue; } else if crate::utils::mkdocs_snippets::is_snippet_section_end(line_info.content(ctx.content)) {
74 in_snippet_section = false;
75 continue; }
77 }
78
79 if is_mkdocs && in_snippet_section {
81 continue;
82 }
83
84 if let Some(heading) = &line_info.heading {
85 if !heading.is_valid {
87 continue;
88 }
89
90 if heading.text.is_empty() {
92 continue;
93 }
94
95 let heading_key: HeadingKey = if self.config.allow_different_link_anchors {
96 (heading.text.clone(), heading.custom_id.clone())
97 } else {
98 (heading.text.clone(), None)
99 };
100 let level = heading.level;
101
102 let text_start_in_line = if let Some(pos) = line_info.content(ctx.content).find(&heading.text) {
104 pos
105 } else {
106 let trimmed = line_info.content(ctx.content).trim_start();
108 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
109 let after_hashes = &trimmed[hash_count..];
110 let text_start_in_trimmed = after_hashes.find(&heading.text).unwrap_or(0);
111 (line_info.byte_len - trimmed.len()) + hash_count + text_start_in_trimmed
112 };
113
114 let (start_line, start_col, end_line, end_col) = calculate_match_range(
115 line_num + 1,
116 line_info.content(ctx.content),
117 text_start_in_line,
118 heading.text.len(),
119 );
120
121 if self.config.siblings_only {
122 while !current_section_path.is_empty() && current_section_path.last().unwrap().0 >= level {
124 current_section_path.pop();
125 }
126
127 let parent_path: Vec<HeadingKey> = current_section_path.iter().map(|(_, k)| k.clone()).collect();
128
129 let siblings = seen_siblings.entry(parent_path).or_default();
131 if siblings.contains(&heading_key) {
132 warnings.push(LintWarning {
133 rule_name: Some(self.name().to_string()),
134 message: format!("Duplicate heading: '{}'.", heading.text),
135 line: start_line,
136 column: start_col,
137 end_line,
138 end_column: end_col,
139 severity: Severity::Error,
140 fix: None,
141 });
142 } else {
143 siblings.insert(heading_key.clone());
144 }
145
146 current_section_path.push((level, heading_key.clone()));
148 } else if self.config.allow_different_nesting {
149 let seen = seen_headings_per_level.entry(level).or_default();
151 if seen.contains(&heading_key) {
152 warnings.push(LintWarning {
153 rule_name: Some(self.name().to_string()),
154 message: format!("Duplicate heading: '{}'.", heading.text),
155 line: start_line,
156 column: start_col,
157 end_line,
158 end_column: end_col,
159 severity: Severity::Error,
160 fix: None,
161 });
162 } else {
163 seen.insert(heading_key.clone());
164 }
165 } else {
166 if seen_headings.contains(&heading_key) {
168 warnings.push(LintWarning {
169 rule_name: Some(self.name().to_string()),
170 message: format!("Duplicate heading: '{}'.", heading.text),
171 line: start_line,
172 column: start_col,
173 end_line,
174 end_column: end_col,
175 severity: Severity::Error,
176 fix: None,
177 });
178 } else {
179 seen_headings.insert(heading_key.clone());
180 }
181 }
182 }
183 }
184
185 Ok(warnings)
186 }
187
188 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
189 Ok(ctx.content.to_string())
191 }
192
193 fn category(&self) -> RuleCategory {
195 RuleCategory::Heading
196 }
197
198 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
200 if !ctx.likely_has_headings() {
202 return true;
203 }
204 ctx.lines.iter().all(|line| line.heading.is_none())
206 }
207
208 fn as_any(&self) -> &dyn std::any::Any {
209 self
210 }
211
212 crate::impl_rule_config_methods!(MD024Config);
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::lint_context::LintContext;
219
220 fn run_test(content: &str, config: MD024Config) -> LintResult {
221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
222 let rule = MD024NoDuplicateHeading::from_config_struct(config);
223 rule.check(&ctx)
224 }
225
226 fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
227 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
228 let rule = MD024NoDuplicateHeading::from_config_struct(config);
229 rule.fix(&ctx)
230 }
231
232 #[test]
233 fn test_no_duplicate_headings() {
234 let content = r#"# First Heading
235
236Some content here.
237
238## Second Heading
239
240More content.
241
242### Third Heading
243
244Even more content.
245
246## Fourth Heading
247
248Final content."#;
249
250 let config = MD024Config::default();
251 let result = run_test(content, config);
252 assert!(result.is_ok());
253 let warnings = result.unwrap();
254 assert_eq!(warnings.len(), 0);
255 }
256
257 #[test]
258 fn test_duplicate_headings_same_level() {
259 let content = r#"# First Heading
260
261Some content here.
262
263## Second Heading
264
265More content.
266
267## Second Heading
268
269This is a duplicate."#;
270
271 let config = MD024Config::default();
272 let result = run_test(content, config);
273 assert!(result.is_ok());
274 let warnings = result.unwrap();
275 assert_eq!(warnings.len(), 1);
276 assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
277 assert_eq!(warnings[0].line, 9);
278 }
279
280 #[test]
281 fn test_duplicate_headings_different_levels_default() {
282 let content = r#"# Main Title
283
284Some content.
285
286## Main Title
287
288This has the same text but different level."#;
289
290 let config = MD024Config {
291 allow_different_nesting: false,
292 siblings_only: false,
293 ..MD024Config::default()
294 };
295 let result = run_test(content, config);
296 assert!(result.is_ok());
297 let warnings = result.unwrap();
298 assert_eq!(warnings.len(), 1);
299 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
300 assert_eq!(warnings[0].line, 5);
301 }
302
303 #[test]
304 fn test_duplicate_headings_different_levels_allow_different_nesting() {
305 let content = r#"# Main Title
306
307Some content.
308
309## Main Title
310
311This has the same text but different level."#;
312
313 let config = MD024Config {
314 allow_different_nesting: true,
315 siblings_only: false,
316 ..MD024Config::default()
317 };
318 let result = run_test(content, config);
319 assert!(result.is_ok());
320 let warnings = result.unwrap();
321 assert_eq!(warnings.len(), 0);
322 }
323
324 #[test]
325 fn test_case_sensitivity() {
326 let content = r#"# First Heading
327
328Some content.
329
330## first heading
331
332Different case.
333
334### FIRST HEADING
335
336All caps."#;
337
338 let config = MD024Config::default();
339 let result = run_test(content, config);
340 assert!(result.is_ok());
341 let warnings = result.unwrap();
342 assert_eq!(warnings.len(), 0);
344 }
345
346 #[test]
347 fn test_headings_with_trailing_punctuation() {
348 let content = r#"# First Heading!
349
350Some content.
351
352## First Heading!
353
354Same with punctuation.
355
356### First Heading
357
358Without punctuation."#;
359
360 let config = MD024Config {
361 allow_different_nesting: false,
362 siblings_only: false,
363 ..MD024Config::default()
364 };
365 let result = run_test(content, config);
366 assert!(result.is_ok());
367 let warnings = result.unwrap();
368 assert_eq!(warnings.len(), 1);
369 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
370 }
371
372 #[test]
373 fn test_headings_with_inline_formatting() {
374 let content = r#"# **Bold Heading**
375
376Some content.
377
378## *Italic Heading*
379
380More content.
381
382### **Bold Heading**
383
384Duplicate with same formatting.
385
386#### `Code Heading`
387
388Code formatted.
389
390##### `Code Heading`
391
392Duplicate code formatted."#;
393
394 let config = MD024Config {
395 allow_different_nesting: false,
396 siblings_only: false,
397 ..MD024Config::default()
398 };
399 let result = run_test(content, config);
400 assert!(result.is_ok());
401 let warnings = result.unwrap();
402 assert_eq!(warnings.len(), 2);
403 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
404 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
405 }
406
407 #[test]
408 fn test_headings_in_different_sections() {
409 let content = r#"# Section One
410
411## Subsection
412
413Some content.
414
415# Section Two
416
417## Subsection
418
419Same subsection name in different section."#;
420
421 let config = MD024Config {
422 allow_different_nesting: false,
423 siblings_only: false,
424 ..MD024Config::default()
425 };
426 let result = run_test(content, config);
427 assert!(result.is_ok());
428 let warnings = result.unwrap();
429 assert_eq!(warnings.len(), 1);
430 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
431 assert_eq!(warnings[0].line, 9);
432 }
433
434 #[test]
435 fn test_multiple_duplicates() {
436 let content = r#"# Title
437
438## Subtitle
439
440### Title
441
442#### Subtitle
443
444## Title
445
446### Subtitle"#;
447
448 let config = MD024Config {
449 allow_different_nesting: false,
450 siblings_only: false,
451 ..MD024Config::default()
452 };
453 let result = run_test(content, config);
454 assert!(result.is_ok());
455 let warnings = result.unwrap();
456 assert_eq!(warnings.len(), 4);
457 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
459 assert_eq!(warnings[0].line, 5);
460 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
462 assert_eq!(warnings[1].line, 7);
463 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
465 assert_eq!(warnings[2].line, 9);
466 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
468 assert_eq!(warnings[3].line, 11);
469 }
470
471 #[test]
472 fn test_empty_headings() {
473 let content = r#"#
474
475Some content.
476
477##
478
479More content.
480
481### Non-empty
482
483####
484
485Another empty."#;
486
487 let config = MD024Config::default();
488 let result = run_test(content, config);
489 assert!(result.is_ok());
490 let warnings = result.unwrap();
491 assert_eq!(warnings.len(), 0);
493 }
494
495 #[test]
496 fn test_unicode_and_special_characters() {
497 let content = r#"# 你好世界
498
499Some content.
500
501## Émojis 🎉🎊
502
503More content.
504
505### 你好世界
506
507Duplicate Chinese.
508
509#### Émojis 🎉🎊
510
511Duplicate emojis.
512
513##### Special <chars> & symbols!
514
515###### Special <chars> & symbols!
516
517Duplicate special chars."#;
518
519 let config = MD024Config {
520 allow_different_nesting: false,
521 siblings_only: false,
522 ..MD024Config::default()
523 };
524 let result = run_test(content, config);
525 assert!(result.is_ok());
526 let warnings = result.unwrap();
527 assert_eq!(warnings.len(), 3);
528 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
529 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
530 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
531 }
532
533 #[test]
534 fn test_allow_different_nesting_with_same_level_duplicates() {
535 let content = r#"# Section One
536
537## Title
538
539### Subsection
540
541## Title
542
543This is a duplicate at the same level.
544
545# Section Two
546
547## Title
548
549Different section, but still a duplicate when allow_different_nesting is true."#;
550
551 let config = MD024Config {
552 allow_different_nesting: true,
553 siblings_only: false,
554 ..MD024Config::default()
555 };
556 let result = run_test(content, config);
557 assert!(result.is_ok());
558 let warnings = result.unwrap();
559 assert_eq!(warnings.len(), 2);
560 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
561 assert_eq!(warnings[0].line, 7);
562 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
563 assert_eq!(warnings[1].line, 13);
564 }
565
566 #[test]
567 fn test_atx_style_headings_with_closing_hashes() {
568 let content = r#"# Heading One #
569
570Some content.
571
572## Heading Two ##
573
574More content.
575
576### Heading One ###
577
578Duplicate with different style."#;
579
580 let config = MD024Config {
581 allow_different_nesting: false,
582 siblings_only: false,
583 ..MD024Config::default()
584 };
585 let result = run_test(content, config);
586 assert!(result.is_ok());
587 let warnings = result.unwrap();
588 assert_eq!(warnings.len(), 1);
590 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
591 assert_eq!(warnings[0].line, 9);
592 }
593
594 #[test]
595 fn test_fix_method_returns_unchanged() {
596 let content = r#"# Duplicate
597
598## Duplicate
599
600This has duplicates."#;
601
602 let config = MD024Config::default();
603 let result = run_fix_test(content, config);
604 assert!(result.is_ok());
605 assert_eq!(result.unwrap(), content);
606 }
607
608 #[test]
609 fn test_empty_content() {
610 let content = "";
611 let config = MD024Config::default();
612 let result = run_test(content, config);
613 assert!(result.is_ok());
614 let warnings = result.unwrap();
615 assert_eq!(warnings.len(), 0);
616 }
617
618 #[test]
619 fn test_no_headings() {
620 let content = r#"This is just regular text.
621
622No headings anywhere.
623
624Just paragraphs."#;
625
626 let config = MD024Config::default();
627 let result = run_test(content, config);
628 assert!(result.is_ok());
629 let warnings = result.unwrap();
630 assert_eq!(warnings.len(), 0);
631 }
632
633 #[test]
634 fn test_whitespace_differences() {
635 let content = r#"# Heading with spaces
636
637Some content.
638
639## Heading with spaces
640
641Different amount of spaces.
642
643### Heading with spaces
644
645Exact match."#;
646
647 let config = MD024Config {
648 allow_different_nesting: false,
649 siblings_only: false,
650 ..MD024Config::default()
651 };
652 let result = run_test(content, config);
653 assert!(result.is_ok());
654 let warnings = result.unwrap();
655 assert_eq!(warnings.len(), 2);
657 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
658 assert_eq!(warnings[0].line, 5);
659 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
660 assert_eq!(warnings[1].line, 9);
661 }
662
663 #[test]
664 fn test_column_positions() {
665 let content = r#"# First
666
667## Second
668
669### First"#;
670
671 let config = MD024Config {
672 allow_different_nesting: false,
673 siblings_only: false,
674 ..MD024Config::default()
675 };
676 let result = run_test(content, config);
677 assert!(result.is_ok());
678 let warnings = result.unwrap();
679 assert_eq!(warnings.len(), 1);
680 assert_eq!(warnings[0].line, 5);
681 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
683 assert_eq!(warnings[0].end_column, 10); }
685
686 #[test]
687 fn test_complex_nesting_scenario() {
688 let content = r#"# Main Document
689
690## Introduction
691
692### Overview
693
694## Implementation
695
696### Overview
697
698This Overview is in a different section.
699
700## Conclusion
701
702### Overview
703
704Another Overview in yet another section."#;
705
706 let config = MD024Config {
707 allow_different_nesting: true,
708 siblings_only: false,
709 ..MD024Config::default()
710 };
711 let result = run_test(content, config);
712 assert!(result.is_ok());
713 let warnings = result.unwrap();
714 assert_eq!(warnings.len(), 2);
716 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
717 assert_eq!(warnings[0].line, 9);
718 assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
719 assert_eq!(warnings[1].line, 15);
720 }
721
722 #[test]
723 fn test_setext_style_headings() {
724 let content = r#"Main Title
725==========
726
727Some content.
728
729Second Title
730------------
731
732More content.
733
734Main Title
735==========
736
737Duplicate setext."#;
738
739 let config = MD024Config::default();
740 let result = run_test(content, config);
741 assert!(result.is_ok());
742 let warnings = result.unwrap();
743 assert_eq!(warnings.len(), 1);
744 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
745 assert_eq!(warnings[0].line, 11);
746 }
747
748 #[test]
749 fn test_mixed_heading_styles() {
750 let content = r#"# ATX Title
751
752Some content.
753
754ATX Title
755=========
756
757Same text, different style."#;
758
759 let config = MD024Config::default();
760 let result = run_test(content, config);
761 assert!(result.is_ok());
762 let warnings = result.unwrap();
763 assert_eq!(warnings.len(), 1);
764 assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
765 assert_eq!(warnings[0].line, 5);
766 }
767
768 #[test]
769 fn test_heading_with_links() {
770 let content = r#"# [Link Text](http://example.com)
771
772Some content.
773
774## [Link Text](http://example.com)
775
776Duplicate heading with link.
777
778### [Different Link](http://example.com)
779
780Not a duplicate."#;
781
782 let config = MD024Config {
783 allow_different_nesting: false,
784 siblings_only: false,
785 ..MD024Config::default()
786 };
787 let result = run_test(content, config);
788 assert!(result.is_ok());
789 let warnings = result.unwrap();
790 assert_eq!(warnings.len(), 1);
791 assert_eq!(
792 warnings[0].message,
793 "Duplicate heading: '[Link Text](http://example.com)'."
794 );
795 assert_eq!(warnings[0].line, 5);
796 }
797
798 #[test]
799 fn test_consecutive_duplicates() {
800 let content = r#"# Title
801
802## Title
803
804### Title
805
806Three in a row."#;
807
808 let config = MD024Config {
809 allow_different_nesting: false,
810 siblings_only: false,
811 ..MD024Config::default()
812 };
813 let result = run_test(content, config);
814 assert!(result.is_ok());
815 let warnings = result.unwrap();
816 assert_eq!(warnings.len(), 2);
817 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
818 assert_eq!(warnings[0].line, 3);
819 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
820 assert_eq!(warnings[1].line, 5);
821 }
822
823 #[test]
824 fn test_siblings_only_config() {
825 let content = r#"# Section One
826
827## Subsection
828
829### Details
830
831# Section Two
832
833## Subsection
834
835Different parent sections, so not siblings - no warning expected."#;
836
837 let config = MD024Config {
838 allow_different_nesting: false,
839 siblings_only: true,
840 ..MD024Config::default()
841 };
842 let result = run_test(content, config);
843 assert!(result.is_ok());
844 let warnings = result.unwrap();
845 assert_eq!(warnings.len(), 0);
847 }
848
849 #[test]
850 fn test_siblings_only_with_actual_siblings() {
851 let content = r#"# Main Section
852
853## First Subsection
854
855### Details
856
857## Second Subsection
858
859### Details
860
861The two 'Details' headings are siblings under different subsections - no warning.
862
863## First Subsection
864
865This 'First Subsection' IS a sibling duplicate."#;
866
867 let config = MD024Config {
868 allow_different_nesting: false,
869 siblings_only: true,
870 ..MD024Config::default()
871 };
872 let result = run_test(content, config);
873 assert!(result.is_ok());
874 let warnings = result.unwrap();
875 assert_eq!(warnings.len(), 1);
877 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
878 assert_eq!(warnings[0].line, 13);
879 }
880
881 #[test]
882 fn test_code_spans_in_headings() {
883 let content = r#"# `code` in heading
884
885Some content.
886
887## `code` in heading
888
889Duplicate with code span."#;
890
891 let config = MD024Config {
892 allow_different_nesting: false,
893 siblings_only: false,
894 ..MD024Config::default()
895 };
896 let result = run_test(content, config);
897 assert!(result.is_ok());
898 let warnings = result.unwrap();
899 assert_eq!(warnings.len(), 1);
900 assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
901 assert_eq!(warnings[0].line, 5);
902 }
903
904 #[test]
905 fn test_very_long_heading() {
906 let long_text = "This is a very long heading that goes on and on and on and contains many words to test how the rule handles long headings";
907 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
908
909 let config = MD024Config {
910 allow_different_nesting: false,
911 siblings_only: false,
912 ..MD024Config::default()
913 };
914 let result = run_test(&content, config);
915 assert!(result.is_ok());
916 let warnings = result.unwrap();
917 assert_eq!(warnings.len(), 1);
918 assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
919 assert_eq!(warnings[0].line, 5);
920 }
921
922 #[test]
923 fn test_heading_with_html_entities() {
924 let content = r#"# Title & More
925
926Some content.
927
928## Title & More
929
930Duplicate with HTML entity."#;
931
932 let config = MD024Config {
933 allow_different_nesting: false,
934 siblings_only: false,
935 ..MD024Config::default()
936 };
937 let result = run_test(content, config);
938 assert!(result.is_ok());
939 let warnings = result.unwrap();
940 assert_eq!(warnings.len(), 1);
941 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
942 assert_eq!(warnings[0].line, 5);
943 }
944
945 #[test]
946 fn test_three_duplicates_different_nesting() {
947 let content = r#"# Main
948
949## Main
950
951### Main
952
953#### Main
954
955All same text, different levels."#;
956
957 let config = MD024Config {
958 allow_different_nesting: true,
959 siblings_only: false,
960 ..MD024Config::default()
961 };
962 let result = run_test(content, config);
963 assert!(result.is_ok());
964 let warnings = result.unwrap();
965 assert_eq!(warnings.len(), 0);
967 }
968
969 #[test]
972 fn test_custom_anchor_different_ids_no_warning_default() {
973 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
975 let config = MD024Config::default();
976 let result = run_test(content, config);
977 assert!(result.is_ok());
978 let warnings = result.unwrap();
979 assert_eq!(
980 warnings.len(),
981 0,
982 "headings with different custom anchors must not be flagged"
983 );
984 }
985
986 #[test]
987 fn test_custom_anchor_same_id_flagged() {
988 let content = "## Overview {#overview}\n\n## Overview {#overview}\n";
990 let config = MD024Config::default();
991 let result = run_test(content, config);
992 assert!(result.is_ok());
993 let warnings = result.unwrap();
994 assert_eq!(
995 warnings.len(),
996 1,
997 "headings with identical custom anchors must be flagged"
998 );
999 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
1000 }
1001
1002 #[test]
1003 fn test_custom_anchor_one_with_id_one_without_no_warning() {
1004 let content = "## Setup\n\n## Setup {#alt-setup}\n";
1006 let config = MD024Config::default();
1007 let result = run_test(content, config);
1008 assert!(result.is_ok());
1009 let warnings = result.unwrap();
1010 assert_eq!(
1011 warnings.len(),
1012 0,
1013 "a plain heading and one with a custom anchor must not collide"
1014 );
1015 }
1016
1017 #[test]
1018 fn test_allow_different_link_anchors_false_restores_original_behavior() {
1019 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
1022 let config = MD024Config {
1023 allow_different_link_anchors: false,
1024 siblings_only: false,
1025 ..MD024Config::default()
1026 };
1027 let result = run_test(content, config);
1028 assert!(result.is_ok());
1029 let warnings = result.unwrap();
1030 assert_eq!(
1031 warnings.len(),
1032 1,
1033 "with allow_different_link_anchors=false the duplicate must be flagged"
1034 );
1035 assert_eq!(warnings[0].message, "Duplicate heading: 'Unit testing'.");
1036 }
1037
1038 #[test]
1039 fn test_custom_anchor_with_siblings_only() {
1040 let content = concat!(
1043 "# Parent\n\n",
1044 "## Section {#section-a}\n\n",
1045 "## Section {#section-b}\n\n",
1046 "## Section {#section-a}\n",
1047 );
1048 let config = MD024Config {
1049 siblings_only: true,
1050 allow_different_link_anchors: true,
1051 ..MD024Config::default()
1052 };
1053 let result = run_test(content, config);
1054 assert!(result.is_ok());
1055 let warnings = result.unwrap();
1056 assert_eq!(
1058 warnings.len(),
1059 1,
1060 "only exact key collision under same parent must be flagged"
1061 );
1062 assert_eq!(warnings[0].message, "Duplicate heading: 'Section'.");
1063 }
1064
1065 #[test]
1066 fn test_custom_anchor_with_allow_different_nesting() {
1067 let content = concat!(
1070 "## Topic {#topic-1}\n\n",
1071 "### Topic {#topic-2}\n\n",
1072 "## Topic {#topic-1}\n",
1073 );
1074 let config = MD024Config {
1075 allow_different_nesting: true,
1076 siblings_only: false,
1077 allow_different_link_anchors: true,
1078 };
1079 let result = run_test(content, config);
1080 assert!(result.is_ok());
1081 let warnings = result.unwrap();
1082 assert_eq!(
1085 warnings.len(),
1086 1,
1087 "same-level, same-anchor headings must still be flagged"
1088 );
1089 assert_eq!(warnings[0].message, "Duplicate heading: 'Topic'.");
1090 }
1091
1092 #[test]
1093 fn test_heading_text_containing_hash_no_false_collision() {
1094 let content = "## Foo#bar\n\n## Foo {#bar}\n";
1099 let config = MD024Config {
1100 allow_different_nesting: false,
1101 siblings_only: false,
1102 allow_different_link_anchors: true,
1103 };
1104 let result = run_test(content, config);
1105 assert!(result.is_ok());
1106 let warnings = result.unwrap();
1107 assert!(
1108 warnings.is_empty(),
1109 "heading text containing # must not collide with a different heading carrying {{#id}}; got: {warnings:#?}",
1110 );
1111 }
1112
1113 #[test]
1114 fn test_heading_text_containing_hash_real_duplicate_still_flagged() {
1115 let content = "## Foo#bar\n\n## Foo#bar\n";
1118 let config = MD024Config {
1119 allow_different_nesting: false,
1120 siblings_only: false,
1121 allow_different_link_anchors: true,
1122 };
1123 let result = run_test(content, config);
1124 assert!(result.is_ok());
1125 let warnings = result.unwrap();
1126 assert_eq!(warnings.len(), 1);
1127 assert_eq!(warnings[0].message, "Duplicate heading: 'Foo#bar'.");
1128 }
1129}