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) = calculate_heading_range(i + 1, &line_info.content);
237
238 warnings.push(LintWarning {
239 rule_name: Some(self.name().to_string()),
240 line: start_line,
241 column: start_col,
242 end_line,
243 end_column: end_col,
244 message: "Heading structure does not match the required structure".to_string(),
245 severity: Severity::Warning,
246 fix: None,
247 });
248 }
249 }
250
251 if warnings.is_empty() {
254 warnings.push(LintWarning {
255 rule_name: Some(self.name().to_string()),
256 line: 1,
257 column: 1,
258 end_line: 1,
259 end_column: 2,
260 message: format!(
261 "Heading structure does not match required structure. Expected: {:?}, Found: {:?}",
262 self.config.headings, actual_headings
263 ),
264 severity: Severity::Warning,
265 fix: None,
266 });
267 }
268 }
269
270 Ok(warnings)
271 }
272
273 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
274 let content = ctx.content;
275 if self.config.headings.is_empty() {
277 return Ok(content.to_string());
278 }
279
280 let actual_headings = self.extract_headings(ctx);
281
282 let (headings_match, _, _) = self.match_headings_with_wildcards(&actual_headings, &self.config.headings);
284 if headings_match {
285 return Ok(content.to_string());
286 }
287
288 Ok(content.to_string())
302 }
303
304 fn category(&self) -> RuleCategory {
306 RuleCategory::Heading
307 }
308
309 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
311 if self.config.headings.is_empty() || ctx.content.is_empty() {
313 return true;
314 }
315
316 let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
318
319 if !has_heading {
322 let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
323 if has_required_wildcards {
324 return false; }
326 }
327
328 !has_heading
329 }
330
331 fn as_any(&self) -> &dyn std::any::Any {
332 self
333 }
334
335 fn default_config_section(&self) -> Option<(String, toml::Value)> {
336 let default_config = MD043Config::default();
337 let json_value = serde_json::to_value(&default_config).ok()?;
338 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
339 if let toml::Value::Table(table) = toml_value {
340 if !table.is_empty() {
341 Some((MD043Config::RULE_NAME.to_string(), toml::Value::Table(table)))
342 } else {
343 None
344 }
345 } else {
346 None
347 }
348 }
349
350 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
351 where
352 Self: Sized,
353 {
354 let rule_config = crate::rule_config_serde::load_rule_config::<MD043Config>(config);
355 Box::new(MD043RequiredHeadings::from_config_struct(rule_config))
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::lint_context::LintContext;
363
364 #[test]
365 fn test_extract_headings_code_blocks() {
366 let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
368 let rule = MD043RequiredHeadings::new(required);
369
370 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.";
372 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
373 let actual_headings = rule.extract_headings(&ctx);
374 assert_eq!(
375 actual_headings,
376 vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
377 "Should extract correct headings and ignore code blocks"
378 );
379
380 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.";
382 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
383 let actual_headings = rule.extract_headings(&ctx);
384 assert_eq!(
385 actual_headings,
386 vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
387 "Should extract actual headings including mismatched ones"
388 );
389 }
390
391 #[test]
392 fn test_with_document_structure() {
393 let required = vec![
395 "# Introduction".to_string(),
396 "# Method".to_string(),
397 "# Results".to_string(),
398 ];
399 let rule = MD043RequiredHeadings::new(required);
400
401 let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
403 let warnings = rule
404 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
405 .unwrap();
406 assert!(warnings.is_empty(), "Expected no warnings for matching headings");
407
408 let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
410 let warnings = rule
411 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
412 .unwrap();
413 assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
414
415 let content = "No headings here, just plain text";
417 let warnings = rule
418 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
419 .unwrap();
420 assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
421
422 let required_setext = vec![
424 "=========== Introduction".to_string(),
425 "------ Method".to_string(),
426 "======= Results".to_string(),
427 ];
428 let rule_setext = MD043RequiredHeadings::new(required_setext);
429 let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
430 let warnings = rule_setext
431 .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
432 .unwrap();
433 assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
434 }
435
436 #[test]
437 fn test_should_skip_no_false_positives() {
438 let required = vec!["Test".to_string()];
440 let rule = MD043RequiredHeadings::new(required);
441
442 let content = "This paragraph contains a # character but is not a heading";
444 assert!(
445 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
446 "Should skip content with # in normal text"
447 );
448
449 let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
451 assert!(
452 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
453 "Should skip content with heading-like syntax in code blocks"
454 );
455
456 let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
458 assert!(
459 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
460 "Should skip content with list items using dash"
461 );
462
463 let content = "Some text\n\n---\n\nMore text below the horizontal rule";
465 assert!(
466 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
467 "Should skip content with horizontal rule"
468 );
469
470 let content = "This is a normal paragraph with equals sign x = y + z";
472 assert!(
473 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
474 "Should skip content with equals sign in normal text"
475 );
476
477 let content = "This is a normal paragraph with minus sign x - y = z";
479 assert!(
480 rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
481 "Should skip content with minus sign in normal text"
482 );
483 }
484
485 #[test]
486 fn test_should_skip_heading_detection() {
487 let required = vec!["Test".to_string()];
489 let rule = MD043RequiredHeadings::new(required);
490
491 let content = "# This is a heading\n\nAnd some content";
493 assert!(
494 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
495 "Should not skip content with ATX heading"
496 );
497
498 let content = "This is a heading\n================\n\nAnd some content";
500 assert!(
501 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
502 "Should not skip content with Setext heading (=)"
503 );
504
505 let content = "This is a subheading\n------------------\n\nAnd some content";
507 assert!(
508 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
509 "Should not skip content with Setext heading (-)"
510 );
511
512 let content = "## This is a heading ##\n\nAnd some content";
514 assert!(
515 !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
516 "Should not skip content with ATX heading with closing hashes"
517 );
518 }
519
520 #[test]
521 fn test_config_match_case_sensitive() {
522 let config = MD043Config {
523 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
524 match_case: true,
525 };
526 let rule = MD043RequiredHeadings::from_config_struct(config);
527
528 let content = "# introduction\n\n# method";
530 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
531 let result = rule.check(&ctx).unwrap();
532
533 assert!(
534 !result.is_empty(),
535 "Should detect case mismatch when match_case is true"
536 );
537 }
538
539 #[test]
540 fn test_config_match_case_insensitive() {
541 let config = MD043Config {
542 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
543 match_case: false,
544 };
545 let rule = MD043RequiredHeadings::from_config_struct(config);
546
547 let content = "# introduction\n\n# method";
549 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
550 let result = rule.check(&ctx).unwrap();
551
552 assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
553 }
554
555 #[test]
556 fn test_config_case_insensitive_mixed() {
557 let config = MD043Config {
558 headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
559 match_case: false,
560 };
561 let rule = MD043RequiredHeadings::from_config_struct(config);
562
563 let content = "# INTRODUCTION\n\n# method";
565 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
566 let result = rule.check(&ctx).unwrap();
567
568 assert!(
569 result.is_empty(),
570 "Should allow mixed case variations when match_case is false"
571 );
572 }
573
574 #[test]
575 fn test_config_case_sensitive_exact_match() {
576 let config = MD043Config {
577 headings: vec!["# Introduction".to_string(), "# Method".to_string()],
578 match_case: true,
579 };
580 let rule = MD043RequiredHeadings::from_config_struct(config);
581
582 let content = "# Introduction\n\n# Method";
584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
585 let result = rule.check(&ctx).unwrap();
586
587 assert!(
588 result.is_empty(),
589 "Should pass with exact case match when match_case is true"
590 );
591 }
592
593 #[test]
594 fn test_default_config() {
595 let rule = MD043RequiredHeadings::default();
596
597 let content = "# Any heading\n\n# Another heading";
599 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
600 let result = rule.check(&ctx).unwrap();
601
602 assert!(result.is_empty(), "Should be disabled with default empty headings");
603 }
604
605 #[test]
606 fn test_default_config_section() {
607 let rule = MD043RequiredHeadings::default();
608 let config_section = rule.default_config_section();
609
610 assert!(config_section.is_some());
611 let (name, value) = config_section.unwrap();
612 assert_eq!(name, "MD043");
613
614 if let toml::Value::Table(table) = value {
616 assert!(table.contains_key("headings"));
617 assert!(table.contains_key("match-case"));
618 assert_eq!(table["headings"], toml::Value::Array(vec![]));
619 assert_eq!(table["match-case"], toml::Value::Boolean(false));
620 } else {
621 panic!("Expected TOML table");
622 }
623 }
624
625 #[test]
626 fn test_headings_match_case_sensitive() {
627 let config = MD043Config {
628 headings: vec![],
629 match_case: true,
630 };
631 let rule = MD043RequiredHeadings::from_config_struct(config);
632
633 assert!(rule.headings_match("Test", "Test"));
634 assert!(!rule.headings_match("Test", "test"));
635 assert!(!rule.headings_match("test", "Test"));
636 }
637
638 #[test]
639 fn test_headings_match_case_insensitive() {
640 let config = MD043Config {
641 headings: vec![],
642 match_case: false,
643 };
644 let rule = MD043RequiredHeadings::from_config_struct(config);
645
646 assert!(rule.headings_match("Test", "Test"));
647 assert!(rule.headings_match("Test", "test"));
648 assert!(rule.headings_match("test", "Test"));
649 assert!(rule.headings_match("TEST", "test"));
650 }
651
652 #[test]
653 fn test_config_empty_headings() {
654 let config = MD043Config {
655 headings: vec![],
656 match_case: true,
657 };
658 let rule = MD043RequiredHeadings::from_config_struct(config);
659
660 let content = "# Any heading\n\n# Another heading";
662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = rule.check(&ctx).unwrap();
664
665 assert!(result.is_empty(), "Should be disabled with empty headings list");
666 }
667
668 #[test]
669 fn test_fix_respects_configuration() {
670 let config = MD043Config {
671 headings: vec!["# Title".to_string(), "# Content".to_string()],
672 match_case: false,
673 };
674 let rule = MD043RequiredHeadings::from_config_struct(config);
675
676 let content = "Wrong content";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678 let fixed = rule.fix(&ctx).unwrap();
679
680 let expected = "Wrong content";
682 assert_eq!(fixed, expected);
683 }
684
685 #[test]
688 fn test_asterisk_wildcard_zero_headings() {
689 let config = MD043Config {
691 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
692 match_case: false,
693 };
694 let rule = MD043RequiredHeadings::from_config_struct(config);
695
696 let content = "# Start\n\n# End";
697 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
698 let result = rule.check(&ctx).unwrap();
699
700 assert!(result.is_empty(), "* should allow zero headings between Start and End");
701 }
702
703 #[test]
704 fn test_asterisk_wildcard_multiple_headings() {
705 let config = MD043Config {
707 headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
708 match_case: false,
709 };
710 let rule = MD043RequiredHeadings::from_config_struct(config);
711
712 let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
714 let result = rule.check(&ctx).unwrap();
715
716 assert!(
717 result.is_empty(),
718 "* should allow multiple headings between Start and End"
719 );
720 }
721
722 #[test]
723 fn test_asterisk_wildcard_at_end() {
724 let config = MD043Config {
726 headings: vec!["# Introduction".to_string(), "*".to_string()],
727 match_case: false,
728 };
729 let rule = MD043RequiredHeadings::from_config_struct(config);
730
731 let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
733 let result = rule.check(&ctx).unwrap();
734
735 assert!(result.is_empty(), "* at end should allow any trailing headings");
736 }
737
738 #[test]
739 fn test_plus_wildcard_requires_at_least_one() {
740 let config = MD043Config {
742 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
743 match_case: false,
744 };
745 let rule = MD043RequiredHeadings::from_config_struct(config);
746
747 let content = "# Start\n\n# End";
749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
750 let result = rule.check(&ctx).unwrap();
751
752 assert!(!result.is_empty(), "+ should require at least one heading");
753 }
754
755 #[test]
756 fn test_plus_wildcard_allows_multiple() {
757 let config = MD043Config {
759 headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
760 match_case: false,
761 };
762 let rule = MD043RequiredHeadings::from_config_struct(config);
763
764 let content = "# Start\n\n## Middle\n\n# End";
766 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
767 let result = rule.check(&ctx).unwrap();
768
769 assert!(result.is_empty(), "+ should allow one heading");
770
771 let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
774 let result = rule.check(&ctx).unwrap();
775
776 assert!(result.is_empty(), "+ should allow multiple headings");
777 }
778
779 #[test]
780 fn test_question_wildcard_exactly_one() {
781 let config = MD043Config {
783 headings: vec!["?".to_string(), "## Description".to_string()],
784 match_case: false,
785 };
786 let rule = MD043RequiredHeadings::from_config_struct(config);
787
788 let content = "# Project Name\n\n## Description";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
791 let result = rule.check(&ctx).unwrap();
792
793 assert!(result.is_empty(), "? should allow exactly one heading");
794 }
795
796 #[test]
797 fn test_question_wildcard_fails_with_zero() {
798 let config = MD043Config {
800 headings: vec!["?".to_string(), "## Description".to_string()],
801 match_case: false,
802 };
803 let rule = MD043RequiredHeadings::from_config_struct(config);
804
805 let content = "## Description";
806 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
807 let result = rule.check(&ctx).unwrap();
808
809 assert!(!result.is_empty(), "? should require exactly one heading");
810 }
811
812 #[test]
813 fn test_complex_wildcard_pattern() {
814 let config = MD043Config {
816 headings: vec![
817 "?".to_string(), "## Overview".to_string(), "*".to_string(), "## License".to_string(), ],
822 match_case: false,
823 };
824 let rule = MD043RequiredHeadings::from_config_struct(config);
825
826 let content = "# My Project\n\n## Overview\n\n## License";
828 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
829 let result = rule.check(&ctx).unwrap();
830
831 assert!(result.is_empty(), "Complex pattern should match minimal structure");
832
833 let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
836 let result = rule.check(&ctx).unwrap();
837
838 assert!(result.is_empty(), "Complex pattern should match with optional sections");
839 }
840
841 #[test]
842 fn test_multiple_asterisks() {
843 let config = MD043Config {
845 headings: vec![
846 "# Title".to_string(),
847 "*".to_string(),
848 "## Middle".to_string(),
849 "*".to_string(),
850 "# End".to_string(),
851 ],
852 match_case: false,
853 };
854 let rule = MD043RequiredHeadings::from_config_struct(config);
855
856 let content = "# Title\n\n## Middle\n\n# End";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
858 let result = rule.check(&ctx).unwrap();
859
860 assert!(result.is_empty(), "Multiple * wildcards should work");
861
862 let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
863 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
864 let result = rule.check(&ctx).unwrap();
865
866 assert!(
867 result.is_empty(),
868 "Multiple * wildcards should allow flexible structure"
869 );
870 }
871
872 #[test]
873 fn test_wildcard_with_case_sensitivity() {
874 let config = MD043Config {
876 headings: vec![
877 "?".to_string(),
878 "## Description".to_string(), ],
880 match_case: true,
881 };
882 let rule = MD043RequiredHeadings::from_config_struct(config);
883
884 let content = "# Title\n\n## Description";
886 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
887 let result = rule.check(&ctx).unwrap();
888
889 assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
890
891 let content = "# Title\n\n## description";
893 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
894 let result = rule.check(&ctx).unwrap();
895
896 assert!(
897 !result.is_empty(),
898 "Case-sensitive matching should detect case mismatch"
899 );
900 }
901
902 #[test]
903 fn test_all_wildcards_pattern() {
904 let config = MD043Config {
906 headings: vec!["*".to_string()],
907 match_case: false,
908 };
909 let rule = MD043RequiredHeadings::from_config_struct(config);
910
911 let content = "# Any\n\n## Headings\n\n### Work";
913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
914 let result = rule.check(&ctx).unwrap();
915
916 assert!(result.is_empty(), "* alone should allow any heading structure");
917
918 let content = "No headings here";
920 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
921 let result = rule.check(&ctx).unwrap();
922
923 assert!(result.is_empty(), "* alone should allow no headings");
924 }
925
926 #[test]
927 fn test_wildcard_edge_cases() {
928 let config = MD043Config {
930 headings: vec!["# Start".to_string(), "+".to_string()],
931 match_case: false,
932 };
933 let rule = MD043RequiredHeadings::from_config_struct(config);
934
935 let content = "# Start";
937 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
938 let result = rule.check(&ctx).unwrap();
939
940 assert!(!result.is_empty(), "+ at end should require at least one more heading");
941
942 let content = "# Start\n\n## More";
944 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
945 let result = rule.check(&ctx).unwrap();
946
947 assert!(result.is_empty(), "+ at end should allow additional headings");
948 }
949
950 #[test]
951 fn test_fix_with_wildcards() {
952 let config = MD043Config {
954 headings: vec!["?".to_string(), "## Description".to_string()],
955 match_case: false,
956 };
957 let rule = MD043RequiredHeadings::from_config_struct(config);
958
959 let content = "# Project\n\n## Description";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
962 let fixed = rule.fix(&ctx).unwrap();
963
964 assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
965
966 let content = "# Project\n\n## Other";
968 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
969 let fixed = rule.fix(&ctx).unwrap();
970
971 assert_eq!(
972 fixed, content,
973 "Fix should preserve non-matching content to prevent data loss"
974 );
975 }
976
977 #[test]
980 fn test_consecutive_wildcards() {
981 let config = MD043Config {
983 headings: vec![
984 "# Start".to_string(),
985 "*".to_string(),
986 "+".to_string(),
987 "# End".to_string(),
988 ],
989 match_case: false,
990 };
991 let rule = MD043RequiredHeadings::from_config_struct(config);
992
993 let content = "# Start\n\n## Middle\n\n# End";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
996 let result = rule.check(&ctx).unwrap();
997
998 assert!(result.is_empty(), "Consecutive * and + should work together");
999
1000 let content = "# Start\n\n# End";
1002 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1003 let result = rule.check(&ctx).unwrap();
1004
1005 assert!(!result.is_empty(), "Should fail when + is not satisfied");
1006 }
1007
1008 #[test]
1009 fn test_question_mark_doesnt_consume_literal_match() {
1010 let config = MD043Config {
1012 headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1013 match_case: false,
1014 };
1015 let rule = MD043RequiredHeadings::from_config_struct(config);
1016
1017 let content = "# Title\n\n## Description\n\n## License";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1020 let result = rule.check(&ctx).unwrap();
1021
1022 assert!(result.is_empty(), "? should consume exactly one heading");
1023
1024 let content = "## Description\n\n## License";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1027 let result = rule.check(&ctx).unwrap();
1028
1029 assert!(!result.is_empty(), "? requires exactly one heading to match");
1030 }
1031
1032 #[test]
1033 fn test_asterisk_between_literals_complex() {
1034 let config = MD043Config {
1036 headings: vec![
1037 "# Title".to_string(),
1038 "## Section A".to_string(),
1039 "*".to_string(),
1040 "## Section B".to_string(),
1041 ],
1042 match_case: false,
1043 };
1044 let rule = MD043RequiredHeadings::from_config_struct(config);
1045
1046 let content = "# Title\n\n## Section A\n\n## Section B";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert!(result.is_empty(), "* should allow zero headings");
1052
1053 let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1056 let result = rule.check(&ctx).unwrap();
1057
1058 assert!(result.is_empty(), "* should allow multiple headings");
1059
1060 let content = "# Title\n\n## Section A\n\n### Sub1";
1062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1063 let result = rule.check(&ctx).unwrap();
1064
1065 assert!(
1066 !result.is_empty(),
1067 "Should fail when required heading after * is missing"
1068 );
1069 }
1070
1071 #[test]
1072 fn test_plus_requires_consumption() {
1073 let config = MD043Config {
1075 headings: vec!["+".to_string()],
1076 match_case: false,
1077 };
1078 let rule = MD043RequiredHeadings::from_config_struct(config);
1079
1080 let content = "No headings here";
1082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1083 let result = rule.check(&ctx).unwrap();
1084
1085 assert!(!result.is_empty(), "+ should fail with zero headings");
1086
1087 let content = "# Any heading";
1089 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1090 let result = rule.check(&ctx).unwrap();
1091
1092 assert!(result.is_empty(), "+ should pass with one heading");
1093
1094 let content = "# First\n\n## Second\n\n### Third";
1096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1097 let result = rule.check(&ctx).unwrap();
1098
1099 assert!(result.is_empty(), "+ should pass with multiple headings");
1100 }
1101
1102 #[test]
1103 fn test_mixed_wildcard_and_literal_ordering() {
1104 let config = MD043Config {
1106 headings: vec![
1107 "# A".to_string(),
1108 "*".to_string(),
1109 "# B".to_string(),
1110 "*".to_string(),
1111 "# C".to_string(),
1112 ],
1113 match_case: false,
1114 };
1115 let rule = MD043RequiredHeadings::from_config_struct(config);
1116
1117 let content = "# A\n\n# B\n\n# C";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert!(result.is_empty(), "Should match literals in correct order");
1123
1124 let content = "# A\n\n# C\n\n# B";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1127 let result = rule.check(&ctx).unwrap();
1128
1129 assert!(!result.is_empty(), "Should fail when literals are out of order");
1130
1131 let content = "# A\n\n# C";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert!(!result.is_empty(), "Should fail when required literal is missing");
1137 }
1138
1139 #[test]
1140 fn test_only_wildcards_with_headings() {
1141 let config = MD043Config {
1143 headings: vec!["?".to_string(), "+".to_string()],
1144 match_case: false,
1145 };
1146 let rule = MD043RequiredHeadings::from_config_struct(config);
1147
1148 let content = "# First\n\n## Second";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1151 let result = rule.check(&ctx).unwrap();
1152
1153 assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1154
1155 let content = "# First";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert!(
1161 !result.is_empty(),
1162 "Should fail with only 1 heading when ? + is required"
1163 );
1164 }
1165
1166 #[test]
1167 fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1168 let config = MD043Config {
1170 headings: vec![
1171 "# Start".to_string(),
1172 "*".to_string(),
1173 "## Target".to_string(),
1174 "# End".to_string(),
1175 ],
1176 match_case: false,
1177 };
1178 let rule = MD043RequiredHeadings::from_config_struct(config);
1179
1180 let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1183 let result = rule.check(&ctx).unwrap();
1184
1185 assert!(result.is_empty(), "* should correctly skip to next literal match");
1186
1187 let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1190 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1191 let result = rule.check(&ctx).unwrap();
1192
1193 assert!(
1194 !result.is_empty(),
1195 "Should fail with extra headings that don't match pattern"
1196 );
1197 }
1198
1199 #[test]
1200 fn test_wildcard_at_start() {
1201 let config = MD043Config {
1203 headings: vec!["*".to_string(), "## End".to_string()],
1204 match_case: false,
1205 };
1206 let rule = MD043RequiredHeadings::from_config_struct(config);
1207
1208 let content = "# Random\n\n## Stuff\n\n## End";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1211 let result = rule.check(&ctx).unwrap();
1212
1213 assert!(result.is_empty(), "* at start should allow any preceding headings");
1214
1215 let config = MD043Config {
1217 headings: vec!["+".to_string(), "## End".to_string()],
1218 match_case: false,
1219 };
1220 let rule = MD043RequiredHeadings::from_config_struct(config);
1221
1222 let content = "## End";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert!(!result.is_empty(), "+ at start should require at least one heading");
1228
1229 let content = "# First\n\n## End";
1230 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1231 let result = rule.check(&ctx).unwrap();
1232
1233 assert!(result.is_empty(), "+ at start should allow headings before End");
1234 }
1235
1236 #[test]
1237 fn test_wildcard_with_setext_headings() {
1238 let config = MD043Config {
1240 headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1241 match_case: false,
1242 };
1243 let rule = MD043RequiredHeadings::from_config_struct(config);
1244
1245 let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1246 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1247 let result = rule.check(&ctx).unwrap();
1248
1249 assert!(result.is_empty(), "Wildcards should work with setext headings");
1250 }
1251
1252 #[test]
1253 fn test_empty_document_with_required_wildcards() {
1254 let config = MD043Config {
1256 headings: vec!["?".to_string()],
1257 match_case: false,
1258 };
1259 let rule = MD043RequiredHeadings::from_config_struct(config);
1260
1261 let content = "No headings";
1262 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1263 let result = rule.check(&ctx).unwrap();
1264
1265 assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1266
1267 let config = MD043Config {
1269 headings: vec!["+".to_string()],
1270 match_case: false,
1271 };
1272 let rule = MD043RequiredHeadings::from_config_struct(config);
1273
1274 let content = "No headings";
1275 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1276 let result = rule.check(&ctx).unwrap();
1277
1278 assert!(!result.is_empty(), "Empty document should fail with + requirement");
1279 }
1280
1281 #[test]
1282 fn test_trailing_headings_after_pattern_completion() {
1283 let config = MD043Config {
1285 headings: vec!["# Title".to_string(), "## Section".to_string()],
1286 match_case: false,
1287 };
1288 let rule = MD043RequiredHeadings::from_config_struct(config);
1289
1290 let content = "# Title\n\n## Section\n\n### Extra";
1292 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1293 let result = rule.check(&ctx).unwrap();
1294
1295 assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1296
1297 let config = MD043Config {
1299 headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1300 match_case: false,
1301 };
1302 let rule = MD043RequiredHeadings::from_config_struct(config);
1303
1304 let content = "# Title\n\n## Section\n\n### Extra";
1305 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1306 let result = rule.check(&ctx).unwrap();
1307
1308 assert!(result.is_empty(), "* at end should allow trailing headings");
1309 }
1310}