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);
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);
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(content, crate::config::MarkdownFlavor::Standard))
406            .unwrap();
407        assert!(warnings.is_empty(), "Expected no warnings for matching headings");
408
409        // Test with mismatched headings
410        let content = "# Introduction\n\nContent\n\n# Results\n\nSkipped method";
411        let warnings = rule
412            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
413            .unwrap();
414        assert!(!warnings.is_empty(), "Expected warnings for mismatched headings");
415
416        // Test with no headings but requirements exist
417        let content = "No headings here, just plain text";
418        let warnings = rule
419            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
420            .unwrap();
421        assert!(!warnings.is_empty(), "Expected warnings when headings are missing");
422
423        // Test with setext headings - use the correct format (marker text)
424        let required_setext = vec![
425            "=========== Introduction".to_string(),
426            "------ Method".to_string(),
427            "======= Results".to_string(),
428        ];
429        let rule_setext = MD043RequiredHeadings::new(required_setext);
430        let content = "Introduction\n===========\n\nContent\n\nMethod\n------\n\nMore content\n\nResults\n=======\n\nFinal content";
431        let warnings = rule_setext
432            .check(&LintContext::new(content, crate::config::MarkdownFlavor::Standard))
433            .unwrap();
434        assert!(warnings.is_empty(), "Expected no warnings for matching setext headings");
435    }
436
437    #[test]
438    fn test_should_skip_no_false_positives() {
439        // Create rule with required headings
440        let required = vec!["Test".to_string()];
441        let rule = MD043RequiredHeadings::new(required);
442
443        // Test 1: Content with '#' character in normal text (not a heading)
444        let content = "This paragraph contains a # character but is not a heading";
445        assert!(
446            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
447            "Should skip content with # in normal text"
448        );
449
450        // Test 2: Content with code block containing heading-like syntax
451        let content = "Regular paragraph\n\n```markdown\n# This is not a real heading\n```\n\nMore text";
452        assert!(
453            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
454            "Should skip content with heading-like syntax in code blocks"
455        );
456
457        // Test 3: Content with list items using '-' character
458        let content = "Some text\n\n- List item 1\n- List item 2\n\nMore text";
459        assert!(
460            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
461            "Should skip content with list items using dash"
462        );
463
464        // Test 4: Content with horizontal rule that uses '---'
465        let content = "Some text\n\n---\n\nMore text below the horizontal rule";
466        assert!(
467            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
468            "Should skip content with horizontal rule"
469        );
470
471        // Test 5: Content with equals sign in normal text
472        let content = "This is a normal paragraph with equals sign x = y + z";
473        assert!(
474            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
475            "Should skip content with equals sign in normal text"
476        );
477
478        // Test 6: Content with dash/minus in normal text
479        let content = "This is a normal paragraph with minus sign x - y = z";
480        assert!(
481            rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
482            "Should skip content with minus sign in normal text"
483        );
484    }
485
486    #[test]
487    fn test_should_skip_heading_detection() {
488        // Create rule with required headings
489        let required = vec!["Test".to_string()];
490        let rule = MD043RequiredHeadings::new(required);
491
492        // Test 1: Content with ATX heading
493        let content = "# This is a heading\n\nAnd some content";
494        assert!(
495            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
496            "Should not skip content with ATX heading"
497        );
498
499        // Test 2: Content with Setext heading (equals sign)
500        let content = "This is a heading\n================\n\nAnd some content";
501        assert!(
502            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
503            "Should not skip content with Setext heading (=)"
504        );
505
506        // Test 3: Content with Setext heading (dash)
507        let content = "This is a subheading\n------------------\n\nAnd some content";
508        assert!(
509            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
510            "Should not skip content with Setext heading (-)"
511        );
512
513        // Test 4: Content with ATX heading with closing hashes
514        let content = "## This is a heading ##\n\nAnd some content";
515        assert!(
516            !rule.should_skip(&LintContext::new(content, crate::config::MarkdownFlavor::Standard)),
517            "Should not skip content with ATX heading with closing hashes"
518        );
519    }
520
521    #[test]
522    fn test_config_match_case_sensitive() {
523        let config = MD043Config {
524            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
525            match_case: true,
526        };
527        let rule = MD043RequiredHeadings::from_config_struct(config);
528
529        // Should fail with different case
530        let content = "# introduction\n\n# method";
531        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
532        let result = rule.check(&ctx).unwrap();
533
534        assert!(
535            !result.is_empty(),
536            "Should detect case mismatch when match_case is true"
537        );
538    }
539
540    #[test]
541    fn test_config_match_case_insensitive() {
542        let config = MD043Config {
543            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
544            match_case: false,
545        };
546        let rule = MD043RequiredHeadings::from_config_struct(config);
547
548        // Should pass with different case
549        let content = "# introduction\n\n# method";
550        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
551        let result = rule.check(&ctx).unwrap();
552
553        assert!(result.is_empty(), "Should allow case mismatch when match_case is false");
554    }
555
556    #[test]
557    fn test_config_case_insensitive_mixed() {
558        let config = MD043Config {
559            headings: vec!["# Introduction".to_string(), "# METHOD".to_string()],
560            match_case: false,
561        };
562        let rule = MD043RequiredHeadings::from_config_struct(config);
563
564        // Should pass with mixed case variations
565        let content = "# INTRODUCTION\n\n# method";
566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
567        let result = rule.check(&ctx).unwrap();
568
569        assert!(
570            result.is_empty(),
571            "Should allow mixed case variations when match_case is false"
572        );
573    }
574
575    #[test]
576    fn test_config_case_sensitive_exact_match() {
577        let config = MD043Config {
578            headings: vec!["# Introduction".to_string(), "# Method".to_string()],
579            match_case: true,
580        };
581        let rule = MD043RequiredHeadings::from_config_struct(config);
582
583        // Should pass with exact case match
584        let content = "# Introduction\n\n# Method";
585        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586        let result = rule.check(&ctx).unwrap();
587
588        assert!(
589            result.is_empty(),
590            "Should pass with exact case match when match_case is true"
591        );
592    }
593
594    #[test]
595    fn test_default_config() {
596        let rule = MD043RequiredHeadings::default();
597
598        // Should be disabled with empty headings
599        let content = "# Any heading\n\n# Another heading";
600        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
601        let result = rule.check(&ctx).unwrap();
602
603        assert!(result.is_empty(), "Should be disabled with default empty headings");
604    }
605
606    #[test]
607    fn test_default_config_section() {
608        let rule = MD043RequiredHeadings::default();
609        let config_section = rule.default_config_section();
610
611        assert!(config_section.is_some());
612        let (name, value) = config_section.unwrap();
613        assert_eq!(name, "MD043");
614
615        // Should contain both headings and match_case options with default values
616        if let toml::Value::Table(table) = value {
617            assert!(table.contains_key("headings"));
618            assert!(table.contains_key("match-case"));
619            assert_eq!(table["headings"], toml::Value::Array(vec![]));
620            assert_eq!(table["match-case"], toml::Value::Boolean(false));
621        } else {
622            panic!("Expected TOML table");
623        }
624    }
625
626    #[test]
627    fn test_headings_match_case_sensitive() {
628        let config = MD043Config {
629            headings: vec![],
630            match_case: true,
631        };
632        let rule = MD043RequiredHeadings::from_config_struct(config);
633
634        assert!(rule.headings_match("Test", "Test"));
635        assert!(!rule.headings_match("Test", "test"));
636        assert!(!rule.headings_match("test", "Test"));
637    }
638
639    #[test]
640    fn test_headings_match_case_insensitive() {
641        let config = MD043Config {
642            headings: vec![],
643            match_case: false,
644        };
645        let rule = MD043RequiredHeadings::from_config_struct(config);
646
647        assert!(rule.headings_match("Test", "Test"));
648        assert!(rule.headings_match("Test", "test"));
649        assert!(rule.headings_match("test", "Test"));
650        assert!(rule.headings_match("TEST", "test"));
651    }
652
653    #[test]
654    fn test_config_empty_headings() {
655        let config = MD043Config {
656            headings: vec![],
657            match_case: true,
658        };
659        let rule = MD043RequiredHeadings::from_config_struct(config);
660
661        // Should skip processing when no headings are required
662        let content = "# Any heading\n\n# Another heading";
663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664        let result = rule.check(&ctx).unwrap();
665
666        assert!(result.is_empty(), "Should be disabled with empty headings list");
667    }
668
669    #[test]
670    fn test_fix_respects_configuration() {
671        let config = MD043Config {
672            headings: vec!["# Title".to_string(), "# Content".to_string()],
673            match_case: false,
674        };
675        let rule = MD043RequiredHeadings::from_config_struct(config);
676
677        let content = "Wrong content";
678        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679        let fixed = rule.fix(&ctx).unwrap();
680
681        // MD043 now preserves original content to prevent data loss
682        let expected = "Wrong content";
683        assert_eq!(fixed, expected);
684    }
685
686    // Wildcard pattern tests
687
688    #[test]
689    fn test_asterisk_wildcard_zero_headings() {
690        // * allows zero headings
691        let config = MD043Config {
692            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
693            match_case: false,
694        };
695        let rule = MD043RequiredHeadings::from_config_struct(config);
696
697        let content = "# Start\n\n# End";
698        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
699        let result = rule.check(&ctx).unwrap();
700
701        assert!(result.is_empty(), "* should allow zero headings between Start and End");
702    }
703
704    #[test]
705    fn test_asterisk_wildcard_multiple_headings() {
706        // * allows multiple headings
707        let config = MD043Config {
708            headings: vec!["# Start".to_string(), "*".to_string(), "# End".to_string()],
709            match_case: false,
710        };
711        let rule = MD043RequiredHeadings::from_config_struct(config);
712
713        let content = "# Start\n\n## Section 1\n\n## Section 2\n\n## Section 3\n\n# End";
714        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
715        let result = rule.check(&ctx).unwrap();
716
717        assert!(
718            result.is_empty(),
719            "* should allow multiple headings between Start and End"
720        );
721    }
722
723    #[test]
724    fn test_asterisk_wildcard_at_end() {
725        // * at end allows any remaining headings
726        let config = MD043Config {
727            headings: vec!["# Introduction".to_string(), "*".to_string()],
728            match_case: false,
729        };
730        let rule = MD043RequiredHeadings::from_config_struct(config);
731
732        let content = "# Introduction\n\n## Details\n\n### Subsection\n\n## More";
733        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
734        let result = rule.check(&ctx).unwrap();
735
736        assert!(result.is_empty(), "* at end should allow any trailing headings");
737    }
738
739    #[test]
740    fn test_plus_wildcard_requires_at_least_one() {
741        // + requires at least one heading
742        let config = MD043Config {
743            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
744            match_case: false,
745        };
746        let rule = MD043RequiredHeadings::from_config_struct(config);
747
748        // Should fail with zero headings
749        let content = "# Start\n\n# End";
750        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
751        let result = rule.check(&ctx).unwrap();
752
753        assert!(!result.is_empty(), "+ should require at least one heading");
754    }
755
756    #[test]
757    fn test_plus_wildcard_allows_multiple() {
758        // + allows multiple headings
759        let config = MD043Config {
760            headings: vec!["# Start".to_string(), "+".to_string(), "# End".to_string()],
761            match_case: false,
762        };
763        let rule = MD043RequiredHeadings::from_config_struct(config);
764
765        // Should pass with one heading
766        let content = "# Start\n\n## Middle\n\n# End";
767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
768        let result = rule.check(&ctx).unwrap();
769
770        assert!(result.is_empty(), "+ should allow one heading");
771
772        // Should pass with multiple headings
773        let content = "# Start\n\n## Middle 1\n\n## Middle 2\n\n## Middle 3\n\n# End";
774        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
775        let result = rule.check(&ctx).unwrap();
776
777        assert!(result.is_empty(), "+ should allow multiple headings");
778    }
779
780    #[test]
781    fn test_question_wildcard_exactly_one() {
782        // ? requires exactly one heading
783        let config = MD043Config {
784            headings: vec!["?".to_string(), "## Description".to_string()],
785            match_case: false,
786        };
787        let rule = MD043RequiredHeadings::from_config_struct(config);
788
789        // Should pass with exactly one heading before Description
790        let content = "# Project Name\n\n## Description";
791        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
792        let result = rule.check(&ctx).unwrap();
793
794        assert!(result.is_empty(), "? should allow exactly one heading");
795    }
796
797    #[test]
798    fn test_question_wildcard_fails_with_zero() {
799        // ? fails with zero headings
800        let config = MD043Config {
801            headings: vec!["?".to_string(), "## Description".to_string()],
802            match_case: false,
803        };
804        let rule = MD043RequiredHeadings::from_config_struct(config);
805
806        let content = "## Description";
807        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
808        let result = rule.check(&ctx).unwrap();
809
810        assert!(!result.is_empty(), "? should require exactly one heading");
811    }
812
813    #[test]
814    fn test_complex_wildcard_pattern() {
815        // Complex pattern: variable title, required sections, optional details
816        let config = MD043Config {
817            headings: vec![
818                "?".to_string(),           // Any project title
819                "## Overview".to_string(), // Required
820                "*".to_string(),           // Optional sections
821                "## License".to_string(),  // Required
822            ],
823            match_case: false,
824        };
825        let rule = MD043RequiredHeadings::from_config_struct(config);
826
827        // Should pass with minimal structure
828        let content = "# My Project\n\n## Overview\n\n## License";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
830        let result = rule.check(&ctx).unwrap();
831
832        assert!(result.is_empty(), "Complex pattern should match minimal structure");
833
834        // Should pass with additional sections
835        let content = "# My Project\n\n## Overview\n\n## Installation\n\n## Usage\n\n## License";
836        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
837        let result = rule.check(&ctx).unwrap();
838
839        assert!(result.is_empty(), "Complex pattern should match with optional sections");
840    }
841
842    #[test]
843    fn test_multiple_asterisks() {
844        // Multiple * wildcards in pattern
845        let config = MD043Config {
846            headings: vec![
847                "# Title".to_string(),
848                "*".to_string(),
849                "## Middle".to_string(),
850                "*".to_string(),
851                "# End".to_string(),
852            ],
853            match_case: false,
854        };
855        let rule = MD043RequiredHeadings::from_config_struct(config);
856
857        let content = "# Title\n\n## Middle\n\n# End";
858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
859        let result = rule.check(&ctx).unwrap();
860
861        assert!(result.is_empty(), "Multiple * wildcards should work");
862
863        let content = "# Title\n\n### Details\n\n## Middle\n\n### More Details\n\n# End";
864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
865        let result = rule.check(&ctx).unwrap();
866
867        assert!(
868            result.is_empty(),
869            "Multiple * wildcards should allow flexible structure"
870        );
871    }
872
873    #[test]
874    fn test_wildcard_with_case_sensitivity() {
875        // Wildcards work with case-sensitive matching
876        let config = MD043Config {
877            headings: vec![
878                "?".to_string(),
879                "## Description".to_string(), // Case-sensitive
880            ],
881            match_case: true,
882        };
883        let rule = MD043RequiredHeadings::from_config_struct(config);
884
885        // Should pass with correct case
886        let content = "# Title\n\n## Description";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
888        let result = rule.check(&ctx).unwrap();
889
890        assert!(result.is_empty(), "Wildcard should work with case-sensitive matching");
891
892        // Should fail with wrong case
893        let content = "# Title\n\n## description";
894        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
895        let result = rule.check(&ctx).unwrap();
896
897        assert!(
898            !result.is_empty(),
899            "Case-sensitive matching should detect case mismatch"
900        );
901    }
902
903    #[test]
904    fn test_all_wildcards_pattern() {
905        // Pattern with only wildcards
906        let config = MD043Config {
907            headings: vec!["*".to_string()],
908            match_case: false,
909        };
910        let rule = MD043RequiredHeadings::from_config_struct(config);
911
912        // Should pass with any headings
913        let content = "# Any\n\n## Headings\n\n### Work";
914        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
915        let result = rule.check(&ctx).unwrap();
916
917        assert!(result.is_empty(), "* alone should allow any heading structure");
918
919        // Should pass with no headings
920        let content = "No headings here";
921        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
922        let result = rule.check(&ctx).unwrap();
923
924        assert!(result.is_empty(), "* alone should allow no headings");
925    }
926
927    #[test]
928    fn test_wildcard_edge_cases() {
929        // Edge case: + at end requires at least one more heading
930        let config = MD043Config {
931            headings: vec!["# Start".to_string(), "+".to_string()],
932            match_case: false,
933        };
934        let rule = MD043RequiredHeadings::from_config_struct(config);
935
936        // Should fail with no additional headings
937        let content = "# Start";
938        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
939        let result = rule.check(&ctx).unwrap();
940
941        assert!(!result.is_empty(), "+ at end should require at least one more heading");
942
943        // Should pass with additional headings
944        let content = "# Start\n\n## More";
945        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
946        let result = rule.check(&ctx).unwrap();
947
948        assert!(result.is_empty(), "+ at end should allow additional headings");
949    }
950
951    #[test]
952    fn test_fix_with_wildcards() {
953        // Fix should preserve content when wildcards are used
954        let config = MD043Config {
955            headings: vec!["?".to_string(), "## Description".to_string()],
956            match_case: false,
957        };
958        let rule = MD043RequiredHeadings::from_config_struct(config);
959
960        // Matching content
961        let content = "# Project\n\n## Description";
962        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
963        let fixed = rule.fix(&ctx).unwrap();
964
965        assert_eq!(fixed, content, "Fix should preserve matching wildcard content");
966
967        // Non-matching content
968        let content = "# Project\n\n## Other";
969        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
970        let fixed = rule.fix(&ctx).unwrap();
971
972        assert_eq!(
973            fixed, content,
974            "Fix should preserve non-matching content to prevent data loss"
975        );
976    }
977
978    // Expert-level edge case tests
979
980    #[test]
981    fn test_consecutive_wildcards() {
982        // Multiple wildcards in a row
983        let config = MD043Config {
984            headings: vec![
985                "# Start".to_string(),
986                "*".to_string(),
987                "+".to_string(),
988                "# End".to_string(),
989            ],
990            match_case: false,
991        };
992        let rule = MD043RequiredHeadings::from_config_struct(config);
993
994        // Should require at least one heading from +
995        let content = "# Start\n\n## Middle\n\n# End";
996        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
997        let result = rule.check(&ctx).unwrap();
998
999        assert!(result.is_empty(), "Consecutive * and + should work together");
1000
1001        // Should fail without the + requirement
1002        let content = "# Start\n\n# End";
1003        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1004        let result = rule.check(&ctx).unwrap();
1005
1006        assert!(!result.is_empty(), "Should fail when + is not satisfied");
1007    }
1008
1009    #[test]
1010    fn test_question_mark_doesnt_consume_literal_match() {
1011        // ? should match exactly one, not more
1012        let config = MD043Config {
1013            headings: vec!["?".to_string(), "## Description".to_string(), "## License".to_string()],
1014            match_case: false,
1015        };
1016        let rule = MD043RequiredHeadings::from_config_struct(config);
1017
1018        // Should match with exactly one before Description
1019        let content = "# Title\n\n## Description\n\n## License";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1021        let result = rule.check(&ctx).unwrap();
1022
1023        assert!(result.is_empty(), "? should consume exactly one heading");
1024
1025        // Should fail if Description comes first (? needs something to match)
1026        let content = "## Description\n\n## License";
1027        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1028        let result = rule.check(&ctx).unwrap();
1029
1030        assert!(!result.is_empty(), "? requires exactly one heading to match");
1031    }
1032
1033    #[test]
1034    fn test_asterisk_between_literals_complex() {
1035        // Test * matching when sandwiched between specific headings
1036        let config = MD043Config {
1037            headings: vec![
1038                "# Title".to_string(),
1039                "## Section A".to_string(),
1040                "*".to_string(),
1041                "## Section B".to_string(),
1042            ],
1043            match_case: false,
1044        };
1045        let rule = MD043RequiredHeadings::from_config_struct(config);
1046
1047        // Should work with zero headings between A and B
1048        let content = "# Title\n\n## Section A\n\n## Section B";
1049        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1050        let result = rule.check(&ctx).unwrap();
1051
1052        assert!(result.is_empty(), "* should allow zero headings");
1053
1054        // Should work with many headings between A and B
1055        let content = "# Title\n\n## Section A\n\n### Sub1\n\n### Sub2\n\n### Sub3\n\n## Section B";
1056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1057        let result = rule.check(&ctx).unwrap();
1058
1059        assert!(result.is_empty(), "* should allow multiple headings");
1060
1061        // Should fail if Section B is missing
1062        let content = "# Title\n\n## Section A\n\n### Sub1";
1063        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1064        let result = rule.check(&ctx).unwrap();
1065
1066        assert!(
1067            !result.is_empty(),
1068            "Should fail when required heading after * is missing"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_plus_requires_consumption() {
1074        // + must consume at least one heading
1075        let config = MD043Config {
1076            headings: vec!["+".to_string()],
1077            match_case: false,
1078        };
1079        let rule = MD043RequiredHeadings::from_config_struct(config);
1080
1081        // Should fail with no headings
1082        let content = "No headings here";
1083        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1084        let result = rule.check(&ctx).unwrap();
1085
1086        assert!(!result.is_empty(), "+ should fail with zero headings");
1087
1088        // Should pass with any heading
1089        let content = "# Any heading";
1090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1091        let result = rule.check(&ctx).unwrap();
1092
1093        assert!(result.is_empty(), "+ should pass with one heading");
1094
1095        // Should pass with multiple headings
1096        let content = "# First\n\n## Second\n\n### Third";
1097        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1098        let result = rule.check(&ctx).unwrap();
1099
1100        assert!(result.is_empty(), "+ should pass with multiple headings");
1101    }
1102
1103    #[test]
1104    fn test_mixed_wildcard_and_literal_ordering() {
1105        // Ensure wildcards don't break literal matching order
1106        let config = MD043Config {
1107            headings: vec![
1108                "# A".to_string(),
1109                "*".to_string(),
1110                "# B".to_string(),
1111                "*".to_string(),
1112                "# C".to_string(),
1113            ],
1114            match_case: false,
1115        };
1116        let rule = MD043RequiredHeadings::from_config_struct(config);
1117
1118        // Should pass in correct order
1119        let content = "# A\n\n# B\n\n# C";
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1121        let result = rule.check(&ctx).unwrap();
1122
1123        assert!(result.is_empty(), "Should match literals in correct order");
1124
1125        // Should fail in wrong order
1126        let content = "# A\n\n# C\n\n# B";
1127        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1128        let result = rule.check(&ctx).unwrap();
1129
1130        assert!(!result.is_empty(), "Should fail when literals are out of order");
1131
1132        // Should fail with missing required literal
1133        let content = "# A\n\n# C";
1134        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1135        let result = rule.check(&ctx).unwrap();
1136
1137        assert!(!result.is_empty(), "Should fail when required literal is missing");
1138    }
1139
1140    #[test]
1141    fn test_only_wildcards_with_headings() {
1142        // Pattern with only wildcards and content
1143        let config = MD043Config {
1144            headings: vec!["?".to_string(), "+".to_string()],
1145            match_case: false,
1146        };
1147        let rule = MD043RequiredHeadings::from_config_struct(config);
1148
1149        // Should require at least 2 headings (? = 1, + = 1+)
1150        let content = "# First\n\n## Second";
1151        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1152        let result = rule.check(&ctx).unwrap();
1153
1154        assert!(result.is_empty(), "? followed by + should require at least 2 headings");
1155
1156        // Should fail with only one heading
1157        let content = "# First";
1158        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1159        let result = rule.check(&ctx).unwrap();
1160
1161        assert!(
1162            !result.is_empty(),
1163            "Should fail with only 1 heading when ? + is required"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_asterisk_matching_algorithm_greedy_vs_lazy() {
1169        // Test that * correctly finds the next literal match
1170        let config = MD043Config {
1171            headings: vec![
1172                "# Start".to_string(),
1173                "*".to_string(),
1174                "## Target".to_string(),
1175                "# End".to_string(),
1176            ],
1177            match_case: false,
1178        };
1179        let rule = MD043RequiredHeadings::from_config_struct(config);
1180
1181        // Should correctly skip to first "Target" match
1182        let content = "# Start\n\n## Other\n\n## Target\n\n# End";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1184        let result = rule.check(&ctx).unwrap();
1185
1186        assert!(result.is_empty(), "* should correctly skip to next literal match");
1187
1188        // Should handle case where there are extra headings after the match
1189        // (First Target matches, second Target is extra - should fail)
1190        let content = "# Start\n\n## Target\n\n## Target\n\n# End";
1191        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1192        let result = rule.check(&ctx).unwrap();
1193
1194        assert!(
1195            !result.is_empty(),
1196            "Should fail with extra headings that don't match pattern"
1197        );
1198    }
1199
1200    #[test]
1201    fn test_wildcard_at_start() {
1202        // Test wildcards at the beginning of pattern
1203        let config = MD043Config {
1204            headings: vec!["*".to_string(), "## End".to_string()],
1205            match_case: false,
1206        };
1207        let rule = MD043RequiredHeadings::from_config_struct(config);
1208
1209        // Should allow any headings before End
1210        let content = "# Random\n\n## Stuff\n\n## End";
1211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1212        let result = rule.check(&ctx).unwrap();
1213
1214        assert!(result.is_empty(), "* at start should allow any preceding headings");
1215
1216        // Test + at start
1217        let config = MD043Config {
1218            headings: vec!["+".to_string(), "## End".to_string()],
1219            match_case: false,
1220        };
1221        let rule = MD043RequiredHeadings::from_config_struct(config);
1222
1223        // Should require at least one heading before End
1224        let content = "## End";
1225        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1226        let result = rule.check(&ctx).unwrap();
1227
1228        assert!(!result.is_empty(), "+ at start should require at least one heading");
1229
1230        let content = "# First\n\n## End";
1231        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1232        let result = rule.check(&ctx).unwrap();
1233
1234        assert!(result.is_empty(), "+ at start should allow headings before End");
1235    }
1236
1237    #[test]
1238    fn test_wildcard_with_setext_headings() {
1239        // Ensure wildcards work with setext headings too
1240        let config = MD043Config {
1241            headings: vec!["?".to_string(), "====== Section".to_string(), "*".to_string()],
1242            match_case: false,
1243        };
1244        let rule = MD043RequiredHeadings::from_config_struct(config);
1245
1246        let content = "Title\n=====\n\nSection\n======\n\nOptional\n--------";
1247        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1248        let result = rule.check(&ctx).unwrap();
1249
1250        assert!(result.is_empty(), "Wildcards should work with setext headings");
1251    }
1252
1253    #[test]
1254    fn test_empty_document_with_required_wildcards() {
1255        // Empty document should fail when + or ? are required
1256        let config = MD043Config {
1257            headings: vec!["?".to_string()],
1258            match_case: false,
1259        };
1260        let rule = MD043RequiredHeadings::from_config_struct(config);
1261
1262        let content = "No headings";
1263        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1264        let result = rule.check(&ctx).unwrap();
1265
1266        assert!(!result.is_empty(), "Empty document should fail with ? requirement");
1267
1268        // Test with +
1269        let config = MD043Config {
1270            headings: vec!["+".to_string()],
1271            match_case: false,
1272        };
1273        let rule = MD043RequiredHeadings::from_config_struct(config);
1274
1275        let content = "No headings";
1276        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1277        let result = rule.check(&ctx).unwrap();
1278
1279        assert!(!result.is_empty(), "Empty document should fail with + requirement");
1280    }
1281
1282    #[test]
1283    fn test_trailing_headings_after_pattern_completion() {
1284        // Extra headings after pattern is satisfied should fail
1285        let config = MD043Config {
1286            headings: vec!["# Title".to_string(), "## Section".to_string()],
1287            match_case: false,
1288        };
1289        let rule = MD043RequiredHeadings::from_config_struct(config);
1290
1291        // Should fail with extra headings
1292        let content = "# Title\n\n## Section\n\n### Extra";
1293        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1294        let result = rule.check(&ctx).unwrap();
1295
1296        assert!(!result.is_empty(), "Should fail with trailing headings beyond pattern");
1297
1298        // But * at end should allow them
1299        let config = MD043Config {
1300            headings: vec!["# Title".to_string(), "## Section".to_string(), "*".to_string()],
1301            match_case: false,
1302        };
1303        let rule = MD043RequiredHeadings::from_config_struct(config);
1304
1305        let content = "# Title\n\n## Section\n\n### Extra";
1306        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
1307        let result = rule.check(&ctx).unwrap();
1308
1309        assert!(result.is_empty(), "* at end should allow trailing headings");
1310    }
1311}