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 allow_different_link_anchors: true,
23 },
24 }
25 }
26
27 pub fn from_config_struct(config: MD024Config) -> Self {
28 Self { config }
29 }
30}
31
32impl Rule for MD024NoDuplicateHeading {
33 fn name(&self) -> &'static str {
34 "MD024"
35 }
36
37 fn description(&self) -> &'static str {
38 "Multiple headings with the same content"
39 }
40
41 fn fix_capability(&self) -> FixCapability {
42 FixCapability::Unfixable
43 }
44
45 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
46 if ctx.lines.is_empty() {
48 return Ok(Vec::new());
49 }
50
51 type HeadingKey = (String, Option<String>);
54
55 let mut warnings = Vec::new();
56 let mut seen_headings: HashSet<HeadingKey> = HashSet::new();
57 let mut seen_headings_per_level: HashMap<u8, HashSet<HeadingKey>> = HashMap::new();
58
59 let mut current_section_path: Vec<(u8, HeadingKey)> = Vec::new();
61 let mut seen_siblings: HashMap<Vec<HeadingKey>, HashSet<HeadingKey>> = HashMap::new();
62
63 let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs;
65 let mut in_snippet_section = false;
66
67 for (line_num, line_info) in ctx.lines.iter().enumerate() {
69 if is_mkdocs {
71 if crate::utils::mkdocs_snippets::is_snippet_section_start(line_info.content(ctx.content)) {
72 in_snippet_section = true;
73 continue; } else if crate::utils::mkdocs_snippets::is_snippet_section_end(line_info.content(ctx.content)) {
75 in_snippet_section = false;
76 continue; }
78 }
79
80 if is_mkdocs && in_snippet_section {
82 continue;
83 }
84
85 if let Some(heading) = &line_info.heading {
86 if !heading.is_valid {
88 continue;
89 }
90
91 if heading.text.is_empty() {
93 continue;
94 }
95
96 let heading_key: HeadingKey = if self.config.allow_different_link_anchors {
97 (heading.text.clone(), heading.custom_id.clone())
98 } else {
99 (heading.text.clone(), None)
100 };
101 let level = heading.level;
102
103 let text_start_in_line = if let Some(pos) = line_info.content(ctx.content).find(&heading.text) {
105 pos
106 } else {
107 let trimmed = line_info.content(ctx.content).trim_start();
109 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
110 let after_hashes = &trimmed[hash_count..];
111 let text_start_in_trimmed = after_hashes.find(&heading.text).unwrap_or(0);
112 (line_info.byte_len - trimmed.len()) + hash_count + text_start_in_trimmed
113 };
114
115 let (start_line, start_col, end_line, end_col) = calculate_match_range(
116 line_num + 1,
117 line_info.content(ctx.content),
118 text_start_in_line,
119 heading.text.len(),
120 );
121
122 if self.config.siblings_only {
123 while !current_section_path.is_empty() && current_section_path.last().unwrap().0 >= level {
125 current_section_path.pop();
126 }
127
128 let parent_path: Vec<HeadingKey> = current_section_path.iter().map(|(_, k)| k.clone()).collect();
129
130 let siblings = seen_siblings.entry(parent_path).or_default();
132 if siblings.contains(&heading_key) {
133 warnings.push(LintWarning {
134 rule_name: Some(self.name().to_string()),
135 message: format!("Duplicate heading: '{}'.", heading.text),
136 line: start_line,
137 column: start_col,
138 end_line,
139 end_column: end_col,
140 severity: Severity::Error,
141 fix: None,
142 });
143 } else {
144 siblings.insert(heading_key.clone());
145 }
146
147 current_section_path.push((level, heading_key.clone()));
149 } else if self.config.allow_different_nesting {
150 let seen = seen_headings_per_level.entry(level).or_default();
152 if seen.contains(&heading_key) {
153 warnings.push(LintWarning {
154 rule_name: Some(self.name().to_string()),
155 message: format!("Duplicate heading: '{}'.", heading.text),
156 line: start_line,
157 column: start_col,
158 end_line,
159 end_column: end_col,
160 severity: Severity::Error,
161 fix: None,
162 });
163 } else {
164 seen.insert(heading_key.clone());
165 }
166 } else {
167 if seen_headings.contains(&heading_key) {
169 warnings.push(LintWarning {
170 rule_name: Some(self.name().to_string()),
171 message: format!("Duplicate heading: '{}'.", heading.text),
172 line: start_line,
173 column: start_col,
174 end_line,
175 end_column: end_col,
176 severity: Severity::Error,
177 fix: None,
178 });
179 } else {
180 seen_headings.insert(heading_key.clone());
181 }
182 }
183 }
184 }
185
186 Ok(warnings)
187 }
188
189 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
190 Ok(ctx.content.to_string())
192 }
193
194 fn category(&self) -> RuleCategory {
196 RuleCategory::Heading
197 }
198
199 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
201 if !ctx.likely_has_headings() {
203 return true;
204 }
205 ctx.lines.iter().all(|line| line.heading.is_none())
207 }
208
209 fn as_any(&self) -> &dyn std::any::Any {
210 self
211 }
212
213 fn default_config_section(&self) -> Option<(String, toml::Value)> {
214 let default_config = MD024Config::default();
215 let json_value = serde_json::to_value(&default_config).ok()?;
216 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
217
218 if let toml::Value::Table(table) = toml_value {
219 if !table.is_empty() {
220 Some((MD024Config::RULE_NAME.to_string(), toml::Value::Table(table)))
221 } else {
222 None
223 }
224 } else {
225 None
226 }
227 }
228
229 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
230 where
231 Self: Sized,
232 {
233 let rule_config = crate::rule_config_serde::load_rule_config::<MD024Config>(config);
234 Box::new(Self::from_config_struct(rule_config))
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::lint_context::LintContext;
242
243 fn run_test(content: &str, config: MD024Config) -> LintResult {
244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
245 let rule = MD024NoDuplicateHeading::from_config_struct(config);
246 rule.check(&ctx)
247 }
248
249 fn run_fix_test(content: &str, config: MD024Config) -> Result<String, LintError> {
250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
251 let rule = MD024NoDuplicateHeading::from_config_struct(config);
252 rule.fix(&ctx)
253 }
254
255 #[test]
256 fn test_no_duplicate_headings() {
257 let content = r#"# First Heading
258
259Some content here.
260
261## Second Heading
262
263More content.
264
265### Third Heading
266
267Even more content.
268
269## Fourth Heading
270
271Final content."#;
272
273 let config = MD024Config::default();
274 let result = run_test(content, config);
275 assert!(result.is_ok());
276 let warnings = result.unwrap();
277 assert_eq!(warnings.len(), 0);
278 }
279
280 #[test]
281 fn test_duplicate_headings_same_level() {
282 let content = r#"# First Heading
283
284Some content here.
285
286## Second Heading
287
288More content.
289
290## Second Heading
291
292This is a duplicate."#;
293
294 let config = MD024Config::default();
295 let result = run_test(content, config);
296 assert!(result.is_ok());
297 let warnings = result.unwrap();
298 assert_eq!(warnings.len(), 1);
299 assert_eq!(warnings[0].message, "Duplicate heading: 'Second Heading'.");
300 assert_eq!(warnings[0].line, 9);
301 }
302
303 #[test]
304 fn test_duplicate_headings_different_levels_default() {
305 let content = r#"# Main Title
306
307Some content.
308
309## Main Title
310
311This has the same text but different level."#;
312
313 let config = MD024Config {
314 allow_different_nesting: false,
315 siblings_only: false,
316 ..MD024Config::default()
317 };
318 let result = run_test(content, config);
319 assert!(result.is_ok());
320 let warnings = result.unwrap();
321 assert_eq!(warnings.len(), 1);
322 assert_eq!(warnings[0].message, "Duplicate heading: 'Main Title'.");
323 assert_eq!(warnings[0].line, 5);
324 }
325
326 #[test]
327 fn test_duplicate_headings_different_levels_allow_different_nesting() {
328 let content = r#"# Main Title
329
330Some content.
331
332## Main Title
333
334This has the same text but different level."#;
335
336 let config = MD024Config {
337 allow_different_nesting: true,
338 siblings_only: false,
339 ..MD024Config::default()
340 };
341 let result = run_test(content, config);
342 assert!(result.is_ok());
343 let warnings = result.unwrap();
344 assert_eq!(warnings.len(), 0);
345 }
346
347 #[test]
348 fn test_case_sensitivity() {
349 let content = r#"# First Heading
350
351Some content.
352
353## first heading
354
355Different case.
356
357### FIRST HEADING
358
359All caps."#;
360
361 let config = MD024Config::default();
362 let result = run_test(content, config);
363 assert!(result.is_ok());
364 let warnings = result.unwrap();
365 assert_eq!(warnings.len(), 0);
367 }
368
369 #[test]
370 fn test_headings_with_trailing_punctuation() {
371 let content = r#"# First Heading!
372
373Some content.
374
375## First Heading!
376
377Same with punctuation.
378
379### First Heading
380
381Without punctuation."#;
382
383 let config = MD024Config {
384 allow_different_nesting: false,
385 siblings_only: false,
386 ..MD024Config::default()
387 };
388 let result = run_test(content, config);
389 assert!(result.is_ok());
390 let warnings = result.unwrap();
391 assert_eq!(warnings.len(), 1);
392 assert_eq!(warnings[0].message, "Duplicate heading: 'First Heading!'.");
393 }
394
395 #[test]
396 fn test_headings_with_inline_formatting() {
397 let content = r#"# **Bold Heading**
398
399Some content.
400
401## *Italic Heading*
402
403More content.
404
405### **Bold Heading**
406
407Duplicate with same formatting.
408
409#### `Code Heading`
410
411Code formatted.
412
413##### `Code Heading`
414
415Duplicate code formatted."#;
416
417 let config = MD024Config {
418 allow_different_nesting: false,
419 siblings_only: false,
420 ..MD024Config::default()
421 };
422 let result = run_test(content, config);
423 assert!(result.is_ok());
424 let warnings = result.unwrap();
425 assert_eq!(warnings.len(), 2);
426 assert_eq!(warnings[0].message, "Duplicate heading: '**Bold Heading**'.");
427 assert_eq!(warnings[1].message, "Duplicate heading: '`Code Heading`'.");
428 }
429
430 #[test]
431 fn test_headings_in_different_sections() {
432 let content = r#"# Section One
433
434## Subsection
435
436Some content.
437
438# Section Two
439
440## Subsection
441
442Same subsection name in different section."#;
443
444 let config = MD024Config {
445 allow_different_nesting: false,
446 siblings_only: false,
447 ..MD024Config::default()
448 };
449 let result = run_test(content, config);
450 assert!(result.is_ok());
451 let warnings = result.unwrap();
452 assert_eq!(warnings.len(), 1);
453 assert_eq!(warnings[0].message, "Duplicate heading: 'Subsection'.");
454 assert_eq!(warnings[0].line, 9);
455 }
456
457 #[test]
458 fn test_multiple_duplicates() {
459 let content = r#"# Title
460
461## Subtitle
462
463### Title
464
465#### Subtitle
466
467## Title
468
469### Subtitle"#;
470
471 let config = MD024Config {
472 allow_different_nesting: false,
473 siblings_only: false,
474 ..MD024Config::default()
475 };
476 let result = run_test(content, config);
477 assert!(result.is_ok());
478 let warnings = result.unwrap();
479 assert_eq!(warnings.len(), 4);
480 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
482 assert_eq!(warnings[0].line, 5);
483 assert_eq!(warnings[1].message, "Duplicate heading: 'Subtitle'.");
485 assert_eq!(warnings[1].line, 7);
486 assert_eq!(warnings[2].message, "Duplicate heading: 'Title'.");
488 assert_eq!(warnings[2].line, 9);
489 assert_eq!(warnings[3].message, "Duplicate heading: 'Subtitle'.");
491 assert_eq!(warnings[3].line, 11);
492 }
493
494 #[test]
495 fn test_empty_headings() {
496 let content = r#"#
497
498Some content.
499
500##
501
502More content.
503
504### Non-empty
505
506####
507
508Another empty."#;
509
510 let config = MD024Config::default();
511 let result = run_test(content, config);
512 assert!(result.is_ok());
513 let warnings = result.unwrap();
514 assert_eq!(warnings.len(), 0);
516 }
517
518 #[test]
519 fn test_unicode_and_special_characters() {
520 let content = r#"# 你好世界
521
522Some content.
523
524## Émojis 🎉🎊
525
526More content.
527
528### 你好世界
529
530Duplicate Chinese.
531
532#### Émojis 🎉🎊
533
534Duplicate emojis.
535
536##### Special <chars> & symbols!
537
538###### Special <chars> & symbols!
539
540Duplicate special chars."#;
541
542 let config = MD024Config {
543 allow_different_nesting: false,
544 siblings_only: false,
545 ..MD024Config::default()
546 };
547 let result = run_test(content, config);
548 assert!(result.is_ok());
549 let warnings = result.unwrap();
550 assert_eq!(warnings.len(), 3);
551 assert_eq!(warnings[0].message, "Duplicate heading: '你好世界'.");
552 assert_eq!(warnings[1].message, "Duplicate heading: 'Émojis 🎉🎊'.");
553 assert_eq!(warnings[2].message, "Duplicate heading: 'Special <chars> & symbols!'.");
554 }
555
556 #[test]
557 fn test_allow_different_nesting_with_same_level_duplicates() {
558 let content = r#"# Section One
559
560## Title
561
562### Subsection
563
564## Title
565
566This is a duplicate at the same level.
567
568# Section Two
569
570## Title
571
572Different section, but still a duplicate when allow_different_nesting is true."#;
573
574 let config = MD024Config {
575 allow_different_nesting: true,
576 siblings_only: false,
577 ..MD024Config::default()
578 };
579 let result = run_test(content, config);
580 assert!(result.is_ok());
581 let warnings = result.unwrap();
582 assert_eq!(warnings.len(), 2);
583 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
584 assert_eq!(warnings[0].line, 7);
585 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
586 assert_eq!(warnings[1].line, 13);
587 }
588
589 #[test]
590 fn test_atx_style_headings_with_closing_hashes() {
591 let content = r#"# Heading One #
592
593Some content.
594
595## Heading Two ##
596
597More content.
598
599### Heading One ###
600
601Duplicate with different style."#;
602
603 let config = MD024Config {
604 allow_different_nesting: false,
605 siblings_only: false,
606 ..MD024Config::default()
607 };
608 let result = run_test(content, config);
609 assert!(result.is_ok());
610 let warnings = result.unwrap();
611 assert_eq!(warnings.len(), 1);
613 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading One'.");
614 assert_eq!(warnings[0].line, 9);
615 }
616
617 #[test]
618 fn test_fix_method_returns_unchanged() {
619 let content = r#"# Duplicate
620
621## Duplicate
622
623This has duplicates."#;
624
625 let config = MD024Config::default();
626 let result = run_fix_test(content, config);
627 assert!(result.is_ok());
628 assert_eq!(result.unwrap(), content);
629 }
630
631 #[test]
632 fn test_empty_content() {
633 let content = "";
634 let config = MD024Config::default();
635 let result = run_test(content, config);
636 assert!(result.is_ok());
637 let warnings = result.unwrap();
638 assert_eq!(warnings.len(), 0);
639 }
640
641 #[test]
642 fn test_no_headings() {
643 let content = r#"This is just regular text.
644
645No headings anywhere.
646
647Just paragraphs."#;
648
649 let config = MD024Config::default();
650 let result = run_test(content, config);
651 assert!(result.is_ok());
652 let warnings = result.unwrap();
653 assert_eq!(warnings.len(), 0);
654 }
655
656 #[test]
657 fn test_whitespace_differences() {
658 let content = r#"# Heading with spaces
659
660Some content.
661
662## Heading with spaces
663
664Different amount of spaces.
665
666### Heading with spaces
667
668Exact match."#;
669
670 let config = MD024Config {
671 allow_different_nesting: false,
672 siblings_only: false,
673 ..MD024Config::default()
674 };
675 let result = run_test(content, config);
676 assert!(result.is_ok());
677 let warnings = result.unwrap();
678 assert_eq!(warnings.len(), 2);
680 assert_eq!(warnings[0].message, "Duplicate heading: 'Heading with spaces'.");
681 assert_eq!(warnings[0].line, 5);
682 assert_eq!(warnings[1].message, "Duplicate heading: 'Heading with spaces'.");
683 assert_eq!(warnings[1].line, 9);
684 }
685
686 #[test]
687 fn test_column_positions() {
688 let content = r#"# First
689
690## Second
691
692### First"#;
693
694 let config = MD024Config {
695 allow_different_nesting: false,
696 siblings_only: false,
697 ..MD024Config::default()
698 };
699 let result = run_test(content, config);
700 assert!(result.is_ok());
701 let warnings = result.unwrap();
702 assert_eq!(warnings.len(), 1);
703 assert_eq!(warnings[0].line, 5);
704 assert_eq!(warnings[0].column, 5); assert_eq!(warnings[0].end_line, 5);
706 assert_eq!(warnings[0].end_column, 10); }
708
709 #[test]
710 fn test_complex_nesting_scenario() {
711 let content = r#"# Main Document
712
713## Introduction
714
715### Overview
716
717## Implementation
718
719### Overview
720
721This Overview is in a different section.
722
723## Conclusion
724
725### Overview
726
727Another Overview in yet another section."#;
728
729 let config = MD024Config {
730 allow_different_nesting: true,
731 siblings_only: false,
732 ..MD024Config::default()
733 };
734 let result = run_test(content, config);
735 assert!(result.is_ok());
736 let warnings = result.unwrap();
737 assert_eq!(warnings.len(), 2);
739 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
740 assert_eq!(warnings[0].line, 9);
741 assert_eq!(warnings[1].message, "Duplicate heading: 'Overview'.");
742 assert_eq!(warnings[1].line, 15);
743 }
744
745 #[test]
746 fn test_setext_style_headings() {
747 let content = r#"Main Title
748==========
749
750Some content.
751
752Second Title
753------------
754
755More content.
756
757Main Title
758==========
759
760Duplicate setext."#;
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: 'Main Title'.");
768 assert_eq!(warnings[0].line, 11);
769 }
770
771 #[test]
772 fn test_mixed_heading_styles() {
773 let content = r#"# ATX Title
774
775Some content.
776
777ATX Title
778=========
779
780Same text, different style."#;
781
782 let config = MD024Config::default();
783 let result = run_test(content, config);
784 assert!(result.is_ok());
785 let warnings = result.unwrap();
786 assert_eq!(warnings.len(), 1);
787 assert_eq!(warnings[0].message, "Duplicate heading: 'ATX Title'.");
788 assert_eq!(warnings[0].line, 5);
789 }
790
791 #[test]
792 fn test_heading_with_links() {
793 let content = r#"# [Link Text](http://example.com)
794
795Some content.
796
797## [Link Text](http://example.com)
798
799Duplicate heading with link.
800
801### [Different Link](http://example.com)
802
803Not a duplicate."#;
804
805 let config = MD024Config {
806 allow_different_nesting: false,
807 siblings_only: false,
808 ..MD024Config::default()
809 };
810 let result = run_test(content, config);
811 assert!(result.is_ok());
812 let warnings = result.unwrap();
813 assert_eq!(warnings.len(), 1);
814 assert_eq!(
815 warnings[0].message,
816 "Duplicate heading: '[Link Text](http://example.com)'."
817 );
818 assert_eq!(warnings[0].line, 5);
819 }
820
821 #[test]
822 fn test_consecutive_duplicates() {
823 let content = r#"# Title
824
825## Title
826
827### Title
828
829Three in a row."#;
830
831 let config = MD024Config {
832 allow_different_nesting: false,
833 siblings_only: false,
834 ..MD024Config::default()
835 };
836 let result = run_test(content, config);
837 assert!(result.is_ok());
838 let warnings = result.unwrap();
839 assert_eq!(warnings.len(), 2);
840 assert_eq!(warnings[0].message, "Duplicate heading: 'Title'.");
841 assert_eq!(warnings[0].line, 3);
842 assert_eq!(warnings[1].message, "Duplicate heading: 'Title'.");
843 assert_eq!(warnings[1].line, 5);
844 }
845
846 #[test]
847 fn test_siblings_only_config() {
848 let content = r#"# Section One
849
850## Subsection
851
852### Details
853
854# Section Two
855
856## Subsection
857
858Different parent sections, so not siblings - no warning expected."#;
859
860 let config = MD024Config {
861 allow_different_nesting: false,
862 siblings_only: true,
863 ..MD024Config::default()
864 };
865 let result = run_test(content, config);
866 assert!(result.is_ok());
867 let warnings = result.unwrap();
868 assert_eq!(warnings.len(), 0);
870 }
871
872 #[test]
873 fn test_siblings_only_with_actual_siblings() {
874 let content = r#"# Main Section
875
876## First Subsection
877
878### Details
879
880## Second Subsection
881
882### Details
883
884The two 'Details' headings are siblings under different subsections - no warning.
885
886## First Subsection
887
888This 'First Subsection' IS a sibling duplicate."#;
889
890 let config = MD024Config {
891 allow_different_nesting: false,
892 siblings_only: true,
893 ..MD024Config::default()
894 };
895 let result = run_test(content, config);
896 assert!(result.is_ok());
897 let warnings = result.unwrap();
898 assert_eq!(warnings.len(), 1);
900 assert_eq!(warnings[0].message, "Duplicate heading: 'First Subsection'.");
901 assert_eq!(warnings[0].line, 13);
902 }
903
904 #[test]
905 fn test_code_spans_in_headings() {
906 let content = r#"# `code` in heading
907
908Some content.
909
910## `code` in heading
911
912Duplicate with code span."#;
913
914 let config = MD024Config {
915 allow_different_nesting: false,
916 siblings_only: false,
917 ..MD024Config::default()
918 };
919 let result = run_test(content, config);
920 assert!(result.is_ok());
921 let warnings = result.unwrap();
922 assert_eq!(warnings.len(), 1);
923 assert_eq!(warnings[0].message, "Duplicate heading: '`code` in heading'.");
924 assert_eq!(warnings[0].line, 5);
925 }
926
927 #[test]
928 fn test_very_long_heading() {
929 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";
930 let content = format!("# {long_text}\n\nSome content.\n\n## {long_text}\n\nDuplicate long heading.");
931
932 let config = MD024Config {
933 allow_different_nesting: false,
934 siblings_only: false,
935 ..MD024Config::default()
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, format!("Duplicate heading: '{long_text}'."));
942 assert_eq!(warnings[0].line, 5);
943 }
944
945 #[test]
946 fn test_heading_with_html_entities() {
947 let content = r#"# Title & More
948
949Some content.
950
951## Title & More
952
953Duplicate with HTML entity."#;
954
955 let config = MD024Config {
956 allow_different_nesting: false,
957 siblings_only: false,
958 ..MD024Config::default()
959 };
960 let result = run_test(content, config);
961 assert!(result.is_ok());
962 let warnings = result.unwrap();
963 assert_eq!(warnings.len(), 1);
964 assert_eq!(warnings[0].message, "Duplicate heading: 'Title & More'.");
965 assert_eq!(warnings[0].line, 5);
966 }
967
968 #[test]
969 fn test_three_duplicates_different_nesting() {
970 let content = r#"# Main
971
972## Main
973
974### Main
975
976#### Main
977
978All same text, different levels."#;
979
980 let config = MD024Config {
981 allow_different_nesting: true,
982 siblings_only: false,
983 ..MD024Config::default()
984 };
985 let result = run_test(content, config);
986 assert!(result.is_ok());
987 let warnings = result.unwrap();
988 assert_eq!(warnings.len(), 0);
990 }
991
992 #[test]
995 fn test_custom_anchor_different_ids_no_warning_default() {
996 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
998 let config = MD024Config::default();
999 let result = run_test(content, config);
1000 assert!(result.is_ok());
1001 let warnings = result.unwrap();
1002 assert_eq!(
1003 warnings.len(),
1004 0,
1005 "headings with different custom anchors must not be flagged"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_custom_anchor_same_id_flagged() {
1011 let content = "## Overview {#overview}\n\n## Overview {#overview}\n";
1013 let config = MD024Config::default();
1014 let result = run_test(content, config);
1015 assert!(result.is_ok());
1016 let warnings = result.unwrap();
1017 assert_eq!(
1018 warnings.len(),
1019 1,
1020 "headings with identical custom anchors must be flagged"
1021 );
1022 assert_eq!(warnings[0].message, "Duplicate heading: 'Overview'.");
1023 }
1024
1025 #[test]
1026 fn test_custom_anchor_one_with_id_one_without_no_warning() {
1027 let content = "## Setup\n\n## Setup {#alt-setup}\n";
1029 let config = MD024Config::default();
1030 let result = run_test(content, config);
1031 assert!(result.is_ok());
1032 let warnings = result.unwrap();
1033 assert_eq!(
1034 warnings.len(),
1035 0,
1036 "a plain heading and one with a custom anchor must not collide"
1037 );
1038 }
1039
1040 #[test]
1041 fn test_allow_different_link_anchors_false_restores_original_behavior() {
1042 let content = "#### Unit testing\n\n#### Unit testing {#custom-anchor}\n";
1045 let config = MD024Config {
1046 allow_different_link_anchors: false,
1047 siblings_only: false,
1048 ..MD024Config::default()
1049 };
1050 let result = run_test(content, config);
1051 assert!(result.is_ok());
1052 let warnings = result.unwrap();
1053 assert_eq!(
1054 warnings.len(),
1055 1,
1056 "with allow_different_link_anchors=false the duplicate must be flagged"
1057 );
1058 assert_eq!(warnings[0].message, "Duplicate heading: 'Unit testing'.");
1059 }
1060
1061 #[test]
1062 fn test_custom_anchor_with_siblings_only() {
1063 let content = concat!(
1066 "# Parent\n\n",
1067 "## Section {#section-a}\n\n",
1068 "## Section {#section-b}\n\n",
1069 "## Section {#section-a}\n",
1070 );
1071 let config = MD024Config {
1072 siblings_only: true,
1073 allow_different_link_anchors: true,
1074 ..MD024Config::default()
1075 };
1076 let result = run_test(content, config);
1077 assert!(result.is_ok());
1078 let warnings = result.unwrap();
1079 assert_eq!(
1081 warnings.len(),
1082 1,
1083 "only exact key collision under same parent must be flagged"
1084 );
1085 assert_eq!(warnings[0].message, "Duplicate heading: 'Section'.");
1086 }
1087
1088 #[test]
1089 fn test_custom_anchor_with_allow_different_nesting() {
1090 let content = concat!(
1093 "## Topic {#topic-1}\n\n",
1094 "### Topic {#topic-2}\n\n",
1095 "## Topic {#topic-1}\n",
1096 );
1097 let config = MD024Config {
1098 allow_different_nesting: true,
1099 siblings_only: false,
1100 allow_different_link_anchors: true,
1101 };
1102 let result = run_test(content, config);
1103 assert!(result.is_ok());
1104 let warnings = result.unwrap();
1105 assert_eq!(
1108 warnings.len(),
1109 1,
1110 "same-level, same-anchor headings must still be flagged"
1111 );
1112 assert_eq!(warnings[0].message, "Duplicate heading: 'Topic'.");
1113 }
1114
1115 #[test]
1116 fn test_heading_text_containing_hash_no_false_collision() {
1117 let content = "## Foo#bar\n\n## Foo {#bar}\n";
1122 let config = MD024Config {
1123 allow_different_nesting: false,
1124 siblings_only: false,
1125 allow_different_link_anchors: true,
1126 };
1127 let result = run_test(content, config);
1128 assert!(result.is_ok());
1129 let warnings = result.unwrap();
1130 assert!(
1131 warnings.is_empty(),
1132 "heading text containing # must not collide with a different heading carrying {{#id}}; got: {warnings:#?}",
1133 );
1134 }
1135
1136 #[test]
1137 fn test_heading_text_containing_hash_real_duplicate_still_flagged() {
1138 let content = "## Foo#bar\n\n## Foo#bar\n";
1141 let config = MD024Config {
1142 allow_different_nesting: false,
1143 siblings_only: false,
1144 allow_different_link_anchors: true,
1145 };
1146 let result = run_test(content, config);
1147 assert!(result.is_ok());
1148 let warnings = result.unwrap();
1149 assert_eq!(warnings.len(), 1);
1150 assert_eq!(warnings[0].message, "Duplicate heading: 'Foo#bar'.");
1151 }
1152}