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 let full_heading = format!("{} {}", heading.marker, heading.text.trim());
78 result.push(full_heading);
79 }
80 }
81
82 result
83 }
84
85 fn match_headings_with_wildcards(
95 &self,
96 actual_headings: &[String],
97 expected_patterns: &[String],
98 ) -> (bool, usize, usize) {
99 let mut exp_idx = 0;
100 let mut act_idx = 0;
101 let mut match_any = false; while exp_idx < expected_patterns.len() && act_idx < actual_headings.len() {
104 let pattern = &expected_patterns[exp_idx];
105
106 if pattern == "*" {
107 exp_idx += 1;
109 if exp_idx >= expected_patterns.len() {
110 return (true, exp_idx, actual_headings.len());
112 }
113 match_any = true;
115 continue;
116 } else if pattern == "+" {
117 if act_idx >= actual_headings.len() {
119 return (false, exp_idx, act_idx); }
121 act_idx += 1;
122 exp_idx += 1;
123 match_any = true;
125 if exp_idx >= expected_patterns.len() {
127 return (true, exp_idx, actual_headings.len());
128 }
129 continue;
130 } else if pattern == "?" {
131 act_idx += 1;
133 exp_idx += 1;
134 match_any = false;
135 continue;
136 }
137
138 let actual = &actual_headings[act_idx];
140 if self.headings_match(pattern, actual) {
141 act_idx += 1;
143 exp_idx += 1;
144 match_any = false;
145 } else if match_any {
146 act_idx += 1;
148 } else {
149 return (false, exp_idx, act_idx);
151 }
152 }
153
154 while exp_idx < expected_patterns.len() {
156 let pattern = &expected_patterns[exp_idx];
157 if pattern == "*" {
158 exp_idx += 1;
160 } else if pattern == "+" {
161 return (false, exp_idx, act_idx);
163 } else if pattern == "?" {
164 return (false, exp_idx, act_idx);
166 } else {
167 return (false, exp_idx, act_idx);
169 }
170 }
171
172 let all_matched = act_idx == actual_headings.len() && exp_idx == expected_patterns.len();
174 (all_matched, exp_idx, act_idx)
175 }
176
177 fn is_heading(&self, line_index: usize, ctx: &crate::lint_context::LintContext) -> bool {
178 if line_index < ctx.lines.len() {
179 ctx.lines[line_index].heading.is_some()
180 } else {
181 false
182 }
183 }
184}
185
186impl Rule for MD043RequiredHeadings {
187 fn name(&self) -> &'static str {
188 "MD043"
189 }
190
191 fn description(&self) -> &'static str {
192 "Required heading structure"
193 }
194
195 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
196 let mut warnings = Vec::new();
197 let actual_headings = self.extract_headings(ctx);
198
199 if self.config.headings.is_empty() {
201 return Ok(warnings);
202 }
203
204 let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
206 if actual_headings.is_empty() && all_optional_wildcards {
207 return Ok(warnings);
210 }
211
212 let (headings_match, _exp_idx, _act_idx) =
214 self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
215
216 if !headings_match {
217 if actual_headings.is_empty() && !self.config.headings.is_empty() {
219 warnings.push(LintWarning {
220 rule_name: Some(self.name().to_string()),
221 line: 1,
222 column: 1,
223 end_line: 1,
224 end_column: 2,
225 message: format!("Required headings not found: {:?}", self.config.headings),
226 severity: Severity::Warning,
227 fix: None,
228 });
229 return Ok(warnings);
230 }
231
232 for (i, line_info) in ctx.lines.iter().enumerate() {
234 if self.is_heading(i, ctx) {
235 let (start_line, start_col, end_line, end_col) =
237 calculate_heading_range(i + 1, line_info.content(ctx.content));
238
239 warnings.push(LintWarning {
240 rule_name: Some(self.name().to_string()),
241 line: start_line,
242 column: start_col,
243 end_line,
244 end_column: end_col,
245 message: "Heading structure does not match the required structure".to_string(),
246 severity: Severity::Warning,
247 fix: None,
248 });
249 }
250 }
251
252 if warnings.is_empty() {
255 warnings.push(LintWarning {
256 rule_name: Some(self.name().to_string()),
257 line: 1,
258 column: 1,
259 end_line: 1,
260 end_column: 2,
261 message: format!(
262 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
263 self.config.headings, actual_headings
264 ),
265 severity: Severity::Warning,
266 fix: None,
267 });
268 }
269 }
270
271 Ok(warnings)
272 }
273
274 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
275 let content = ctx.content;
276 if self.config.headings.is_empty() {
278 return Ok(content.to_string());
279 }
280
281 let actual_headings = self.extract_headings(ctx);
282
283 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
285 if headings_match {
286 return Ok(content.to_string());
287 }
288
289 Ok(content.to_string())
303 }
304
305 fn category(&self) -> RuleCategory {
307 RuleCategory::Heading
308 }
309
310 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
312 if self.config.headings.is_empty() || ctx.content.is_empty() {
314 return true;
315 }
316
317 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
319
320 if !has_heading {
323 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
324 if has_required_wildcards {
325 return false; }
327 }
328
329 !has_heading
330 }
331
332 fn as_any(&self) -> &dyn std::any::Any {
333 self
334 }
335
336 fn default_config_section(&self) -> Option<(String, toml::Value)> {
337 let default_config = MD043Config::default();
338 let json_value = serde_json::to_value(&default_config).ok()?;
339 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
340 if let toml::Value::Table(table) = toml_value {
341 if !table.is_empty() {
342 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
343 } else {
344 None
345 }
346 } else {
347 None
348 }
349 }
350
351 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
352 where
353 Self: Sized,
354 {
355 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
356 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::lint_context::LintContext;
364
365 #[test]
366 fn test_extract_headings_code_blocks() {
367 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
369 let rule = MD043RequiredHeadings::new(required);
370
371 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.";
373 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
374 let actual_headings = rule.extract_headings(&ctx);
375 assert_eq!(
376 actual_headings,
377 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
378 "Should extract correct headings and ignore code blocks"
379 );
380
381 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.";
383 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
384 let actual_headings = rule.extract_headings(&ctx);
385 assert_eq!(
386 actual_headings,
387 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
388 "Should extract actual headings including mismatched ones"
389 );
390 }
391
392 #[test]
393 fn test_with_document_structure() {
394 let required = vec![
396 "# Introduction".to_string(),
397 "# Method".to_string(),
398 "# Results".to_string(),
399 ];
400 let rule = MD043RequiredHeadings::new(required);
401
402 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
404 let warnings = rule
405 .check(&LintContext::new(
406 content,
407 crate::config::MarkdownFlavor::Standard,
408 None,
409 ))
410 .unwrap();
411 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
412
413 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
415 let warnings = rule
416 .check(&LintContext::new(
417 content,
418 crate::config::MarkdownFlavor::Standard,
419 None,
420 ))
421 .unwrap();
422 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
423
424 let content = "No headings here, just plain text";
426 let warnings = rule
427 .check(&LintContext::new(
428 content,
429 crate::config::MarkdownFlavor::Standard,
430 None,
431 ))
432 .unwrap();
433 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
434
435 let required_setext = vec![
437 "=========== Introduction".to_string(),
438 "------ Method".to_string(),
439 "======= Results".to_string(),
440 ];
441 let rule_setext = MD043RequiredHeadings::new(required_setext);
442 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
443 let warnings = rule_setext
444 .check(&LintContext::new(
445 content,
446 crate::config::MarkdownFlavor::Standard,
447 None,
448 ))
449 .unwrap();
450 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
451 }
452
453 #[test]
454 fn test_should_skip_no_false_positives() {
455 let required = vec!["Test".to_string()];
457 let rule = MD043RequiredHeadings::new(required);
458
459 let content = "This paragraph contains a # character but is not a heading";
461 assert!(
462 rule.should_skip(&LintContext::new(
463 content,
464 crate::config::MarkdownFlavor::Standard,
465 None
466 )),
467 "Should skip content with # in normal text"
468 );
469
470 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
472 assert!(
473 rule.should_skip(&LintContext::new(
474 content,
475 crate::config::MarkdownFlavor::Standard,
476 None
477 )),
478 "Should skip content with heading-like syntax in code blocks"
479 );
480
481 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
483 assert!(
484 rule.should_skip(&LintContext::new(
485 content,
486 crate::config::MarkdownFlavor::Standard,
487 None
488 )),
489 "Should skip content with list items using dash"
490 );
491
492 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
494 assert!(
495 rule.should_skip(&LintContext::new(
496 content,
497 crate::config::MarkdownFlavor::Standard,
498 None
499 )),
500 "Should skip content with horizontal rule"
501 );
502
503 let content = "This is a normal paragraph with equals sign x = y + z";
505 assert!(
506 rule.should_skip(&LintContext::new(
507 content,
508 crate::config::MarkdownFlavor::Standard,
509 None
510 )),
511 "Should skip content with equals sign in normal text"
512 );
513
514 let content = "This is a normal paragraph with minus sign x - y = z";
516 assert!(
517 rule.should_skip(&LintContext::new(
518 content,
519 crate::config::MarkdownFlavor::Standard,
520 None
521 )),
522 "Should skip content with minus sign in normal text"
523 );
524 }
525
526 #[test]
527 fn test_should_skip_heading_detection() {
528 let required = vec!["Test".to_string()];
530 let rule = MD043RequiredHeadings::new(required);
531
532 let content = "# This is a heading\n\nAnd some content";
534 assert!(
535 !rule.should_skip(&LintContext::new(
536 content,
537 crate::config::MarkdownFlavor::Standard,
538 None
539 )),
540 "Should not skip content with ATX heading"
541 );
542
543 let content = "This is a heading\n================\n\nAnd some content";
545 assert!(
546 !rule.should_skip(&LintContext::new(
547 content,
548 crate::config::MarkdownFlavor::Standard,
549 None
550 )),
551 "Should not skip content with Setext heading (=)"
552 );
553
554 let content = "This is a subheading\n------------------\n\nAnd some content";
556 assert!(
557 !rule.should_skip(&LintContext::new(
558 content,
559 crate::config::MarkdownFlavor::Standard,
560 None
561 )),
562 "Should not skip content with Setext heading (-)"
563 );
564
565 let content = "## This is a heading ##\n\nAnd some content";
567 assert!(
568 !rule.should_skip(&LintContext::new(
569 content,
570 crate::config::MarkdownFlavor::Standard,
571 None
572 )),
573 "Should not skip content with ATX heading with closing hashes"
574 );
575 }
576
577 #[test]
578 fn test_config_match_case_sensitive() {
579 let config = MD043Config {
580 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
581 match_case: true,
582 };
583 let rule = MD043RequiredHeadings::from_config_struct(config);
584
585 let content = "# introduction\n\n# method";
587 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
588 let result = rule.check(&ctx).unwrap();
589
590 assert!(
591 !result.is_empty(),
592 "Should detect case mismatch when match_case is true"
593 );
594 }
595
596 #[test]
597 fn test_config_match_case_insensitive() {
598 let config = MD043Config {
599 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
600 match_case: false,
601 };
602 let rule = MD043RequiredHeadings::from_config_struct(config);
603
604 let content = "# introduction\n\n# method";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607 let result = rule.check(&ctx).unwrap();
608
609 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
610 }
611
612 #[test]
613 fn test_config_case_insensitive_mixed() {
614 let config = MD043Config {
615 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
616 match_case: false,
617 };
618 let rule = MD043RequiredHeadings::from_config_struct(config);
619
620 let content = "# INTRODUCTION\n\n# method";
622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623 let result = rule.check(&ctx).unwrap();
624
625 assert!(
626 result.is_empty(),
627 "Should allow mixed case variations when match_case is false"
628 );
629 }
630
631 #[test]
632 fn test_config_case_sensitive_exact_match() {
633 let config = MD043Config {
634 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
635 match_case: true,
636 };
637 let rule = MD043RequiredHeadings::from_config_struct(config);
638
639 let content = "# Introduction\n\n# Method";
641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642 let result = rule.check(&ctx).unwrap();
643
644 assert!(
645 result.is_empty(),
646 "Should pass with exact case match when match_case is true"
647 );
648 }
649
650 #[test]
651 fn test_default_config() {
652 let rule = MD043RequiredHeadings::default();
653
654 let content = "# Any heading\n\n# Another heading";
656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658
659 assert!(result.is_empty(), "Should be disabled with default empty headings");
660 }
661
662 #[test]
663 fn test_default_config_section() {
664 let rule = MD043RequiredHeadings::default();
665 let config_section = rule.default_config_section();
666
667 assert!(config_section.is_some());
668 let (name, value) = config_section.unwrap();
669 assert_eq!(name, "MD043");
670
671 if let toml::Value::Table(table) = value {
673 assert!(table.contains_key("headings"));
674 assert!(table.contains_key("match-case"));
675 assert_eq!(table["headings"], toml::Value::Array(vec![]));
676 assert_eq!(table["match-case"], toml::Value::Boolean(false));
677 } else {
678 panic!("Expected TOML table");
679 }
680 }
681
682 #[test]
683 fn test_headings_match_case_sensitive() {
684 let config = MD043Config {
685 headings: vec![],
686 match_case: true,
687 };
688 let rule = MD043RequiredHeadings::from_config_struct(config);
689
690 assert!(rule.headings_match("Test", "Test"));
691 assert!(!rule.headings_match("Test", "test"));
692 assert!(!rule.headings_match("test", "Test"));
693 }
694
695 #[test]
696 fn test_headings_match_case_insensitive() {
697 let config = MD043Config {
698 headings: vec![],
699 match_case: false,
700 };
701 let rule = MD043RequiredHeadings::from_config_struct(config);
702
703 assert!(rule.headings_match("Test", "Test"));
704 assert!(rule.headings_match("Test", "test"));
705 assert!(rule.headings_match("test", "Test"));
706 assert!(rule.headings_match("TEST", "test"));
707 }
708
709 #[test]
710 fn test_config_empty_headings() {
711 let config = MD043Config {
712 headings: vec![],
713 match_case: true,
714 };
715 let rule = MD043RequiredHeadings::from_config_struct(config);
716
717 let content = "# Any heading\n\n# Another heading";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let result = rule.check(&ctx).unwrap();
721
722 assert!(result.is_empty(), "Should be disabled with empty headings list");
723 }
724
725 #[test]
726 fn test_fix_respects_configuration() {
727 let config = MD043Config {
728 headings: vec!["# Title".to_string(), "# Content".to_string()],
729 match_case: false,
730 };
731 let rule = MD043RequiredHeadings::from_config_struct(config);
732
733 let content = "Wrong content";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let fixed = rule.fix(&ctx).unwrap();
736
737 let expected = "Wrong content";
739 assert_eq!(fixed, expected);
740 }
741
742 #[test]
745 fn test_asterisk_wildcard_zero_headings() {
746 let config = MD043Config {
748 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
749 match_case: false,
750 };
751 let rule = MD043RequiredHeadings::from_config_struct(config);
752
753 let content = "# Start\n\n# End";
754 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
755 let result = rule.check(&ctx).unwrap();
756
757 assert!(result.is_empty(), "* should allow zero headings between Start and End");
758 }
759
760 #[test]
761 fn test_asterisk_wildcard_multiple_headings() {
762 let config = MD043Config {
764 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
765 match_case: false,
766 };
767 let rule = MD043RequiredHeadings::from_config_struct(config);
768
769 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772
773 assert!(
774 result.is_empty(),
775 "* should allow multiple headings between Start and End"
776 );
777 }
778
779 #[test]
780 fn test_asterisk_wildcard_at_end() {
781 let config = MD043Config {
783 headings: vec!["# Introduction".to_string(), "*".to_string()],
784 match_case: false,
785 };
786 let rule = MD043RequiredHeadings::from_config_struct(config);
787
788 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
789 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790 let result = rule.check(&ctx).unwrap();
791
792 assert!(result.is_empty(), "* at end should allow any trailing headings");
793 }
794
795 #[test]
796 fn test_plus_wildcard_requires_at_least_one() {
797 let config = MD043Config {
799 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
800 match_case: false,
801 };
802 let rule = MD043RequiredHeadings::from_config_struct(config);
803
804 let content = "# Start\n\n# End";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
807 let result = rule.check(&ctx).unwrap();
808
809 assert!(!result.is_empty(), "+ should require at least one heading");
810 }
811
812 #[test]
813 fn test_plus_wildcard_allows_multiple() {
814 let config = MD043Config {
816 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
817 match_case: false,
818 };
819 let rule = MD043RequiredHeadings::from_config_struct(config);
820
821 let content = "# Start\n\n## Middle\n\n# End";
823 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824 let result = rule.check(&ctx).unwrap();
825
826 assert!(result.is_empty(), "+ should allow one heading");
827
828 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832
833 assert!(result.is_empty(), "+ should allow multiple headings");
834 }
835
836 #[test]
837 fn test_question_wildcard_exactly_one() {
838 let config = MD043Config {
840 headings: vec!["?".to_string(), "## Description".to_string()],
841 match_case: false,
842 };
843 let rule = MD043RequiredHeadings::from_config_struct(config);
844
845 let content = "# Project Name\n\n## Description";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let result = rule.check(&ctx).unwrap();
849
850 assert!(result.is_empty(), "? should allow exactly one heading");
851 }
852
853 #[test]
854 fn test_question_wildcard_fails_with_zero() {
855 let config = MD043Config {
857 headings: vec!["?".to_string(), "## Description".to_string()],
858 match_case: false,
859 };
860 let rule = MD043RequiredHeadings::from_config_struct(config);
861
862 let content = "## Description";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
864 let result = rule.check(&ctx).unwrap();
865
866 assert!(!result.is_empty(), "? should require exactly one heading");
867 }
868
869 #[test]
870 fn test_complex_wildcard_pattern() {
871 let config = MD043Config {
873 headings: vec![
874 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
879 match_case: false,
880 };
881 let rule = MD043RequiredHeadings::from_config_struct(config);
882
883 let content = "# My Project\n\n## Overview\n\n## License";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887
888 assert!(result.is_empty(), "Complex pattern should match minimal structure");
889
890 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894
895 assert!(result.is_empty(), "Complex pattern should match with optional sections");
896 }
897
898 #[test]
899 fn test_multiple_asterisks() {
900 let config = MD043Config {
902 headings: vec![
903 "# Title".to_string(),
904 "*".to_string(),
905 "## Middle".to_string(),
906 "*".to_string(),
907 "# End".to_string(),
908 ],
909 match_case: false,
910 };
911 let rule = MD043RequiredHeadings::from_config_struct(config);
912
913 let content = "# Title\n\n## Middle\n\n# End";
914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
915 let result = rule.check(&ctx).unwrap();
916
917 assert!(result.is_empty(), "Multiple * wildcards should work");
918
919 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
921 let result = rule.check(&ctx).unwrap();
922
923 assert!(
924 result.is_empty(),
925 "Multiple * wildcards should allow flexible structure"
926 );
927 }
928
929 #[test]
930 fn test_wildcard_with_case_sensitivity() {
931 let config = MD043Config {
933 headings: vec![
934 "?".to_string(),
935 "## Description".to_string(), ],
937 match_case: true,
938 };
939 let rule = MD043RequiredHeadings::from_config_struct(config);
940
941 let content = "# Title\n\n## Description";
943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944 let result = rule.check(&ctx).unwrap();
945
946 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
947
948 let content = "# Title\n\n## description";
950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951 let result = rule.check(&ctx).unwrap();
952
953 assert!(
954 !result.is_empty(),
955 "Case-sensitive matching should detect case mismatch"
956 );
957 }
958
959 #[test]
960 fn test_all_wildcards_pattern() {
961 let config = MD043Config {
963 headings: vec!["*".to_string()],
964 match_case: false,
965 };
966 let rule = MD043RequiredHeadings::from_config_struct(config);
967
968 let content = "# Any\n\n## Headings\n\n### Work";
970 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971 let result = rule.check(&ctx).unwrap();
972
973 assert!(result.is_empty(), "* alone should allow any heading structure");
974
975 let content = "No headings here";
977 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 let result = rule.check(&ctx).unwrap();
979
980 assert!(result.is_empty(), "* alone should allow no headings");
981 }
982
983 #[test]
984 fn test_wildcard_edge_cases() {
985 let config = MD043Config {
987 headings: vec!["# Start".to_string(), "+".to_string()],
988 match_case: false,
989 };
990 let rule = MD043RequiredHeadings::from_config_struct(config);
991
992 let content = "# Start";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert!(!result.is_empty(), "+ at end should require at least one more heading");
998
999 let content = "# Start\n\n## More";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003
1004 assert!(result.is_empty(), "+ at end should allow additional headings");
1005 }
1006
1007 #[test]
1008 fn test_fix_with_wildcards() {
1009 let config = MD043Config {
1011 headings: vec!["?".to_string(), "## Description".to_string()],
1012 match_case: false,
1013 };
1014 let rule = MD043RequiredHeadings::from_config_struct(config);
1015
1016 let content = "# Project\n\n## Description";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let fixed = rule.fix(&ctx).unwrap();
1020
1021 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
1022
1023 let content = "# Project\n\n## Other";
1025 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026 let fixed = rule.fix(&ctx).unwrap();
1027
1028 assert_eq!(
1029 fixed, content,
1030 "Fix should preserve non-matching content to prevent data loss"
1031 );
1032 }
1033
1034 #[test]
1037 fn test_consecutive_wildcards() {
1038 let config = MD043Config {
1040 headings: vec![
1041 "# Start".to_string(),
1042 "*".to_string(),
1043 "+".to_string(),
1044 "# End".to_string(),
1045 ],
1046 match_case: false,
1047 };
1048 let rule = MD043RequiredHeadings::from_config_struct(config);
1049
1050 let content = "# Start\n\n## Middle\n\n# End";
1052 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1053 let result = rule.check(&ctx).unwrap();
1054
1055 assert!(result.is_empty(), "Consecutive * and + should work together");
1056
1057 let content = "# Start\n\n# End";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061
1062 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1063 }
1064
1065 #[test]
1066 fn test_question_mark_doesnt_consume_literal_match() {
1067 let config = MD043Config {
1069 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1070 match_case: false,
1071 };
1072 let rule = MD043RequiredHeadings::from_config_struct(config);
1073
1074 let content = "# Title\n\n## Description\n\n## License";
1076 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1077 let result = rule.check(&ctx).unwrap();
1078
1079 assert!(result.is_empty(), "? should consume exactly one heading");
1080
1081 let content = "## Description\n\n## License";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085
1086 assert!(!result.is_empty(), "? requires exactly one heading to match");
1087 }
1088
1089 #[test]
1090 fn test_asterisk_between_literals_complex() {
1091 let config = MD043Config {
1093 headings: vec![
1094 "# Title".to_string(),
1095 "## Section A".to_string(),
1096 "*".to_string(),
1097 "## Section B".to_string(),
1098 ],
1099 match_case: false,
1100 };
1101 let rule = MD043RequiredHeadings::from_config_struct(config);
1102
1103 let content = "# Title\n\n## Section A\n\n## Section B";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert!(result.is_empty(), "* should allow zero headings");
1109
1110 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let result = rule.check(&ctx).unwrap();
1114
1115 assert!(result.is_empty(), "* should allow multiple headings");
1116
1117 let content = "# Title\n\n## Section A\n\n### Sub1";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert!(
1123 !result.is_empty(),
1124 "Should fail when required heading after * is missing"
1125 );
1126 }
1127
1128 #[test]
1129 fn test_plus_requires_consumption() {
1130 let config = MD043Config {
1132 headings: vec!["+".to_string()],
1133 match_case: false,
1134 };
1135 let rule = MD043RequiredHeadings::from_config_struct(config);
1136
1137 let content = "No headings here";
1139 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1140 let result = rule.check(&ctx).unwrap();
1141
1142 assert!(!result.is_empty(), "+ should fail with zero headings");
1143
1144 let content = "# Any heading";
1146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert!(result.is_empty(), "+ should pass with one heading");
1150
1151 let content = "# First\n\n## Second\n\n### Third";
1153 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1154 let result = rule.check(&ctx).unwrap();
1155
1156 assert!(result.is_empty(), "+ should pass with multiple headings");
1157 }
1158
1159 #[test]
1160 fn test_mixed_wildcard_and_literal_ordering() {
1161 let config = MD043Config {
1163 headings: vec![
1164 "# A".to_string(),
1165 "*".to_string(),
1166 "# B".to_string(),
1167 "*".to_string(),
1168 "# C".to_string(),
1169 ],
1170 match_case: false,
1171 };
1172 let rule = MD043RequiredHeadings::from_config_struct(config);
1173
1174 let content = "# A\n\n# B\n\n# C";
1176 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177 let result = rule.check(&ctx).unwrap();
1178
1179 assert!(result.is_empty(), "Should match literals in correct order");
1180
1181 let content = "# A\n\n# C\n\n# B";
1183 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184 let result = rule.check(&ctx).unwrap();
1185
1186 assert!(!result.is_empty(), "Should fail when literals are out of order");
1187
1188 let content = "# A\n\n# C";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert!(!result.is_empty(), "Should fail when required literal is missing");
1194 }
1195
1196 #[test]
1197 fn test_only_wildcards_with_headings() {
1198 let config = MD043Config {
1200 headings: vec!["?".to_string(), "+".to_string()],
1201 match_case: false,
1202 };
1203 let rule = MD043RequiredHeadings::from_config_struct(config);
1204
1205 let content = "# First\n\n## Second";
1207 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1208 let result = rule.check(&ctx).unwrap();
1209
1210 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1211
1212 let content = "# First";
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216
1217 assert!(
1218 !result.is_empty(),
1219 "Should fail with only 1 heading when ? + is required"
1220 );
1221 }
1222
1223 #[test]
1224 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1225 let config = MD043Config {
1227 headings: vec![
1228 "# Start".to_string(),
1229 "*".to_string(),
1230 "## Target".to_string(),
1231 "# End".to_string(),
1232 ],
1233 match_case: false,
1234 };
1235 let rule = MD043RequiredHeadings::from_config_struct(config);
1236
1237 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241
1242 assert!(result.is_empty(), "* should correctly skip to next literal match");
1243
1244 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1247 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1248 let result = rule.check(&ctx).unwrap();
1249
1250 assert!(
1251 !result.is_empty(),
1252 "Should fail with extra headings that don't match pattern"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_wildcard_at_start() {
1258 let config = MD043Config {
1260 headings: vec!["*".to_string(), "## End".to_string()],
1261 match_case: false,
1262 };
1263 let rule = MD043RequiredHeadings::from_config_struct(config);
1264
1265 let content = "# Random\n\n## Stuff\n\n## End";
1267 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268 let result = rule.check(&ctx).unwrap();
1269
1270 assert!(result.is_empty(), "* at start should allow any preceding headings");
1271
1272 let config = MD043Config {
1274 headings: vec!["+".to_string(), "## End".to_string()],
1275 match_case: false,
1276 };
1277 let rule = MD043RequiredHeadings::from_config_struct(config);
1278
1279 let content = "## 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 require at least one heading");
1285
1286 let content = "# First\n\n## End";
1287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1288 let result = rule.check(&ctx).unwrap();
1289
1290 assert!(result.is_empty(), "+ at start should allow headings before End");
1291 }
1292
1293 #[test]
1294 fn test_wildcard_with_setext_headings() {
1295 let config = MD043Config {
1297 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1298 match_case: false,
1299 };
1300 let rule = MD043RequiredHeadings::from_config_struct(config);
1301
1302 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let result = rule.check(&ctx).unwrap();
1305
1306 assert!(result.is_empty(), "Wildcards should work with setext headings");
1307 }
1308
1309 #[test]
1310 fn test_empty_document_with_required_wildcards() {
1311 let config = MD043Config {
1313 headings: vec!["?".to_string()],
1314 match_case: false,
1315 };
1316 let rule = MD043RequiredHeadings::from_config_struct(config);
1317
1318 let content = "No headings";
1319 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1320 let result = rule.check(&ctx).unwrap();
1321
1322 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1323
1324 let config = MD043Config {
1326 headings: vec!["+".to_string()],
1327 match_case: false,
1328 };
1329 let rule = MD043RequiredHeadings::from_config_struct(config);
1330
1331 let content = "No headings";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333 let result = rule.check(&ctx).unwrap();
1334
1335 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1336 }
1337
1338 #[test]
1339 fn test_trailing_headings_after_pattern_completion() {
1340 let config = MD043Config {
1342 headings: vec!["# Title".to_string(), "## Section".to_string()],
1343 match_case: false,
1344 };
1345 let rule = MD043RequiredHeadings::from_config_struct(config);
1346
1347 let content = "# Title\n\n## Section\n\n### Extra";
1349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1350 let result = rule.check(&ctx).unwrap();
1351
1352 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1353
1354 let config = MD043Config {
1356 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1357 match_case: false,
1358 };
1359 let rule = MD043RequiredHeadings::from_config_struct(config);
1360
1361 let content = "# Title\n\n## Section\n\n### Extra";
1362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1363 let result = rule.check(&ctx).unwrap();
1364
1365 assert!(result.is_empty(), "* at end should allow trailing headings");
1366 }
1367}