rumdl_lib/rules/
md024_no_duplicate_heading.rs

1use toml;
2
3use crate::rule::{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            },
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 check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
41        // Early return for empty content
42        if ctx.lines.is_empty() {
43            return Ok(Vec::new());
44        }
45
46        let mut warnings = Vec::new();
47        let mut seen_headings: HashSet<String> = HashSet::new();
48        let mut seen_headings_per_level: HashMap<u8, HashSet<String>> = HashMap::new();
49
50        // For siblings_only mode, track heading hierarchy
51        let mut current_section_path: Vec<(u8, String)> = Vec::new(); // Stack of (level, heading_text)
52        let mut seen_siblings: HashMap<String, HashSet<String>> = HashMap::new(); // parent_path -> set of child headings
53
54        // Track if we're in a snippet section (MkDocs flavor)
55        let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
56        let mut in_snippet_section = false;
57
58        // Process headings using cached heading information
59        for (line_num, line_info) in ctx.lines.iter().enumerate() {
60            // Check for MkDocs snippet markers if using MkDocs flavor
61            if is_mkdocs {
62                if crate::utils::mkdocs_snippets::is_snippet_section_start(&line_info.content) {
63                    in_snippet_section = true;
64                    continue; // Skip this line
65                } else if crate::utils::mkdocs_snippets::is_snippet_section_end(&line_info.content) {
66                    in_snippet_section = false;
67                    continue; // Skip this line
68                }
69            }
70
71            // Skip lines within snippet sections (for MkDocs)
72            if is_mkdocs && in_snippet_section {
73                continue;
74            }
75
76            if let Some(heading) = &line_info.heading {
77                // Skip empty headings
78                if heading.text.is_empty() {
79                    continue;
80                }
81
82                let heading_key = heading.text.clone();
83                let level = heading.level;
84
85                // Calculate precise character range for the heading text content
86                let text_start_in_line = if let Some(pos) = line_info.content.find(&heading.text) {
87                    pos
88                } else {
89                    // Fallback: find after hash markers
90                    let trimmed = line_info.content.trim_start();
91                    let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
92                    let after_hashes = &trimmed[hash_count..];
93                    let text_start_in_trimmed = after_hashes.find(&heading.text).unwrap_or(0);
94                    (line_info.content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
95                };
96
97                let (start_line, start_col, end_line, end_col) =
98                    calculate_match_range(line_num + 1, &line_info.content, text_start_in_line, heading.text.len());
99
100                if self.config.siblings_only {
101                    // Update the section path based on the current heading level
102                    while !current_section_path.is_empty() && current_section_path.last().unwrap().0 >= level {
103                        current_section_path.pop();
104                    }
105
106                    // Build parent path for sibling detection
107                    let parent_path = current_section_path
108                        .iter()
109                        .map(|(_, text)| text.as_str())
110                        .collect::<Vec<_>>()
111                        .join("/");
112
113                    // Check if this heading is a duplicate among its siblings
114                    let siblings = seen_siblings.entry(parent_path.clone()).or_default();
115                    if siblings.contains(&heading_key) {
116                        warnings.push(LintWarning {
117                            rule_name: Some(self.name()),
118                            message: format!("Duplicate heading: '{}'.", heading.text),
119                            line: start_line,
120                            column: start_col,
121                            end_line,
122                            end_column: end_col,
123                            severity: Severity::Warning,
124                            fix: None,
125                        });
126                    } else {
127                        siblings.insert(heading_key.clone());
128                    }
129
130                    // Add current heading to the section path
131                    current_section_path.push((level, heading_key.clone()));
132                } else if self.config.allow_different_nesting {
133                    // Only flag duplicates at the same level
134                    let seen = seen_headings_per_level.entry(level).or_default();
135                    if seen.contains(&heading_key) {
136                        warnings.push(LintWarning {
137                            rule_name: Some(self.name()),
138                            message: format!("Duplicate heading: '{}'.", heading.text),
139                            line: start_line,
140                            column: start_col,
141                            end_line,
142                            end_column: end_col,
143                            severity: Severity::Warning,
144                            fix: None,
145                        });
146                    } else {
147                        seen.insert(heading_key.clone());
148                    }
149                } else {
150                    // Flag all duplicates, regardless of level
151                    if seen_headings.contains(&heading_key) {
152                        warnings.push(LintWarning {
153                            rule_name: Some(self.name()),
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::Warning,
160                            fix: None,
161                        });
162                    } else {
163                        seen_headings.insert(heading_key.clone());
164                    }
165                }
166            }
167        }
168
169        Ok(warnings)
170    }
171
172    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
173        // MD024 does not support auto-fixing. Removing duplicate headings is not a safe or meaningful fix.
174        Ok(ctx.content.to_string())
175    }
176
177    /// Get the category of this rule for selective processing
178    fn category(&self) -> RuleCategory {
179        RuleCategory::Heading
180    }
181
182    /// Check if this rule should be skipped
183    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
184        // Fast path: check if document likely has headings
185        if !ctx.likely_has_headings() {
186            return true;
187        }
188        // Verify headings actually exist
189        ctx.lines.iter().all(|line| line.heading.is_none())
190    }
191
192    fn as_any(&self) -> &dyn std::any::Any {
193        self
194    }
195
196    fn default_config_section(&self) -> Option<(String, toml::Value)> {
197        let default_config = MD024Config::default();
198        let json_value = serde_json::to_value(&default_config).ok()?;
199        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
200
201        if let toml::Value::Table(table) = toml_value {
202            if !table.is_empty() {
203                Some((MD024Config::RULE_NAME.to_string(), toml::Value::Table(table)))
204            } else {
205                None
206            }
207        } else {
208            None
209        }
210    }
211
212    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
213    where
214        Self: Sized,
215    {
216        let rule_config = crate::rule_config_serde::load_rule_config::<MD024Config>(config);
217        Box::new(Self::from_config_struct(rule_config))
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::lint_context::LintContext;
225
226    fn run_test(content: &str, config: MD024Config) -> LintResult {
227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
228        let rule = MD024NoDuplicateHeading::from_config_struct(config);
229        rule.check(&ctx)
230    }
231
232    fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
233        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
234        let rule = MD024NoDuplicateHeading::from_config_struct(config);
235        rule.fix(&ctx)
236    }
237
238    #[test]
239    fn test_no_duplicate_headings() {
240        let content = r#"# First Heading
241
242Some content here.
243
244## Second Heading
245
246More content.
247
248### Third Heading
249
250Even more content.
251
252## Fourth Heading
253
254Final content."#;
255
256        let config = MD024Config::default();
257        let result = run_test(content, config);
258        assert!(result.is_ok());
259        let warnings = result.unwrap();
260        assert_eq!(warnings.len(), 0);
261    }
262
263    #[test]
264    fn test_duplicate_headings_same_level() {
265        let content = r#"# First Heading
266
267Some content here.
268
269## Second Heading
270
271More content.
272
273## Second Heading
274
275This is a duplicate."#;
276
277        let config = MD024Config::default();
278        let result = run_test(content, config);
279        assert!(result.is_ok());
280        let warnings = result.unwrap();
281        assert_eq!(warnings.len(), 1);
282        assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
283        assert_eq!(warnings[0].line, 9);
284    }
285
286    #[test]
287    fn test_duplicate_headings_different_levels_default() {
288        let content = r#"# Main Title
289
290Some content.
291
292## Main Title
293
294This has the same text but different level."#;
295
296        let config = MD024Config::default();
297        let result = run_test(content, config);
298        assert!(result.is_ok());
299        let warnings = result.unwrap();
300        assert_eq!(warnings.len(), 1);
301        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
302        assert_eq!(warnings[0].line, 5);
303    }
304
305    #[test]
306    fn test_duplicate_headings_different_levels_allow_different_nesting() {
307        let content = r#"# Main Title
308
309Some content.
310
311## Main Title
312
313This has the same text but different level."#;
314
315        let config = MD024Config {
316            allow_different_nesting: true,
317            siblings_only: false,
318        };
319        let result = run_test(content, config);
320        assert!(result.is_ok());
321        let warnings = result.unwrap();
322        assert_eq!(warnings.len(), 0);
323    }
324
325    #[test]
326    fn test_case_sensitivity() {
327        let content = r#"# First Heading
328
329Some content.
330
331## first heading
332
333Different case.
334
335### FIRST HEADING
336
337All caps."#;
338
339        let config = MD024Config::default();
340        let result = run_test(content, config);
341        assert!(result.is_ok());
342        let warnings = result.unwrap();
343        // The rule is case-sensitive, so these should not be duplicates
344        assert_eq!(warnings.len(), 0);
345    }
346
347    #[test]
348    fn test_headings_with_trailing_punctuation() {
349        let content = r#"# First Heading!
350
351Some content.
352
353## First Heading!
354
355Same with punctuation.
356
357### First Heading
358
359Without punctuation."#;
360
361        let config = MD024Config::default();
362        let result = run_test(content, config);
363        assert!(result.is_ok());
364        let warnings = result.unwrap();
365        assert_eq!(warnings.len(), 1);
366        assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
367    }
368
369    #[test]
370    fn test_headings_with_inline_formatting() {
371        let content = r#"# **Bold Heading**
372
373Some content.
374
375## *Italic Heading*
376
377More content.
378
379### **Bold Heading**
380
381Duplicate with same formatting.
382
383#### `Code Heading`
384
385Code formatted.
386
387##### `Code Heading`
388
389Duplicate code formatted."#;
390
391        let config = MD024Config::default();
392        let result = run_test(content, config);
393        assert!(result.is_ok());
394        let warnings = result.unwrap();
395        assert_eq!(warnings.len(), 2);
396        assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
397        assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
398    }
399
400    #[test]
401    fn test_headings_in_different_sections() {
402        let content = r#"# Section One
403
404## Subsection
405
406Some content.
407
408# Section Two
409
410## Subsection
411
412Same subsection name in different section."#;
413
414        let config = MD024Config::default();
415        let result = run_test(content, config);
416        assert!(result.is_ok());
417        let warnings = result.unwrap();
418        assert_eq!(warnings.len(), 1);
419        assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
420        assert_eq!(warnings[0].line, 9);
421    }
422
423    #[test]
424    fn test_multiple_duplicates() {
425        let content = r#"# Title
426
427## Subtitle
428
429### Title
430
431#### Subtitle
432
433## Title
434
435### Subtitle"#;
436
437        let config = MD024Config::default();
438        let result = run_test(content, config);
439        assert!(result.is_ok());
440        let warnings = result.unwrap();
441        assert_eq!(warnings.len(), 4);
442        // First duplicate of "Title"
443        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
444        assert_eq!(warnings[0].line, 5);
445        // First duplicate of "Subtitle"
446        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
447        assert_eq!(warnings[1].line, 7);
448        // Second duplicate of "Title"
449        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
450        assert_eq!(warnings[2].line, 9);
451        // Second duplicate of "Subtitle"
452        assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
453        assert_eq!(warnings[3].line, 11);
454    }
455
456    #[test]
457    fn test_empty_headings() {
458        let content = r#"#
459
460Some content.
461
462##
463
464More content.
465
466### Non-empty
467
468####
469
470Another empty."#;
471
472        let config = MD024Config::default();
473        let result = run_test(content, config);
474        assert!(result.is_ok());
475        let warnings = result.unwrap();
476        // Empty headings are skipped
477        assert_eq!(warnings.len(), 0);
478    }
479
480    #[test]
481    fn test_unicode_and_special_characters() {
482        let content = r#"# 你好世界
483
484Some content.
485
486## Émojis 🎉🎊
487
488More content.
489
490### 你好世界
491
492Duplicate Chinese.
493
494#### Émojis 🎉🎊
495
496Duplicate emojis.
497
498##### Special <chars> & symbols!
499
500###### Special <chars> & symbols!
501
502Duplicate special chars."#;
503
504        let config = MD024Config::default();
505        let result = run_test(content, config);
506        assert!(result.is_ok());
507        let warnings = result.unwrap();
508        assert_eq!(warnings.len(), 3);
509        assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
510        assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
511        assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
512    }
513
514    #[test]
515    fn test_allow_different_nesting_with_same_level_duplicates() {
516        let content = r#"# Section One
517
518## Title
519
520### Subsection
521
522## Title
523
524This is a duplicate at the same level.
525
526# Section Two
527
528## Title
529
530Different section, but still a duplicate when allow_different_nesting is true."#;
531
532        let config = MD024Config {
533            allow_different_nesting: true,
534            siblings_only: false,
535        };
536        let result = run_test(content, config);
537        assert!(result.is_ok());
538        let warnings = result.unwrap();
539        assert_eq!(warnings.len(), 2);
540        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
541        assert_eq!(warnings[0].line, 7);
542        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
543        assert_eq!(warnings[1].line, 13);
544    }
545
546    #[test]
547    fn test_atx_style_headings_with_closing_hashes() {
548        let content = r#"# Heading One #
549
550Some content.
551
552## Heading Two ##
553
554More content.
555
556### Heading One ###
557
558Duplicate with different style."#;
559
560        let config = MD024Config::default();
561        let result = run_test(content, config);
562        assert!(result.is_ok());
563        let warnings = result.unwrap();
564        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
565        assert_eq!(warnings.len(), 1);
566        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
567        assert_eq!(warnings[0].line, 9);
568    }
569
570    #[test]
571    fn test_fix_method_returns_unchanged() {
572        let content = r#"# Duplicate
573
574## Duplicate
575
576This has duplicates."#;
577
578        let config = MD024Config::default();
579        let result = run_fix_test(content, config);
580        assert!(result.is_ok());
581        assert_eq!(result.unwrap(), content);
582    }
583
584    #[test]
585    fn test_empty_content() {
586        let content = "";
587        let config = MD024Config::default();
588        let result = run_test(content, config);
589        assert!(result.is_ok());
590        let warnings = result.unwrap();
591        assert_eq!(warnings.len(), 0);
592    }
593
594    #[test]
595    fn test_no_headings() {
596        let content = r#"This is just regular text.
597
598No headings anywhere.
599
600Just paragraphs."#;
601
602        let config = MD024Config::default();
603        let result = run_test(content, config);
604        assert!(result.is_ok());
605        let warnings = result.unwrap();
606        assert_eq!(warnings.len(), 0);
607    }
608
609    #[test]
610    fn test_whitespace_differences() {
611        let content = r#"# Heading with spaces
612
613Some content.
614
615##  Heading with spaces
616
617Different amount of spaces.
618
619### Heading with spaces
620
621Exact match."#;
622
623        let config = MD024Config::default();
624        let result = run_test(content, config);
625        assert!(result.is_ok());
626        let warnings = result.unwrap();
627        // The heading text is trimmed, so all three are duplicates
628        assert_eq!(warnings.len(), 2);
629        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
630        assert_eq!(warnings[0].line, 5);
631        assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
632        assert_eq!(warnings[1].line, 9);
633    }
634
635    #[test]
636    fn test_column_positions() {
637        let content = r#"# First
638
639## Second
640
641### First"#;
642
643        let config = MD024Config::default();
644        let result = run_test(content, config);
645        assert!(result.is_ok());
646        let warnings = result.unwrap();
647        assert_eq!(warnings.len(), 1);
648        assert_eq!(warnings[0].line, 5);
649        assert_eq!(warnings[0].column, 5); // After "### "
650        assert_eq!(warnings[0].end_line, 5);
651        assert_eq!(warnings[0].end_column, 10); // End of "First"
652    }
653
654    #[test]
655    fn test_complex_nesting_scenario() {
656        let content = r#"# Main Document
657
658## Introduction
659
660### Overview
661
662## Implementation
663
664### Overview
665
666This Overview is in a different section.
667
668## Conclusion
669
670### Overview
671
672Another Overview in yet another section."#;
673
674        let config = MD024Config {
675            allow_different_nesting: true,
676            siblings_only: false,
677        };
678        let result = run_test(content, config);
679        assert!(result.is_ok());
680        let warnings = result.unwrap();
681        // When allow_different_nesting is true, only same-level duplicates are flagged
682        assert_eq!(warnings.len(), 2);
683        assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
684        assert_eq!(warnings[0].line, 9);
685        assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
686        assert_eq!(warnings[1].line, 15);
687    }
688
689    #[test]
690    fn test_setext_style_headings() {
691        let content = r#"Main Title
692==========
693
694Some content.
695
696Second Title
697------------
698
699More content.
700
701Main Title
702==========
703
704Duplicate setext."#;
705
706        let config = MD024Config::default();
707        let result = run_test(content, config);
708        assert!(result.is_ok());
709        let warnings = result.unwrap();
710        assert_eq!(warnings.len(), 1);
711        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
712        assert_eq!(warnings[0].line, 11);
713    }
714
715    #[test]
716    fn test_mixed_heading_styles() {
717        let content = r#"# ATX Title
718
719Some content.
720
721ATX Title
722=========
723
724Same text, different style."#;
725
726        let config = MD024Config::default();
727        let result = run_test(content, config);
728        assert!(result.is_ok());
729        let warnings = result.unwrap();
730        assert_eq!(warnings.len(), 1);
731        assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
732        assert_eq!(warnings[0].line, 5);
733    }
734
735    #[test]
736    fn test_heading_with_links() {
737        let content = r#"# [Link Text](http://example.com)
738
739Some content.
740
741## [Link Text](http://example.com)
742
743Duplicate heading with link.
744
745### [Different Link](http://example.com)
746
747Not a duplicate."#;
748
749        let config = MD024Config::default();
750        let result = run_test(content, config);
751        assert!(result.is_ok());
752        let warnings = result.unwrap();
753        assert_eq!(warnings.len(), 1);
754        assert_eq!(
755            warnings[0].message,
756            "Duplicate heading: '[Link Text](http://example.com)'."
757        );
758        assert_eq!(warnings[0].line, 5);
759    }
760
761    #[test]
762    fn test_consecutive_duplicates() {
763        let content = r#"# Title
764
765## Title
766
767### Title
768
769Three in a row."#;
770
771        let config = MD024Config::default();
772        let result = run_test(content, config);
773        assert!(result.is_ok());
774        let warnings = result.unwrap();
775        assert_eq!(warnings.len(), 2);
776        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
777        assert_eq!(warnings[0].line, 3);
778        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
779        assert_eq!(warnings[1].line, 5);
780    }
781
782    #[test]
783    fn test_siblings_only_config() {
784        let content = r#"# Section One
785
786## Subsection
787
788### Details
789
790# Section Two
791
792## Subsection
793
794Different parent sections, so not siblings - no warning expected."#;
795
796        let config = MD024Config {
797            allow_different_nesting: false,
798            siblings_only: true,
799        };
800        let result = run_test(content, config);
801        assert!(result.is_ok());
802        let warnings = result.unwrap();
803        // With siblings_only, these are not flagged because they're under different parents
804        assert_eq!(warnings.len(), 0);
805    }
806
807    #[test]
808    fn test_siblings_only_with_actual_siblings() {
809        let content = r#"# Main Section
810
811## First Subsection
812
813### Details
814
815## Second Subsection
816
817### Details
818
819The two 'Details' headings are siblings under different subsections - no warning.
820
821## First Subsection
822
823This 'First Subsection' IS a sibling duplicate."#;
824
825        let config = MD024Config {
826            allow_different_nesting: false,
827            siblings_only: true,
828        };
829        let result = run_test(content, config);
830        assert!(result.is_ok());
831        let warnings = result.unwrap();
832        // Only the duplicate "First Subsection" at the same level should be flagged
833        assert_eq!(warnings.len(), 1);
834        assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
835        assert_eq!(warnings[0].line, 13);
836    }
837
838    #[test]
839    fn test_code_spans_in_headings() {
840        let content = r#"# `code` in heading
841
842Some content.
843
844## `code` in heading
845
846Duplicate with code span."#;
847
848        let config = MD024Config::default();
849        let result = run_test(content, config);
850        assert!(result.is_ok());
851        let warnings = result.unwrap();
852        assert_eq!(warnings.len(), 1);
853        assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
854        assert_eq!(warnings[0].line, 5);
855    }
856
857    #[test]
858    fn test_very_long_heading() {
859        let long_text = "This is a very long heading that goes on and on and on and contains many words to test how the rule handles long headings";
860        let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
861
862        let config = MD024Config::default();
863        let result = run_test(&content, config);
864        assert!(result.is_ok());
865        let warnings = result.unwrap();
866        assert_eq!(warnings.len(), 1);
867        assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
868        assert_eq!(warnings[0].line, 5);
869    }
870
871    #[test]
872    fn test_heading_with_html_entities() {
873        let content = r#"# Title &amp; More
874
875Some content.
876
877## Title &amp; More
878
879Duplicate with HTML entity."#;
880
881        let config = MD024Config::default();
882        let result = run_test(content, config);
883        assert!(result.is_ok());
884        let warnings = result.unwrap();
885        assert_eq!(warnings.len(), 1);
886        assert_eq!(warnings[0].message, "Duplicate heading: 'Title &amp; More'.");
887        assert_eq!(warnings[0].line, 5);
888    }
889
890    #[test]
891    fn test_three_duplicates_different_nesting() {
892        let content = r#"# Main
893
894## Main
895
896### Main
897
898#### Main
899
900All same text, different levels."#;
901
902        let config = MD024Config {
903            allow_different_nesting: true,
904            siblings_only: false,
905        };
906        let result = run_test(content, config);
907        assert!(result.is_ok());
908        let warnings = result.unwrap();
909        // With allow_different_nesting, there should be no warnings
910        assert_eq!(warnings.len(), 0);
911    }
912}