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