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 fn default_config_section(&self) -> Option<(String, toml::Value)> {
331 let default_config = MD043Config::default();
332 let json_value = serde_json::to_value(&default_config).ok()?;
333 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
334 if let toml::Value::Table(table) = toml_value {
335 if !table.is_empty() {
336 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
337 } else {
338 None
339 }
340 } else {
341 None
342 }
343 }
344
345 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
346 where
347 Self: Sized,
348 {
349 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
350 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::lint_context::LintContext;
358
359 #[test]
360 fn test_extract_headings_code_blocks() {
361 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
363 let rule = MD043RequiredHeadings::new(required);
364
365 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.";
367 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let actual_headings = rule.extract_headings(&ctx);
369 assert_eq!(
370 actual_headings,
371 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
372 "Should extract correct headings and ignore code blocks"
373 );
374
375 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.";
377 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
378 let actual_headings = rule.extract_headings(&ctx);
379 assert_eq!(
380 actual_headings,
381 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
382 "Should extract actual headings including mismatched ones"
383 );
384 }
385
386 #[test]
387 fn test_with_document_structure() {
388 let required = vec![
390 "# Introduction".to_string(),
391 "# Method".to_string(),
392 "# Results".to_string(),
393 ];
394 let rule = MD043RequiredHeadings::new(required);
395
396 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
398 let warnings = rule
399 .check(&LintContext::new(
400 content,
401 crate::config::MarkdownFlavor::Standard,
402 None,
403 ))
404 .unwrap();
405 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
406
407 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
409 let warnings = rule
410 .check(&LintContext::new(
411 content,
412 crate::config::MarkdownFlavor::Standard,
413 None,
414 ))
415 .unwrap();
416 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
417
418 let content = "No headings here, just plain text";
420 let warnings = rule
421 .check(&LintContext::new(
422 content,
423 crate::config::MarkdownFlavor::Standard,
424 None,
425 ))
426 .unwrap();
427 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
428
429 let required_setext = vec![
431 "=========== Introduction".to_string(),
432 "------ Method".to_string(),
433 "======= Results".to_string(),
434 ];
435 let rule_setext = MD043RequiredHeadings::new(required_setext);
436 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
437 let warnings = rule_setext
438 .check(&LintContext::new(
439 content,
440 crate::config::MarkdownFlavor::Standard,
441 None,
442 ))
443 .unwrap();
444 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
445 }
446
447 #[test]
448 fn test_should_skip_no_false_positives() {
449 let required = vec!["Test".to_string()];
451 let rule = MD043RequiredHeadings::new(required);
452
453 let content = "This paragraph contains a # character but is not a heading";
455 assert!(
456 rule.should_skip(&LintContext::new(
457 content,
458 crate::config::MarkdownFlavor::Standard,
459 None
460 )),
461 "Should skip content with # in normal text"
462 );
463
464 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
466 assert!(
467 rule.should_skip(&LintContext::new(
468 content,
469 crate::config::MarkdownFlavor::Standard,
470 None
471 )),
472 "Should skip content with heading-like syntax in code blocks"
473 );
474
475 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
477 assert!(
478 rule.should_skip(&LintContext::new(
479 content,
480 crate::config::MarkdownFlavor::Standard,
481 None
482 )),
483 "Should skip content with list items using dash"
484 );
485
486 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
488 assert!(
489 rule.should_skip(&LintContext::new(
490 content,
491 crate::config::MarkdownFlavor::Standard,
492 None
493 )),
494 "Should skip content with horizontal rule"
495 );
496
497 let content = "This is a normal paragraph with equals sign x = y + z";
499 assert!(
500 rule.should_skip(&LintContext::new(
501 content,
502 crate::config::MarkdownFlavor::Standard,
503 None
504 )),
505 "Should skip content with equals sign in normal text"
506 );
507
508 let content = "This is a normal paragraph with minus sign x - y = z";
510 assert!(
511 rule.should_skip(&LintContext::new(
512 content,
513 crate::config::MarkdownFlavor::Standard,
514 None
515 )),
516 "Should skip content with minus sign in normal text"
517 );
518 }
519
520 #[test]
521 fn test_should_skip_heading_detection() {
522 let required = vec!["Test".to_string()];
524 let rule = MD043RequiredHeadings::new(required);
525
526 let content = "# This is a heading\n\nAnd some content";
528 assert!(
529 !rule.should_skip(&LintContext::new(
530 content,
531 crate::config::MarkdownFlavor::Standard,
532 None
533 )),
534 "Should not skip content with ATX heading"
535 );
536
537 let content = "This is a heading\n================\n\nAnd some content";
539 assert!(
540 !rule.should_skip(&LintContext::new(
541 content,
542 crate::config::MarkdownFlavor::Standard,
543 None
544 )),
545 "Should not skip content with Setext heading (=)"
546 );
547
548 let content = "This is a subheading\n------------------\n\nAnd some content";
550 assert!(
551 !rule.should_skip(&LintContext::new(
552 content,
553 crate::config::MarkdownFlavor::Standard,
554 None
555 )),
556 "Should not skip content with Setext heading (-)"
557 );
558
559 let content = "## This is a heading ##\n\nAnd some content";
561 assert!(
562 !rule.should_skip(&LintContext::new(
563 content,
564 crate::config::MarkdownFlavor::Standard,
565 None
566 )),
567 "Should not skip content with ATX heading with closing hashes"
568 );
569 }
570
571 #[test]
572 fn test_config_match_case_sensitive() {
573 let config = MD043Config {
574 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
575 match_case: true,
576 };
577 let rule = MD043RequiredHeadings::from_config_struct(config);
578
579 let content = "# introduction\n\n# method";
581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
582 let result = rule.check(&ctx).unwrap();
583
584 assert!(
585 !result.is_empty(),
586 "Should detect case mismatch when match_case is true"
587 );
588 }
589
590 #[test]
591 fn test_config_match_case_insensitive() {
592 let config = MD043Config {
593 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
594 match_case: false,
595 };
596 let rule = MD043RequiredHeadings::from_config_struct(config);
597
598 let content = "# introduction\n\n# method";
600 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
601 let result = rule.check(&ctx).unwrap();
602
603 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
604 }
605
606 #[test]
607 fn test_config_case_insensitive_mixed() {
608 let config = MD043Config {
609 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
610 match_case: false,
611 };
612 let rule = MD043RequiredHeadings::from_config_struct(config);
613
614 let content = "# INTRODUCTION\n\n# method";
616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
617 let result = rule.check(&ctx).unwrap();
618
619 assert!(
620 result.is_empty(),
621 "Should allow mixed case variations when match_case is false"
622 );
623 }
624
625 #[test]
626 fn test_config_case_sensitive_exact_match() {
627 let config = MD043Config {
628 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
629 match_case: true,
630 };
631 let rule = MD043RequiredHeadings::from_config_struct(config);
632
633 let content = "# Introduction\n\n# Method";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637
638 assert!(
639 result.is_empty(),
640 "Should pass with exact case match when match_case is true"
641 );
642 }
643
644 #[test]
645 fn test_default_config() {
646 let rule = MD043RequiredHeadings::default();
647
648 let content = "# Any heading\n\n# Another heading";
650 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652
653 assert!(result.is_empty(), "Should be disabled with default empty headings");
654 }
655
656 #[test]
657 fn test_default_config_section() {
658 let rule = MD043RequiredHeadings::default();
659 let config_section = rule.default_config_section();
660
661 assert!(config_section.is_some());
662 let (name, value) = config_section.unwrap();
663 assert_eq!(name, "MD043");
664
665 if let toml::Value::Table(table) = value {
667 assert!(table.contains_key("headings"));
668 assert!(table.contains_key("match-case"));
669 assert_eq!(table["headings"], toml::Value::Array(vec![]));
670 assert_eq!(table["match-case"], toml::Value::Boolean(false));
671 } else {
672 panic!("Expected TOML table");
673 }
674 }
675
676 #[test]
677 fn test_headings_match_case_sensitive() {
678 let config = MD043Config {
679 headings: vec![],
680 match_case: true,
681 };
682 let rule = MD043RequiredHeadings::from_config_struct(config);
683
684 assert!(rule.headings_match("Test", "Test"));
685 assert!(!rule.headings_match("Test", "test"));
686 assert!(!rule.headings_match("test", "Test"));
687 }
688
689 #[test]
690 fn test_headings_match_case_insensitive() {
691 let config = MD043Config {
692 headings: vec![],
693 match_case: false,
694 };
695 let rule = MD043RequiredHeadings::from_config_struct(config);
696
697 assert!(rule.headings_match("Test", "Test"));
698 assert!(rule.headings_match("Test", "test"));
699 assert!(rule.headings_match("test", "Test"));
700 assert!(rule.headings_match("TEST", "test"));
701 }
702
703 #[test]
704 fn test_config_empty_headings() {
705 let config = MD043Config {
706 headings: vec![],
707 match_case: true,
708 };
709 let rule = MD043RequiredHeadings::from_config_struct(config);
710
711 let content = "# Any heading\n\n# Another heading";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715
716 assert!(result.is_empty(), "Should be disabled with empty headings list");
717 }
718
719 #[test]
720 fn test_fix_respects_configuration() {
721 let config = MD043Config {
722 headings: vec!["# Title".to_string(), "# Content".to_string()],
723 match_case: false,
724 };
725 let rule = MD043RequiredHeadings::from_config_struct(config);
726
727 let content = "Wrong content";
728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
729 let fixed = rule.fix(&ctx).unwrap();
730
731 let expected = "Wrong content";
733 assert_eq!(fixed, expected);
734 }
735
736 #[test]
739 fn test_asterisk_wildcard_zero_headings() {
740 let config = MD043Config {
742 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
743 match_case: false,
744 };
745 let rule = MD043RequiredHeadings::from_config_struct(config);
746
747 let content = "# Start\n\n# End";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let result = rule.check(&ctx).unwrap();
750
751 assert!(result.is_empty(), "* should allow zero headings between Start and End");
752 }
753
754 #[test]
755 fn test_asterisk_wildcard_multiple_headings() {
756 let config = MD043Config {
758 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
759 match_case: false,
760 };
761 let rule = MD043RequiredHeadings::from_config_struct(config);
762
763 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let result = rule.check(&ctx).unwrap();
766
767 assert!(
768 result.is_empty(),
769 "* should allow multiple headings between Start and End"
770 );
771 }
772
773 #[test]
774 fn test_asterisk_wildcard_at_end() {
775 let config = MD043Config {
777 headings: vec!["# Introduction".to_string(), "*".to_string()],
778 match_case: false,
779 };
780 let rule = MD043RequiredHeadings::from_config_struct(config);
781
782 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785
786 assert!(result.is_empty(), "* at end should allow any trailing headings");
787 }
788
789 #[test]
790 fn test_plus_wildcard_requires_at_least_one() {
791 let config = MD043Config {
793 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
794 match_case: false,
795 };
796 let rule = MD043RequiredHeadings::from_config_struct(config);
797
798 let content = "# Start\n\n# End";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801 let result = rule.check(&ctx).unwrap();
802
803 assert!(!result.is_empty(), "+ should require at least one heading");
804 }
805
806 #[test]
807 fn test_plus_wildcard_allows_multiple() {
808 let config = MD043Config {
810 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
811 match_case: false,
812 };
813 let rule = MD043RequiredHeadings::from_config_struct(config);
814
815 let content = "# Start\n\n## Middle\n\n# End";
817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
818 let result = rule.check(&ctx).unwrap();
819
820 assert!(result.is_empty(), "+ should allow one heading");
821
822 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\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 allow multiple headings");
828 }
829
830 #[test]
831 fn test_question_wildcard_exactly_one() {
832 let config = MD043Config {
834 headings: vec!["?".to_string(), "## Description".to_string()],
835 match_case: false,
836 };
837 let rule = MD043RequiredHeadings::from_config_struct(config);
838
839 let content = "# Project Name\n\n## Description";
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 exactly one heading");
845 }
846
847 #[test]
848 fn test_question_wildcard_fails_with_zero() {
849 let config = MD043Config {
851 headings: vec!["?".to_string(), "## Description".to_string()],
852 match_case: false,
853 };
854 let rule = MD043RequiredHeadings::from_config_struct(config);
855
856 let content = "## Description";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let result = rule.check(&ctx).unwrap();
859
860 assert!(!result.is_empty(), "? should require exactly one heading");
861 }
862
863 #[test]
864 fn test_complex_wildcard_pattern() {
865 let config = MD043Config {
867 headings: vec![
868 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
873 match_case: false,
874 };
875 let rule = MD043RequiredHeadings::from_config_struct(config);
876
877 let content = "# My Project\n\n## Overview\n\n## License";
879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880 let result = rule.check(&ctx).unwrap();
881
882 assert!(result.is_empty(), "Complex pattern should match minimal structure");
883
884 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
887 let result = rule.check(&ctx).unwrap();
888
889 assert!(result.is_empty(), "Complex pattern should match with optional sections");
890 }
891
892 #[test]
893 fn test_multiple_asterisks() {
894 let config = MD043Config {
896 headings: vec![
897 "# Title".to_string(),
898 "*".to_string(),
899 "## Middle".to_string(),
900 "*".to_string(),
901 "# End".to_string(),
902 ],
903 match_case: false,
904 };
905 let rule = MD043RequiredHeadings::from_config_struct(config);
906
907 let content = "# Title\n\n## Middle\n\n# End";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910
911 assert!(result.is_empty(), "Multiple * wildcards should work");
912
913 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916
917 assert!(
918 result.is_empty(),
919 "Multiple * wildcards should allow flexible structure"
920 );
921 }
922
923 #[test]
924 fn test_wildcard_with_case_sensitivity() {
925 let config = MD043Config {
927 headings: vec![
928 "?".to_string(),
929 "## Description".to_string(), ],
931 match_case: true,
932 };
933 let rule = MD043RequiredHeadings::from_config_struct(config);
934
935 let content = "# Title\n\n## Description";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
938 let result = rule.check(&ctx).unwrap();
939
940 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
941
942 let content = "# Title\n\n## description";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let result = rule.check(&ctx).unwrap();
946
947 assert!(
948 !result.is_empty(),
949 "Case-sensitive matching should detect case mismatch"
950 );
951 }
952
953 #[test]
954 fn test_all_wildcards_pattern() {
955 let config = MD043Config {
957 headings: vec!["*".to_string()],
958 match_case: false,
959 };
960 let rule = MD043RequiredHeadings::from_config_struct(config);
961
962 let content = "# Any\n\n## Headings\n\n### Work";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966
967 assert!(result.is_empty(), "* alone should allow any heading structure");
968
969 let content = "No headings here";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert!(result.is_empty(), "* alone should allow no headings");
975 }
976
977 #[test]
978 fn test_wildcard_edge_cases() {
979 let config = MD043Config {
981 headings: vec!["# Start".to_string(), "+".to_string()],
982 match_case: false,
983 };
984 let rule = MD043RequiredHeadings::from_config_struct(config);
985
986 let content = "# Start";
988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989 let result = rule.check(&ctx).unwrap();
990
991 assert!(!result.is_empty(), "+ at end should require at least one more heading");
992
993 let content = "# Start\n\n## More";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert!(result.is_empty(), "+ at end should allow additional headings");
999 }
1000
1001 #[test]
1002 fn test_fix_with_wildcards() {
1003 let config = MD043Config {
1005 headings: vec!["?".to_string(), "## Description".to_string()],
1006 match_case: false,
1007 };
1008 let rule = MD043RequiredHeadings::from_config_struct(config);
1009
1010 let content = "# Project\n\n## Description";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let fixed = rule.fix(&ctx).unwrap();
1014
1015 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
1016
1017 let content = "# Project\n\n## Other";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let fixed = rule.fix(&ctx).unwrap();
1021
1022 assert_eq!(
1023 fixed, content,
1024 "Fix should preserve non-matching content to prevent data loss"
1025 );
1026 }
1027
1028 #[test]
1031 fn test_consecutive_wildcards() {
1032 let config = MD043Config {
1034 headings: vec![
1035 "# Start".to_string(),
1036 "*".to_string(),
1037 "+".to_string(),
1038 "# End".to_string(),
1039 ],
1040 match_case: false,
1041 };
1042 let rule = MD043RequiredHeadings::from_config_struct(config);
1043
1044 let content = "# Start\n\n## Middle\n\n# End";
1046 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1047 let result = rule.check(&ctx).unwrap();
1048
1049 assert!(result.is_empty(), "Consecutive * and + should work together");
1050
1051 let content = "# Start\n\n# End";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let result = rule.check(&ctx).unwrap();
1055
1056 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1057 }
1058
1059 #[test]
1060 fn test_question_mark_doesnt_consume_literal_match() {
1061 let config = MD043Config {
1063 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1064 match_case: false,
1065 };
1066 let rule = MD043RequiredHeadings::from_config_struct(config);
1067
1068 let content = "# Title\n\n## Description\n\n## License";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072
1073 assert!(result.is_empty(), "? should consume exactly one heading");
1074
1075 let content = "## Description\n\n## License";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let result = rule.check(&ctx).unwrap();
1079
1080 assert!(!result.is_empty(), "? requires exactly one heading to match");
1081 }
1082
1083 #[test]
1084 fn test_asterisk_between_literals_complex() {
1085 let config = MD043Config {
1087 headings: vec![
1088 "# Title".to_string(),
1089 "## Section A".to_string(),
1090 "*".to_string(),
1091 "## Section B".to_string(),
1092 ],
1093 match_case: false,
1094 };
1095 let rule = MD043RequiredHeadings::from_config_struct(config);
1096
1097 let content = "# Title\n\n## Section A\n\n## Section B";
1099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100 let result = rule.check(&ctx).unwrap();
1101
1102 assert!(result.is_empty(), "* should allow zero headings");
1103
1104 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1106 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108
1109 assert!(result.is_empty(), "* should allow multiple headings");
1110
1111 let content = "# Title\n\n## Section A\n\n### Sub1";
1113 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1114 let result = rule.check(&ctx).unwrap();
1115
1116 assert!(
1117 !result.is_empty(),
1118 "Should fail when required heading after * is missing"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_plus_requires_consumption() {
1124 let config = MD043Config {
1126 headings: vec!["+".to_string()],
1127 match_case: false,
1128 };
1129 let rule = MD043RequiredHeadings::from_config_struct(config);
1130
1131 let content = "No headings here";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert!(!result.is_empty(), "+ should fail with zero headings");
1137
1138 let content = "# Any heading";
1140 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1141 let result = rule.check(&ctx).unwrap();
1142
1143 assert!(result.is_empty(), "+ should pass with one heading");
1144
1145 let content = "# First\n\n## Second\n\n### Third";
1147 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1148 let result = rule.check(&ctx).unwrap();
1149
1150 assert!(result.is_empty(), "+ should pass with multiple headings");
1151 }
1152
1153 #[test]
1154 fn test_mixed_wildcard_and_literal_ordering() {
1155 let config = MD043Config {
1157 headings: vec![
1158 "# A".to_string(),
1159 "*".to_string(),
1160 "# B".to_string(),
1161 "*".to_string(),
1162 "# C".to_string(),
1163 ],
1164 match_case: false,
1165 };
1166 let rule = MD043RequiredHeadings::from_config_struct(config);
1167
1168 let content = "# A\n\n# B\n\n# C";
1170 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1171 let result = rule.check(&ctx).unwrap();
1172
1173 assert!(result.is_empty(), "Should match literals in correct order");
1174
1175 let content = "# A\n\n# C\n\n# B";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179
1180 assert!(!result.is_empty(), "Should fail when literals are out of order");
1181
1182 let content = "# A\n\n# C";
1184 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1185 let result = rule.check(&ctx).unwrap();
1186
1187 assert!(!result.is_empty(), "Should fail when required literal is missing");
1188 }
1189
1190 #[test]
1191 fn test_only_wildcards_with_headings() {
1192 let config = MD043Config {
1194 headings: vec!["?".to_string(), "+".to_string()],
1195 match_case: false,
1196 };
1197 let rule = MD043RequiredHeadings::from_config_struct(config);
1198
1199 let content = "# First\n\n## Second";
1201 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1202 let result = rule.check(&ctx).unwrap();
1203
1204 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1205
1206 let content = "# First";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert!(
1212 !result.is_empty(),
1213 "Should fail with only 1 heading when ? + is required"
1214 );
1215 }
1216
1217 #[test]
1218 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1219 let config = MD043Config {
1221 headings: vec![
1222 "# Start".to_string(),
1223 "*".to_string(),
1224 "## Target".to_string(),
1225 "# End".to_string(),
1226 ],
1227 match_case: false,
1228 };
1229 let rule = MD043RequiredHeadings::from_config_struct(config);
1230
1231 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1234 let result = rule.check(&ctx).unwrap();
1235
1236 assert!(result.is_empty(), "* should correctly skip to next literal match");
1237
1238 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert!(
1245 !result.is_empty(),
1246 "Should fail with extra headings that don't match pattern"
1247 );
1248 }
1249
1250 #[test]
1251 fn test_wildcard_at_start() {
1252 let config = MD043Config {
1254 headings: vec!["*".to_string(), "## End".to_string()],
1255 match_case: false,
1256 };
1257 let rule = MD043RequiredHeadings::from_config_struct(config);
1258
1259 let content = "# Random\n\n## Stuff\n\n## End";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let result = rule.check(&ctx).unwrap();
1263
1264 assert!(result.is_empty(), "* at start should allow any preceding headings");
1265
1266 let config = MD043Config {
1268 headings: vec!["+".to_string(), "## End".to_string()],
1269 match_case: false,
1270 };
1271 let rule = MD043RequiredHeadings::from_config_struct(config);
1272
1273 let content = "## End";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 assert!(!result.is_empty(), "+ at start should require at least one heading");
1279
1280 let content = "# First\n\n## End";
1281 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1282 let result = rule.check(&ctx).unwrap();
1283
1284 assert!(result.is_empty(), "+ at start should allow headings before End");
1285 }
1286
1287 #[test]
1288 fn test_wildcard_with_setext_headings() {
1289 let config = MD043Config {
1291 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1292 match_case: false,
1293 };
1294 let rule = MD043RequiredHeadings::from_config_struct(config);
1295
1296 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299
1300 assert!(result.is_empty(), "Wildcards should work with setext headings");
1301 }
1302
1303 #[test]
1304 fn test_empty_document_with_required_wildcards() {
1305 let config = MD043Config {
1307 headings: vec!["?".to_string()],
1308 match_case: false,
1309 };
1310 let rule = MD043RequiredHeadings::from_config_struct(config);
1311
1312 let content = "No headings";
1313 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1314 let result = rule.check(&ctx).unwrap();
1315
1316 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1317
1318 let config = MD043Config {
1320 headings: vec!["+".to_string()],
1321 match_case: false,
1322 };
1323 let rule = MD043RequiredHeadings::from_config_struct(config);
1324
1325 let content = "No headings";
1326 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327 let result = rule.check(&ctx).unwrap();
1328
1329 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1330 }
1331
1332 #[test]
1333 fn test_trailing_headings_after_pattern_completion() {
1334 let config = MD043Config {
1336 headings: vec!["# Title".to_string(), "## Section".to_string()],
1337 match_case: false,
1338 };
1339 let rule = MD043RequiredHeadings::from_config_struct(config);
1340
1341 let content = "# Title\n\n## Section\n\n### Extra";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let result = rule.check(&ctx).unwrap();
1345
1346 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1347
1348 let config = MD043Config {
1350 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1351 match_case: false,
1352 };
1353 let rule = MD043RequiredHeadings::from_config_struct(config);
1354
1355 let content = "# Title\n\n## Section\n\n### Extra";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358
1359 assert!(result.is_empty(), "* at end should allow trailing headings");
1360 }
1361}