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 fn expected_pattern_description(pattern: &str) -> String {
191 match pattern {
192 "+" => "one or more headings".to_string(),
193 "?" => "one heading".to_string(),
194 "*" => "zero or more headings".to_string(),
195 _ => format!("heading '{pattern}'"),
196 }
197 }
198
199 fn structure_mismatch_message(&self, actual_headings: &[String], exp_idx: usize, act_idx: usize) -> String {
200 let prefix = "Heading structure does not match required structure.";
201
202 if let Some(expected) = self.config.headings.get(exp_idx) {
203 if let Some(previous) = exp_idx.checked_sub(1).and_then(|idx| self.config.headings.get(idx))
204 && matches!(previous.as_str(), "+" | "?")
205 && act_idx == actual_headings.len()
206 && let Some(actual) = actual_headings.last()
207 && self.headings_match(expected, actual)
208 {
209 let requirement = Self::expected_pattern_description(previous);
210 return format!(
211 "{prefix} Expected {requirement} before '{expected}', but found '{actual}' immediately"
212 );
213 }
214
215 let expected = Self::expected_pattern_description(expected);
216 if let Some(actual) = actual_headings.get(act_idx) {
217 format!(
218 "{prefix} Expected {expected} at position {}, but found '{actual}'",
219 act_idx + 1
220 )
221 } else {
222 format!(
223 "{prefix} Expected {expected} at position {}, but found no more headings",
224 act_idx + 1
225 )
226 }
227 } else if let Some(actual) = actual_headings.get(act_idx) {
228 format!(
229 "{prefix} Expected no more headings after position {act_idx}, but found '{actual}' at position {}",
230 act_idx + 1
231 )
232 } else {
233 prefix.to_string()
234 }
235 }
236}
237
238impl Rule for MD043RequiredHeadings {
239 fn name(&self) -> &'static str {
240 "MD043"
241 }
242
243 fn description(&self) -> &'static str {
244 "Required heading structure"
245 }
246
247 fn fix_capability(&self) -> FixCapability {
248 FixCapability::Unfixable
249 }
250
251 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
252 let mut warnings = Vec::new();
253 let actual_headings = self.extract_headings(ctx);
254
255 if self.config.headings.is_empty() {
257 return Ok(warnings);
258 }
259
260 let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
262 if actual_headings.is_empty() && all_optional_wildcards {
263 return Ok(warnings);
266 }
267
268 let (headings_match, exp_idx, act_idx) =
270 self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
271
272 if !headings_match {
273 let message = self.structure_mismatch_message(&actual_headings, exp_idx, act_idx);
274
275 if actual_headings.is_empty() && !self.config.headings.is_empty() {
277 warnings.push(LintWarning {
278 rule_name: Some(self.name().to_string()),
279 line: 1,
280 column: 1,
281 end_line: 1,
282 end_column: 2,
283 message,
284 severity: Severity::Warning,
285 fix: None,
286 });
287 return Ok(warnings);
288 }
289
290 for (i, line_info) in ctx.lines.iter().enumerate() {
292 if self.is_heading(i, ctx) {
293 let (start_line, start_col, end_line, end_col) =
295 calculate_heading_range(i + 1, line_info.content(ctx.content));
296
297 warnings.push(LintWarning {
298 rule_name: Some(self.name().to_string()),
299 line: start_line,
300 column: start_col,
301 end_line,
302 end_column: end_col,
303 message: message.clone(),
304 severity: Severity::Warning,
305 fix: None,
306 });
307 }
308 }
309
310 if warnings.is_empty() {
312 warnings.push(LintWarning {
313 rule_name: Some(self.name().to_string()),
314 line: 1,
315 column: 1,
316 end_line: 1,
317 end_column: 2,
318 message,
319 severity: Severity::Warning,
320 fix: None,
321 });
322 }
323 }
324
325 Ok(warnings)
326 }
327
328 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
329 let content = ctx.content;
330 if self.config.headings.is_empty() {
332 return Ok(content.to_string());
333 }
334
335 let actual_headings = self.extract_headings(ctx);
336
337 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
339 if headings_match {
340 return Ok(content.to_string());
341 }
342
343 Ok(content.to_string())
347 }
348
349 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
351 if self.config.headings.is_empty() || ctx.content.is_empty() {
353 return true;
354 }
355
356 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
358
359 if !has_heading {
362 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
363 if has_required_wildcards {
364 return false; }
366 }
367
368 !has_heading
369 }
370
371 fn as_any(&self) -> &dyn std::any::Any {
372 self
373 }
374
375 crate::impl_rule_config_methods!(MD043Config);
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::lint_context::LintContext;
382
383 #[test]
384 fn test_extract_headings_code_blocks() {
385 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
387 let rule = MD043RequiredHeadings::new(required);
388
389 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.";
391 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
392 let actual_headings = rule.extract_headings(&ctx);
393 assert_eq!(
394 actual_headings,
395 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
396 "Should extract correct headings and ignore code blocks"
397 );
398
399 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.";
401 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let actual_headings = rule.extract_headings(&ctx);
403 assert_eq!(
404 actual_headings,
405 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
406 "Should extract actual headings including mismatched ones"
407 );
408 }
409
410 #[test]
411 fn test_with_document_structure() {
412 let required = vec![
414 "# Introduction".to_string(),
415 "# Method".to_string(),
416 "# Results".to_string(),
417 ];
418 let rule = MD043RequiredHeadings::new(required);
419
420 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
422 let warnings = rule
423 .check(&LintContext::new(
424 content,
425 crate::config::MarkdownFlavor::Standard,
426 None,
427 ))
428 .unwrap();
429 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
430
431 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
433 let warnings = rule
434 .check(&LintContext::new(
435 content,
436 crate::config::MarkdownFlavor::Standard,
437 None,
438 ))
439 .unwrap();
440 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
441
442 let content = "No headings here, just plain text";
444 let warnings = rule
445 .check(&LintContext::new(
446 content,
447 crate::config::MarkdownFlavor::Standard,
448 None,
449 ))
450 .unwrap();
451 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
452
453 let required_setext = vec![
455 "=========== Introduction".to_string(),
456 "------ Method".to_string(),
457 "======= Results".to_string(),
458 ];
459 let rule_setext = MD043RequiredHeadings::new(required_setext);
460 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
461 let warnings = rule_setext
462 .check(&LintContext::new(
463 content,
464 crate::config::MarkdownFlavor::Standard,
465 None,
466 ))
467 .unwrap();
468 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
469 }
470
471 #[test]
472 fn test_should_skip_no_false_positives() {
473 let required = vec!["Test".to_string()];
475 let rule = MD043RequiredHeadings::new(required);
476
477 let content = "This paragraph contains a # character but is not a heading";
479 assert!(
480 rule.should_skip(&LintContext::new(
481 content,
482 crate::config::MarkdownFlavor::Standard,
483 None
484 )),
485 "Should skip content with # in normal text"
486 );
487
488 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
490 assert!(
491 rule.should_skip(&LintContext::new(
492 content,
493 crate::config::MarkdownFlavor::Standard,
494 None
495 )),
496 "Should skip content with heading-like syntax in code blocks"
497 );
498
499 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
501 assert!(
502 rule.should_skip(&LintContext::new(
503 content,
504 crate::config::MarkdownFlavor::Standard,
505 None
506 )),
507 "Should skip content with list items using dash"
508 );
509
510 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
512 assert!(
513 rule.should_skip(&LintContext::new(
514 content,
515 crate::config::MarkdownFlavor::Standard,
516 None
517 )),
518 "Should skip content with horizontal rule"
519 );
520
521 let content = "This is a normal paragraph with equals sign x = y + z";
523 assert!(
524 rule.should_skip(&LintContext::new(
525 content,
526 crate::config::MarkdownFlavor::Standard,
527 None
528 )),
529 "Should skip content with equals sign in normal text"
530 );
531
532 let content = "This is a normal paragraph with minus sign x - y = z";
534 assert!(
535 rule.should_skip(&LintContext::new(
536 content,
537 crate::config::MarkdownFlavor::Standard,
538 None
539 )),
540 "Should skip content with minus sign in normal text"
541 );
542 }
543
544 #[test]
545 fn test_should_skip_heading_detection() {
546 let required = vec!["Test".to_string()];
548 let rule = MD043RequiredHeadings::new(required);
549
550 let content = "# This is a heading\n\nAnd some content";
552 assert!(
553 !rule.should_skip(&LintContext::new(
554 content,
555 crate::config::MarkdownFlavor::Standard,
556 None
557 )),
558 "Should not skip content with ATX heading"
559 );
560
561 let content = "This is a heading\n================\n\nAnd some content";
563 assert!(
564 !rule.should_skip(&LintContext::new(
565 content,
566 crate::config::MarkdownFlavor::Standard,
567 None
568 )),
569 "Should not skip content with Setext heading (=)"
570 );
571
572 let content = "This is a subheading\n------------------\n\nAnd some content";
574 assert!(
575 !rule.should_skip(&LintContext::new(
576 content,
577 crate::config::MarkdownFlavor::Standard,
578 None
579 )),
580 "Should not skip content with Setext heading (-)"
581 );
582
583 let content = "## This is a heading ##\n\nAnd some content";
585 assert!(
586 !rule.should_skip(&LintContext::new(
587 content,
588 crate::config::MarkdownFlavor::Standard,
589 None
590 )),
591 "Should not skip content with ATX heading with closing hashes"
592 );
593 }
594
595 #[test]
596 fn test_config_match_case_sensitive() {
597 let config = MD043Config {
598 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
599 match_case: true,
600 };
601 let rule = MD043RequiredHeadings::from_config_struct(config);
602
603 let content = "# introduction\n\n# method";
605 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
606 let result = rule.check(&ctx).unwrap();
607
608 assert!(
609 !result.is_empty(),
610 "Should detect case mismatch when match_case is true"
611 );
612 }
613
614 #[test]
615 fn test_config_match_case_insensitive() {
616 let config = MD043Config {
617 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
618 match_case: false,
619 };
620 let rule = MD043RequiredHeadings::from_config_struct(config);
621
622 let content = "# introduction\n\n# method";
624 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626
627 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
628 }
629
630 #[test]
631 fn test_config_case_insensitive_mixed() {
632 let config = MD043Config {
633 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
634 match_case: false,
635 };
636 let rule = MD043RequiredHeadings::from_config_struct(config);
637
638 let content = "# INTRODUCTION\n\n# method";
640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
641 let result = rule.check(&ctx).unwrap();
642
643 assert!(
644 result.is_empty(),
645 "Should allow mixed case variations when match_case is false"
646 );
647 }
648
649 #[test]
650 fn test_config_case_sensitive_exact_match() {
651 let config = MD043Config {
652 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
653 match_case: true,
654 };
655 let rule = MD043RequiredHeadings::from_config_struct(config);
656
657 let content = "# Introduction\n\n# Method";
659 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661
662 assert!(
663 result.is_empty(),
664 "Should pass with exact case match when match_case is true"
665 );
666 }
667
668 #[test]
669 fn test_default_config() {
670 let rule = MD043RequiredHeadings::default();
671
672 let content = "# Any heading\n\n# Another heading";
674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
675 let result = rule.check(&ctx).unwrap();
676
677 assert!(result.is_empty(), "Should be disabled with default empty headings");
678 }
679
680 #[test]
681 fn test_default_config_section() {
682 let rule = MD043RequiredHeadings::default();
683 let config_section = rule.default_config_section();
684
685 assert!(config_section.is_some());
686 let (name, value) = config_section.unwrap();
687 assert_eq!(name, "MD043");
688
689 if let toml::Value::Table(table) = value {
691 assert!(table.contains_key("headings"));
692 assert!(table.contains_key("match-case"));
693 assert_eq!(table["headings"], toml::Value::Array(vec![]));
694 assert_eq!(table["match-case"], toml::Value::Boolean(false));
695 } else {
696 panic!("Expected TOML table");
697 }
698 }
699
700 #[test]
701 fn test_headings_match_case_sensitive() {
702 let config = MD043Config {
703 headings: vec![],
704 match_case: true,
705 };
706 let rule = MD043RequiredHeadings::from_config_struct(config);
707
708 assert!(rule.headings_match("Test", "Test"));
709 assert!(!rule.headings_match("Test", "test"));
710 assert!(!rule.headings_match("test", "Test"));
711 }
712
713 #[test]
714 fn test_headings_match_case_insensitive() {
715 let config = MD043Config {
716 headings: vec![],
717 match_case: false,
718 };
719 let rule = MD043RequiredHeadings::from_config_struct(config);
720
721 assert!(rule.headings_match("Test", "Test"));
722 assert!(rule.headings_match("Test", "test"));
723 assert!(rule.headings_match("test", "Test"));
724 assert!(rule.headings_match("TEST", "test"));
725 }
726
727 #[test]
728 fn test_config_empty_headings() {
729 let config = MD043Config {
730 headings: vec![],
731 match_case: true,
732 };
733 let rule = MD043RequiredHeadings::from_config_struct(config);
734
735 let content = "# Any heading\n\n# Another heading";
737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738 let result = rule.check(&ctx).unwrap();
739
740 assert!(result.is_empty(), "Should be disabled with empty headings list");
741 }
742
743 #[test]
744 fn test_fix_respects_configuration() {
745 let config = MD043Config {
746 headings: vec!["# Title".to_string(), "# Content".to_string()],
747 match_case: false,
748 };
749 let rule = MD043RequiredHeadings::from_config_struct(config);
750
751 let content = "Wrong content";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let fixed = rule.fix(&ctx).unwrap();
754
755 let expected = "Wrong content";
757 assert_eq!(fixed, expected);
758 }
759
760 #[test]
763 fn test_asterisk_wildcard_zero_headings() {
764 let config = MD043Config {
766 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
767 match_case: false,
768 };
769 let rule = MD043RequiredHeadings::from_config_struct(config);
770
771 let content = "# Start\n\n# End";
772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774
775 assert!(result.is_empty(), "* should allow zero headings between Start and End");
776 }
777
778 #[test]
779 fn test_asterisk_wildcard_multiple_headings() {
780 let config = MD043Config {
782 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
783 match_case: false,
784 };
785 let rule = MD043RequiredHeadings::from_config_struct(config);
786
787 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
789 let result = rule.check(&ctx).unwrap();
790
791 assert!(
792 result.is_empty(),
793 "* should allow multiple headings between Start and End"
794 );
795 }
796
797 #[test]
798 fn test_asterisk_wildcard_at_end() {
799 let config = MD043Config {
801 headings: vec!["# Introduction".to_string(), "*".to_string()],
802 match_case: false,
803 };
804 let rule = MD043RequiredHeadings::from_config_struct(config);
805
806 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
808 let result = rule.check(&ctx).unwrap();
809
810 assert!(result.is_empty(), "* at end should allow any trailing headings");
811 }
812
813 #[test]
814 fn test_plus_wildcard_requires_at_least_one() {
815 let config = MD043Config {
817 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
818 match_case: false,
819 };
820 let rule = MD043RequiredHeadings::from_config_struct(config);
821
822 let content = "# Start\n\n# End";
824 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826
827 assert!(!result.is_empty(), "+ should require at least one heading");
828 }
829
830 #[test]
831 fn test_plus_wildcard_allows_multiple() {
832 let config = MD043Config {
834 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
835 match_case: false,
836 };
837 let rule = MD043RequiredHeadings::from_config_struct(config);
838
839 let content = "# Start\n\n## Middle\n\n# End";
841 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843
844 assert!(result.is_empty(), "+ should allow one heading");
845
846 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
848 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
849 let result = rule.check(&ctx).unwrap();
850
851 assert!(result.is_empty(), "+ should allow multiple headings");
852 }
853
854 #[test]
855 fn test_question_wildcard_exactly_one() {
856 let config = MD043Config {
858 headings: vec!["?".to_string(), "## Description".to_string()],
859 match_case: false,
860 };
861 let rule = MD043RequiredHeadings::from_config_struct(config);
862
863 let content = "# Project Name\n\n## Description";
865 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867
868 assert!(result.is_empty(), "? should allow exactly one heading");
869 }
870
871 #[test]
872 fn test_question_wildcard_fails_with_zero() {
873 let config = MD043Config {
875 headings: vec!["?".to_string(), "## Description".to_string()],
876 match_case: false,
877 };
878 let rule = MD043RequiredHeadings::from_config_struct(config);
879
880 let content = "## Description";
881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
882 let result = rule.check(&ctx).unwrap();
883
884 assert!(!result.is_empty(), "? should require exactly one heading");
885 }
886
887 #[test]
888 fn test_complex_wildcard_pattern() {
889 let config = MD043Config {
891 headings: vec![
892 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
897 match_case: false,
898 };
899 let rule = MD043RequiredHeadings::from_config_struct(config);
900
901 let content = "# My Project\n\n## Overview\n\n## License";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905
906 assert!(result.is_empty(), "Complex pattern should match minimal structure");
907
908 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912
913 assert!(result.is_empty(), "Complex pattern should match with optional sections");
914 }
915
916 #[test]
917 fn test_multiple_asterisks() {
918 let config = MD043Config {
920 headings: vec![
921 "# Title".to_string(),
922 "*".to_string(),
923 "## Middle".to_string(),
924 "*".to_string(),
925 "# End".to_string(),
926 ],
927 match_case: false,
928 };
929 let rule = MD043RequiredHeadings::from_config_struct(config);
930
931 let content = "# Title\n\n## Middle\n\n# End";
932 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
933 let result = rule.check(&ctx).unwrap();
934
935 assert!(result.is_empty(), "Multiple * wildcards should work");
936
937 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
938 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
939 let result = rule.check(&ctx).unwrap();
940
941 assert!(
942 result.is_empty(),
943 "Multiple * wildcards should allow flexible structure"
944 );
945 }
946
947 #[test]
948 fn test_wildcard_with_case_sensitivity() {
949 let config = MD043Config {
951 headings: vec![
952 "?".to_string(),
953 "## Description".to_string(), ],
955 match_case: true,
956 };
957 let rule = MD043RequiredHeadings::from_config_struct(config);
958
959 let content = "# Title\n\n## Description";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let result = rule.check(&ctx).unwrap();
963
964 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
965
966 let content = "# Title\n\n## description";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
969 let result = rule.check(&ctx).unwrap();
970
971 assert!(
972 !result.is_empty(),
973 "Case-sensitive matching should detect case mismatch"
974 );
975 }
976
977 #[test]
978 fn test_all_wildcards_pattern() {
979 let config = MD043Config {
981 headings: vec!["*".to_string()],
982 match_case: false,
983 };
984 let rule = MD043RequiredHeadings::from_config_struct(config);
985
986 let content = "# Any\n\n## Headings\n\n### Work";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990
991 assert!(result.is_empty(), "* alone should allow any heading structure");
992
993 let content = "No headings here";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert!(result.is_empty(), "* alone should allow no headings");
999 }
1000
1001 #[test]
1002 fn test_wildcard_edge_cases() {
1003 let config = MD043Config {
1005 headings: vec!["# Start".to_string(), "+".to_string()],
1006 match_case: false,
1007 };
1008 let rule = MD043RequiredHeadings::from_config_struct(config);
1009
1010 let content = "# Start";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014
1015 assert!(!result.is_empty(), "+ at end should require at least one more heading");
1016
1017 let content = "# Start\n\n## More";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021
1022 assert!(result.is_empty(), "+ at end should allow additional headings");
1023 }
1024
1025 #[test]
1026 fn test_fix_with_wildcards() {
1027 let config = MD043Config {
1029 headings: vec!["?".to_string(), "## Description".to_string()],
1030 match_case: false,
1031 };
1032 let rule = MD043RequiredHeadings::from_config_struct(config);
1033
1034 let content = "# Project\n\n## Description";
1036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let fixed = rule.fix(&ctx).unwrap();
1038
1039 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
1040
1041 let content = "# Project\n\n## Other";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let fixed = rule.fix(&ctx).unwrap();
1045
1046 assert_eq!(
1047 fixed, content,
1048 "Fix should preserve non-matching content to prevent data loss"
1049 );
1050 }
1051
1052 #[test]
1055 fn test_consecutive_wildcards() {
1056 let config = MD043Config {
1058 headings: vec![
1059 "# Start".to_string(),
1060 "*".to_string(),
1061 "+".to_string(),
1062 "# End".to_string(),
1063 ],
1064 match_case: false,
1065 };
1066 let rule = MD043RequiredHeadings::from_config_struct(config);
1067
1068 let content = "# Start\n\n## Middle\n\n# End";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072
1073 assert!(result.is_empty(), "Consecutive * and + should work together");
1074
1075 let content = "# Start\n\n# End";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079
1080 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1081 }
1082
1083 #[test]
1084 fn test_question_mark_doesnt_consume_literal_match() {
1085 let config = MD043Config {
1087 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1088 match_case: false,
1089 };
1090 let rule = MD043RequiredHeadings::from_config_struct(config);
1091
1092 let content = "# Title\n\n## Description\n\n## License";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let result = rule.check(&ctx).unwrap();
1096
1097 assert!(result.is_empty(), "? should consume exactly one heading");
1098
1099 let content = "## Description\n\n## License";
1101 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1102 let result = rule.check(&ctx).unwrap();
1103
1104 assert!(!result.is_empty(), "? requires exactly one heading to match");
1105 }
1106
1107 #[test]
1108 fn test_asterisk_between_literals_complex() {
1109 let config = MD043Config {
1111 headings: vec![
1112 "# Title".to_string(),
1113 "## Section A".to_string(),
1114 "*".to_string(),
1115 "## Section B".to_string(),
1116 ],
1117 match_case: false,
1118 };
1119 let rule = MD043RequiredHeadings::from_config_struct(config);
1120
1121 let content = "# Title\n\n## Section A\n\n## Section B";
1123 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let result = rule.check(&ctx).unwrap();
1125
1126 assert!(result.is_empty(), "* should allow zero headings");
1127
1128 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1130 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1131 let result = rule.check(&ctx).unwrap();
1132
1133 assert!(result.is_empty(), "* should allow multiple headings");
1134
1135 let content = "# Title\n\n## Section A\n\n### Sub1";
1137 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138 let result = rule.check(&ctx).unwrap();
1139
1140 assert!(
1141 !result.is_empty(),
1142 "Should fail when required heading after * is missing"
1143 );
1144 }
1145
1146 #[test]
1147 fn test_plus_requires_consumption() {
1148 let config = MD043Config {
1150 headings: vec!["+".to_string()],
1151 match_case: false,
1152 };
1153 let rule = MD043RequiredHeadings::from_config_struct(config);
1154
1155 let content = "No headings here";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert!(!result.is_empty(), "+ should fail with zero headings");
1161
1162 let content = "# Any heading";
1164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1165 let result = rule.check(&ctx).unwrap();
1166
1167 assert!(result.is_empty(), "+ should pass with one heading");
1168
1169 let content = "# First\n\n## Second\n\n### Third";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert!(result.is_empty(), "+ should pass with multiple headings");
1175 }
1176
1177 #[test]
1178 fn test_mixed_wildcard_and_literal_ordering() {
1179 let config = MD043Config {
1181 headings: vec![
1182 "# A".to_string(),
1183 "*".to_string(),
1184 "# B".to_string(),
1185 "*".to_string(),
1186 "# C".to_string(),
1187 ],
1188 match_case: false,
1189 };
1190 let rule = MD043RequiredHeadings::from_config_struct(config);
1191
1192 let content = "# A\n\n# B\n\n# C";
1194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1195 let result = rule.check(&ctx).unwrap();
1196
1197 assert!(result.is_empty(), "Should match literals in correct order");
1198
1199 let content = "# A\n\n# C\n\n# B";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203
1204 assert!(!result.is_empty(), "Should fail when literals are out of order");
1205
1206 let content = "# A\n\n# C";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(!result.is_empty(), "Should fail when required literal is missing");
1212 }
1213
1214 #[test]
1215 fn test_only_wildcards_with_headings() {
1216 let config = MD043Config {
1218 headings: vec!["?".to_string(), "+".to_string()],
1219 match_case: false,
1220 };
1221 let rule = MD043RequiredHeadings::from_config_struct(config);
1222
1223 let content = "# First\n\n## Second";
1225 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1229
1230 let content = "# First";
1232 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233 let result = rule.check(&ctx).unwrap();
1234
1235 assert!(
1236 !result.is_empty(),
1237 "Should fail with only 1 heading when ? + is required"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1243 let config = MD043Config {
1245 headings: vec![
1246 "# Start".to_string(),
1247 "*".to_string(),
1248 "## Target".to_string(),
1249 "# End".to_string(),
1250 ],
1251 match_case: false,
1252 };
1253 let rule = MD043RequiredHeadings::from_config_struct(config);
1254
1255 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1257 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259
1260 assert!(result.is_empty(), "* should correctly skip to next literal match");
1261
1262 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1265 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1266 let result = rule.check(&ctx).unwrap();
1267
1268 assert!(
1269 !result.is_empty(),
1270 "Should fail with extra headings that don't match pattern"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_wildcard_at_start() {
1276 let config = MD043Config {
1278 headings: vec!["*".to_string(), "## End".to_string()],
1279 match_case: false,
1280 };
1281 let rule = MD043RequiredHeadings::from_config_struct(config);
1282
1283 let content = "# Random\n\n## Stuff\n\n## End";
1285 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1286 let result = rule.check(&ctx).unwrap();
1287
1288 assert!(result.is_empty(), "* at start should allow any preceding headings");
1289
1290 let config = MD043Config {
1292 headings: vec!["+".to_string(), "## End".to_string()],
1293 match_case: false,
1294 };
1295 let rule = MD043RequiredHeadings::from_config_struct(config);
1296
1297 let content = "## End";
1299 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1300 let result = rule.check(&ctx).unwrap();
1301
1302 assert!(!result.is_empty(), "+ at start should require at least one heading");
1303
1304 let content = "# First\n\n## End";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert!(result.is_empty(), "+ at start should allow headings before End");
1309 }
1310
1311 #[test]
1312 fn test_wildcard_with_setext_headings() {
1313 let config = MD043Config {
1315 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1316 match_case: false,
1317 };
1318 let rule = MD043RequiredHeadings::from_config_struct(config);
1319
1320 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert!(result.is_empty(), "Wildcards should work with setext headings");
1325 }
1326
1327 #[test]
1328 fn test_empty_document_with_required_wildcards() {
1329 let config = MD043Config {
1331 headings: vec!["?".to_string()],
1332 match_case: false,
1333 };
1334 let rule = MD043RequiredHeadings::from_config_struct(config);
1335
1336 let content = "No headings";
1337 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338 let result = rule.check(&ctx).unwrap();
1339
1340 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1341
1342 let config = MD043Config {
1344 headings: vec!["+".to_string()],
1345 match_case: false,
1346 };
1347 let rule = MD043RequiredHeadings::from_config_struct(config);
1348
1349 let content = "No headings";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let result = rule.check(&ctx).unwrap();
1352
1353 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1354 }
1355
1356 #[test]
1357 fn test_trailing_headings_after_pattern_completion() {
1358 let config = MD043Config {
1360 headings: vec!["# Title".to_string(), "## Section".to_string()],
1361 match_case: false,
1362 };
1363 let rule = MD043RequiredHeadings::from_config_struct(config);
1364
1365 let content = "# Title\n\n## Section\n\n### Extra";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368 let result = rule.check(&ctx).unwrap();
1369
1370 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1371
1372 let config = MD043Config {
1374 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1375 match_case: false,
1376 };
1377 let rule = MD043RequiredHeadings::from_config_struct(config);
1378
1379 let content = "# Title\n\n## Section\n\n### Extra";
1380 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1381 let result = rule.check(&ctx).unwrap();
1382
1383 assert!(result.is_empty(), "* at end should allow trailing headings");
1384 }
1385}