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