Skip to main content

ailint_core/rules/structural/
required_section.rs

1//! AIL003 `missing-required-section` — required top-level heading is missing.
2//!
3//! See: `docs/rules/structural/AIL003.md`
4
5use std::collections::BTreeMap;
6
7use serde::Deserialize;
8
9use crate::file_type::FileType;
10use crate::parser::{DocumentContent, ParsedDocument};
11use crate::rules::structural::AIL003;
12use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
13
14/// AIL003 missing-required-section: file type mandates a section that is absent.
15#[derive(Debug, Default)]
16pub struct MissingRequiredSectionRule;
17
18#[derive(Debug, Default, Deserialize)]
19#[serde(default)]
20struct RuleOptions {
21    required: Vec<String>,
22    per_file_type: BTreeMap<String, Vec<String>>,
23}
24
25impl Rule for MissingRequiredSectionRule {
26    fn id(&self) -> RuleId {
27        AIL003
28    }
29
30    fn default_severity(&self) -> Severity {
31        Severity::Warning
32    }
33
34    fn description(&self) -> &'static str {
35        "Required top-level heading is not present."
36    }
37
38    fn fix_hint(&self) -> &'static str {
39        "Add a top-level heading that matches the required section name."
40    }
41
42    fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
43        let DocumentContent::Markdown(md) = &doc.content else {
44            return Vec::new();
45        };
46        let opts: RuleOptions = match ctx.options {
47            Some(v) => match serde_yaml::from_value(v.clone()) {
48                Ok(c) => c,
49                Err(_) => return Vec::new(),
50            },
51            None => RuleOptions::default(),
52        };
53        let key = file_type_key(doc.file_type);
54        let required = opts
55            .per_file_type
56            .get(key)
57            .cloned()
58            .unwrap_or(opts.required);
59        if required.is_empty() {
60            return Vec::new();
61        }
62        let top_headings: Vec<String> = md
63            .headings
64            .iter()
65            .filter(|h| h.level <= 2)
66            .map(|h| h.text.to_ascii_lowercase())
67            .collect();
68        let mut out = Vec::new();
69        for entry in required {
70            let needle = entry.to_ascii_lowercase();
71            if top_headings.iter().any(|h| h.contains(&needle)) {
72                continue;
73            }
74            let v = Violation::new(
75                AIL003,
76                self.default_severity(),
77                doc.path.clone(),
78                "missing required section",
79            )
80            .with_detail(entry);
81            out.push(v);
82        }
83        out
84    }
85}
86
87fn file_type_key(ft: FileType) -> &'static str {
88    match ft {
89        FileType::ClaudeMd => "claudemd",
90        FileType::AgentsMd => "agentsmd",
91        FileType::CopilotCustomization => "copilotcustomization",
92        FileType::CopilotInstructions => "copilotinstructions",
93        FileType::CursorRules => "cursorrules",
94        FileType::WindsurfRules => "windsurfrules",
95        FileType::ClineRules => "clinerules",
96        FileType::JunieGuidelines => "junieguidelines",
97        FileType::GenericSystemPrompt => "genericsystemprompt",
98        FileType::AiderConventions => "aiderconventions",
99        FileType::ContinueRules => "continuerules",
100        FileType::GitHubSkill => "githubskill",
101        FileType::CustomProjectRules => "customprojectrules",
102        FileType::GenericMarkdown => "genericmarkdown",
103        FileType::GenericYaml => "genericyaml",
104        FileType::Unknown => "unknown",
105    }
106}