rumdl_lib/rules/
md024_no_duplicate_heading.rs

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