ailint_core/rules/structural/
required_section.rs1use 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#[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}