rumdl_lib/rules/
md043_required_headings.rs

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