Skip to main content

rumdl_lib/rules/
md043_required_headings.rs

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