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