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