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().to_string()),
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().to_string()),
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().to_string()),
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 {
297            allow_different_nesting: false,
298            siblings_only: false,
299        };
300        let result = run_test(content, config);
301        assert!(result.is_ok());
302        let warnings = result.unwrap();
303        assert_eq!(warnings.len(), 1);
304        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
305        assert_eq!(warnings[0].line, 5);
306    }
307
308    #[test]
309    fn test_duplicate_headings_different_levels_allow_different_nesting() {
310        let content = r#"# Main Title
311
312Some content.
313
314## Main Title
315
316This has the same text but different level."#;
317
318        let config = MD024Config {
319            allow_different_nesting: true,
320            siblings_only: false,
321        };
322        let result = run_test(content, config);
323        assert!(result.is_ok());
324        let warnings = result.unwrap();
325        assert_eq!(warnings.len(), 0);
326    }
327
328    #[test]
329    fn test_case_sensitivity() {
330        let content = r#"# First Heading
331
332Some content.
333
334## first heading
335
336Different case.
337
338### FIRST HEADING
339
340All caps."#;
341
342        let config = MD024Config::default();
343        let result = run_test(content, config);
344        assert!(result.is_ok());
345        let warnings = result.unwrap();
346        // The rule is case-sensitive, so these should not be duplicates
347        assert_eq!(warnings.len(), 0);
348    }
349
350    #[test]
351    fn test_headings_with_trailing_punctuation() {
352        let content = r#"# First Heading!
353
354Some content.
355
356## First Heading!
357
358Same with punctuation.
359
360### First Heading
361
362Without punctuation."#;
363
364        let config = MD024Config {
365            allow_different_nesting: false,
366            siblings_only: false,
367        };
368        let result = run_test(content, config);
369        assert!(result.is_ok());
370        let warnings = result.unwrap();
371        assert_eq!(warnings.len(), 1);
372        assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
373    }
374
375    #[test]
376    fn test_headings_with_inline_formatting() {
377        let content = r#"# **Bold Heading**
378
379Some content.
380
381## *Italic Heading*
382
383More content.
384
385### **Bold Heading**
386
387Duplicate with same formatting.
388
389#### `Code Heading`
390
391Code formatted.
392
393##### `Code Heading`
394
395Duplicate code formatted."#;
396
397        let config = MD024Config {
398            allow_different_nesting: false,
399            siblings_only: false,
400        };
401        let result = run_test(content, config);
402        assert!(result.is_ok());
403        let warnings = result.unwrap();
404        assert_eq!(warnings.len(), 2);
405        assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
406        assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
407    }
408
409    #[test]
410    fn test_headings_in_different_sections() {
411        let content = r#"# Section One
412
413## Subsection
414
415Some content.
416
417# Section Two
418
419## Subsection
420
421Same subsection name in different section."#;
422
423        let config = MD024Config {
424            allow_different_nesting: false,
425            siblings_only: false,
426        };
427        let result = run_test(content, config);
428        assert!(result.is_ok());
429        let warnings = result.unwrap();
430        assert_eq!(warnings.len(), 1);
431        assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
432        assert_eq!(warnings[0].line, 9);
433    }
434
435    #[test]
436    fn test_multiple_duplicates() {
437        let content = r#"# Title
438
439## Subtitle
440
441### Title
442
443#### Subtitle
444
445## Title
446
447### Subtitle"#;
448
449        let config = MD024Config {
450            allow_different_nesting: false,
451            siblings_only: false,
452        };
453        let result = run_test(content, config);
454        assert!(result.is_ok());
455        let warnings = result.unwrap();
456        assert_eq!(warnings.len(), 4);
457        // First duplicate of "Title"
458        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
459        assert_eq!(warnings[0].line, 5);
460        // First duplicate of "Subtitle"
461        assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
462        assert_eq!(warnings[1].line, 7);
463        // Second duplicate of "Title"
464        assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
465        assert_eq!(warnings[2].line, 9);
466        // Second duplicate of "Subtitle"
467        assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
468        assert_eq!(warnings[3].line, 11);
469    }
470
471    #[test]
472    fn test_empty_headings() {
473        let content = r#"#
474
475Some content.
476
477##
478
479More content.
480
481### Non-empty
482
483####
484
485Another empty."#;
486
487        let config = MD024Config::default();
488        let result = run_test(content, config);
489        assert!(result.is_ok());
490        let warnings = result.unwrap();
491        // Empty headings are skipped
492        assert_eq!(warnings.len(), 0);
493    }
494
495    #[test]
496    fn test_unicode_and_special_characters() {
497        let content = r#"# 你好世界
498
499Some content.
500
501## Émojis 🎉🎊
502
503More content.
504
505### 你好世界
506
507Duplicate Chinese.
508
509#### Émojis 🎉🎊
510
511Duplicate emojis.
512
513##### Special <chars> & symbols!
514
515###### Special <chars> & symbols!
516
517Duplicate special chars."#;
518
519        let config = MD024Config {
520            allow_different_nesting: false,
521            siblings_only: false,
522        };
523        let result = run_test(content, config);
524        assert!(result.is_ok());
525        let warnings = result.unwrap();
526        assert_eq!(warnings.len(), 3);
527        assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
528        assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
529        assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
530    }
531
532    #[test]
533    fn test_allow_different_nesting_with_same_level_duplicates() {
534        let content = r#"# Section One
535
536## Title
537
538### Subsection
539
540## Title
541
542This is a duplicate at the same level.
543
544# Section Two
545
546## Title
547
548Different section, but still a duplicate when allow_different_nesting is true."#;
549
550        let config = MD024Config {
551            allow_different_nesting: true,
552            siblings_only: false,
553        };
554        let result = run_test(content, config);
555        assert!(result.is_ok());
556        let warnings = result.unwrap();
557        assert_eq!(warnings.len(), 2);
558        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
559        assert_eq!(warnings[0].line, 7);
560        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
561        assert_eq!(warnings[1].line, 13);
562    }
563
564    #[test]
565    fn test_atx_style_headings_with_closing_hashes() {
566        let content = r#"# Heading One #
567
568Some content.
569
570## Heading Two ##
571
572More content.
573
574### Heading One ###
575
576Duplicate with different style."#;
577
578        let config = MD024Config {
579            allow_different_nesting: false,
580            siblings_only: false,
581        };
582        let result = run_test(content, config);
583        assert!(result.is_ok());
584        let warnings = result.unwrap();
585        // The heading text excludes the closing hashes, so "Heading One" is a duplicate
586        assert_eq!(warnings.len(), 1);
587        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
588        assert_eq!(warnings[0].line, 9);
589    }
590
591    #[test]
592    fn test_fix_method_returns_unchanged() {
593        let content = r#"# Duplicate
594
595## Duplicate
596
597This has duplicates."#;
598
599        let config = MD024Config::default();
600        let result = run_fix_test(content, config);
601        assert!(result.is_ok());
602        assert_eq!(result.unwrap(), content);
603    }
604
605    #[test]
606    fn test_empty_content() {
607        let content = "";
608        let config = MD024Config::default();
609        let result = run_test(content, config);
610        assert!(result.is_ok());
611        let warnings = result.unwrap();
612        assert_eq!(warnings.len(), 0);
613    }
614
615    #[test]
616    fn test_no_headings() {
617        let content = r#"This is just regular text.
618
619No headings anywhere.
620
621Just paragraphs."#;
622
623        let config = MD024Config::default();
624        let result = run_test(content, config);
625        assert!(result.is_ok());
626        let warnings = result.unwrap();
627        assert_eq!(warnings.len(), 0);
628    }
629
630    #[test]
631    fn test_whitespace_differences() {
632        let content = r#"# Heading with spaces
633
634Some content.
635
636##  Heading with spaces
637
638Different amount of spaces.
639
640### Heading with spaces
641
642Exact match."#;
643
644        let config = MD024Config {
645            allow_different_nesting: false,
646            siblings_only: false,
647        };
648        let result = run_test(content, config);
649        assert!(result.is_ok());
650        let warnings = result.unwrap();
651        // The heading text is trimmed, so all three are duplicates
652        assert_eq!(warnings.len(), 2);
653        assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
654        assert_eq!(warnings[0].line, 5);
655        assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
656        assert_eq!(warnings[1].line, 9);
657    }
658
659    #[test]
660    fn test_column_positions() {
661        let content = r#"# First
662
663## Second
664
665### First"#;
666
667        let config = MD024Config {
668            allow_different_nesting: false,
669            siblings_only: false,
670        };
671        let result = run_test(content, config);
672        assert!(result.is_ok());
673        let warnings = result.unwrap();
674        assert_eq!(warnings.len(), 1);
675        assert_eq!(warnings[0].line, 5);
676        assert_eq!(warnings[0].column, 5); // After "### "
677        assert_eq!(warnings[0].end_line, 5);
678        assert_eq!(warnings[0].end_column, 10); // End of "First"
679    }
680
681    #[test]
682    fn test_complex_nesting_scenario() {
683        let content = r#"# Main Document
684
685## Introduction
686
687### Overview
688
689## Implementation
690
691### Overview
692
693This Overview is in a different section.
694
695## Conclusion
696
697### Overview
698
699Another Overview in yet another section."#;
700
701        let config = MD024Config {
702            allow_different_nesting: true,
703            siblings_only: false,
704        };
705        let result = run_test(content, config);
706        assert!(result.is_ok());
707        let warnings = result.unwrap();
708        // When allow_different_nesting is true, only same-level duplicates are flagged
709        assert_eq!(warnings.len(), 2);
710        assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
711        assert_eq!(warnings[0].line, 9);
712        assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
713        assert_eq!(warnings[1].line, 15);
714    }
715
716    #[test]
717    fn test_setext_style_headings() {
718        let content = r#"Main Title
719==========
720
721Some content.
722
723Second Title
724------------
725
726More content.
727
728Main Title
729==========
730
731Duplicate setext."#;
732
733        let config = MD024Config::default();
734        let result = run_test(content, config);
735        assert!(result.is_ok());
736        let warnings = result.unwrap();
737        assert_eq!(warnings.len(), 1);
738        assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
739        assert_eq!(warnings[0].line, 11);
740    }
741
742    #[test]
743    fn test_mixed_heading_styles() {
744        let content = r#"# ATX Title
745
746Some content.
747
748ATX Title
749=========
750
751Same text, different style."#;
752
753        let config = MD024Config::default();
754        let result = run_test(content, config);
755        assert!(result.is_ok());
756        let warnings = result.unwrap();
757        assert_eq!(warnings.len(), 1);
758        assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
759        assert_eq!(warnings[0].line, 5);
760    }
761
762    #[test]
763    fn test_heading_with_links() {
764        let content = r#"# [Link Text](http://example.com)
765
766Some content.
767
768## [Link Text](http://example.com)
769
770Duplicate heading with link.
771
772### [Different Link](http://example.com)
773
774Not a duplicate."#;
775
776        let config = MD024Config {
777            allow_different_nesting: false,
778            siblings_only: false,
779        };
780        let result = run_test(content, config);
781        assert!(result.is_ok());
782        let warnings = result.unwrap();
783        assert_eq!(warnings.len(), 1);
784        assert_eq!(
785            warnings[0].message,
786            "Duplicate heading: '[Link Text](http://example.com)'."
787        );
788        assert_eq!(warnings[0].line, 5);
789    }
790
791    #[test]
792    fn test_consecutive_duplicates() {
793        let content = r#"# Title
794
795## Title
796
797### Title
798
799Three in a row."#;
800
801        let config = MD024Config {
802            allow_different_nesting: false,
803            siblings_only: false,
804        };
805        let result = run_test(content, config);
806        assert!(result.is_ok());
807        let warnings = result.unwrap();
808        assert_eq!(warnings.len(), 2);
809        assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
810        assert_eq!(warnings[0].line, 3);
811        assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
812        assert_eq!(warnings[1].line, 5);
813    }
814
815    #[test]
816    fn test_siblings_only_config() {
817        let content = r#"# Section One
818
819## Subsection
820
821### Details
822
823# Section Two
824
825## Subsection
826
827Different parent sections, so not siblings - no warning expected."#;
828
829        let config = MD024Config {
830            allow_different_nesting: false,
831            siblings_only: true,
832        };
833        let result = run_test(content, config);
834        assert!(result.is_ok());
835        let warnings = result.unwrap();
836        // With siblings_only, these are not flagged because they're under different parents
837        assert_eq!(warnings.len(), 0);
838    }
839
840    #[test]
841    fn test_siblings_only_with_actual_siblings() {
842        let content = r#"# Main Section
843
844## First Subsection
845
846### Details
847
848## Second Subsection
849
850### Details
851
852The two 'Details' headings are siblings under different subsections - no warning.
853
854## First Subsection
855
856This 'First Subsection' IS a sibling duplicate."#;
857
858        let config = MD024Config {
859            allow_different_nesting: false,
860            siblings_only: true,
861        };
862        let result = run_test(content, config);
863        assert!(result.is_ok());
864        let warnings = result.unwrap();
865        // Only the duplicate "First Subsection" at the same level should be flagged
866        assert_eq!(warnings.len(), 1);
867        assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
868        assert_eq!(warnings[0].line, 13);
869    }
870
871    #[test]
872    fn test_code_spans_in_headings() {
873        let content = r#"# `code` in heading
874
875Some content.
876
877## `code` in heading
878
879Duplicate with code span."#;
880
881        let config = MD024Config {
882            allow_different_nesting: false,
883            siblings_only: false,
884        };
885        let result = run_test(content, config);
886        assert!(result.is_ok());
887        let warnings = result.unwrap();
888        assert_eq!(warnings.len(), 1);
889        assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
890        assert_eq!(warnings[0].line, 5);
891    }
892
893    #[test]
894    fn test_very_long_heading() {
895        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";
896        let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
897
898        let config = MD024Config {
899            allow_different_nesting: false,
900            siblings_only: false,
901        };
902        let result = run_test(&content, config);
903        assert!(result.is_ok());
904        let warnings = result.unwrap();
905        assert_eq!(warnings.len(), 1);
906        assert_eq!(warnings[0].message, format!("Duplicate heading: '{long_text}'."));
907        assert_eq!(warnings[0].line, 5);
908    }
909
910    #[test]
911    fn test_heading_with_html_entities() {
912        let content = r#"# Title &amp; More
913
914Some content.
915
916## Title &amp; More
917
918Duplicate with HTML entity."#;
919
920        let config = MD024Config {
921            allow_different_nesting: false,
922            siblings_only: false,
923        };
924        let result = run_test(content, config);
925        assert!(result.is_ok());
926        let warnings = result.unwrap();
927        assert_eq!(warnings.len(), 1);
928        assert_eq!(warnings[0].message, "Duplicate heading: 'Title &amp; More'.");
929        assert_eq!(warnings[0].line, 5);
930    }
931
932    #[test]
933    fn test_three_duplicates_different_nesting() {
934        let content = r#"# Main
935
936## Main
937
938### Main
939
940#### Main
941
942All same text, different levels."#;
943
944        let config = MD024Config {
945            allow_different_nesting: true,
946            siblings_only: false,
947        };
948        let result = run_test(content, config);
949        assert!(result.is_ok());
950        let warnings = result.unwrap();
951        // With allow_different_nesting, there should be no warnings
952        assert_eq!(warnings.len(), 0);
953    }
954}