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