1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, 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 check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
201 let mut warnings = Vec::new();
202 let actual_headings = self.extract_headings(ctx);
203
204 if self.config.headings.is_empty() {
206 return Ok(warnings);
207 }
208
209 let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
211 if actual_headings.is_empty() && all_optional_wildcards {
212 return Ok(warnings);
215 }
216
217 let (headings_match, _exp_idx, _act_idx) =
219 self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
220
221 if !headings_match {
222 if actual_headings.is_empty() && !self.config.headings.is_empty() {
224 warnings.push(LintWarning {
225 rule_name: Some(self.name().to_string()),
226 line: 1,
227 column: 1,
228 end_line: 1,
229 end_column: 2,
230 message: format!("Required headings not found: {:?}", self.config.headings),
231 severity: Severity::Warning,
232 fix: None,
233 });
234 return Ok(warnings);
235 }
236
237 for (i, line_info) in ctx.lines.iter().enumerate() {
239 if self.is_heading(i, ctx) {
240 let (start_line, start_col, end_line, end_col) =
242 calculate_heading_range(i + 1, line_info.content(ctx.content));
243
244 warnings.push(LintWarning {
245 rule_name: Some(self.name().to_string()),
246 line: start_line,
247 column: start_col,
248 end_line,
249 end_column: end_col,
250 message: "Heading structure does not match the required structure".to_string(),
251 severity: Severity::Warning,
252 fix: None,
253 });
254 }
255 }
256
257 if warnings.is_empty() {
260 warnings.push(LintWarning {
261 rule_name: Some(self.name().to_string()),
262 line: 1,
263 column: 1,
264 end_line: 1,
265 end_column: 2,
266 message: format!(
267 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
268 self.config.headings, actual_headings
269 ),
270 severity: Severity::Warning,
271 fix: None,
272 });
273 }
274 }
275
276 Ok(warnings)
277 }
278
279 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
280 let content = ctx.content;
281 if self.config.headings.is_empty() {
283 return Ok(content.to_string());
284 }
285
286 let actual_headings = self.extract_headings(ctx);
287
288 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
290 if headings_match {
291 return Ok(content.to_string());
292 }
293
294 Ok(content.to_string())
308 }
309
310 fn category(&self) -> RuleCategory {
312 RuleCategory::Heading
313 }
314
315 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
317 if self.config.headings.is_empty() || ctx.content.is_empty() {
319 return true;
320 }
321
322 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
324
325 if !has_heading {
328 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
329 if has_required_wildcards {
330 return false; }
332 }
333
334 !has_heading
335 }
336
337 fn as_any(&self) -> &dyn std::any::Any {
338 self
339 }
340
341 fn default_config_section(&self) -> Option<(String, toml::Value)> {
342 let default_config = MD043Config::default();
343 let json_value = serde_json::to_value(&default_config).ok()?;
344 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
345 if let toml::Value::Table(table) = toml_value {
346 if !table.is_empty() {
347 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
348 } else {
349 None
350 }
351 } else {
352 None
353 }
354 }
355
356 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
357 where
358 Self: Sized,
359 {
360 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
361 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::lint_context::LintContext;
369
370 #[test]
371 fn test_extract_headings_code_blocks() {
372 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
374 let rule = MD043RequiredHeadings::new(required);
375
376 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.";
378 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let actual_headings = rule.extract_headings(&ctx);
380 assert_eq!(
381 actual_headings,
382 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
383 "Should extract correct headings and ignore code blocks"
384 );
385
386 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.";
388 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let actual_headings = rule.extract_headings(&ctx);
390 assert_eq!(
391 actual_headings,
392 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
393 "Should extract actual headings including mismatched ones"
394 );
395 }
396
397 #[test]
398 fn test_with_document_structure() {
399 let required = vec![
401 "# Introduction".to_string(),
402 "# Method".to_string(),
403 "# Results".to_string(),
404 ];
405 let rule = MD043RequiredHeadings::new(required);
406
407 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
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 no warnings for matching headings");
417
418 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
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 for mismatched headings");
428
429 let content = "No headings here, just plain text";
431 let warnings = rule
432 .check(&LintContext::new(
433 content,
434 crate::config::MarkdownFlavor::Standard,
435 None,
436 ))
437 .unwrap();
438 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
439
440 let required_setext = vec![
442 "=========== Introduction".to_string(),
443 "------ Method".to_string(),
444 "======= Results".to_string(),
445 ];
446 let rule_setext = MD043RequiredHeadings::new(required_setext);
447 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
448 let warnings = rule_setext
449 .check(&LintContext::new(
450 content,
451 crate::config::MarkdownFlavor::Standard,
452 None,
453 ))
454 .unwrap();
455 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
456 }
457
458 #[test]
459 fn test_should_skip_no_false_positives() {
460 let required = vec!["Test".to_string()];
462 let rule = MD043RequiredHeadings::new(required);
463
464 let content = "This paragraph contains a # character but is not a heading";
466 assert!(
467 rule.should_skip(&LintContext::new(
468 content,
469 crate::config::MarkdownFlavor::Standard,
470 None
471 )),
472 "Should skip content with # in normal text"
473 );
474
475 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\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 heading-like syntax in code blocks"
484 );
485
486 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
488 assert!(
489 rule.should_skip(&LintContext::new(
490 content,
491 crate::config::MarkdownFlavor::Standard,
492 None
493 )),
494 "Should skip content with list items using dash"
495 );
496
497 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
499 assert!(
500 rule.should_skip(&LintContext::new(
501 content,
502 crate::config::MarkdownFlavor::Standard,
503 None
504 )),
505 "Should skip content with horizontal rule"
506 );
507
508 let content = "This is a normal paragraph with equals 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 equals sign in normal text"
517 );
518
519 let content = "This is a normal paragraph with minus sign x - y = z";
521 assert!(
522 rule.should_skip(&LintContext::new(
523 content,
524 crate::config::MarkdownFlavor::Standard,
525 None
526 )),
527 "Should skip content with minus sign in normal text"
528 );
529 }
530
531 #[test]
532 fn test_should_skip_heading_detection() {
533 let required = vec!["Test".to_string()];
535 let rule = MD043RequiredHeadings::new(required);
536
537 let content = "# This is a heading\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 ATX heading"
546 );
547
548 let content = "This is a heading\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 subheading\n------------------\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 Setext heading (-)"
568 );
569
570 let content = "## This is a heading ##\n\nAnd some content";
572 assert!(
573 !rule.should_skip(&LintContext::new(
574 content,
575 crate::config::MarkdownFlavor::Standard,
576 None
577 )),
578 "Should not skip content with ATX heading with closing hashes"
579 );
580 }
581
582 #[test]
583 fn test_config_match_case_sensitive() {
584 let config = MD043Config {
585 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
586 match_case: true,
587 };
588 let rule = MD043RequiredHeadings::from_config_struct(config);
589
590 let content = "# introduction\n\n# method";
592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
593 let result = rule.check(&ctx).unwrap();
594
595 assert!(
596 !result.is_empty(),
597 "Should detect case mismatch when match_case is true"
598 );
599 }
600
601 #[test]
602 fn test_config_match_case_insensitive() {
603 let config = MD043Config {
604 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
605 match_case: false,
606 };
607 let rule = MD043RequiredHeadings::from_config_struct(config);
608
609 let content = "# introduction\n\n# method";
611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
612 let result = rule.check(&ctx).unwrap();
613
614 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
615 }
616
617 #[test]
618 fn test_config_case_insensitive_mixed() {
619 let config = MD043Config {
620 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
621 match_case: false,
622 };
623 let rule = MD043RequiredHeadings::from_config_struct(config);
624
625 let content = "# INTRODUCTION\n\n# method";
627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
628 let result = rule.check(&ctx).unwrap();
629
630 assert!(
631 result.is_empty(),
632 "Should allow mixed case variations when match_case is false"
633 );
634 }
635
636 #[test]
637 fn test_config_case_sensitive_exact_match() {
638 let config = MD043Config {
639 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
640 match_case: true,
641 };
642 let rule = MD043RequiredHeadings::from_config_struct(config);
643
644 let content = "# Introduction\n\n# Method";
646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
647 let result = rule.check(&ctx).unwrap();
648
649 assert!(
650 result.is_empty(),
651 "Should pass with exact case match when match_case is true"
652 );
653 }
654
655 #[test]
656 fn test_default_config() {
657 let rule = MD043RequiredHeadings::default();
658
659 let content = "# Any heading\n\n# Another heading";
661 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
662 let result = rule.check(&ctx).unwrap();
663
664 assert!(result.is_empty(), "Should be disabled with default empty headings");
665 }
666
667 #[test]
668 fn test_default_config_section() {
669 let rule = MD043RequiredHeadings::default();
670 let config_section = rule.default_config_section();
671
672 assert!(config_section.is_some());
673 let (name, value) = config_section.unwrap();
674 assert_eq!(name, "MD043");
675
676 if let toml::Value::Table(table) = value {
678 assert!(table.contains_key("headings"));
679 assert!(table.contains_key("match-case"));
680 assert_eq!(table["headings"], toml::Value::Array(vec![]));
681 assert_eq!(table["match-case"], toml::Value::Boolean(false));
682 } else {
683 panic!("Expected TOML table");
684 }
685 }
686
687 #[test]
688 fn test_headings_match_case_sensitive() {
689 let config = MD043Config {
690 headings: vec![],
691 match_case: true,
692 };
693 let rule = MD043RequiredHeadings::from_config_struct(config);
694
695 assert!(rule.headings_match("Test", "Test"));
696 assert!(!rule.headings_match("Test", "test"));
697 assert!(!rule.headings_match("test", "Test"));
698 }
699
700 #[test]
701 fn test_headings_match_case_insensitive() {
702 let config = MD043Config {
703 headings: vec![],
704 match_case: false,
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 assert!(rule.headings_match("TEST", "test"));
712 }
713
714 #[test]
715 fn test_config_empty_headings() {
716 let config = MD043Config {
717 headings: vec![],
718 match_case: true,
719 };
720 let rule = MD043RequiredHeadings::from_config_struct(config);
721
722 let content = "# Any heading\n\n# Another heading";
724 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
725 let result = rule.check(&ctx).unwrap();
726
727 assert!(result.is_empty(), "Should be disabled with empty headings list");
728 }
729
730 #[test]
731 fn test_fix_respects_configuration() {
732 let config = MD043Config {
733 headings: vec!["# Title".to_string(), "# Content".to_string()],
734 match_case: false,
735 };
736 let rule = MD043RequiredHeadings::from_config_struct(config);
737
738 let content = "Wrong content";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let fixed = rule.fix(&ctx).unwrap();
741
742 let expected = "Wrong content";
744 assert_eq!(fixed, expected);
745 }
746
747 #[test]
750 fn test_asterisk_wildcard_zero_headings() {
751 let config = MD043Config {
753 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
754 match_case: false,
755 };
756 let rule = MD043RequiredHeadings::from_config_struct(config);
757
758 let content = "# Start\n\n# End";
759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761
762 assert!(result.is_empty(), "* should allow zero headings between Start and End");
763 }
764
765 #[test]
766 fn test_asterisk_wildcard_multiple_headings() {
767 let config = MD043Config {
769 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
770 match_case: false,
771 };
772 let rule = MD043RequiredHeadings::from_config_struct(config);
773
774 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
775 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
776 let result = rule.check(&ctx).unwrap();
777
778 assert!(
779 result.is_empty(),
780 "* should allow multiple headings between Start and End"
781 );
782 }
783
784 #[test]
785 fn test_asterisk_wildcard_at_end() {
786 let config = MD043Config {
788 headings: vec!["# Introduction".to_string(), "*".to_string()],
789 match_case: false,
790 };
791 let rule = MD043RequiredHeadings::from_config_struct(config);
792
793 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
795 let result = rule.check(&ctx).unwrap();
796
797 assert!(result.is_empty(), "* at end should allow any trailing headings");
798 }
799
800 #[test]
801 fn test_plus_wildcard_requires_at_least_one() {
802 let config = MD043Config {
804 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
805 match_case: false,
806 };
807 let rule = MD043RequiredHeadings::from_config_struct(config);
808
809 let content = "# Start\n\n# End";
811 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812 let result = rule.check(&ctx).unwrap();
813
814 assert!(!result.is_empty(), "+ should require at least one heading");
815 }
816
817 #[test]
818 fn test_plus_wildcard_allows_multiple() {
819 let config = MD043Config {
821 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
822 match_case: false,
823 };
824 let rule = MD043RequiredHeadings::from_config_struct(config);
825
826 let content = "# Start\n\n## Middle\n\n# End";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
829 let result = rule.check(&ctx).unwrap();
830
831 assert!(result.is_empty(), "+ should allow one heading");
832
833 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837
838 assert!(result.is_empty(), "+ should allow multiple headings");
839 }
840
841 #[test]
842 fn test_question_wildcard_exactly_one() {
843 let config = MD043Config {
845 headings: vec!["?".to_string(), "## Description".to_string()],
846 match_case: false,
847 };
848 let rule = MD043RequiredHeadings::from_config_struct(config);
849
850 let content = "# Project Name\n\n## Description";
852 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854
855 assert!(result.is_empty(), "? should allow exactly one heading");
856 }
857
858 #[test]
859 fn test_question_wildcard_fails_with_zero() {
860 let config = MD043Config {
862 headings: vec!["?".to_string(), "## Description".to_string()],
863 match_case: false,
864 };
865 let rule = MD043RequiredHeadings::from_config_struct(config);
866
867 let content = "## Description";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870
871 assert!(!result.is_empty(), "? should require exactly one heading");
872 }
873
874 #[test]
875 fn test_complex_wildcard_pattern() {
876 let config = MD043Config {
878 headings: vec![
879 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
884 match_case: false,
885 };
886 let rule = MD043RequiredHeadings::from_config_struct(config);
887
888 let content = "# My Project\n\n## Overview\n\n## License";
890 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
891 let result = rule.check(&ctx).unwrap();
892
893 assert!(result.is_empty(), "Complex pattern should match minimal structure");
894
895 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
897 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let result = rule.check(&ctx).unwrap();
899
900 assert!(result.is_empty(), "Complex pattern should match with optional sections");
901 }
902
903 #[test]
904 fn test_multiple_asterisks() {
905 let config = MD043Config {
907 headings: vec![
908 "# Title".to_string(),
909 "*".to_string(),
910 "## Middle".to_string(),
911 "*".to_string(),
912 "# End".to_string(),
913 ],
914 match_case: false,
915 };
916 let rule = MD043RequiredHeadings::from_config_struct(config);
917
918 let content = "# Title\n\n## Middle\n\n# End";
919 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
920 let result = rule.check(&ctx).unwrap();
921
922 assert!(result.is_empty(), "Multiple * wildcards should work");
923
924 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
926 let result = rule.check(&ctx).unwrap();
927
928 assert!(
929 result.is_empty(),
930 "Multiple * wildcards should allow flexible structure"
931 );
932 }
933
934 #[test]
935 fn test_wildcard_with_case_sensitivity() {
936 let config = MD043Config {
938 headings: vec![
939 "?".to_string(),
940 "## Description".to_string(), ],
942 match_case: true,
943 };
944 let rule = MD043RequiredHeadings::from_config_struct(config);
945
946 let content = "# Title\n\n## Description";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950
951 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
952
953 let content = "# Title\n\n## description";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert!(
959 !result.is_empty(),
960 "Case-sensitive matching should detect case mismatch"
961 );
962 }
963
964 #[test]
965 fn test_all_wildcards_pattern() {
966 let config = MD043Config {
968 headings: vec!["*".to_string()],
969 match_case: false,
970 };
971 let rule = MD043RequiredHeadings::from_config_struct(config);
972
973 let content = "# Any\n\n## Headings\n\n### Work";
975 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
976 let result = rule.check(&ctx).unwrap();
977
978 assert!(result.is_empty(), "* alone should allow any heading structure");
979
980 let content = "No headings here";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let result = rule.check(&ctx).unwrap();
984
985 assert!(result.is_empty(), "* alone should allow no headings");
986 }
987
988 #[test]
989 fn test_wildcard_edge_cases() {
990 let config = MD043Config {
992 headings: vec!["# Start".to_string(), "+".to_string()],
993 match_case: false,
994 };
995 let rule = MD043RequiredHeadings::from_config_struct(config);
996
997 let content = "# Start";
999 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1000 let result = rule.check(&ctx).unwrap();
1001
1002 assert!(!result.is_empty(), "+ at end should require at least one more heading");
1003
1004 let content = "# Start\n\n## More";
1006 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007 let result = rule.check(&ctx).unwrap();
1008
1009 assert!(result.is_empty(), "+ at end should allow additional headings");
1010 }
1011
1012 #[test]
1013 fn test_fix_with_wildcards() {
1014 let config = MD043Config {
1016 headings: vec!["?".to_string(), "## Description".to_string()],
1017 match_case: false,
1018 };
1019 let rule = MD043RequiredHeadings::from_config_struct(config);
1020
1021 let content = "# Project\n\n## Description";
1023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1024 let fixed = rule.fix(&ctx).unwrap();
1025
1026 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
1027
1028 let content = "# Project\n\n## Other";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let fixed = rule.fix(&ctx).unwrap();
1032
1033 assert_eq!(
1034 fixed, content,
1035 "Fix should preserve non-matching content to prevent data loss"
1036 );
1037 }
1038
1039 #[test]
1042 fn test_consecutive_wildcards() {
1043 let config = MD043Config {
1045 headings: vec![
1046 "# Start".to_string(),
1047 "*".to_string(),
1048 "+".to_string(),
1049 "# End".to_string(),
1050 ],
1051 match_case: false,
1052 };
1053 let rule = MD043RequiredHeadings::from_config_struct(config);
1054
1055 let content = "# Start\n\n## Middle\n\n# End";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059
1060 assert!(result.is_empty(), "Consecutive * and + should work together");
1061
1062 let content = "# Start\n\n# End";
1064 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065 let result = rule.check(&ctx).unwrap();
1066
1067 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1068 }
1069
1070 #[test]
1071 fn test_question_mark_doesnt_consume_literal_match() {
1072 let config = MD043Config {
1074 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1075 match_case: false,
1076 };
1077 let rule = MD043RequiredHeadings::from_config_struct(config);
1078
1079 let content = "# Title\n\n## Description\n\n## License";
1081 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1082 let result = rule.check(&ctx).unwrap();
1083
1084 assert!(result.is_empty(), "? should consume exactly one heading");
1085
1086 let content = "## Description\n\n## License";
1088 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1089 let result = rule.check(&ctx).unwrap();
1090
1091 assert!(!result.is_empty(), "? requires exactly one heading to match");
1092 }
1093
1094 #[test]
1095 fn test_asterisk_between_literals_complex() {
1096 let config = MD043Config {
1098 headings: vec![
1099 "# Title".to_string(),
1100 "## Section A".to_string(),
1101 "*".to_string(),
1102 "## Section B".to_string(),
1103 ],
1104 match_case: false,
1105 };
1106 let rule = MD043RequiredHeadings::from_config_struct(config);
1107
1108 let content = "# Title\n\n## Section A\n\n## Section B";
1110 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1111 let result = rule.check(&ctx).unwrap();
1112
1113 assert!(result.is_empty(), "* should allow zero headings");
1114
1115 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1117 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1118 let result = rule.check(&ctx).unwrap();
1119
1120 assert!(result.is_empty(), "* should allow multiple headings");
1121
1122 let content = "# Title\n\n## Section A\n\n### Sub1";
1124 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1125 let result = rule.check(&ctx).unwrap();
1126
1127 assert!(
1128 !result.is_empty(),
1129 "Should fail when required heading after * is missing"
1130 );
1131 }
1132
1133 #[test]
1134 fn test_plus_requires_consumption() {
1135 let config = MD043Config {
1137 headings: vec!["+".to_string()],
1138 match_case: false,
1139 };
1140 let rule = MD043RequiredHeadings::from_config_struct(config);
1141
1142 let content = "No headings here";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let result = rule.check(&ctx).unwrap();
1146
1147 assert!(!result.is_empty(), "+ should fail with zero headings");
1148
1149 let content = "# Any heading";
1151 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1152 let result = rule.check(&ctx).unwrap();
1153
1154 assert!(result.is_empty(), "+ should pass with one heading");
1155
1156 let content = "# First\n\n## Second\n\n### Third";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert!(result.is_empty(), "+ should pass with multiple headings");
1162 }
1163
1164 #[test]
1165 fn test_mixed_wildcard_and_literal_ordering() {
1166 let config = MD043Config {
1168 headings: vec![
1169 "# A".to_string(),
1170 "*".to_string(),
1171 "# B".to_string(),
1172 "*".to_string(),
1173 "# C".to_string(),
1174 ],
1175 match_case: false,
1176 };
1177 let rule = MD043RequiredHeadings::from_config_struct(config);
1178
1179 let content = "# A\n\n# B\n\n# C";
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert!(result.is_empty(), "Should match literals in correct order");
1185
1186 let content = "# A\n\n# C\n\n# B";
1188 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert!(!result.is_empty(), "Should fail when literals are out of order");
1192
1193 let content = "# A\n\n# C";
1195 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196 let result = rule.check(&ctx).unwrap();
1197
1198 assert!(!result.is_empty(), "Should fail when required literal is missing");
1199 }
1200
1201 #[test]
1202 fn test_only_wildcards_with_headings() {
1203 let config = MD043Config {
1205 headings: vec!["?".to_string(), "+".to_string()],
1206 match_case: false,
1207 };
1208 let rule = MD043RequiredHeadings::from_config_struct(config);
1209
1210 let content = "# First\n\n## Second";
1212 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213 let result = rule.check(&ctx).unwrap();
1214
1215 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1216
1217 let content = "# First";
1219 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1220 let result = rule.check(&ctx).unwrap();
1221
1222 assert!(
1223 !result.is_empty(),
1224 "Should fail with only 1 heading when ? + is required"
1225 );
1226 }
1227
1228 #[test]
1229 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1230 let config = MD043Config {
1232 headings: vec![
1233 "# Start".to_string(),
1234 "*".to_string(),
1235 "## Target".to_string(),
1236 "# End".to_string(),
1237 ],
1238 match_case: false,
1239 };
1240 let rule = MD043RequiredHeadings::from_config_struct(config);
1241
1242 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1244 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1245 let result = rule.check(&ctx).unwrap();
1246
1247 assert!(result.is_empty(), "* should correctly skip to next literal match");
1248
1249 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1252 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1253 let result = rule.check(&ctx).unwrap();
1254
1255 assert!(
1256 !result.is_empty(),
1257 "Should fail with extra headings that don't match pattern"
1258 );
1259 }
1260
1261 #[test]
1262 fn test_wildcard_at_start() {
1263 let config = MD043Config {
1265 headings: vec!["*".to_string(), "## End".to_string()],
1266 match_case: false,
1267 };
1268 let rule = MD043RequiredHeadings::from_config_struct(config);
1269
1270 let content = "# Random\n\n## Stuff\n\n## End";
1272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1273 let result = rule.check(&ctx).unwrap();
1274
1275 assert!(result.is_empty(), "* at start should allow any preceding headings");
1276
1277 let config = MD043Config {
1279 headings: vec!["+".to_string(), "## End".to_string()],
1280 match_case: false,
1281 };
1282 let rule = MD043RequiredHeadings::from_config_struct(config);
1283
1284 let content = "## End";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let result = rule.check(&ctx).unwrap();
1288
1289 assert!(!result.is_empty(), "+ at start should require at least one heading");
1290
1291 let content = "# First\n\n## End";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293 let result = rule.check(&ctx).unwrap();
1294
1295 assert!(result.is_empty(), "+ at start should allow headings before End");
1296 }
1297
1298 #[test]
1299 fn test_wildcard_with_setext_headings() {
1300 let config = MD043Config {
1302 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1303 match_case: false,
1304 };
1305 let rule = MD043RequiredHeadings::from_config_struct(config);
1306
1307 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1308 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1309 let result = rule.check(&ctx).unwrap();
1310
1311 assert!(result.is_empty(), "Wildcards should work with setext headings");
1312 }
1313
1314 #[test]
1315 fn test_empty_document_with_required_wildcards() {
1316 let config = MD043Config {
1318 headings: vec!["?".to_string()],
1319 match_case: false,
1320 };
1321 let rule = MD043RequiredHeadings::from_config_struct(config);
1322
1323 let content = "No headings";
1324 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let result = rule.check(&ctx).unwrap();
1326
1327 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1328
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
1343 #[test]
1344 fn test_trailing_headings_after_pattern_completion() {
1345 let config = MD043Config {
1347 headings: vec!["# Title".to_string(), "## Section".to_string()],
1348 match_case: false,
1349 };
1350 let rule = MD043RequiredHeadings::from_config_struct(config);
1351
1352 let content = "# Title\n\n## Section\n\n### Extra";
1354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1355 let result = rule.check(&ctx).unwrap();
1356
1357 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1358
1359 let config = MD043Config {
1361 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1362 match_case: false,
1363 };
1364 let rule = MD043RequiredHeadings::from_config_struct(config);
1365
1366 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(), "* at end should allow trailing headings");
1371 }
1372}