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