1#[derive(Debug, Clone)]
11pub struct AiConfigFile {
12 pub tool: AiTool,
14 pub path: String,
16 pub content: String,
18 pub quality_score: f64,
20}
21
22#[derive(Debug, Clone, PartialEq)]
24pub enum AiTool {
25 Claude, Cursor, Copilot, Windsurf, Aider, Generic, }
32
33impl AiTool {
34 pub fn name(&self) -> &'static str {
35 match self {
36 AiTool::Claude => "Claude",
37 AiTool::Cursor => "Cursor",
38 AiTool::Copilot => "Copilot",
39 AiTool::Windsurf => "Windsurf",
40 AiTool::Aider => "Aider",
41 AiTool::Generic => "Generic",
42 }
43 }
44}
45
46#[derive(Debug, Clone)]
48pub struct AiConfigsResult {
49 pub tool_count: usize,
51 pub configs: Vec<AiConfigFile>,
53 pub aggregate_score: f64,
55 pub has_multi_tool: bool,
57 pub tools_found: Vec<String>,
59}
60
61pub struct AiConfigsAnalyzer;
62
63impl AiConfigsAnalyzer {
64 pub fn analyze<F>(file_paths: &[String], read_fn: F) -> AiConfigsResult
69 where
70 F: Fn(&str) -> Option<String>,
71 {
72 let mut configs = Vec::new();
73
74 for path in file_paths {
75 let tool = Self::detect_tool(path);
76 let tool = match tool {
77 Some(t) => t,
78 None => continue,
79 };
80
81 let content = match read_fn(path) {
82 Some(c) if !c.trim().is_empty() => c,
83 _ => continue,
84 };
85
86 let quality_score = Self::score_config(&tool, &content);
87
88 configs.push(AiConfigFile {
89 tool,
90 path: path.clone(),
91 content,
92 quality_score,
93 });
94 }
95
96 let tool_count = configs.len();
97 let has_multi_tool = configs.iter().any(|c| c.tool != AiTool::Claude);
98 let tools_found: Vec<String> = {
99 let mut seen = std::collections::HashSet::new();
100 configs.iter()
101 .filter(|c| seen.insert(c.tool.name()))
102 .map(|c| c.tool.name().to_string())
103 .collect()
104 };
105
106 let aggregate_score = if configs.is_empty() {
107 0.0
108 } else {
109 configs.iter().map(|c| c.quality_score).sum::<f64>() / configs.len() as f64
110 };
111
112 AiConfigsResult {
113 tool_count,
114 configs,
115 aggregate_score,
116 has_multi_tool,
117 tools_found,
118 }
119 }
120
121 fn detect_tool(path: &str) -> Option<AiTool> {
123 let lower = path.to_lowercase();
124 let filename = path.split('/').next_back().unwrap_or(path);
125 let filename_lower = filename.to_lowercase();
126
127 if lower.starts_with(".cursor/rules/") && lower.ends_with(".mdc") {
129 return Some(AiTool::Cursor);
130 }
131
132 if filename_lower == "copilot-instructions.md" {
134 return Some(AiTool::Copilot);
135 }
136
137 if filename_lower == ".windsurfrules" {
139 return Some(AiTool::Windsurf);
140 }
141
142 if filename_lower == "aider.md" || filename_lower == ".aider.conf.yml" {
144 return Some(AiTool::Aider);
145 }
146
147 None
148 }
149
150 fn score_config(tool: &AiTool, content: &str) -> f64 {
152 match tool {
153 AiTool::Cursor => Self::score_mdc(content),
154 AiTool::Copilot => Self::score_generic_md(content),
155 AiTool::Windsurf => Self::score_generic_md(content),
156 AiTool::Aider => Self::score_generic_md(content),
157 _ => Self::score_generic_md(content),
158 }
159 }
160
161 fn score_mdc(content: &str) -> f64 {
164 let lines: Vec<&str> = content.lines().collect();
165
166 let has_frontmatter = lines.first().map(|l| *l == "---").unwrap_or(false);
168 let frontmatter_score = if has_frontmatter {
169 let has_description = content.contains("description:") || content.contains("globs:");
171 if has_description { 1.0 } else { 0.5 }
172 } else {
173 0.0
174 };
175
176 let body_score = Self::score_generic_md(content);
177
178 (frontmatter_score * 0.3 + body_score * 0.7).min(1.0)
180 }
181
182 fn score_generic_md(content: &str) -> f64 {
184 let chars = content.len();
185
186 let length_score = if chars < 200 {
188 chars as f64 / 200.0 * 0.4
189 } else if chars < 1000 {
190 0.4 + (chars - 200) as f64 / 800.0 * 0.3
191 } else if chars < 3000 {
192 0.7 + (chars - 1000) as f64 / 2000.0 * 0.2
193 } else {
194 0.9 + (0.1_f64).min((chars - 3000) as f64 / 3000.0 * 0.1)
195 };
196
197 let actionable_keywords = ["MUST", "NEVER", "ALWAYS", "DO NOT", "REQUIRED", "SHALL"];
199 let actionable_count: usize = actionable_keywords.iter()
200 .map(|kw| content.matches(kw).count())
201 .sum();
202 let actionable_score = (actionable_count as f64 / 5.0).min(1.0);
203
204 let heading_count = content.lines().filter(|l| l.starts_with('#')).count();
206 let code_block_count = content.matches("```").count() / 2;
207 let structure_score = ((heading_count + code_block_count) as f64 / 5.0).min(1.0);
208
209 (length_score * 0.4 + actionable_score * 0.35 + structure_score * 0.25).min(1.0)
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 fn make_read_fn(files: Vec<(&'static str, &'static str)>) -> impl Fn(&str) -> Option<String> {
219 move |path: &str| {
220 files.iter()
221 .find(|(p, _)| *p == path)
222 .map(|(_, c)| c.to_string())
223 }
224 }
225
226 #[test]
227 fn test_no_ai_configs() {
228 let paths = vec!["README.md".to_string(), "src/main.rs".to_string()];
229 let result = AiConfigsAnalyzer::analyze(&paths, |_| None);
230 assert_eq!(result.tool_count, 0);
231 assert!(!result.has_multi_tool);
232 assert_eq!(result.aggregate_score, 0.0);
233 }
234
235 #[test]
236 fn test_cursor_mdc_detected() {
237 let paths = vec![
238 ".cursor/rules/coding.mdc".to_string(),
239 "README.md".to_string(),
240 ];
241 let read_fn = make_read_fn(vec![
242 (".cursor/rules/coding.mdc", "---\ndescription: coding rules\nglobs: [\"src/**\"]\n---\n# Rules\nNEVER use var. ALWAYS use const.")
243 ]);
244 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
245 assert_eq!(result.tool_count, 1);
246 assert!(result.tools_found.contains(&"Cursor".to_string()));
247 }
248
249 #[test]
250 fn test_copilot_detected() {
251 let paths = vec!["copilot-instructions.md".to_string()];
252 let read_fn = make_read_fn(vec![
253 ("copilot-instructions.md", "# Copilot Instructions\nAlways write tests. NEVER use console.log in production.")
254 ]);
255 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
256 assert_eq!(result.tool_count, 1);
257 assert!(result.tools_found.contains(&"Copilot".to_string()));
258 }
259
260 #[test]
261 fn test_windsurf_detected() {
262 let paths = vec![".windsurfrules".to_string()];
263 let read_fn = make_read_fn(vec![
264 (".windsurfrules", "MUST follow TypeScript strict mode. NEVER use any type.")
265 ]);
266 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
267 assert_eq!(result.tool_count, 1);
268 assert!(result.tools_found.contains(&"Windsurf".to_string()));
269 }
270
271 #[test]
272 fn test_multi_tool() {
273 let paths = vec![
274 "CLAUDE.md".to_string(),
275 ".cursor/rules/main.mdc".to_string(),
276 "copilot-instructions.md".to_string(),
277 ];
278 let read_fn = make_read_fn(vec![
281 (".cursor/rules/main.mdc", "---\ndescription: main rules\n---\n# Rules\nNEVER skip tests."),
282 ("copilot-instructions.md", "# Copilot Instructions\nALWAYS write docstrings."),
283 ]);
284 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
285 assert_eq!(result.tool_count, 2);
286 assert!(result.has_multi_tool);
287 }
288
289 #[test]
290 fn test_aider_detected() {
291 let paths = vec!["AIDER.md".to_string()];
292 let read_fn = make_read_fn(vec![
293 ("AIDER.md", "# Aider Config\nALWAYS produce clean diffs. NEVER break existing tests.")
294 ]);
295 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
296 assert_eq!(result.tool_count, 1);
297 assert!(result.tools_found.contains(&"Aider".to_string()));
298 }
299
300 #[test]
301 fn test_empty_content_skipped() {
302 let paths = vec![".windsurfrules".to_string()];
303 let read_fn = make_read_fn(vec![
304 (".windsurfrules", " ")
305 ]);
306 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
307 assert_eq!(result.tool_count, 0);
308 }
309
310 #[test]
311 fn test_mdc_frontmatter_bonus() {
312 let with_frontmatter = "---\ndescription: rules\nglobs: [\"**\"]\n---\n# Rules\nNEVER use var.";
313 let without_frontmatter = "# Rules\nNEVER use var.";
314 let score_with = AiConfigsAnalyzer::score_mdc(with_frontmatter);
315 let score_without = AiConfigsAnalyzer::score_mdc(without_frontmatter);
316 assert!(score_with > score_without, "MDC with frontmatter should score higher");
317 }
318
319 #[test]
320 fn test_aggregate_score_average() {
321 let paths = vec![
322 ".cursor/rules/a.mdc".to_string(),
323 ".cursor/rules/b.mdc".to_string(),
324 ];
325 let read_fn = make_read_fn(vec![
326 (".cursor/rules/a.mdc", "---\ndescription: a\n---\n# A\nMUST do this. NEVER do that. ALWAYS check."),
327 (".cursor/rules/b.mdc", "# B\nsome rules here"),
328 ]);
329 let result = AiConfigsAnalyzer::analyze(&paths, read_fn);
330 assert_eq!(result.tool_count, 2);
331 let expected = (result.configs[0].quality_score + result.configs[1].quality_score) / 2.0;
333 assert!((result.aggregate_score - expected).abs() < 1e-10);
334 }
335}