Skip to main content

rumdl_lib/rules/
md024_no_duplicate_heading.rs

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        // Early return for empty content
43        if ctx.lines.is_empty() {
44            return Ok(Vec::new());
45        }
46
47        // Dedup key pairs the heading's visible text with its `{#custom-id}` (if any).
48        // Using a tuple avoids ambiguity when the text itself contains `#`.
49        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        // For siblings_only mode, track heading hierarchy
56        let mut current_section_path: Vec<(u8, HeadingKey<'_>)> = Vec::new();
57        let mut seen_siblings: HashMap<Vec<HeadingKey<'_>>, HashSet<HeadingKey<'_>>> = HashMap::new();
58
59        // Track if we're in a snippet section (MkDocs flavor)
60        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
61        let mut in_snippet_section = false;
62
63        // Process headings using cached heading information
64        for (line_num, line_info) in ctx.lines.iter().enumerate() {
65            // Check for MkDocs snippet markers if using MkDocs flavor
66            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; // Skip this line
70                } else if crate::utils::mkdocs_snippets::is_snippet_section_end(line_info.content(ctx.content)) {
71                    in_snippet_section = false;
72                    continue; // Skip this line
73                }
74            }
75
76            // Skip lines within snippet sections (for MkDocs)
77            if is_mkdocs && in_snippet_section {
78                continue;
79            }
80
81            // A Setext heading is recorded on the last line of its text, and the
82            // parsed form carries the whole span its warning covers.
83            if line_info.heading.is_some()
84                && let Some(parsed) = ctx.heading_on_line(line_num + 1)
85            {
86                let heading = parsed.heading;
87                // Skip empty headings
88                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                    // Update the section path based on the current heading level.
101                    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                // The range covers the heading text, from its start on the
122                // first text line to its end on the last.
123                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        // MD024 does not support auto-fixing. Removing duplicate headings is not a safe or meaningful fix.
143        Ok(ctx.content.to_string())
144    }
145
146    /// Get the category of this rule for selective processing
147    fn category(&self) -> RuleCategory {
148        RuleCategory::Heading
149    }
150
151    /// Check if this rule should be skipped
152    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
153        // Fast path: check if document likely has headings
154        if !ctx.likely_has_headings() {
155            return true;
156        }
157        // Verify headings actually exist
158        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        // The rule is case-sensitive, so these should not be duplicates
296        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        // First duplicate of "Title"
411        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
412        assert_eq!(warnings[0].line, 5);
413        // First duplicate of "Subtitle"
414        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
415        assert_eq!(warnings[1].line, 7);
416        // Second duplicate of "Title"
417        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
418        assert_eq!(warnings[2].line, 9);
419        // Second duplicate of "Subtitle"
420        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        // Empty headings are skipped
445        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        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
542        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        // The heading text is trimmed, so all three are duplicates
609        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); // After "### "
635        assert_eq!(warnings[0].end_line, 5);
636        assert_eq!(warnings[0].end_column, 10); // End of "First"
637    }
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        // When allow_different_nesting is true, only same-level duplicates are flagged
668        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        // With siblings_only, these are not flagged because they're under different parents
799        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        // Only the duplicate "First Subsection" at the same level should be flagged
829        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 &amp; More
878
879Some content.
880
881## Title &amp; 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 &amp; 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        // With allow_different_nesting, there should be no warnings
919        assert_eq!(warnings.len(), 0);
920    }
921
922    // --- allow_different_link_anchors tests ---
923
924    #[test]
925    fn test_custom_anchor_different_ids_no_warning_default() {
926        // Reporter's exact repro: same visible text, different {#id} → no warning with default config.
927        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        // Same text and same explicit {#id} → the rendered anchor is identical, must flag.
942        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        // Same visible text but one has a {#id} suffix and the other has none → distinct keys.
958        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        // When the option is disabled the {#id} is stripped before dedup, so both headings
973        // share the key "Unit testing" and the second is flagged.
974        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        // With siblings_only=true, headings under the same parent but with different anchors
994        // must not be flagged; headings with the same text AND same anchor under the same parent must be.
995        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        // Only the third "## Section {#section-a}" duplicates the first one.
1010        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        // With allow_different_nesting=true, two headings at different levels that would otherwise
1021        // be exempt are still compared separately per-level. Anchors still differentiate same-level dups.
1022        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        // The two h2 headings share the same key "Topic#topic-1" → flagged.
1036        // The h3 has a distinct key → not flagged.
1037        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        // A `#` in visible heading text must not collide with a different heading that
1048        // carries a `{#id}` suffix. Regression: previously the dedup key was built with
1049        // string concatenation `"{text}#{id}"`, which made `## Foo#bar` and `## Foo {#bar}`
1050        // share the same key and triggered a false-positive duplicate warning.
1051        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        // Sanity guardrail: after the tuple-key fix, genuine duplicate text
1069        // containing `#` is still flagged.
1070        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        // Every MDG keyword accepts a name after the colon, so a duplicate
1086        // heading is always avoidable while staying valid Gherkin. MD024 must
1087        // therefore keep enforcing uniqueness under this flavor.
1088        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        // `Examples: valid cards` keeps the Examples keyword and gains a name,
1108        // so naming the blocks resolves the duplication without losing nodes.
1109        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}