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