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