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            // Headings inside blockquotes take part like any other heading.
84            if let Some(parsed) = ctx.heading_on_line(line_num + 1) {
85                let heading = parsed.heading;
86                // Admit only visible structural markers before updating any
87                // duplicate set or sibling path. Inline dynamic text remains
88                // eligible; a Setext heading's marker is its underline.
89                let marker_idx = line_num + usize::from(parsed.is_setext());
90                if ctx.lines.get(marker_idx).is_some_and(|line| {
91                    let marker_offset = line.byte_offset + line.indent;
92                    ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
93                }) {
94                    continue;
95                }
96                // Skip empty headings
97                if heading.text.is_empty() {
98                    continue;
99                }
100
101                let heading_key: HeadingKey<'_> = if self.config.allow_different_link_anchors {
102                    (heading.text.as_str(), heading.custom_id.as_deref())
103                } else {
104                    (heading.text.as_str(), None)
105                };
106                let level = heading.level;
107
108                let is_duplicate = if self.config.siblings_only {
109                    // Update the section path based on the current heading level.
110                    while current_section_path
111                        .last()
112                        .is_some_and(|(parent_level, _)| *parent_level >= level)
113                    {
114                        current_section_path.pop();
115                    }
116                    let parent_path = current_section_path.iter().map(|(_, key)| *key).collect();
117                    let is_duplicate = !seen_siblings.entry(parent_path).or_default().insert(heading_key);
118                    current_section_path.push((level, heading_key));
119                    is_duplicate
120                } else if self.config.allow_different_nesting {
121                    !seen_headings_per_level.entry(level).or_default().insert(heading_key)
122                } else {
123                    !seen_headings.insert(heading_key)
124                };
125
126                if !is_duplicate {
127                    continue;
128                }
129
130                // The range covers the heading text, from its start on the
131                // first text line to its end on the last.
132                let (start_line, start_col, end_line, end_col) = parsed.text_position_range(ctx);
133
134                warnings.push(LintWarning {
135                    rule_name: Some(self.name().to_string()),
136                    message: format!("Duplicate heading: '{}'.", heading.text),
137                    line: start_line,
138                    column: start_col,
139                    end_line,
140                    end_column: end_col,
141                    severity: Severity::Error,
142                    fix: None,
143                });
144            }
145        }
146
147        Ok(warnings)
148    }
149
150    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
151        // MD024 does not support auto-fixing. Removing duplicate headings is not a safe or meaningful fix.
152        Ok(ctx.content.to_string())
153    }
154
155    /// Get the category of this rule for selective processing
156    fn category(&self) -> RuleCategory {
157        RuleCategory::Heading
158    }
159
160    /// Check if this rule should be skipped
161    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
162        // Fast path: check if document likely has headings
163        if !ctx.likely_has_headings() {
164            return true;
165        }
166        // Verify headings actually exist
167        ctx.headings().next().is_none()
168    }
169
170    fn as_any(&self) -> &dyn std::any::Any {
171        self
172    }
173
174    crate::impl_rule_config_methods!(MD024Config);
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::lint_context::LintContext;
181
182    fn run_test(content: &str, config: MD024Config) -> LintResult {
183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
184        let rule = MD024NoDuplicateHeading::from_config_struct(config);
185        rule.check(&ctx)
186    }
187
188    fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
189        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
190        let rule = MD024NoDuplicateHeading::from_config_struct(config);
191        rule.fix(&ctx)
192    }
193
194    #[test]
195    fn test_no_duplicate_headings() {
196        let content = r#"# First Heading
197
198Some content here.
199
200## Second Heading
201
202More content.
203
204### Third Heading
205
206Even more content.
207
208## Fourth Heading
209
210Final content."#;
211
212        let config = MD024Config::default();
213        let result = run_test(content, config);
214        assert!(result.is_ok());
215        let warnings = result.unwrap();
216        assert_eq!(warnings.len(), 0);
217    }
218
219    #[test]
220    fn test_duplicate_headings_same_level() {
221        let content = r#"# First Heading
222
223Some content here.
224
225## Second Heading
226
227More content.
228
229## Second Heading
230
231This is a duplicate."#;
232
233        let config = MD024Config::default();
234        let result = run_test(content, config);
235        assert!(result.is_ok());
236        let warnings = result.unwrap();
237        assert_eq!(warnings.len(), 1);
238        assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
239        assert_eq!(warnings[0].line, 9);
240    }
241
242    #[test]
243    fn test_duplicate_headings_different_levels_default() {
244        let content = r#"# Main Title
245
246Some content.
247
248## Main Title
249
250This has the same text but different level."#;
251
252        let config = MD024Config {
253            allow_different_nesting: false,
254            siblings_only: false,
255            ..MD024Config::default()
256        };
257        let result = run_test(content, config);
258        assert!(result.is_ok());
259        let warnings = result.unwrap();
260        assert_eq!(warnings.len(), 1);
261        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
262        assert_eq!(warnings[0].line, 5);
263    }
264
265    #[test]
266    fn test_duplicate_headings_different_levels_allow_different_nesting() {
267        let content = r#"# Main Title
268
269Some content.
270
271## Main Title
272
273This has the same text but different level."#;
274
275        let config = MD024Config {
276            allow_different_nesting: true,
277            siblings_only: false,
278            ..MD024Config::default()
279        };
280        let result = run_test(content, config);
281        assert!(result.is_ok());
282        let warnings = result.unwrap();
283        assert_eq!(warnings.len(), 0);
284    }
285
286    #[test]
287    fn test_case_sensitivity() {
288        let content = r#"# First Heading
289
290Some content.
291
292## first heading
293
294Different case.
295
296### FIRST HEADING
297
298All caps."#;
299
300        let config = MD024Config::default();
301        let result = run_test(content, config);
302        assert!(result.is_ok());
303        let warnings = result.unwrap();
304        // The rule is case-sensitive, so these should not be duplicates
305        assert_eq!(warnings.len(), 0);
306    }
307
308    #[test]
309    fn test_headings_with_trailing_punctuation() {
310        let content = r#"# First Heading!
311
312Some content.
313
314## First Heading!
315
316Same with punctuation.
317
318### First Heading
319
320Without punctuation."#;
321
322        let config = MD024Config {
323            allow_different_nesting: false,
324            siblings_only: false,
325            ..MD024Config::default()
326        };
327        let result = run_test(content, config);
328        assert!(result.is_ok());
329        let warnings = result.unwrap();
330        assert_eq!(warnings.len(), 1);
331        assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
332    }
333
334    #[test]
335    fn test_headings_with_inline_formatting() {
336        let content = r#"# **Bold Heading**
337
338Some content.
339
340## *Italic Heading*
341
342More content.
343
344### **Bold Heading**
345
346Duplicate with same formatting.
347
348#### `Code Heading`
349
350Code formatted.
351
352##### `Code Heading`
353
354Duplicate code formatted."#;
355
356        let config = MD024Config {
357            allow_different_nesting: false,
358            siblings_only: false,
359            ..MD024Config::default()
360        };
361        let result = run_test(content, config);
362        assert!(result.is_ok());
363        let warnings = result.unwrap();
364        assert_eq!(warnings.len(), 2);
365        assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
366        assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
367    }
368
369    #[test]
370    fn test_headings_in_different_sections() {
371        let content = r#"# Section One
372
373## Subsection
374
375Some content.
376
377# Section Two
378
379## Subsection
380
381Same subsection name in different section."#;
382
383        let config = MD024Config {
384            allow_different_nesting: false,
385            siblings_only: false,
386            ..MD024Config::default()
387        };
388        let result = run_test(content, config);
389        assert!(result.is_ok());
390        let warnings = result.unwrap();
391        assert_eq!(warnings.len(), 1);
392        assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
393        assert_eq!(warnings[0].line, 9);
394    }
395
396    #[test]
397    fn test_multiple_duplicates() {
398        let content = r#"# Title
399
400## Subtitle
401
402### Title
403
404#### Subtitle
405
406## Title
407
408### Subtitle"#;
409
410        let config = MD024Config {
411            allow_different_nesting: false,
412            siblings_only: false,
413            ..MD024Config::default()
414        };
415        let result = run_test(content, config);
416        assert!(result.is_ok());
417        let warnings = result.unwrap();
418        assert_eq!(warnings.len(), 4);
419        // First duplicate of "Title"
420        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
421        assert_eq!(warnings[0].line, 5);
422        // First duplicate of "Subtitle"
423        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
424        assert_eq!(warnings[1].line, 7);
425        // Second duplicate of "Title"
426        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
427        assert_eq!(warnings[2].line, 9);
428        // Second duplicate of "Subtitle"
429        assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
430        assert_eq!(warnings[3].line, 11);
431    }
432
433    #[test]
434    fn test_empty_headings() {
435        let content = r#"#
436
437Some content.
438
439##
440
441More content.
442
443### Non-empty
444
445####
446
447Another empty."#;
448
449        let config = MD024Config::default();
450        let result = run_test(content, config);
451        assert!(result.is_ok());
452        let warnings = result.unwrap();
453        // Empty headings are skipped
454        assert_eq!(warnings.len(), 0);
455    }
456
457    #[test]
458    fn test_unicode_and_special_characters() {
459        let content = r#"# 你好世界
460
461Some content.
462
463## Émojis 🎉🎊
464
465More content.
466
467### 你好世界
468
469Duplicate Chinese.
470
471#### Émojis 🎉🎊
472
473Duplicate emojis.
474
475##### Special <chars> & symbols!
476
477###### Special <chars> & symbols!
478
479Duplicate special chars."#;
480
481        let config = MD024Config {
482            allow_different_nesting: false,
483            siblings_only: false,
484            ..MD024Config::default()
485        };
486        let result = run_test(content, config);
487        assert!(result.is_ok());
488        let warnings = result.unwrap();
489        assert_eq!(warnings.len(), 3);
490        assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
491        assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
492        assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
493    }
494
495    #[test]
496    fn test_allow_different_nesting_with_same_level_duplicates() {
497        let content = r#"# Section One
498
499## Title
500
501### Subsection
502
503## Title
504
505This is a duplicate at the same level.
506
507# Section Two
508
509## Title
510
511Different section, but still a duplicate when allow_different_nesting is true."#;
512
513        let config = MD024Config {
514            allow_different_nesting: true,
515            siblings_only: false,
516            ..MD024Config::default()
517        };
518        let result = run_test(content, config);
519        assert!(result.is_ok());
520        let warnings = result.unwrap();
521        assert_eq!(warnings.len(), 2);
522        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
523        assert_eq!(warnings[0].line, 7);
524        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
525        assert_eq!(warnings[1].line, 13);
526    }
527
528    #[test]
529    fn test_atx_style_headings_with_closing_hashes() {
530        let content = r#"# Heading One #
531
532Some content.
533
534## Heading Two ##
535
536More content.
537
538### Heading One ###
539
540Duplicate with different style."#;
541
542        let config = MD024Config {
543            allow_different_nesting: false,
544            siblings_only: false,
545            ..MD024Config::default()
546        };
547        let result = run_test(content, config);
548        assert!(result.is_ok());
549        let warnings = result.unwrap();
550        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
551        assert_eq!(warnings.len(), 1);
552        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
553        assert_eq!(warnings[0].line, 9);
554    }
555
556    #[test]
557    fn test_fix_method_returns_unchanged() {
558        let content = r#"# Duplicate
559
560## Duplicate
561
562This has duplicates."#;
563
564        let config = MD024Config::default();
565        let result = run_fix_test(content, config);
566        assert!(result.is_ok());
567        assert_eq!(result.unwrap(), content);
568    }
569
570    #[test]
571    fn test_empty_content() {
572        let content = "";
573        let config = MD024Config::default();
574        let result = run_test(content, config);
575        assert!(result.is_ok());
576        let warnings = result.unwrap();
577        assert_eq!(warnings.len(), 0);
578    }
579
580    #[test]
581    fn test_no_headings() {
582        let content = r#"This is just regular text.
583
584No headings anywhere.
585
586Just paragraphs."#;
587
588        let config = MD024Config::default();
589        let result = run_test(content, config);
590        assert!(result.is_ok());
591        let warnings = result.unwrap();
592        assert_eq!(warnings.len(), 0);
593    }
594
595    #[test]
596    fn test_whitespace_differences() {
597        let content = r#"# Heading with spaces
598
599Some content.
600
601##  Heading with spaces
602
603Different amount of spaces.
604
605### Heading with spaces
606
607Exact match."#;
608
609        let config = MD024Config {
610            allow_different_nesting: false,
611            siblings_only: false,
612            ..MD024Config::default()
613        };
614        let result = run_test(content, config);
615        assert!(result.is_ok());
616        let warnings = result.unwrap();
617        // The heading text is trimmed, so all three are duplicates
618        assert_eq!(warnings.len(), 2);
619        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
620        assert_eq!(warnings[0].line, 5);
621        assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
622        assert_eq!(warnings[1].line, 9);
623    }
624
625    #[test]
626    fn test_column_positions() {
627        let content = r#"# First
628
629## Second
630
631### First"#;
632
633        let config = MD024Config {
634            allow_different_nesting: false,
635            siblings_only: false,
636            ..MD024Config::default()
637        };
638        let result = run_test(content, config);
639        assert!(result.is_ok());
640        let warnings = result.unwrap();
641        assert_eq!(warnings.len(), 1);
642        assert_eq!(warnings[0].line, 5);
643        assert_eq!(warnings[0].column, 5); // After "### "
644        assert_eq!(warnings[0].end_line, 5);
645        assert_eq!(warnings[0].end_column, 10); // End of "First"
646    }
647
648    #[test]
649    fn test_complex_nesting_scenario() {
650        let content = r#"# Main Document
651
652## Introduction
653
654### Overview
655
656## Implementation
657
658### Overview
659
660This Overview is in a different section.
661
662## Conclusion
663
664### Overview
665
666Another Overview in yet another section."#;
667
668        let config = MD024Config {
669            allow_different_nesting: true,
670            siblings_only: false,
671            ..MD024Config::default()
672        };
673        let result = run_test(content, config);
674        assert!(result.is_ok());
675        let warnings = result.unwrap();
676        // When allow_different_nesting is true, only same-level duplicates are flagged
677        assert_eq!(warnings.len(), 2);
678        assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
679        assert_eq!(warnings[0].line, 9);
680        assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
681        assert_eq!(warnings[1].line, 15);
682    }
683
684    #[test]
685    fn test_setext_style_headings() {
686        let content = r#"Main Title
687==========
688
689Some content.
690
691Second Title
692------------
693
694More content.
695
696Main Title
697==========
698
699Duplicate setext."#;
700
701        let config = MD024Config::default();
702        let result = run_test(content, config);
703        assert!(result.is_ok());
704        let warnings = result.unwrap();
705        assert_eq!(warnings.len(), 1);
706        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
707        assert_eq!(warnings[0].line, 11);
708    }
709
710    #[test]
711    fn test_mixed_heading_styles() {
712        let content = r#"# ATX Title
713
714Some content.
715
716ATX Title
717=========
718
719Same text, different style."#;
720
721        let config = MD024Config::default();
722        let result = run_test(content, config);
723        assert!(result.is_ok());
724        let warnings = result.unwrap();
725        assert_eq!(warnings.len(), 1);
726        assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
727        assert_eq!(warnings[0].line, 5);
728    }
729
730    #[test]
731    fn test_heading_with_links() {
732        let content = r#"# [Link Text](http://example.com)
733
734Some content.
735
736## [Link Text](http://example.com)
737
738Duplicate heading with link.
739
740### [Different Link](http://example.com)
741
742Not a duplicate."#;
743
744        let config = MD024Config {
745            allow_different_nesting: false,
746            siblings_only: false,
747            ..MD024Config::default()
748        };
749        let result = run_test(content, config);
750        assert!(result.is_ok());
751        let warnings = result.unwrap();
752        assert_eq!(warnings.len(), 1);
753        assert_eq!(
754            warnings[0].message,
755            "Duplicate heading: '[Link Text](http://example.com)'."
756        );
757        assert_eq!(warnings[0].line, 5);
758    }
759
760    #[test]
761    fn test_consecutive_duplicates() {
762        let content = r#"# Title
763
764## Title
765
766### Title
767
768Three in a row."#;
769
770        let config = MD024Config {
771            allow_different_nesting: false,
772            siblings_only: false,
773            ..MD024Config::default()
774        };
775        let result = run_test(content, config);
776        assert!(result.is_ok());
777        let warnings = result.unwrap();
778        assert_eq!(warnings.len(), 2);
779        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
780        assert_eq!(warnings[0].line, 3);
781        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
782        assert_eq!(warnings[1].line, 5);
783    }
784
785    #[test]
786    fn test_siblings_only_config() {
787        let content = r#"# Section One
788
789## Subsection
790
791### Details
792
793# Section Two
794
795## Subsection
796
797Different parent sections, so not siblings - no warning expected."#;
798
799        let config = MD024Config {
800            allow_different_nesting: false,
801            siblings_only: true,
802            ..MD024Config::default()
803        };
804        let result = run_test(content, config);
805        assert!(result.is_ok());
806        let warnings = result.unwrap();
807        // With siblings_only, these are not flagged because they're under different parents
808        assert_eq!(warnings.len(), 0);
809    }
810
811    #[test]
812    fn test_siblings_only_with_actual_siblings() {
813        let content = r#"# Main Section
814
815## First Subsection
816
817### Details
818
819## Second Subsection
820
821### Details
822
823The two 'Details' headings are siblings under different subsections - no warning.
824
825## First Subsection
826
827This 'First Subsection' IS a sibling duplicate."#;
828
829        let config = MD024Config {
830            allow_different_nesting: false,
831            siblings_only: true,
832            ..MD024Config::default()
833        };
834        let result = run_test(content, config);
835        assert!(result.is_ok());
836        let warnings = result.unwrap();
837        // Only the duplicate "First Subsection" at the same level should be flagged
838        assert_eq!(warnings.len(), 1);
839        assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
840        assert_eq!(warnings[0].line, 13);
841    }
842
843    #[test]
844    fn test_code_spans_in_headings() {
845        let content = r#"# `code` in heading
846
847Some content.
848
849## `code` in heading
850
851Duplicate with code span."#;
852
853        let config = MD024Config {
854            allow_different_nesting: false,
855            siblings_only: false,
856            ..MD024Config::default()
857        };
858        let result = run_test(content, config);
859        assert!(result.is_ok());
860        let warnings = result.unwrap();
861        assert_eq!(warnings.len(), 1);
862        assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
863        assert_eq!(warnings[0].line, 5);
864    }
865
866    #[test]
867    fn test_very_long_heading() {
868        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";
869        let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
870
871        let config = MD024Config {
872            allow_different_nesting: false,
873            siblings_only: false,
874            ..MD024Config::default()
875        };
876        let result = run_test(&content, config);
877        assert!(result.is_ok());
878        let warnings = result.unwrap();
879        assert_eq!(warnings.len(), 1);
880        assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
881        assert_eq!(warnings[0].line, 5);
882    }
883
884    #[test]
885    fn test_heading_with_html_entities() {
886        let content = r#"# Title &amp; More
887
888Some content.
889
890## Title &amp; More
891
892Duplicate with HTML entity."#;
893
894        let config = MD024Config {
895            allow_different_nesting: false,
896            siblings_only: false,
897            ..MD024Config::default()
898        };
899        let result = run_test(content, config);
900        assert!(result.is_ok());
901        let warnings = result.unwrap();
902        assert_eq!(warnings.len(), 1);
903        assert_eq!(warnings[0].message, "Duplicate heading: 'Title &amp; More'.");
904        assert_eq!(warnings[0].line, 5);
905    }
906
907    #[test]
908    fn test_three_duplicates_different_nesting() {
909        let content = r#"# Main
910
911## Main
912
913### Main
914
915#### Main
916
917All same text, different levels."#;
918
919        let config = MD024Config {
920            allow_different_nesting: true,
921            siblings_only: false,
922            ..MD024Config::default()
923        };
924        let result = run_test(content, config);
925        assert!(result.is_ok());
926        let warnings = result.unwrap();
927        // With allow_different_nesting, there should be no warnings
928        assert_eq!(warnings.len(), 0);
929    }
930
931    // --- allow_different_link_anchors tests ---
932
933    #[test]
934    fn test_custom_anchor_different_ids_no_warning_default() {
935        // Reporter's exact repro: same visible text, different {#id} → no warning with default config.
936        let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
937        let config = MD024Config::default();
938        let result = run_test(content, config);
939        assert!(result.is_ok());
940        let warnings = result.unwrap();
941        assert_eq!(
942            warnings.len(),
943            0,
944            "headings with different custom anchors must not be flagged"
945        );
946    }
947
948    #[test]
949    fn test_custom_anchor_same_id_flagged() {
950        // Same text and same explicit {#id} → the rendered anchor is identical, must flag.
951        let content = "## Overview {#overview}\n\n## Overview {#overview}\n";
952        let config = MD024Config::default();
953        let result = run_test(content, config);
954        assert!(result.is_ok());
955        let warnings = result.unwrap();
956        assert_eq!(
957            warnings.len(),
958            1,
959            "headings with identical custom anchors must be flagged"
960        );
961        assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
962    }
963
964    #[test]
965    fn test_custom_anchor_one_with_id_one_without_no_warning() {
966        // Same visible text but one has a {#id} suffix and the other has none → distinct keys.
967        let content = "## Setup\n\n## Setup {#alt-setup}\n";
968        let config = MD024Config::default();
969        let result = run_test(content, config);
970        assert!(result.is_ok());
971        let warnings = result.unwrap();
972        assert_eq!(
973            warnings.len(),
974            0,
975            "a plain heading and one with a custom anchor must not collide"
976        );
977    }
978
979    #[test]
980    fn test_allow_different_link_anchors_false_restores_original_behavior() {
981        // When the option is disabled the {#id} is stripped before dedup, so both headings
982        // share the key "Unit testing" and the second is flagged.
983        let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
984        let config = MD024Config {
985            allow_different_link_anchors: false,
986            siblings_only: false,
987            ..MD024Config::default()
988        };
989        let result = run_test(content, config);
990        assert!(result.is_ok());
991        let warnings = result.unwrap();
992        assert_eq!(
993            warnings.len(),
994            1,
995            "with allow_different_link_anchors=false the duplicate must be flagged"
996        );
997        assert_eq!(warnings[0].message, "Duplicate heading: 'Unit testing'.");
998    }
999
1000    #[test]
1001    fn test_custom_anchor_with_siblings_only() {
1002        // With siblings_only=true, headings under the same parent but with different anchors
1003        // must not be flagged; headings with the same text AND same anchor under the same parent must be.
1004        let content = concat!(
1005            "# Parent\n\n",
1006            "## Section {#section-a}\n\n",
1007            "## Section {#section-b}\n\n",
1008            "## Section {#section-a}\n",
1009        );
1010        let config = MD024Config {
1011            siblings_only: true,
1012            allow_different_link_anchors: true,
1013            ..MD024Config::default()
1014        };
1015        let result = run_test(content, config);
1016        assert!(result.is_ok());
1017        let warnings = result.unwrap();
1018        // Only the third "## Section {#section-a}" duplicates the first one.
1019        assert_eq!(
1020            warnings.len(),
1021            1,
1022            "only exact key collision under same parent must be flagged"
1023        );
1024        assert_eq!(warnings[0].message, "Duplicate heading: 'Section'.");
1025    }
1026
1027    #[test]
1028    fn test_custom_anchor_with_allow_different_nesting() {
1029        // With allow_different_nesting=true, two headings at different levels that would otherwise
1030        // be exempt are still compared separately per-level. Anchors still differentiate same-level dups.
1031        let content = concat!(
1032            "## Topic {#topic-1}\n\n",
1033            "### Topic {#topic-2}\n\n",
1034            "## Topic {#topic-1}\n",
1035        );
1036        let config = MD024Config {
1037            allow_different_nesting: true,
1038            siblings_only: false,
1039            allow_different_link_anchors: true,
1040        };
1041        let result = run_test(content, config);
1042        assert!(result.is_ok());
1043        let warnings = result.unwrap();
1044        // The two h2 headings share the same key "Topic#topic-1" → flagged.
1045        // The h3 has a distinct key → not flagged.
1046        assert_eq!(
1047            warnings.len(),
1048            1,
1049            "same-level, same-anchor headings must still be flagged"
1050        );
1051        assert_eq!(warnings[0].message, "Duplicate heading: 'Topic'.");
1052    }
1053
1054    #[test]
1055    fn test_heading_text_containing_hash_no_false_collision() {
1056        // A `#` in visible heading text must not collide with a different heading that
1057        // carries a `{#id}` suffix. Regression: previously the dedup key was built with
1058        // string concatenation `"{text}#{id}"`, which made `## Foo#bar` and `## Foo {#bar}`
1059        // share the same key and triggered a false-positive duplicate warning.
1060        let content = "## Foo#bar\n\n## Foo {#bar}\n";
1061        let config = MD024Config {
1062            allow_different_nesting: false,
1063            siblings_only: false,
1064            allow_different_link_anchors: true,
1065        };
1066        let result = run_test(content, config);
1067        assert!(result.is_ok());
1068        let warnings = result.unwrap();
1069        assert!(
1070            warnings.is_empty(),
1071            "heading text containing # must not collide with a different heading carrying {{#id}}; got: {warnings:#?}",
1072        );
1073    }
1074
1075    #[test]
1076    fn test_heading_text_containing_hash_real_duplicate_still_flagged() {
1077        // Sanity guardrail: after the tuple-key fix, genuine duplicate text
1078        // containing `#` is still flagged.
1079        let content = "## Foo#bar\n\n## Foo#bar\n";
1080        let config = MD024Config {
1081            allow_different_nesting: false,
1082            siblings_only: false,
1083            allow_different_link_anchors: true,
1084        };
1085        let result = run_test(content, config);
1086        assert!(result.is_ok());
1087        let warnings = result.unwrap();
1088        assert_eq!(warnings.len(), 1);
1089        assert_eq!(warnings[0].message, "Duplicate heading: 'Foo#bar'.");
1090    }
1091
1092    #[test]
1093    fn test_mdg_flags_repeated_structural_headings() {
1094        // Every MDG keyword accepts a name after the colon, so a duplicate
1095        // heading is always avoidable while staying valid Gherkin. MD024 must
1096        // therefore keep enforcing uniqueness under this flavor.
1097        let rule = MD024NoDuplicateHeading::default();
1098        let content = "# Feature: Checkout\n\n## Scenario: Purchase\n\n## Scenario: Purchase\n\n#### Examples:\n\n#### Examples:\n";
1099
1100        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1101        let warnings = rule.check(&mdg_ctx).unwrap();
1102        assert_eq!(warnings.len(), 2, "MDG duplicates must be reported: {warnings:?}");
1103        assert_eq!(warnings[0].message, "Duplicate heading: 'Scenario: Purchase'.");
1104        assert_eq!(warnings[1].message, "Duplicate heading: 'Examples:'.");
1105
1106        let standard_ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107        assert_eq!(
1108            rule.check(&standard_ctx).unwrap().len(),
1109            warnings.len(),
1110            "MDG must not differ from Standard"
1111        );
1112    }
1113
1114    #[test]
1115    fn test_mdg_accepts_uniquely_named_structural_headings() {
1116        // `Examples: valid cards` keeps the Examples keyword and gains a name,
1117        // so naming the blocks resolves the duplication without losing nodes.
1118        let rule = MD024NoDuplicateHeading::default();
1119        let content = "# Feature: Checkout\n\n## Scenario Outline: Purchase\n\n#### Examples: valid cards\n\n#### Examples: expired cards\n";
1120
1121        let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1122        assert!(rule.check(&mdg_ctx).unwrap().is_empty());
1123    }
1124    #[test]
1125    fn test_literal_headings_do_not_enter_duplicate_sets() {
1126        for rule in [
1127            MD024NoDuplicateHeading::new(false, false),
1128            MD024NoDuplicateHeading::new(true, false),
1129            MD024NoDuplicateHeading::new(false, true),
1130            MD024NoDuplicateHeading::new(true, true),
1131        ] {
1132            for literal in ["# Visible", "\nVisible\n===\n"] {
1133                for code in [
1134                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1135                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1136                ] {
1137                    let flavor = if code.starts_with("{{<") {
1138                        crate::config::MarkdownFlavor::Hugo
1139                    } else {
1140                        crate::config::MarkdownFlavor::Standard
1141                    };
1142                    for source in [format!("# Visible\n\n{code}\n"), format!("{code}\n\n# Visible\n")] {
1143                        for ending in ["\n", "\r\n"] {
1144                            let source = source.replace('\n', ending);
1145                            let ctx = LintContext::new(&source, flavor, None);
1146                            assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
1147                            assert_eq!(rule.fix(&ctx).unwrap(), source);
1148                        }
1149                    }
1150                }
1151            }
1152        }
1153    }
1154
1155    #[test]
1156    fn test_literal_parent_headings_do_not_hide_visible_sibling_duplicates() {
1157        for rule in [
1158            MD024NoDuplicateHeading::new(false, true),
1159            MD024NoDuplicateHeading::new(true, true),
1160        ] {
1161            for literal in ["# Fake", "\nFake\n===\n"] {
1162                for code in [
1163                    format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
1164                    format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
1165                ] {
1166                    let flavor = if code.starts_with("{{<") {
1167                        crate::config::MarkdownFlavor::Hugo
1168                    } else {
1169                        crate::config::MarkdownFlavor::Standard
1170                    };
1171                    for ending in ["\n", "\r\n"] {
1172                        let source = format!("# Parent\n\n## Repeat\n\n{code}\n\n## Repeat\n").replace('\n', ending);
1173                        let ctx = LintContext::new(&source, flavor, None);
1174                        let warnings = rule.check(&ctx).unwrap();
1175                        assert_eq!(warnings.len(), 1, "{source}: {warnings:?}");
1176                        assert_eq!(warnings[0].line, source.lines().count());
1177                        assert_eq!(rule.fix(&ctx).unwrap(), source);
1178                    }
1179                }
1180            }
1181        }
1182    }
1183
1184    #[test]
1185    fn test_duplicate_dynamic_heading_text_remains_visible() {
1186        let rule = MD024NoDuplicateHeading::default();
1187        for title in ["# {{ title }}", "Title {{ title }}\n==="] {
1188            let source = format!("{title}\n\n{title}\n");
1189            let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1190            assert_eq!(rule.check(&ctx).unwrap().len(), 1);
1191            assert_eq!(rule.fix(&ctx).unwrap(), source);
1192        }
1193    }
1194    mod quoted_headings {
1195        use super::*;
1196
1197        fn lines(content: &str, config: MD024Config) -> Vec<usize> {
1198            run_test(content, config).unwrap().iter().map(|w| w.line).collect()
1199        }
1200
1201        fn flat() -> MD024Config {
1202            MD024Config {
1203                siblings_only: false,
1204                ..Default::default()
1205            }
1206        }
1207
1208        #[test]
1209        fn quoted_duplicate_of_an_unquoted_heading_is_flagged() {
1210            assert_eq!(lines("# Same\n\n> # Same\n", flat()), vec![3]);
1211            assert_eq!(lines("> # Same\n\n# Same\n", flat()), vec![3]);
1212        }
1213
1214        #[test]
1215        fn quoted_headings_duplicate_each_other() {
1216            assert_eq!(lines("> ## Same\n\n> ## Same\n", flat()), vec![3]);
1217            assert_eq!(lines("> Same\n> ---\n\n> Same\n> ---\n", flat()), vec![4]);
1218        }
1219
1220        #[test]
1221        fn nested_and_listed_quotes_are_checked() {
1222            assert_eq!(lines("# Same\n\n> > # Same\n", flat()), vec![3]);
1223            assert_eq!(lines("# Same\n\n- x\n\n  > # Same\n", flat()), vec![5]);
1224            assert_eq!(lines("# Same\n\n> - # Same\n", flat()), vec![3]);
1225            assert_eq!(lines("# Same\n\n- > # Same\n", flat()), vec![3]);
1226        }
1227
1228        #[test]
1229        fn quoted_headings_follow_the_nesting_options() {
1230            let nesting = MD024Config {
1231                allow_different_nesting: true,
1232                siblings_only: false,
1233                ..Default::default()
1234            };
1235            assert!(lines("# Same\n\n> ## Same\n", nesting.clone()).is_empty());
1236            assert_eq!(lines("## Same\n\n> ## Same\n", nesting), vec![3]);
1237        }
1238
1239        #[test]
1240        fn quoted_headings_are_scoped_by_siblings_only() {
1241            let siblings = MD024Config {
1242                siblings_only: true,
1243                ..Default::default()
1244            };
1245            // Same parent: duplicates.
1246            assert_eq!(lines("# A\n\n## Note\n\n> ## Note\n", siblings.clone()), vec![5]);
1247            // Different parents: not duplicates.
1248            assert!(lines("# A\n\n## Note\n\n# B\n\n> ## Note\n", siblings).is_empty());
1249        }
1250
1251        #[test]
1252        fn quote_only_document_is_not_skipped() {
1253            let ctx = LintContext::new("> # Same\n\n> # Same\n", crate::config::MarkdownFlavor::Standard, None);
1254            assert!(!MD024NoDuplicateHeading::default().should_skip(&ctx));
1255        }
1256
1257        #[test]
1258        fn distinct_and_code_look_alikes_are_not_flagged() {
1259            assert!(lines("# A\n\n> # B\n", flat()).is_empty());
1260            assert!(lines("# Same\n\n> ```\n> # Same\n> ```\n", flat()).is_empty());
1261        }
1262    }
1263}