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, TextEdit, 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.clone())
81            .with_fix(append_section_fix(&doc.raw, &entry));
82            out.push(v);
83        }
84        out
85    }
86}
87
88fn file_type_key(ft: FileType) -> &'static str {
89    match ft {
90        FileType::ClaudeMd => "claudemd",
91        FileType::AgentsMd => "agentsmd",
92        FileType::CopilotCustomization => "copilotcustomization",
93        FileType::CopilotInstructions => "copilotinstructions",
94        FileType::CursorRules => "cursorrules",
95        FileType::WindsurfRules => "windsurfrules",
96        FileType::ClineRules => "clinerules",
97        FileType::JunieGuidelines => "junieguidelines",
98        FileType::GenericSystemPrompt => "genericsystemprompt",
99        FileType::AiderConventions => "aiderconventions",
100        FileType::ContinueRules => "continuerules",
101        FileType::GitHubSkill => "githubskill",
102        FileType::CustomProjectRules => "customprojectrules",
103        FileType::GenericMarkdown => "genericmarkdown",
104        FileType::GenericYaml => "genericyaml",
105        FileType::McpConfig => "mcpconfig",
106        FileType::SourceCode(_) => "sourcecode",
107        FileType::Unknown => "unknown",
108    }
109}
110
111// Append `\n## <name>\n\n` at end of file, normalizing trailing newlines.
112fn append_section_fix(raw: &str, name: &str) -> TextEdit {
113    let end = raw.len();
114    let has_trailing_newline = raw.ends_with('\n');
115    let prefix = if raw.is_empty() {
116        ""
117    } else if has_trailing_newline {
118        "\n"
119    } else {
120        "\n\n"
121    };
122    let replacement = format!("{prefix}## {name}\n\n");
123    TextEdit {
124        range: end..end,
125        replacement,
126    }
127}