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 invalid headings (e.g., `#NoSpace` which lacks required space after #)
88                if !heading.is_valid {
89                    continue;
90                }
91
92                // Skip empty headings
93                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                    // Update the section path based on the current heading level.
106                    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                // The range covers the heading text, from its start on the
127                // first text line to its end on the last.
128                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        // MD024 does not support auto-fixing. Removing duplicate headings is not a safe or meaningful fix.
148        Ok(ctx.content.to_string())
149    }
150
151    /// Get the category of this rule for selective processing
152    fn category(&self) -> RuleCategory {
153        RuleCategory::Heading
154    }
155
156    /// Check if this rule should be skipped
157    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
158        // Fast path: check if document likely has headings
159        if !ctx.likely_has_headings() {
160            return true;
161        }
162        // Verify headings actually exist
163        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        // The rule is case-sensitive, so these should not be duplicates
301        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        // First duplicate of "Title"
416        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
417        assert_eq!(warnings[0].line, 5);
418        // First duplicate of "Subtitle"
419        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
420        assert_eq!(warnings[1].line, 7);
421        // Second duplicate of "Title"
422        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
423        assert_eq!(warnings[2].line, 9);
424        // Second duplicate of "Subtitle"
425        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        // Empty headings are skipped
450        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        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
547        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        // The heading text is trimmed, so all three are duplicates
614        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); // After "### "
640        assert_eq!(warnings[0].end_line, 5);
641        assert_eq!(warnings[0].end_column, 10); // End of "First"
642    }
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        // When allow_different_nesting is true, only same-level duplicates are flagged
673        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        // With siblings_only, these are not flagged because they're under different parents
804        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        // Only the duplicate "First Subsection" at the same level should be flagged
834        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 &amp; More
883
884Some content.
885
886## Title &amp; 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 &amp; 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        // With allow_different_nesting, there should be no warnings
924        assert_eq!(warnings.len(), 0);
925    }
926
927    // --- allow_different_link_anchors tests ---
928
929    #[test]
930    fn test_custom_anchor_different_ids_no_warning_default() {
931        // Reporter's exact repro: same visible text, different {#id} → no warning with default config.
932        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        // Same text and same explicit {#id} → the rendered anchor is identical, must flag.
947        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        // Same visible text but one has a {#id} suffix and the other has none → distinct keys.
963        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        // When the option is disabled the {#id} is stripped before dedup, so both headings
978        // share the key "Unit testing" and the second is flagged.
979        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        // With siblings_only=true, headings under the same parent but with different anchors
999        // must not be flagged; headings with the same text AND same anchor under the same parent must be.
1000        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        // Only the third "## Section {#section-a}" duplicates the first one.
1015        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        // With allow_different_nesting=true, two headings at different levels that would otherwise
1026        // be exempt are still compared separately per-level. Anchors still differentiate same-level dups.
1027        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        // The two h2 headings share the same key "Topic#topic-1" → flagged.
1041        // The h3 has a distinct key → not flagged.
1042        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        // A `#` in visible heading text must not collide with a different heading that
1053        // carries a `{#id}` suffix. Regression: previously the dedup key was built with
1054        // string concatenation `"{text}#{id}"`, which made `## Foo#bar` and `## Foo {#bar}`
1055        // share the same key and triggered a false-positive duplicate warning.
1056        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        // Sanity guardrail: after the tuple-key fix, genuine duplicate text
1074        // containing `#` is still flagged.
1075        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        // Every MDG keyword accepts a name after the colon, so a duplicate
1091        // heading is always avoidable while staying valid Gherkin. MD024 must
1092        // therefore keep enforcing uniqueness under this flavor.
1093        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        // `Examples: valid cards` keeps the Examples keyword and gains a name,
1113        // so naming the blocks resolves the duplication without losing nodes.
1114        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}