1use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::utils::range_utils::calculate_heading_range;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "kebab-case")]
9pub struct MD043Config {
10 #[serde(default = "default_headings")]
12 pub headings: Vec<String>,
13 #[serde(default = "default_match_case")]
15 pub match_case: bool,
16}
17
18impl Default for MD043Config {
19 fn default() -> Self {
20 Self {
21 headings: default_headings(),
22 match_case: default_match_case(),
23 }
24 }
25}
26
27fn default_headings() -> Vec<String> {
28 Vec::new()
29}
30
31fn default_match_case() -> bool {
32 false
33}
34
35impl RuleConfig for MD043Config {
36 const RULE_NAME: &'static str = "MD043";
37}
38
39#[derive(Clone, Default)]
43pub struct MD043RequiredHeadings {
44 config: MD043Config,
45}
46
47impl MD043RequiredHeadings {
48 pub fn new(headings: Vec<String>) -> Self {
49 Self {
50 config: MD043Config {
51 headings,
52 match_case: default_match_case(),
53 },
54 }
55 }
56
57 pub fn from_config_struct(config: MD043Config) -> Self {
59 Self { config }
60 }
61
62 fn headings_match(&self, expected: &str, actual: &str) -> bool {
64 if self.config.match_case {
65 expected == actual
66 } else {
67 expected.to_lowercase() == actual.to_lowercase()
68 }
69 }
70
71 fn extract_headings(&self, ctx: &crate::lint_context::LintContext) -> Vec<String> {
72 let mut result = Vec::new();
73
74 for line_info in &ctx.lines {
75 if let Some(heading) = &line_info.heading {
76 if !heading.is_valid {
78 continue;
79 }
80
81 let full_heading = format!("{} {}", heading.marker, heading.text.trim());
83 result.push(full_heading);
84 }
85 }
86
87 result
88 }
89
90 fn match_headings_with_wildcards(
100 &self,
101 actual_headings: &[String],
102 expected_patterns: &[String],
103 ) -> (bool, usize, usize) {
104 let mut exp_idx = 0;
105 let mut act_idx = 0;
106 let mut match_any = false; while exp_idx < expected_patterns.len() && act_idx < actual_headings.len() {
109 let pattern = &expected_patterns[exp_idx];
110
111 if pattern == "*" {
112 exp_idx += 1;
114 if exp_idx >= expected_patterns.len() {
115 return (true, exp_idx, actual_headings.len());
117 }
118 match_any = true;
120 continue;
121 } else if pattern == "+" {
122 if act_idx >= actual_headings.len() {
124 return (false, exp_idx, act_idx); }
126 act_idx += 1;
127 exp_idx += 1;
128 match_any = true;
130 if exp_idx >= expected_patterns.len() {
132 return (true, exp_idx, actual_headings.len());
133 }
134 continue;
135 } else if pattern == "?" {
136 act_idx += 1;
138 exp_idx += 1;
139 match_any = false;
140 continue;
141 }
142
143 let actual = &actual_headings[act_idx];
145 if self.headings_match(pattern, actual) {
146 act_idx += 1;
148 exp_idx += 1;
149 match_any = false;
150 } else if match_any {
151 act_idx += 1;
153 } else {
154 return (false, exp_idx, act_idx);
156 }
157 }
158
159 while exp_idx < expected_patterns.len() {
161 let pattern = &expected_patterns[exp_idx];
162 if pattern == "*" {
163 exp_idx += 1;
165 } else if pattern == "+" {
166 return (false, exp_idx, act_idx);
168 } else if pattern == "?" {
169 return (false, exp_idx, act_idx);
171 } else {
172 return (false, exp_idx, act_idx);
174 }
175 }
176
177 let all_matched = act_idx == actual_headings.len() && exp_idx == expected_patterns.len();
179 (all_matched, exp_idx, act_idx)
180 }
181
182 fn is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
183 if line_index < ctx.lines.len() {
184 ctx.lines[line_index].heading.is_some()
185 } else {
186 false
187 }
188 }
189}
190
191impl Rule for MD043RequiredHeadings {
192 fn name(&self) -> &'static str {
193 "MD043"
194 }
195
196 fn description(&self) -> &'static str {
197 "Required heading structure"
198 }
199
200 fn fix_capability(&self) -> FixCapability {
201 FixCapability::Unfixable
202 }
203
204 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
205 let mut warnings = Vec::new();
206 let actual_headings = self.extract_headings(ctx);
207
208 if self.config.headings.is_empty() {
210 return Ok(warnings);
211 }
212
213 let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
215 if actual_headings.is_empty() && all_optional_wildcards {
216 return Ok(warnings);
219 }
220
221 let (headings_match, _exp_idx, _act_idx) =
223 self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
224
225 if !headings_match {
226 if actual_headings.is_empty() && !self.config.headings.is_empty() {
228 warnings.push(LintWarning {
229 rule_name: Some(self.name().to_string()),
230 line: 1,
231 column: 1,
232 end_line: 1,
233 end_column: 2,
234 message: format!("Required headings not found: {:?}", self.config.headings),
235 severity: Severity::Warning,
236 fix: None,
237 });
238 return Ok(warnings);
239 }
240
241 for (i, line_info) in ctx.lines.iter().enumerate() {
243 if self.is_heading(i, ctx) {
244 let (start_line, start_col, end_line, end_col) =
246 calculate_heading_range(i + 1, line_info.content(ctx.content));
247
248 warnings.push(LintWarning {
249 rule_name: Some(self.name().to_string()),
250 line: start_line,
251 column: start_col,
252 end_line,
253 end_column: end_col,
254 message: "Heading structure does not match the required structure".to_string(),
255 severity: Severity::Warning,
256 fix: None,
257 });
258 }
259 }
260
261 if warnings.is_empty() {
264 warnings.push(LintWarning {
265 rule_name: Some(self.name().to_string()),
266 line: 1,
267 column: 1,
268 end_line: 1,
269 end_column: 2,
270 message: format!(
271 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
272 self.config.headings, actual_headings
273 ),
274 severity: Severity::Warning,
275 fix: None,
276 });
277 }
278 }
279
280 Ok(warnings)
281 }
282
283 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
284 let content = ctx.content;
285 if self.config.headings.is_empty() {
287 return Ok(content.to_string());
288 }
289
290 let actual_headings = self.extract_headings(ctx);
291
292 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
294 if headings_match {
295 return Ok(content.to_string());
296 }
297
298 Ok(content.to_string())
302 }
303
304 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
306 if self.config.headings.is_empty() || ctx.content.is_empty() {
308 return true;
309 }
310
311 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
313
314 if !has_heading {
317 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
318 if has_required_wildcards {
319 return false; }
321 }
322
323 !has_heading
324 }
325
326 fn as_any(&self) -> &dyn std::any::Any {
327 self
328 }
329
330 crate::impl_rule_config_methods!(MD043Config);
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::lint_context::LintContext;
337
338 #[test]
339 fn test_extract_headings_code_blocks() {
340 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
342 let rule = MD043RequiredHeadings::new(required);
343
344 let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## Another heading in code block\n```\n\n## Real heading 2\n\nSome content.";
346 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
347 let actual_headings = rule.extract_headings(&ctx);
348 assert_eq!(
349 actual_headings,
350 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
351 "Should extract correct headings and ignore code blocks"
352 );
353
354 let content = "# Test Document\n\nThis is regular content.\n\n```markdown\n# This is a heading in a code block\n## This should be ignored\n```\n\n## Not Real heading 2\n\nSome content.";
356 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357 let actual_headings = rule.extract_headings(&ctx);
358 assert_eq!(
359 actual_headings,
360 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
361 "Should extract actual headings including mismatched ones"
362 );
363 }
364
365 #[test]
366 fn test_with_document_structure() {
367 let required = vec![
369 "# Introduction".to_string(),
370 "# Method".to_string(),
371 "# Results".to_string(),
372 ];
373 let rule = MD043RequiredHeadings::new(required);
374
375 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
377 let warnings = rule
378 .check(&LintContext::new(
379 content,
380 crate::config::MarkdownFlavor::Standard,
381 None,
382 ))
383 .unwrap();
384 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
385
386 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
388 let warnings = rule
389 .check(&LintContext::new(
390 content,
391 crate::config::MarkdownFlavor::Standard,
392 None,
393 ))
394 .unwrap();
395 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
396
397 let content = "No headings here, just plain text";
399 let warnings = rule
400 .check(&LintContext::new(
401 content,
402 crate::config::MarkdownFlavor::Standard,
403 None,
404 ))
405 .unwrap();
406 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
407
408 let required_setext = vec![
410 "=========== Introduction".to_string(),
411 "------ Method".to_string(),
412 "======= Results".to_string(),
413 ];
414 let rule_setext = MD043RequiredHeadings::new(required_setext);
415 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
416 let warnings = rule_setext
417 .check(&LintContext::new(
418 content,
419 crate::config::MarkdownFlavor::Standard,
420 None,
421 ))
422 .unwrap();
423 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
424 }
425
426 #[test]
427 fn test_should_skip_no_false_positives() {
428 let required = vec!["Test".to_string()];
430 let rule = MD043RequiredHeadings::new(required);
431
432 let content = "This paragraph contains a # character but is not a heading";
434 assert!(
435 rule.should_skip(&LintContext::new(
436 content,
437 crate::config::MarkdownFlavor::Standard,
438 None
439 )),
440 "Should skip content with # in normal text"
441 );
442
443 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
445 assert!(
446 rule.should_skip(&LintContext::new(
447 content,
448 crate::config::MarkdownFlavor::Standard,
449 None
450 )),
451 "Should skip content with heading-like syntax in code blocks"
452 );
453
454 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
456 assert!(
457 rule.should_skip(&LintContext::new(
458 content,
459 crate::config::MarkdownFlavor::Standard,
460 None
461 )),
462 "Should skip content with list items using dash"
463 );
464
465 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
467 assert!(
468 rule.should_skip(&LintContext::new(
469 content,
470 crate::config::MarkdownFlavor::Standard,
471 None
472 )),
473 "Should skip content with horizontal rule"
474 );
475
476 let content = "This is a normal paragraph with equals sign x = y + z";
478 assert!(
479 rule.should_skip(&LintContext::new(
480 content,
481 crate::config::MarkdownFlavor::Standard,
482 None
483 )),
484 "Should skip content with equals sign in normal text"
485 );
486
487 let content = "This is a normal paragraph with minus sign x - y = z";
489 assert!(
490 rule.should_skip(&LintContext::new(
491 content,
492 crate::config::MarkdownFlavor::Standard,
493 None
494 )),
495 "Should skip content with minus sign in normal text"
496 );
497 }
498
499 #[test]
500 fn test_should_skip_heading_detection() {
501 let required = vec!["Test".to_string()];
503 let rule = MD043RequiredHeadings::new(required);
504
505 let content = "# This is a heading\n\nAnd some content";
507 assert!(
508 !rule.should_skip(&LintContext::new(
509 content,
510 crate::config::MarkdownFlavor::Standard,
511 None
512 )),
513 "Should not skip content with ATX heading"
514 );
515
516 let content = "This is a heading\n================\n\nAnd some content";
518 assert!(
519 !rule.should_skip(&LintContext::new(
520 content,
521 crate::config::MarkdownFlavor::Standard,
522 None
523 )),
524 "Should not skip content with Setext heading (=)"
525 );
526
527 let content = "This is a subheading\n------------------\n\nAnd some content";
529 assert!(
530 !rule.should_skip(&LintContext::new(
531 content,
532 crate::config::MarkdownFlavor::Standard,
533 None
534 )),
535 "Should not skip content with Setext heading (-)"
536 );
537
538 let content = "## This is a heading ##\n\nAnd some content";
540 assert!(
541 !rule.should_skip(&LintContext::new(
542 content,
543 crate::config::MarkdownFlavor::Standard,
544 None
545 )),
546 "Should not skip content with ATX heading with closing hashes"
547 );
548 }
549
550 #[test]
551 fn test_config_match_case_sensitive() {
552 let config = MD043Config {
553 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
554 match_case: true,
555 };
556 let rule = MD043RequiredHeadings::from_config_struct(config);
557
558 let content = "# introduction\n\n# method";
560 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561 let result = rule.check(&ctx).unwrap();
562
563 assert!(
564 !result.is_empty(),
565 "Should detect case mismatch when match_case is true"
566 );
567 }
568
569 #[test]
570 fn test_config_match_case_insensitive() {
571 let config = MD043Config {
572 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
573 match_case: false,
574 };
575 let rule = MD043RequiredHeadings::from_config_struct(config);
576
577 let content = "# introduction\n\n# method";
579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580 let result = rule.check(&ctx).unwrap();
581
582 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
583 }
584
585 #[test]
586 fn test_config_case_insensitive_mixed() {
587 let config = MD043Config {
588 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
589 match_case: false,
590 };
591 let rule = MD043RequiredHeadings::from_config_struct(config);
592
593 let content = "# INTRODUCTION\n\n# method";
595 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597
598 assert!(
599 result.is_empty(),
600 "Should allow mixed case variations when match_case is false"
601 );
602 }
603
604 #[test]
605 fn test_config_case_sensitive_exact_match() {
606 let config = MD043Config {
607 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
608 match_case: true,
609 };
610 let rule = MD043RequiredHeadings::from_config_struct(config);
611
612 let content = "# Introduction\n\n# Method";
614 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615 let result = rule.check(&ctx).unwrap();
616
617 assert!(
618 result.is_empty(),
619 "Should pass with exact case match when match_case is true"
620 );
621 }
622
623 #[test]
624 fn test_default_config() {
625 let rule = MD043RequiredHeadings::default();
626
627 let content = "# Any heading\n\n# Another heading";
629 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630 let result = rule.check(&ctx).unwrap();
631
632 assert!(result.is_empty(), "Should be disabled with default empty headings");
633 }
634
635 #[test]
636 fn test_default_config_section() {
637 let rule = MD043RequiredHeadings::default();
638 let config_section = rule.default_config_section();
639
640 assert!(config_section.is_some());
641 let (name, value) = config_section.unwrap();
642 assert_eq!(name, "MD043");
643
644 if let toml::Value::Table(table) = value {
646 assert!(table.contains_key("headings"));
647 assert!(table.contains_key("match-case"));
648 assert_eq!(table["headings"], toml::Value::Array(vec![]));
649 assert_eq!(table["match-case"], toml::Value::Boolean(false));
650 } else {
651 panic!("Expected TOML table");
652 }
653 }
654
655 #[test]
656 fn test_headings_match_case_sensitive() {
657 let config = MD043Config {
658 headings: vec![],
659 match_case: true,
660 };
661 let rule = MD043RequiredHeadings::from_config_struct(config);
662
663 assert!(rule.headings_match("Test", "Test"));
664 assert!(!rule.headings_match("Test", "test"));
665 assert!(!rule.headings_match("test", "Test"));
666 }
667
668 #[test]
669 fn test_headings_match_case_insensitive() {
670 let config = MD043Config {
671 headings: vec![],
672 match_case: false,
673 };
674 let rule = MD043RequiredHeadings::from_config_struct(config);
675
676 assert!(rule.headings_match("Test", "Test"));
677 assert!(rule.headings_match("Test", "test"));
678 assert!(rule.headings_match("test", "Test"));
679 assert!(rule.headings_match("TEST", "test"));
680 }
681
682 #[test]
683 fn test_config_empty_headings() {
684 let config = MD043Config {
685 headings: vec![],
686 match_case: true,
687 };
688 let rule = MD043RequiredHeadings::from_config_struct(config);
689
690 let content = "# Any heading\n\n# Another heading";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694
695 assert!(result.is_empty(), "Should be disabled with empty headings list");
696 }
697
698 #[test]
699 fn test_fix_respects_configuration() {
700 let config = MD043Config {
701 headings: vec!["# Title".to_string(), "# Content".to_string()],
702 match_case: false,
703 };
704 let rule = MD043RequiredHeadings::from_config_struct(config);
705
706 let content = "Wrong content";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let fixed = rule.fix(&ctx).unwrap();
709
710 let expected = "Wrong content";
712 assert_eq!(fixed, expected);
713 }
714
715 #[test]
718 fn test_asterisk_wildcard_zero_headings() {
719 let config = MD043Config {
721 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
722 match_case: false,
723 };
724 let rule = MD043RequiredHeadings::from_config_struct(config);
725
726 let content = "# Start\n\n# End";
727 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728 let result = rule.check(&ctx).unwrap();
729
730 assert!(result.is_empty(), "* should allow zero headings between Start and End");
731 }
732
733 #[test]
734 fn test_asterisk_wildcard_multiple_headings() {
735 let config = MD043Config {
737 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
738 match_case: false,
739 };
740 let rule = MD043RequiredHeadings::from_config_struct(config);
741
742 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745
746 assert!(
747 result.is_empty(),
748 "* should allow multiple headings between Start and End"
749 );
750 }
751
752 #[test]
753 fn test_asterisk_wildcard_at_end() {
754 let config = MD043Config {
756 headings: vec!["# Introduction".to_string(), "*".to_string()],
757 match_case: false,
758 };
759 let rule = MD043RequiredHeadings::from_config_struct(config);
760
761 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
762 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763 let result = rule.check(&ctx).unwrap();
764
765 assert!(result.is_empty(), "* at end should allow any trailing headings");
766 }
767
768 #[test]
769 fn test_plus_wildcard_requires_at_least_one() {
770 let config = MD043Config {
772 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
773 match_case: false,
774 };
775 let rule = MD043RequiredHeadings::from_config_struct(config);
776
777 let content = "# Start\n\n# End";
779 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780 let result = rule.check(&ctx).unwrap();
781
782 assert!(!result.is_empty(), "+ should require at least one heading");
783 }
784
785 #[test]
786 fn test_plus_wildcard_allows_multiple() {
787 let config = MD043Config {
789 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
790 match_case: false,
791 };
792 let rule = MD043RequiredHeadings::from_config_struct(config);
793
794 let content = "# Start\n\n## Middle\n\n# End";
796 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let result = rule.check(&ctx).unwrap();
798
799 assert!(result.is_empty(), "+ should allow one heading");
800
801 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805
806 assert!(result.is_empty(), "+ should allow multiple headings");
807 }
808
809 #[test]
810 fn test_question_wildcard_exactly_one() {
811 let config = MD043Config {
813 headings: vec!["?".to_string(), "## Description".to_string()],
814 match_case: false,
815 };
816 let rule = MD043RequiredHeadings::from_config_struct(config);
817
818 let content = "# Project Name\n\n## Description";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822
823 assert!(result.is_empty(), "? should allow exactly one heading");
824 }
825
826 #[test]
827 fn test_question_wildcard_fails_with_zero() {
828 let config = MD043Config {
830 headings: vec!["?".to_string(), "## Description".to_string()],
831 match_case: false,
832 };
833 let rule = MD043RequiredHeadings::from_config_struct(config);
834
835 let content = "## Description";
836 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838
839 assert!(!result.is_empty(), "? should require exactly one heading");
840 }
841
842 #[test]
843 fn test_complex_wildcard_pattern() {
844 let config = MD043Config {
846 headings: vec![
847 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
852 match_case: false,
853 };
854 let rule = MD043RequiredHeadings::from_config_struct(config);
855
856 let content = "# My Project\n\n## Overview\n\n## License";
858 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859 let result = rule.check(&ctx).unwrap();
860
861 assert!(result.is_empty(), "Complex pattern should match minimal structure");
862
863 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867
868 assert!(result.is_empty(), "Complex pattern should match with optional sections");
869 }
870
871 #[test]
872 fn test_multiple_asterisks() {
873 let config = MD043Config {
875 headings: vec![
876 "# Title".to_string(),
877 "*".to_string(),
878 "## Middle".to_string(),
879 "*".to_string(),
880 "# End".to_string(),
881 ],
882 match_case: false,
883 };
884 let rule = MD043RequiredHeadings::from_config_struct(config);
885
886 let content = "# Title\n\n## Middle\n\n# End";
887 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888 let result = rule.check(&ctx).unwrap();
889
890 assert!(result.is_empty(), "Multiple * wildcards should work");
891
892 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895
896 assert!(
897 result.is_empty(),
898 "Multiple * wildcards should allow flexible structure"
899 );
900 }
901
902 #[test]
903 fn test_wildcard_with_case_sensitivity() {
904 let config = MD043Config {
906 headings: vec![
907 "?".to_string(),
908 "## Description".to_string(), ],
910 match_case: true,
911 };
912 let rule = MD043RequiredHeadings::from_config_struct(config);
913
914 let content = "# Title\n\n## Description";
916 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917 let result = rule.check(&ctx).unwrap();
918
919 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
920
921 let content = "# Title\n\n## description";
923 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924 let result = rule.check(&ctx).unwrap();
925
926 assert!(
927 !result.is_empty(),
928 "Case-sensitive matching should detect case mismatch"
929 );
930 }
931
932 #[test]
933 fn test_all_wildcards_pattern() {
934 let config = MD043Config {
936 headings: vec!["*".to_string()],
937 match_case: false,
938 };
939 let rule = MD043RequiredHeadings::from_config_struct(config);
940
941 let content = "# Any\n\n## Headings\n\n### Work";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945
946 assert!(result.is_empty(), "* alone should allow any heading structure");
947
948 let content = "No headings here";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952
953 assert!(result.is_empty(), "* alone should allow no headings");
954 }
955
956 #[test]
957 fn test_wildcard_edge_cases() {
958 let config = MD043Config {
960 headings: vec!["# Start".to_string(), "+".to_string()],
961 match_case: false,
962 };
963 let rule = MD043RequiredHeadings::from_config_struct(config);
964
965 let content = "# Start";
967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968 let result = rule.check(&ctx).unwrap();
969
970 assert!(!result.is_empty(), "+ at end should require at least one more heading");
971
972 let content = "# Start\n\n## More";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976
977 assert!(result.is_empty(), "+ at end should allow additional headings");
978 }
979
980 #[test]
981 fn test_fix_with_wildcards() {
982 let config = MD043Config {
984 headings: vec!["?".to_string(), "## Description".to_string()],
985 match_case: false,
986 };
987 let rule = MD043RequiredHeadings::from_config_struct(config);
988
989 let content = "# Project\n\n## Description";
991 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
992 let fixed = rule.fix(&ctx).unwrap();
993
994 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
995
996 let content = "# Project\n\n## Other";
998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999 let fixed = rule.fix(&ctx).unwrap();
1000
1001 assert_eq!(
1002 fixed, content,
1003 "Fix should preserve non-matching content to prevent data loss"
1004 );
1005 }
1006
1007 #[test]
1010 fn test_consecutive_wildcards() {
1011 let config = MD043Config {
1013 headings: vec![
1014 "# Start".to_string(),
1015 "*".to_string(),
1016 "+".to_string(),
1017 "# End".to_string(),
1018 ],
1019 match_case: false,
1020 };
1021 let rule = MD043RequiredHeadings::from_config_struct(config);
1022
1023 let content = "# Start\n\n## Middle\n\n# End";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let result = rule.check(&ctx).unwrap();
1027
1028 assert!(result.is_empty(), "Consecutive * and + should work together");
1029
1030 let content = "# Start\n\n# End";
1032 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034
1035 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1036 }
1037
1038 #[test]
1039 fn test_question_mark_doesnt_consume_literal_match() {
1040 let config = MD043Config {
1042 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1043 match_case: false,
1044 };
1045 let rule = MD043RequiredHeadings::from_config_struct(config);
1046
1047 let content = "# Title\n\n## Description\n\n## License";
1049 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050 let result = rule.check(&ctx).unwrap();
1051
1052 assert!(result.is_empty(), "? should consume exactly one heading");
1053
1054 let content = "## Description\n\n## License";
1056 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057 let result = rule.check(&ctx).unwrap();
1058
1059 assert!(!result.is_empty(), "? requires exactly one heading to match");
1060 }
1061
1062 #[test]
1063 fn test_asterisk_between_literals_complex() {
1064 let config = MD043Config {
1066 headings: vec![
1067 "# Title".to_string(),
1068 "## Section A".to_string(),
1069 "*".to_string(),
1070 "## Section B".to_string(),
1071 ],
1072 match_case: false,
1073 };
1074 let rule = MD043RequiredHeadings::from_config_struct(config);
1075
1076 let content = "# Title\n\n## Section A\n\n## Section B";
1078 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079 let result = rule.check(&ctx).unwrap();
1080
1081 assert!(result.is_empty(), "* should allow zero headings");
1082
1083 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1085 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086 let result = rule.check(&ctx).unwrap();
1087
1088 assert!(result.is_empty(), "* should allow multiple headings");
1089
1090 let content = "# Title\n\n## Section A\n\n### Sub1";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let result = rule.check(&ctx).unwrap();
1094
1095 assert!(
1096 !result.is_empty(),
1097 "Should fail when required heading after * is missing"
1098 );
1099 }
1100
1101 #[test]
1102 fn test_plus_requires_consumption() {
1103 let config = MD043Config {
1105 headings: vec!["+".to_string()],
1106 match_case: false,
1107 };
1108 let rule = MD043RequiredHeadings::from_config_struct(config);
1109
1110 let content = "No headings here";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 assert!(!result.is_empty(), "+ should fail with zero headings");
1116
1117 let content = "# Any heading";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert!(result.is_empty(), "+ should pass with one heading");
1123
1124 let content = "# First\n\n## Second\n\n### Third";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 let result = rule.check(&ctx).unwrap();
1128
1129 assert!(result.is_empty(), "+ should pass with multiple headings");
1130 }
1131
1132 #[test]
1133 fn test_mixed_wildcard_and_literal_ordering() {
1134 let config = MD043Config {
1136 headings: vec![
1137 "# A".to_string(),
1138 "*".to_string(),
1139 "# B".to_string(),
1140 "*".to_string(),
1141 "# C".to_string(),
1142 ],
1143 match_case: false,
1144 };
1145 let rule = MD043RequiredHeadings::from_config_struct(config);
1146
1147 let content = "# A\n\n# B\n\n# C";
1149 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151
1152 assert!(result.is_empty(), "Should match literals in correct order");
1153
1154 let content = "# A\n\n# C\n\n# B";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158
1159 assert!(!result.is_empty(), "Should fail when literals are out of order");
1160
1161 let content = "# A\n\n# C";
1163 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164 let result = rule.check(&ctx).unwrap();
1165
1166 assert!(!result.is_empty(), "Should fail when required literal is missing");
1167 }
1168
1169 #[test]
1170 fn test_only_wildcards_with_headings() {
1171 let config = MD043Config {
1173 headings: vec!["?".to_string(), "+".to_string()],
1174 match_case: false,
1175 };
1176 let rule = MD043RequiredHeadings::from_config_struct(config);
1177
1178 let content = "# First\n\n## Second";
1180 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181 let result = rule.check(&ctx).unwrap();
1182
1183 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1184
1185 let content = "# First";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert!(
1191 !result.is_empty(),
1192 "Should fail with only 1 heading when ? + is required"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1198 let config = MD043Config {
1200 headings: vec![
1201 "# Start".to_string(),
1202 "*".to_string(),
1203 "## Target".to_string(),
1204 "# End".to_string(),
1205 ],
1206 match_case: false,
1207 };
1208 let rule = MD043RequiredHeadings::from_config_struct(config);
1209
1210 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214
1215 assert!(result.is_empty(), "* should correctly skip to next literal match");
1216
1217 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1220 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221 let result = rule.check(&ctx).unwrap();
1222
1223 assert!(
1224 !result.is_empty(),
1225 "Should fail with extra headings that don't match pattern"
1226 );
1227 }
1228
1229 #[test]
1230 fn test_wildcard_at_start() {
1231 let config = MD043Config {
1233 headings: vec!["*".to_string(), "## End".to_string()],
1234 match_case: false,
1235 };
1236 let rule = MD043RequiredHeadings::from_config_struct(config);
1237
1238 let content = "# Random\n\n## Stuff\n\n## End";
1240 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241 let result = rule.check(&ctx).unwrap();
1242
1243 assert!(result.is_empty(), "* at start should allow any preceding headings");
1244
1245 let config = MD043Config {
1247 headings: vec!["+".to_string(), "## End".to_string()],
1248 match_case: false,
1249 };
1250 let rule = MD043RequiredHeadings::from_config_struct(config);
1251
1252 let content = "## End";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let result = rule.check(&ctx).unwrap();
1256
1257 assert!(!result.is_empty(), "+ at start should require at least one heading");
1258
1259 let content = "# First\n\n## End";
1260 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let result = rule.check(&ctx).unwrap();
1262
1263 assert!(result.is_empty(), "+ at start should allow headings before End");
1264 }
1265
1266 #[test]
1267 fn test_wildcard_with_setext_headings() {
1268 let config = MD043Config {
1270 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1271 match_case: false,
1272 };
1273 let rule = MD043RequiredHeadings::from_config_struct(config);
1274
1275 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278
1279 assert!(result.is_empty(), "Wildcards should work with setext headings");
1280 }
1281
1282 #[test]
1283 fn test_empty_document_with_required_wildcards() {
1284 let config = MD043Config {
1286 headings: vec!["?".to_string()],
1287 match_case: false,
1288 };
1289 let rule = MD043RequiredHeadings::from_config_struct(config);
1290
1291 let content = "No headings";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294
1295 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1296
1297 let config = MD043Config {
1299 headings: vec!["+".to_string()],
1300 match_case: false,
1301 };
1302 let rule = MD043RequiredHeadings::from_config_struct(config);
1303
1304 let content = "No headings";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1309 }
1310
1311 #[test]
1312 fn test_trailing_headings_after_pattern_completion() {
1313 let config = MD043Config {
1315 headings: vec!["# Title".to_string(), "## Section".to_string()],
1316 match_case: false,
1317 };
1318 let rule = MD043RequiredHeadings::from_config_struct(config);
1319
1320 let content = "# Title\n\n## Section\n\n### Extra";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let result = rule.check(&ctx).unwrap();
1324
1325 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1326
1327 let config = MD043Config {
1329 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1330 match_case: false,
1331 };
1332 let rule = MD043RequiredHeadings::from_config_struct(config);
1333
1334 let content = "# Title\n\n## Section\n\n### Extra";
1335 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336 let result = rule.check(&ctx).unwrap();
1337
1338 assert!(result.is_empty(), "* at end should allow trailing headings");
1339 }
1340}