Skip to main content

rumdl_lib/rules/
md024_no_duplicate_heading.rs

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