Skip to main content

rumdl_lib/rules/
md043_required_headings.rs

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/// Configuration for MD043 rule
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8#[serde(rename_all = "kebab-case")]
9pub struct MD043Config {
10    /// Required heading patterns
11    #[serde(default = "default_headings")]
12    pub headings: Vec<String>,
13    /// Case-sensitive matching (default: false)
14    #[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/// Rule MD043: Required headings present
40///
41/// See [docs/md043.md](../../docs/md043.md) for full documentation, configuration, and examples.
42#[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    /// Create a new instance with the given configuration
58    pub fn from_config_struct(config: MD043Config) -> Self {
59        Self { config }
60    }
61
62    /// Compare two headings based on the match_case configuration
63    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                // Skip invalid headings (e.g., `#NoSpace` which lacks required space after #)
77                if !heading.is_valid {
78                    continue;
79                }
80
81                // Reconstruct the full heading format with the hash symbols
82                let full_heading = format!("{} {}", heading.marker, heading.text.trim());
83                result.push(full_heading);
84            }
85        }
86
87        result
88    }
89
90    /// Match headings against patterns with wildcard support
91    ///
92    /// Wildcards:
93    /// - `*` - Zero or more unspecified headings
94    /// - `+` - One or more unspecified headings
95    /// - `?` - Exactly one unspecified heading
96    ///
97    /// Returns (matched, expected_index, actual_index) indicating whether
98    /// all patterns were satisfied and the final positions in both sequences.
99    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; // Flexible matching mode for * and +
107
108        while exp_idx < expected_patterns.len() && act_idx < actual_headings.len() {
109            let pattern = &expected_patterns[exp_idx];
110
111            if pattern == "*" {
112                // Zero or more headings: peek ahead to next required pattern
113                exp_idx += 1;
114                if exp_idx >= expected_patterns.len() {
115                    // * at end means rest of headings are allowed
116                    return (true, exp_idx, actual_headings.len());
117                }
118                // Enable flexible matching until we find next required pattern
119                match_any = true;
120                continue;
121            } else if pattern == "+" {
122                // One or more headings: consume at least one
123                if act_idx >= actual_headings.len() {
124                    return (false, exp_idx, act_idx); // Need at least one heading
125                }
126                act_idx += 1;
127                exp_idx += 1;
128                // Enable flexible matching for remaining headings
129                match_any = true;
130                // If + is at the end, consume all remaining headings
131                if exp_idx >= expected_patterns.len() {
132                    return (true, exp_idx, actual_headings.len());
133                }
134                continue;
135            } else if pattern == "?" {
136                // Exactly one unspecified heading
137                act_idx += 1;
138                exp_idx += 1;
139                match_any = false;
140                continue;
141            }
142
143            // Literal pattern matching
144            let actual = &actual_headings[act_idx];
145            if self.headings_match(pattern, actual) {
146                // Exact match found
147                act_idx += 1;
148                exp_idx += 1;
149                match_any = false;
150            } else if match_any {
151                // In flexible mode, try next heading
152                act_idx += 1;
153            } else {
154                // No match and not in flexible mode
155                return (false, exp_idx, act_idx);
156            }
157        }
158
159        // Handle remaining patterns
160        while exp_idx < expected_patterns.len() {
161            let pattern = &expected_patterns[exp_idx];
162            if pattern == "*" {
163                // * allows zero headings, continue
164                exp_idx += 1;
165            } else if pattern == "+" {
166                // + requires at least one heading but we're out of headings
167                return (false, exp_idx, act_idx);
168            } else if pattern == "?" {
169                // ? requires exactly one heading but we're out
170                return (false, exp_idx, act_idx);
171            } else {
172                // Literal pattern not satisfied
173                return (false, exp_idx, act_idx);
174            }
175        }
176
177        // Check if we consumed all actual headings
178        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 no required headings are specified, the rule is disabled
209        if self.config.headings.is_empty() {
210            return Ok(warnings);
211        }
212
213        // Check if all patterns are only * wildcards (which allow zero headings)
214        let all_optional_wildcards = self.config.headings.iter().all(|p| p == "*");
215        if actual_headings.is_empty() && all_optional_wildcards {
216            // Allow empty documents when only * wildcards are specified
217            // (? and + require at least some headings)
218            return Ok(warnings);
219        }
220
221        // Use wildcard matching for pattern support
222        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 no headings found but we have required headings, create a warning
227            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            // Create warnings for each heading that doesn't match
242            for (i, line_info) in ctx.lines.iter().enumerate() {
243                if self.is_heading(i, ctx) {
244                    // Calculate precise character range for the entire heading
245                    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 we have no warnings but headings don't match (could happen if we have no headings),
262            // add a warning at the beginning of the file
263            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 no required headings are specified, return content as is
286        if self.config.headings.is_empty() {
287            return Ok(content.to_string());
288        }
289
290        let actual_headings = self.extract_headings(ctx);
291
292        // Check if headings already match using wildcard support - if so, no fix needed
293        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        // Auto-fixing MD043 would require restructuring the document (inserting,
299        // renaming, or reordering headings), which risks data loss. Return the
300        // content unchanged and let the user address the violation manually.
301        Ok(content.to_string())
302    }
303
304    /// Check if this rule should be skipped
305    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
306        // Skip if no heading requirements or content is empty
307        if self.config.headings.is_empty() || ctx.content.is_empty() {
308            return true;
309        }
310
311        // Check if any heading exists using cached information
312        let has_heading = ctx.lines.iter().any(|line| line.heading.is_some());
313
314        // Don't skip if we have wildcard requirements that need headings (? or +)
315        // even when no headings exist, because we need to report the error
316        if !has_heading {
317            let has_required_wildcards = self.config.headings.iter().any(|p| p == "?" || p == "+");
318            if has_required_wildcards {
319                return false; // Don't skip - we need to check and report error
320            }
321        }
322
323        !has_heading
324    }
325
326    fn as_any(&self) -> &dyn std::any::Any {
327        self
328    }
329
330    crate::impl_rule_config_methods!(MD043Config);
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use crate::lint_context::LintContext;
337
338    #[test]
339    fn test_extract_headings_code_blocks() {
340        // Create rule with required headings (now with hash symbols)
341        let required = vec!["# Test Document".to_string(), "## Real heading 2".to_string()];
342        let rule = MD043RequiredHeadings::new(required);
343
344        // Test 1: Basic content with code block
345        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.";
346        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
347        let actual_headings = rule.extract_headings(&ctx);
348        assert_eq!(
349            actual_headings,
350            vec!["# Test Document".to_string(), "## Real heading 2".to_string()],
351            "Should extract correct headings and ignore code blocks"
352        );
353
354        // Test 2: Content with invalid headings
355        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.";
356        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
357        let actual_headings = rule.extract_headings(&ctx);
358        assert_eq!(
359            actual_headings,
360            vec!["# Test Document".to_string(), "## Not Real heading 2".to_string()],
361            "Should extract actual headings including mismatched ones"
362        );
363    }
364
365    #[test]
366    fn test_with_document_structure() {
367        // Test with required headings (now with hash symbols)
368        let required = vec![
369            "# Introduction".to_string(),
370            "# Method".to_string(),
371            "# Results".to_string(),
372        ];
373        let rule = MD043RequiredHeadings::new(required);
374
375        // Test with matching headings
376        let content = "# Introduction\n\nContent\n\n# Method\n\nMore content\n\n# Results\n\nFinal content";
377        let warnings = rule
378            .check(&LintContext::new(
379                content,
380                crate::config::MarkdownFlavor::Standard,
381                None,
382            ))
383            .unwrap();
384        assert!(warnings.is_empty(), "Expected no warnings for matching headings");
385
386        // Test with mismatched headings
387        let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
388        let warnings = rule
389            .check(&LintContext::new(
390                content,
391                crate::config::MarkdownFlavor::Standard,
392                None,
393            ))
394            .unwrap();
395        assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
396
397        // Test with no headings but requirements exist
398        let content = "No headings here, just plain text";
399        let warnings = rule
400            .check(&LintContext::new(
401                content,
402                crate::config::MarkdownFlavor::Standard,
403                None,
404            ))
405            .unwrap();
406        assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
407
408        // Test with setext headings - use the correct format (marker text)
409        let required_setext = vec![
410            "=========== Introduction".to_string(),
411            "------ Method".to_string(),
412            "======= Results".to_string(),
413        ];
414        let rule_setext = MD043RequiredHeadings::new(required_setext);
415        let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
416        let warnings = rule_setext
417            .check(&LintContext::new(
418                content,
419                crate::config::MarkdownFlavor::Standard,
420                None,
421            ))
422            .unwrap();
423        assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
424    }
425
426    #[test]
427    fn test_should_skip_no_false_positives() {
428        // Create rule with required headings
429        let required = vec!["Test".to_string()];
430        let rule = MD043RequiredHeadings::new(required);
431
432        // Test 1: Content with '#' character in normal text (not a heading)
433        let content = "This paragraph contains a # character but is not a heading";
434        assert!(
435            rule.should_skip(&LintContext::new(
436                content,
437                crate::config::MarkdownFlavor::Standard,
438                None
439            )),
440            "Should skip content with # in normal text"
441        );
442
443        // Test 2: Content with code block containing heading-like syntax
444        let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
445        assert!(
446            rule.should_skip(&LintContext::new(
447                content,
448                crate::config::MarkdownFlavor::Standard,
449                None
450            )),
451            "Should skip content with heading-like syntax in code blocks"
452        );
453
454        // Test 3: Content with list items using '-' character
455        let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
456        assert!(
457            rule.should_skip(&LintContext::new(
458                content,
459                crate::config::MarkdownFlavor::Standard,
460                None
461            )),
462            "Should skip content with list items using dash"
463        );
464
465        // Test 4: Content with horizontal rule that uses '---'
466        let content = "Some text\n\n---\n\nMore text below the horizontal rule";
467        assert!(
468            rule.should_skip(&LintContext::new(
469                content,
470                crate::config::MarkdownFlavor::Standard,
471                None
472            )),
473            "Should skip content with horizontal rule"
474        );
475
476        // Test 5: Content with equals sign in normal text
477        let content = "This is a normal paragraph with equals sign x = y + z";
478        assert!(
479            rule.should_skip(&LintContext::new(
480                content,
481                crate::config::MarkdownFlavor::Standard,
482                None
483            )),
484            "Should skip content with equals sign in normal text"
485        );
486
487        // Test 6: Content with dash/minus in normal text
488        let content = "This is a normal paragraph with minus sign x - y = z";
489        assert!(
490            rule.should_skip(&LintContext::new(
491                content,
492                crate::config::MarkdownFlavor::Standard,
493                None
494            )),
495            "Should skip content with minus sign in normal text"
496        );
497    }
498
499    #[test]
500    fn test_should_skip_heading_detection() {
501        // Create rule with required headings
502        let required = vec!["Test".to_string()];
503        let rule = MD043RequiredHeadings::new(required);
504
505        // Test 1: Content with ATX heading
506        let content = "# This is a heading\n\nAnd some content";
507        assert!(
508            !rule.should_skip(&LintContext::new(
509                content,
510                crate::config::MarkdownFlavor::Standard,
511                None
512            )),
513            "Should not skip content with ATX heading"
514        );
515
516        // Test 2: Content with Setext heading (equals sign)
517        let content = "This is a heading\n================\n\nAnd some content";
518        assert!(
519            !rule.should_skip(&LintContext::new(
520                content,
521                crate::config::MarkdownFlavor::Standard,
522                None
523            )),
524            "Should not skip content with Setext heading (=)"
525        );
526
527        // Test 3: Content with Setext heading (dash)
528        let content = "This is a subheading\n------------------\n\nAnd some content";
529        assert!(
530            !rule.should_skip(&LintContext::new(
531                content,
532                crate::config::MarkdownFlavor::Standard,
533                None
534            )),
535            "Should not skip content with Setext heading (-)"
536        );
537
538        // Test 4: Content with ATX heading with closing hashes
539        let content = "## This is a heading ##\n\nAnd some content";
540        assert!(
541            !rule.should_skip(&LintContext::new(
542                content,
543                crate::config::MarkdownFlavor::Standard,
544                None
545            )),
546            "Should not skip content with ATX heading with closing hashes"
547        );
548    }
549
550    #[test]
551    fn test_config_match_case_sensitive() {
552        let config = MD043Config {
553            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
554            match_case: true,
555        };
556        let rule = MD043RequiredHeadings::from_config_struct(config);
557
558        // Should fail with different case
559        let content = "# introduction\n\n# method";
560        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
561        let result = rule.check(&ctx).unwrap();
562
563        assert!(
564            !result.is_empty(),
565            "Should detect case mismatch when match_case is true"
566        );
567    }
568
569    #[test]
570    fn test_config_match_case_insensitive() {
571        let config = MD043Config {
572            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
573            match_case: false,
574        };
575        let rule = MD043RequiredHeadings::from_config_struct(config);
576
577        // Should pass with different case
578        let content = "# introduction\n\n# method";
579        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
580        let result = rule.check(&ctx).unwrap();
581
582        assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
583    }
584
585    #[test]
586    fn test_config_case_insensitive_mixed() {
587        let config = MD043Config {
588            headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
589            match_case: false,
590        };
591        let rule = MD043RequiredHeadings::from_config_struct(config);
592
593        // Should pass with mixed case variations
594        let content = "# INTRODUCTION\n\n# method";
595        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596        let result = rule.check(&ctx).unwrap();
597
598        assert!(
599            result.is_empty(),
600            "Should allow mixed case variations when match_case is false"
601        );
602    }
603
604    #[test]
605    fn test_config_case_sensitive_exact_match() {
606        let config = MD043Config {
607            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
608            match_case: true,
609        };
610        let rule = MD043RequiredHeadings::from_config_struct(config);
611
612        // Should pass with exact case match
613        let content = "# Introduction\n\n# Method";
614        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615        let result = rule.check(&ctx).unwrap();
616
617        assert!(
618            result.is_empty(),
619            "Should pass with exact case match when match_case is true"
620        );
621    }
622
623    #[test]
624    fn test_default_config() {
625        let rule = MD043RequiredHeadings::default();
626
627        // Should be disabled with empty headings
628        let content = "# Any heading\n\n# Another heading";
629        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630        let result = rule.check(&ctx).unwrap();
631
632        assert!(result.is_empty(), "Should be disabled with default empty headings");
633    }
634
635    #[test]
636    fn test_default_config_section() {
637        let rule = MD043RequiredHeadings::default();
638        let config_section = rule.default_config_section();
639
640        assert!(config_section.is_some());
641        let (name, value) = config_section.unwrap();
642        assert_eq!(name, "MD043");
643
644        // Should contain both headings and match_case options with default values
645        if let toml::Value::Table(table) = value {
646            assert!(table.contains_key("headings"));
647            assert!(table.contains_key("match-case"));
648            assert_eq!(table["headings"], toml::Value::Array(vec![]));
649            assert_eq!(table["match-case"], toml::Value::Boolean(false));
650        } else {
651            panic!("Expected TOML table");
652        }
653    }
654
655    #[test]
656    fn test_headings_match_case_sensitive() {
657        let config = MD043Config {
658            headings: vec![],
659            match_case: true,
660        };
661        let rule = MD043RequiredHeadings::from_config_struct(config);
662
663        assert!(rule.headings_match("Test", "Test"));
664        assert!(!rule.headings_match("Test", "test"));
665        assert!(!rule.headings_match("test", "Test"));
666    }
667
668    #[test]
669    fn test_headings_match_case_insensitive() {
670        let config = MD043Config {
671            headings: vec![],
672            match_case: false,
673        };
674        let rule = MD043RequiredHeadings::from_config_struct(config);
675
676        assert!(rule.headings_match("Test", "Test"));
677        assert!(rule.headings_match("Test", "test"));
678        assert!(rule.headings_match("test", "Test"));
679        assert!(rule.headings_match("TEST", "test"));
680    }
681
682    #[test]
683    fn test_config_empty_headings() {
684        let config = MD043Config {
685            headings: vec![],
686            match_case: true,
687        };
688        let rule = MD043RequiredHeadings::from_config_struct(config);
689
690        // Should skip processing when no headings are required
691        let content = "# Any heading\n\n# Another heading";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.check(&ctx).unwrap();
694
695        assert!(result.is_empty(), "Should be disabled with empty headings list");
696    }
697
698    #[test]
699    fn test_fix_respects_configuration() {
700        let config = MD043Config {
701            headings: vec!["# Title".to_string(), "# Content".to_string()],
702            match_case: false,
703        };
704        let rule = MD043RequiredHeadings::from_config_struct(config);
705
706        let content = "Wrong content";
707        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708        let fixed = rule.fix(&ctx).unwrap();
709
710        // MD043 now preserves original content to prevent data loss
711        let expected = "Wrong content";
712        assert_eq!(fixed, expected);
713    }
714
715    // Wildcard pattern tests
716
717    #[test]
718    fn test_asterisk_wildcard_zero_headings() {
719        // * allows zero headings
720        let config = MD043Config {
721            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
722            match_case: false,
723        };
724        let rule = MD043RequiredHeadings::from_config_struct(config);
725
726        let content = "# Start\n\n# End";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729
730        assert!(result.is_empty(), "* should allow zero headings between Start and End");
731    }
732
733    #[test]
734    fn test_asterisk_wildcard_multiple_headings() {
735        // * allows multiple headings
736        let config = MD043Config {
737            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
738            match_case: false,
739        };
740        let rule = MD043RequiredHeadings::from_config_struct(config);
741
742        let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let result = rule.check(&ctx).unwrap();
745
746        assert!(
747            result.is_empty(),
748            "* should allow multiple headings between Start and End"
749        );
750    }
751
752    #[test]
753    fn test_asterisk_wildcard_at_end() {
754        // * at end allows any remaining headings
755        let config = MD043Config {
756            headings: vec!["# Introduction".to_string(), "*".to_string()],
757            match_case: false,
758        };
759        let rule = MD043RequiredHeadings::from_config_struct(config);
760
761        let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764
765        assert!(result.is_empty(), "* at end should allow any trailing headings");
766    }
767
768    #[test]
769    fn test_plus_wildcard_requires_at_least_one() {
770        // + requires at least one heading
771        let config = MD043Config {
772            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
773            match_case: false,
774        };
775        let rule = MD043RequiredHeadings::from_config_struct(config);
776
777        // Should fail with zero headings
778        let content = "# Start\n\n# End";
779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let result = rule.check(&ctx).unwrap();
781
782        assert!(!result.is_empty(), "+ should require at least one heading");
783    }
784
785    #[test]
786    fn test_plus_wildcard_allows_multiple() {
787        // + allows multiple headings
788        let config = MD043Config {
789            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
790            match_case: false,
791        };
792        let rule = MD043RequiredHeadings::from_config_struct(config);
793
794        // Should pass with one heading
795        let content = "# Start\n\n## Middle\n\n# End";
796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797        let result = rule.check(&ctx).unwrap();
798
799        assert!(result.is_empty(), "+ should allow one heading");
800
801        // Should pass with multiple headings
802        let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
803        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804        let result = rule.check(&ctx).unwrap();
805
806        assert!(result.is_empty(), "+ should allow multiple headings");
807    }
808
809    #[test]
810    fn test_question_wildcard_exactly_one() {
811        // ? requires exactly one heading
812        let config = MD043Config {
813            headings: vec!["?".to_string(), "## Description".to_string()],
814            match_case: false,
815        };
816        let rule = MD043RequiredHeadings::from_config_struct(config);
817
818        // Should pass with exactly one heading before Description
819        let content = "# Project Name\n\n## Description";
820        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821        let result = rule.check(&ctx).unwrap();
822
823        assert!(result.is_empty(), "? should allow exactly one heading");
824    }
825
826    #[test]
827    fn test_question_wildcard_fails_with_zero() {
828        // ? fails with zero headings
829        let config = MD043Config {
830            headings: vec!["?".to_string(), "## Description".to_string()],
831            match_case: false,
832        };
833        let rule = MD043RequiredHeadings::from_config_struct(config);
834
835        let content = "## Description";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837        let result = rule.check(&ctx).unwrap();
838
839        assert!(!result.is_empty(), "? should require exactly one heading");
840    }
841
842    #[test]
843    fn test_complex_wildcard_pattern() {
844        // Complex pattern: variable title, required sections, optional details
845        let config = MD043Config {
846            headings: vec![
847                "?".to_string(),           // Any project title
848                "## Overview".to_string(), // Required
849                "*".to_string(),           // Optional sections
850                "## License".to_string(),  // Required
851            ],
852            match_case: false,
853        };
854        let rule = MD043RequiredHeadings::from_config_struct(config);
855
856        // Should pass with minimal structure
857        let content = "# My Project\n\n## Overview\n\n## License";
858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859        let result = rule.check(&ctx).unwrap();
860
861        assert!(result.is_empty(), "Complex pattern should match minimal structure");
862
863        // Should pass with additional sections
864        let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867
868        assert!(result.is_empty(), "Complex pattern should match with optional sections");
869    }
870
871    #[test]
872    fn test_multiple_asterisks() {
873        // Multiple * wildcards in pattern
874        let config = MD043Config {
875            headings: vec![
876                "# Title".to_string(),
877                "*".to_string(),
878                "## Middle".to_string(),
879                "*".to_string(),
880                "# End".to_string(),
881            ],
882            match_case: false,
883        };
884        let rule = MD043RequiredHeadings::from_config_struct(config);
885
886        let content = "# Title\n\n## Middle\n\n# End";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889
890        assert!(result.is_empty(), "Multiple * wildcards should work");
891
892        let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894        let result = rule.check(&ctx).unwrap();
895
896        assert!(
897            result.is_empty(),
898            "Multiple * wildcards should allow flexible structure"
899        );
900    }
901
902    #[test]
903    fn test_wildcard_with_case_sensitivity() {
904        // Wildcards work with case-sensitive matching
905        let config = MD043Config {
906            headings: vec![
907                "?".to_string(),
908                "## Description".to_string(), // Case-sensitive
909            ],
910            match_case: true,
911        };
912        let rule = MD043RequiredHeadings::from_config_struct(config);
913
914        // Should pass with correct case
915        let content = "# Title\n\n## Description";
916        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
917        let result = rule.check(&ctx).unwrap();
918
919        assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
920
921        // Should fail with wrong case
922        let content = "# Title\n\n## description";
923        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
924        let result = rule.check(&ctx).unwrap();
925
926        assert!(
927            !result.is_empty(),
928            "Case-sensitive matching should detect case mismatch"
929        );
930    }
931
932    #[test]
933    fn test_all_wildcards_pattern() {
934        // Pattern with only wildcards
935        let config = MD043Config {
936            headings: vec!["*".to_string()],
937            match_case: false,
938        };
939        let rule = MD043RequiredHeadings::from_config_struct(config);
940
941        // Should pass with any headings
942        let content = "# Any\n\n## Headings\n\n### Work";
943        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944        let result = rule.check(&ctx).unwrap();
945
946        assert!(result.is_empty(), "* alone should allow any heading structure");
947
948        // Should pass with no headings
949        let content = "No headings here";
950        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
951        let result = rule.check(&ctx).unwrap();
952
953        assert!(result.is_empty(), "* alone should allow no headings");
954    }
955
956    #[test]
957    fn test_wildcard_edge_cases() {
958        // Edge case: + at end requires at least one more heading
959        let config = MD043Config {
960            headings: vec!["# Start".to_string(), "+".to_string()],
961            match_case: false,
962        };
963        let rule = MD043RequiredHeadings::from_config_struct(config);
964
965        // Should fail with no additional headings
966        let content = "# Start";
967        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
968        let result = rule.check(&ctx).unwrap();
969
970        assert!(!result.is_empty(), "+ at end should require at least one more heading");
971
972        // Should pass with additional headings
973        let content = "# Start\n\n## More";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let result = rule.check(&ctx).unwrap();
976
977        assert!(result.is_empty(), "+ at end should allow additional headings");
978    }
979
980    #[test]
981    fn test_fix_with_wildcards() {
982        // Fix should preserve content when wildcards are used
983        let config = MD043Config {
984            headings: vec!["?".to_string(), "## Description".to_string()],
985            match_case: false,
986        };
987        let rule = MD043RequiredHeadings::from_config_struct(config);
988
989        // Matching content
990        let content = "# Project\n\n## Description";
991        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
992        let fixed = rule.fix(&ctx).unwrap();
993
994        assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
995
996        // Non-matching content
997        let content = "# Project\n\n## Other";
998        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
999        let fixed = rule.fix(&ctx).unwrap();
1000
1001        assert_eq!(
1002            fixed, content,
1003            "Fix should preserve non-matching content to prevent data loss"
1004        );
1005    }
1006
1007    // Comprehensive edge case tests
1008
1009    #[test]
1010    fn test_consecutive_wildcards() {
1011        // Multiple wildcards in a row
1012        let config = MD043Config {
1013            headings: vec![
1014                "# Start".to_string(),
1015                "*".to_string(),
1016                "+".to_string(),
1017                "# End".to_string(),
1018            ],
1019            match_case: false,
1020        };
1021        let rule = MD043RequiredHeadings::from_config_struct(config);
1022
1023        // Should require at least one heading from +
1024        let content = "# Start\n\n## Middle\n\n# End";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027
1028        assert!(result.is_empty(), "Consecutive * and + should work together");
1029
1030        // Should fail without the + requirement
1031        let content = "# Start\n\n# End";
1032        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034
1035        assert!(!result.is_empty(), "Should fail when + is not satisfied");
1036    }
1037
1038    #[test]
1039    fn test_question_mark_doesnt_consume_literal_match() {
1040        // ? should match exactly one, not more
1041        let config = MD043Config {
1042            headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1043            match_case: false,
1044        };
1045        let rule = MD043RequiredHeadings::from_config_struct(config);
1046
1047        // Should match with exactly one before Description
1048        let content = "# Title\n\n## Description\n\n## License";
1049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1050        let result = rule.check(&ctx).unwrap();
1051
1052        assert!(result.is_empty(), "? should consume exactly one heading");
1053
1054        // Should fail if Description comes first (? needs something to match)
1055        let content = "## Description\n\n## License";
1056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057        let result = rule.check(&ctx).unwrap();
1058
1059        assert!(!result.is_empty(), "? requires exactly one heading to match");
1060    }
1061
1062    #[test]
1063    fn test_asterisk_between_literals_complex() {
1064        // Test * matching when sandwiched between specific headings
1065        let config = MD043Config {
1066            headings: vec![
1067                "# Title".to_string(),
1068                "## Section A".to_string(),
1069                "*".to_string(),
1070                "## Section B".to_string(),
1071            ],
1072            match_case: false,
1073        };
1074        let rule = MD043RequiredHeadings::from_config_struct(config);
1075
1076        // Should work with zero headings between A and B
1077        let content = "# Title\n\n## Section A\n\n## Section B";
1078        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1079        let result = rule.check(&ctx).unwrap();
1080
1081        assert!(result.is_empty(), "* should allow zero headings");
1082
1083        // Should work with many headings between A and B
1084        let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1085        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1086        let result = rule.check(&ctx).unwrap();
1087
1088        assert!(result.is_empty(), "* should allow multiple headings");
1089
1090        // Should fail if Section B is missing
1091        let content = "# Title\n\n## Section A\n\n### Sub1";
1092        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093        let result = rule.check(&ctx).unwrap();
1094
1095        assert!(
1096            !result.is_empty(),
1097            "Should fail when required heading after * is missing"
1098        );
1099    }
1100
1101    #[test]
1102    fn test_plus_requires_consumption() {
1103        // + must consume at least one heading
1104        let config = MD043Config {
1105            headings: vec!["+".to_string()],
1106            match_case: false,
1107        };
1108        let rule = MD043RequiredHeadings::from_config_struct(config);
1109
1110        // Should fail with no headings
1111        let content = "No headings here";
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 fail with zero headings");
1116
1117        // Should pass with any heading
1118        let content = "# Any heading";
1119        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120        let result = rule.check(&ctx).unwrap();
1121
1122        assert!(result.is_empty(), "+ should pass with one heading");
1123
1124        // Should pass with multiple headings
1125        let content = "# First\n\n## Second\n\n### Third";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127        let result = rule.check(&ctx).unwrap();
1128
1129        assert!(result.is_empty(), "+ should pass with multiple headings");
1130    }
1131
1132    #[test]
1133    fn test_mixed_wildcard_and_literal_ordering() {
1134        // Ensure wildcards don't break literal matching order
1135        let config = MD043Config {
1136            headings: vec![
1137                "# A".to_string(),
1138                "*".to_string(),
1139                "# B".to_string(),
1140                "*".to_string(),
1141                "# C".to_string(),
1142            ],
1143            match_case: false,
1144        };
1145        let rule = MD043RequiredHeadings::from_config_struct(config);
1146
1147        // Should pass in correct order
1148        let content = "# A\n\n# B\n\n# C";
1149        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1150        let result = rule.check(&ctx).unwrap();
1151
1152        assert!(result.is_empty(), "Should match literals in correct order");
1153
1154        // Should fail in wrong order
1155        let content = "# A\n\n# C\n\n# B";
1156        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157        let result = rule.check(&ctx).unwrap();
1158
1159        assert!(!result.is_empty(), "Should fail when literals are out of order");
1160
1161        // Should fail with missing required literal
1162        let content = "# A\n\n# C";
1163        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1164        let result = rule.check(&ctx).unwrap();
1165
1166        assert!(!result.is_empty(), "Should fail when required literal is missing");
1167    }
1168
1169    #[test]
1170    fn test_only_wildcards_with_headings() {
1171        // Pattern with only wildcards and content
1172        let config = MD043Config {
1173            headings: vec!["?".to_string(), "+".to_string()],
1174            match_case: false,
1175        };
1176        let rule = MD043RequiredHeadings::from_config_struct(config);
1177
1178        // Should require at least 2 headings (? = 1, + = 1+)
1179        let content = "# First\n\n## Second";
1180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181        let result = rule.check(&ctx).unwrap();
1182
1183        assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1184
1185        // Should fail with only one heading
1186        let content = "# First";
1187        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188        let result = rule.check(&ctx).unwrap();
1189
1190        assert!(
1191            !result.is_empty(),
1192            "Should fail with only 1 heading when ? + is required"
1193        );
1194    }
1195
1196    #[test]
1197    fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1198        // Test that * correctly finds the next literal match
1199        let config = MD043Config {
1200            headings: vec![
1201                "# Start".to_string(),
1202                "*".to_string(),
1203                "## Target".to_string(),
1204                "# End".to_string(),
1205            ],
1206            match_case: false,
1207        };
1208        let rule = MD043RequiredHeadings::from_config_struct(config);
1209
1210        // Should correctly skip to first "Target" match
1211        let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1212        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1213        let result = rule.check(&ctx).unwrap();
1214
1215        assert!(result.is_empty(), "* should correctly skip to next literal match");
1216
1217        // Should handle case where there are extra headings after the match
1218        // (First Target matches, second Target is extra - should fail)
1219        let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222
1223        assert!(
1224            !result.is_empty(),
1225            "Should fail with extra headings that don't match pattern"
1226        );
1227    }
1228
1229    #[test]
1230    fn test_wildcard_at_start() {
1231        // Test wildcards at the beginning of pattern
1232        let config = MD043Config {
1233            headings: vec!["*".to_string(), "## End".to_string()],
1234            match_case: false,
1235        };
1236        let rule = MD043RequiredHeadings::from_config_struct(config);
1237
1238        // Should allow any headings before End
1239        let content = "# Random\n\n## Stuff\n\n## End";
1240        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1241        let result = rule.check(&ctx).unwrap();
1242
1243        assert!(result.is_empty(), "* at start should allow any preceding headings");
1244
1245        // Test + at start
1246        let config = MD043Config {
1247            headings: vec!["+".to_string(), "## End".to_string()],
1248            match_case: false,
1249        };
1250        let rule = MD043RequiredHeadings::from_config_struct(config);
1251
1252        // Should require at least one heading before End
1253        let content = "## End";
1254        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255        let result = rule.check(&ctx).unwrap();
1256
1257        assert!(!result.is_empty(), "+ at start should require at least one heading");
1258
1259        let content = "# First\n\n## End";
1260        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261        let result = rule.check(&ctx).unwrap();
1262
1263        assert!(result.is_empty(), "+ at start should allow headings before End");
1264    }
1265
1266    #[test]
1267    fn test_wildcard_with_setext_headings() {
1268        // Ensure wildcards work with setext headings too
1269        let config = MD043Config {
1270            headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1271            match_case: false,
1272        };
1273        let rule = MD043RequiredHeadings::from_config_struct(config);
1274
1275        let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1276        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277        let result = rule.check(&ctx).unwrap();
1278
1279        assert!(result.is_empty(), "Wildcards should work with setext headings");
1280    }
1281
1282    #[test]
1283    fn test_empty_document_with_required_wildcards() {
1284        // Empty document should fail when + or ? are required
1285        let config = MD043Config {
1286            headings: vec!["?".to_string()],
1287            match_case: false,
1288        };
1289        let rule = MD043RequiredHeadings::from_config_struct(config);
1290
1291        let content = "No headings";
1292        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1293        let result = rule.check(&ctx).unwrap();
1294
1295        assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1296
1297        // Test with +
1298        let config = MD043Config {
1299            headings: vec!["+".to_string()],
1300            match_case: false,
1301        };
1302        let rule = MD043RequiredHeadings::from_config_struct(config);
1303
1304        let content = "No headings";
1305        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1306        let result = rule.check(&ctx).unwrap();
1307
1308        assert!(!result.is_empty(), "Empty document should fail with + requirement");
1309    }
1310
1311    #[test]
1312    fn test_trailing_headings_after_pattern_completion() {
1313        // Extra headings after pattern is satisfied should fail
1314        let config = MD043Config {
1315            headings: vec!["# Title".to_string(), "## Section".to_string()],
1316            match_case: false,
1317        };
1318        let rule = MD043RequiredHeadings::from_config_struct(config);
1319
1320        // Should fail with extra headings
1321        let content = "# Title\n\n## Section\n\n### Extra";
1322        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323        let result = rule.check(&ctx).unwrap();
1324
1325        assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1326
1327        // But * at end should allow them
1328        let config = MD043Config {
1329            headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1330            match_case: false,
1331        };
1332        let rule = MD043RequiredHeadings::from_config_struct(config);
1333
1334        let content = "# Title\n\n## Section\n\n### Extra";
1335        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1336        let result = rule.check(&ctx).unwrap();
1337
1338        assert!(result.is_empty(), "* at end should allow trailing headings");
1339    }
1340}