Skip to main content

rumdl_lib/rules/
md024_no_duplicate_heading.rs

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