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, TextEdit, 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.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
111fn 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}