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