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 run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
35        let DocumentContent::Markdown(md) = &doc.content else {
36            return Vec::new();
37        };
38        let opts: RuleOptions = match ctx.options {
39            Some(v) => match serde_yaml::from_value(v.clone()) {
40                Ok(c) => c,
41                Err(_) => return Vec::new(),
42            },
43            None => RuleOptions::default(),
44        };
45        let key = file_type_key(doc.file_type);
46        let required = opts
47            .per_file_type
48            .get(key)
49            .cloned()
50            .unwrap_or(opts.required);
51        if required.is_empty() {
52            return Vec::new();
53        }
54        let top_headings: Vec<String> = md
55            .headings
56            .iter()
57            .filter(|h| h.level <= 2)
58            .map(|h| h.text.to_ascii_lowercase())
59            .collect();
60        let mut out = Vec::new();
61        for entry in required {
62            let needle = entry.to_ascii_lowercase();
63            if top_headings.iter().any(|h| h.contains(&needle)) {
64                continue;
65            }
66            let mut v = Violation::new(
67                AIL003,
68                self.default_severity(),
69                doc.path.clone(),
70                format!("missing required section: '{entry}'"),
71            )
72            .at(1, 1);
73            v.fix_hint = Some(format!("add a top-level heading containing '{entry}'"));
74            out.push(v);
75        }
76        out
77    }
78}
79
80fn file_type_key(ft: FileType) -> &'static str {
81    match ft {
82        FileType::ClaudeMd => "claudemd",
83        FileType::AgentsMd => "agentsmd",
84        FileType::CopilotCustomization => "copilotcustomization",
85        FileType::CopilotInstructions => "copilotinstructions",
86        FileType::CursorRules => "cursorrules",
87        FileType::WindsurfRules => "windsurfrules",
88        FileType::ClineRules => "clinerules",
89        FileType::JunieGuidelines => "junieguidelines",
90        FileType::GenericSystemPrompt => "genericsystemprompt",
91        FileType::AiderConventions => "aiderconventions",
92        FileType::ContinueRules => "continuerules",
93        FileType::GitHubSkill => "githubskill",
94        FileType::CustomProjectRules => "customprojectrules",
95        FileType::GenericMarkdown => "genericmarkdown",
96        FileType::GenericYaml => "genericyaml",
97        FileType::Unknown => "unknown",
98    }
99}