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        ctx.lines.iter().all(|line| line.heading.is_none())
185    }
186
187    fn as_any(&self) -> &dyn std::any::Any {
188        self
189    }
190
191    fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
192        None
193    }
194
195    fn default_config_section(&self) -> Option<(String, toml::Value)> {
196        let default_config = MD024Config::default();
197        let json_value = serde_json::to_value(&default_config).ok()?;
198        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
199
200        if let toml::Value::Table(table) = toml_value {
201            if !table.is_empty() {
202                Some((MD024Config::RULE_NAME.to_string(), toml::Value::Table(table)))
203            } else {
204                None
205            }
206        } else {
207            None
208        }
209    }
210
211    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
212    where
213        Self: Sized,
214    {
215        let rule_config = crate::rule_config_serde::load_rule_config::<MD024Config>(config);
216        Box::new(Self::from_config_struct(rule_config))
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use crate::lint_context::LintContext;
224
225    fn run_test(content: &str, config: MD024Config) -> LintResult {
226        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
227        let rule = MD024NoDuplicateHeading::from_config_struct(config);
228        rule.check(&ctx)
229    }
230
231    fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
233        let rule = MD024NoDuplicateHeading::from_config_struct(config);
234        rule.fix(&ctx)
235    }
236
237    #[test]
238    fn test_no_duplicate_headings() {
239        let content = r#"# First Heading
240
241Some content here.
242
243## Second Heading
244
245More content.
246
247### Third Heading
248
249Even more content.
250
251## Fourth Heading
252
253Final content."#;
254
255        let config = MD024Config::default();
256        let result = run_test(content, config);
257        assert!(result.is_ok());
258        let warnings = result.unwrap();
259        assert_eq!(warnings.len(), 0);
260    }
261
262    #[test]
263    fn test_duplicate_headings_same_level() {
264        let content = r#"# First Heading
265
266Some content here.
267
268## Second Heading
269
270More content.
271
272## Second Heading
273
274This is a duplicate."#;
275
276        let config = MD024Config::default();
277        let result = run_test(content, config);
278        assert!(result.is_ok());
279        let warnings = result.unwrap();
280        assert_eq!(warnings.len(), 1);
281        assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
282        assert_eq!(warnings[0].line, 9);
283    }
284
285    #[test]
286    fn test_duplicate_headings_different_levels_default() {
287        let content = r#"# Main Title
288
289Some content.
290
291## Main Title
292
293This has the same text but different level."#;
294
295        let config = MD024Config::default();
296        let result = run_test(content, config);
297        assert!(result.is_ok());
298        let warnings = result.unwrap();
299        assert_eq!(warnings.len(), 1);
300        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
301        assert_eq!(warnings[0].line, 5);
302    }
303
304    #[test]
305    fn test_duplicate_headings_different_levels_allow_different_nesting() {
306        let content = r#"# Main Title
307
308Some content.
309
310## Main Title
311
312This has the same text but different level."#;
313
314        let config = MD024Config {
315            allow_different_nesting: true,
316            siblings_only: false,
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::default();
361        let result = run_test(content, config);
362        assert!(result.is_ok());
363        let warnings = result.unwrap();
364        assert_eq!(warnings.len(), 1);
365        assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
366    }
367
368    #[test]
369    fn test_headings_with_inline_formatting() {
370        let content = r#"# **Bold Heading**
371
372Some content.
373
374## *Italic Heading*
375
376More content.
377
378### **Bold Heading**
379
380Duplicate with same formatting.
381
382#### `Code Heading`
383
384Code formatted.
385
386##### `Code Heading`
387
388Duplicate code formatted."#;
389
390        let config = MD024Config::default();
391        let result = run_test(content, config);
392        assert!(result.is_ok());
393        let warnings = result.unwrap();
394        assert_eq!(warnings.len(), 2);
395        assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
396        assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
397    }
398
399    #[test]
400    fn test_headings_in_different_sections() {
401        let content = r#"# Section One
402
403## Subsection
404
405Some content.
406
407# Section Two
408
409## Subsection
410
411Same subsection name in different section."#;
412
413        let config = MD024Config::default();
414        let result = run_test(content, config);
415        assert!(result.is_ok());
416        let warnings = result.unwrap();
417        assert_eq!(warnings.len(), 1);
418        assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
419        assert_eq!(warnings[0].line, 9);
420    }
421
422    #[test]
423    fn test_multiple_duplicates() {
424        let content = r#"# Title
425
426## Subtitle
427
428### Title
429
430#### Subtitle
431
432## Title
433
434### Subtitle"#;
435
436        let config = MD024Config::default();
437        let result = run_test(content, config);
438        assert!(result.is_ok());
439        let warnings = result.unwrap();
440        assert_eq!(warnings.len(), 4);
441        // First duplicate of "Title"
442        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
443        assert_eq!(warnings[0].line, 5);
444        // First duplicate of "Subtitle"
445        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
446        assert_eq!(warnings[1].line, 7);
447        // Second duplicate of "Title"
448        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
449        assert_eq!(warnings[2].line, 9);
450        // Second duplicate of "Subtitle"
451        assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
452        assert_eq!(warnings[3].line, 11);
453    }
454
455    #[test]
456    fn test_empty_headings() {
457        let content = r#"#
458
459Some content.
460
461##
462
463More content.
464
465### Non-empty
466
467####
468
469Another empty."#;
470
471        let config = MD024Config::default();
472        let result = run_test(content, config);
473        assert!(result.is_ok());
474        let warnings = result.unwrap();
475        // Empty headings are skipped
476        assert_eq!(warnings.len(), 0);
477    }
478
479    #[test]
480    fn test_unicode_and_special_characters() {
481        let content = r#"# 你好世界
482
483Some content.
484
485## Émojis 🎉🎊
486
487More content.
488
489### 你好世界
490
491Duplicate Chinese.
492
493#### Émojis 🎉🎊
494
495Duplicate emojis.
496
497##### Special <chars> & symbols!
498
499###### Special <chars> & symbols!
500
501Duplicate special chars."#;
502
503        let config = MD024Config::default();
504        let result = run_test(content, config);
505        assert!(result.is_ok());
506        let warnings = result.unwrap();
507        assert_eq!(warnings.len(), 3);
508        assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
509        assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
510        assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
511    }
512
513    #[test]
514    fn test_allow_different_nesting_with_same_level_duplicates() {
515        let content = r#"# Section One
516
517## Title
518
519### Subsection
520
521## Title
522
523This is a duplicate at the same level.
524
525# Section Two
526
527## Title
528
529Different section, but still a duplicate when allow_different_nesting is true."#;
530
531        let config = MD024Config {
532            allow_different_nesting: true,
533            siblings_only: false,
534        };
535        let result = run_test(content, config);
536        assert!(result.is_ok());
537        let warnings = result.unwrap();
538        assert_eq!(warnings.len(), 2);
539        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
540        assert_eq!(warnings[0].line, 7);
541        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
542        assert_eq!(warnings[1].line, 13);
543    }
544
545    #[test]
546    fn test_atx_style_headings_with_closing_hashes() {
547        let content = r#"# Heading One #
548
549Some content.
550
551## Heading Two ##
552
553More content.
554
555### Heading One ###
556
557Duplicate with different style."#;
558
559        let config = MD024Config::default();
560        let result = run_test(content, config);
561        assert!(result.is_ok());
562        let warnings = result.unwrap();
563        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
564        assert_eq!(warnings.len(), 1);
565        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
566        assert_eq!(warnings[0].line, 9);
567    }
568
569    #[test]
570    fn test_fix_method_returns_unchanged() {
571        let content = r#"# Duplicate
572
573## Duplicate
574
575This has duplicates."#;
576
577        let config = MD024Config::default();
578        let result = run_fix_test(content, config);
579        assert!(result.is_ok());
580        assert_eq!(result.unwrap(), content);
581    }
582
583    #[test]
584    fn test_empty_content() {
585        let content = "";
586        let config = MD024Config::default();
587        let result = run_test(content, config);
588        assert!(result.is_ok());
589        let warnings = result.unwrap();
590        assert_eq!(warnings.len(), 0);
591    }
592
593    #[test]
594    fn test_no_headings() {
595        let content = r#"This is just regular text.
596
597No headings anywhere.
598
599Just paragraphs."#;
600
601        let config = MD024Config::default();
602        let result = run_test(content, config);
603        assert!(result.is_ok());
604        let warnings = result.unwrap();
605        assert_eq!(warnings.len(), 0);
606    }
607
608    #[test]
609    fn test_whitespace_differences() {
610        let content = r#"# Heading with spaces
611
612Some content.
613
614##  Heading with spaces
615
616Different amount of spaces.
617
618### Heading with spaces
619
620Exact match."#;
621
622        let config = MD024Config::default();
623        let result = run_test(content, config);
624        assert!(result.is_ok());
625        let warnings = result.unwrap();
626        // The heading text is trimmed, so all three are duplicates
627        assert_eq!(warnings.len(), 2);
628        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
629        assert_eq!(warnings[0].line, 5);
630        assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
631        assert_eq!(warnings[1].line, 9);
632    }
633
634    #[test]
635    fn test_column_positions() {
636        let content = r#"# First
637
638## Second
639
640### First"#;
641
642        let config = MD024Config::default();
643        let result = run_test(content, config);
644        assert!(result.is_ok());
645        let warnings = result.unwrap();
646        assert_eq!(warnings.len(), 1);
647        assert_eq!(warnings[0].line, 5);
648        assert_eq!(warnings[0].column, 5); // After "### "
649        assert_eq!(warnings[0].end_line, 5);
650        assert_eq!(warnings[0].end_column, 10); // End of "First"
651    }
652
653    #[test]
654    fn test_complex_nesting_scenario() {
655        let content = r#"# Main Document
656
657## Introduction
658
659### Overview
660
661## Implementation
662
663### Overview
664
665This Overview is in a different section.
666
667## Conclusion
668
669### Overview
670
671Another Overview in yet another section."#;
672
673        let config = MD024Config {
674            allow_different_nesting: true,
675            siblings_only: false,
676        };
677        let result = run_test(content, config);
678        assert!(result.is_ok());
679        let warnings = result.unwrap();
680        // When allow_different_nesting is true, only same-level duplicates are flagged
681        assert_eq!(warnings.len(), 2);
682        assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
683        assert_eq!(warnings[0].line, 9);
684        assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
685        assert_eq!(warnings[1].line, 15);
686    }
687
688    #[test]
689    fn test_setext_style_headings() {
690        let content = r#"Main Title
691==========
692
693Some content.
694
695Second Title
696------------
697
698More content.
699
700Main Title
701==========
702
703Duplicate setext."#;
704
705        let config = MD024Config::default();
706        let result = run_test(content, config);
707        assert!(result.is_ok());
708        let warnings = result.unwrap();
709        assert_eq!(warnings.len(), 1);
710        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
711        assert_eq!(warnings[0].line, 11);
712    }
713
714    #[test]
715    fn test_mixed_heading_styles() {
716        let content = r#"# ATX Title
717
718Some content.
719
720ATX Title
721=========
722
723Same text, different style."#;
724
725        let config = MD024Config::default();
726        let result = run_test(content, config);
727        assert!(result.is_ok());
728        let warnings = result.unwrap();
729        assert_eq!(warnings.len(), 1);
730        assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
731        assert_eq!(warnings[0].line, 5);
732    }
733
734    #[test]
735    fn test_heading_with_links() {
736        let content = r#"# [Link Text](http://example.com)
737
738Some content.
739
740## [Link Text](http://example.com)
741
742Duplicate heading with link.
743
744### [Different Link](http://example.com)
745
746Not a duplicate."#;
747
748        let config = MD024Config::default();
749        let result = run_test(content, config);
750        assert!(result.is_ok());
751        let warnings = result.unwrap();
752        assert_eq!(warnings.len(), 1);
753        assert_eq!(
754            warnings[0].message,
755            "Duplicate heading: '[Link Text](http://example.com)'."
756        );
757        assert_eq!(warnings[0].line, 5);
758    }
759
760    #[test]
761    fn test_consecutive_duplicates() {
762        let content = r#"# Title
763
764## Title
765
766### Title
767
768Three in a row."#;
769
770        let config = MD024Config::default();
771        let result = run_test(content, config);
772        assert!(result.is_ok());
773        let warnings = result.unwrap();
774        assert_eq!(warnings.len(), 2);
775        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
776        assert_eq!(warnings[0].line, 3);
777        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
778        assert_eq!(warnings[1].line, 5);
779    }
780
781    #[test]
782    fn test_siblings_only_config() {
783        let content = r#"# Section One
784
785## Subsection
786
787### Details
788
789# Section Two
790
791## Subsection
792
793Different parent sections, so not siblings - no warning expected."#;
794
795        let config = MD024Config {
796            allow_different_nesting: false,
797            siblings_only: true,
798        };
799        let result = run_test(content, config);
800        assert!(result.is_ok());
801        let warnings = result.unwrap();
802        // With siblings_only, these are not flagged because they're under different parents
803        assert_eq!(warnings.len(), 0);
804    }
805
806    #[test]
807    fn test_siblings_only_with_actual_siblings() {
808        let content = r#"# Main Section
809
810## First Subsection
811
812### Details
813
814## Second Subsection
815
816### Details
817
818The two 'Details' headings are siblings under different subsections - no warning.
819
820## First Subsection
821
822This 'First Subsection' IS a sibling duplicate."#;
823
824        let config = MD024Config {
825            allow_different_nesting: false,
826            siblings_only: true,
827        };
828        let result = run_test(content, config);
829        assert!(result.is_ok());
830        let warnings = result.unwrap();
831        // Only the duplicate "First Subsection" at the same level should be flagged
832        assert_eq!(warnings.len(), 1);
833        assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
834        assert_eq!(warnings[0].line, 13);
835    }
836
837    #[test]
838    fn test_code_spans_in_headings() {
839        let content = r#"# `code` in heading
840
841Some content.
842
843## `code` in heading
844
845Duplicate with code span."#;
846
847        let config = MD024Config::default();
848        let result = run_test(content, config);
849        assert!(result.is_ok());
850        let warnings = result.unwrap();
851        assert_eq!(warnings.len(), 1);
852        assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
853        assert_eq!(warnings[0].line, 5);
854    }
855
856    #[test]
857    fn test_very_long_heading() {
858        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";
859        let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
860
861        let config = MD024Config::default();
862        let result = run_test(&content, config);
863        assert!(result.is_ok());
864        let warnings = result.unwrap();
865        assert_eq!(warnings.len(), 1);
866        assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
867        assert_eq!(warnings[0].line, 5);
868    }
869
870    #[test]
871    fn test_heading_with_html_entities() {
872        let content = r#"# Title &amp; More
873
874Some content.
875
876## Title &amp; More
877
878Duplicate with HTML entity."#;
879
880        let config = MD024Config::default();
881        let result = run_test(content, config);
882        assert!(result.is_ok());
883        let warnings = result.unwrap();
884        assert_eq!(warnings.len(), 1);
885        assert_eq!(warnings[0].message, "Duplicate heading: 'Title &amp; More'.");
886        assert_eq!(warnings[0].line, 5);
887    }
888
889    #[test]
890    fn test_three_duplicates_different_nesting() {
891        let content = r#"# Main
892
893## Main
894
895### Main
896
897#### Main
898
899All same text, different levels."#;
900
901        let config = MD024Config {
902            allow_different_nesting: true,
903            siblings_only: false,
904        };
905        let result = run_test(content, config);
906        assert!(result.is_ok());
907        let warnings = result.unwrap();
908        // With allow_different_nesting, there should be no warnings
909        assert_eq!(warnings.len(), 0);
910    }
911}