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