Skip to main content

kardo_core/analysis/
ai_configs.rs

1//! Multi-tool AI config analysis.
2//!
3//! Analyzes AI tool configurations beyond CLAUDE.md:
4//! - Cursor: .cursor/rules/*.mdc files
5//! - Copilot: .github/copilot-instructions.md
6//! - Windsurf: .windsurfrules
7//! - Aider: AIDER.md or .aider.conf.yml
8
9/// A detected AI config file from any tool.
10#[derive(Debug, Clone)]
11pub struct AiConfigFile {
12    /// Which AI tool this config belongs to.
13    pub tool: AiTool,
14    /// Relative path of the config file.
15    pub path: String,
16    /// Raw content of the config file.
17    pub content: String,
18    /// Quality score for this config (0.0-1.0).
19    pub quality_score: f64,
20}
21
22/// Supported AI tools.
23#[derive(Debug, Clone, PartialEq)]
24pub enum AiTool {
25    Claude,       // CLAUDE.md, .claude/instructions
26    Cursor,       // .cursor/rules/*.mdc
27    Copilot,      // .github/copilot-instructions.md, copilot-instructions.md
28    Windsurf,     // .windsurfrules
29    Aider,        // AIDER.md, .aider.conf.yml
30    Generic,      // Other AI config patterns
31}
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/// Result of multi-tool AI config analysis.
47#[derive(Debug, Clone)]
48pub struct AiConfigsResult {
49    /// Number of AI tools configured.
50    pub tool_count: usize,
51    /// List of all detected AI config files.
52    pub configs: Vec<AiConfigFile>,
53    /// Aggregate quality score across all tools (0.0-1.0).
54    pub aggregate_score: f64,
55    /// Whether any non-Claude tool is configured.
56    pub has_multi_tool: bool,
57    /// List of tool names found.
58    pub tools_found: Vec<String>,
59}
60
61pub struct AiConfigsAnalyzer;
62
63impl AiConfigsAnalyzer {
64    /// Detect AI config files from the discovered file list.
65    ///
66    /// `file_paths`: list of relative paths of discovered files.
67    /// `read_fn`: function to read file content by relative path.
68    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    /// Detect which AI tool a file path belongs to.
122    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        // Cursor: .cursor/rules/*.mdc
128        if lower.starts_with(".cursor/rules/") && lower.ends_with(".mdc") {
129            return Some(AiTool::Cursor);
130        }
131
132        // Copilot: .github/copilot-instructions.md or copilot-instructions.md
133        if filename_lower == "copilot-instructions.md" {
134            return Some(AiTool::Copilot);
135        }
136
137        // Windsurf: .windsurfrules
138        if filename_lower == ".windsurfrules" {
139            return Some(AiTool::Windsurf);
140        }
141
142        // Aider: AIDER.md or .aider.conf.yml
143        if filename_lower == "aider.md" || filename_lower == ".aider.conf.yml" {
144            return Some(AiTool::Aider);
145        }
146
147        None
148    }
149
150    /// Score the quality of an AI config file.
151    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    /// Score a Cursor .mdc file.
162    /// MDC format: optional YAML frontmatter + markdown body.
163    fn score_mdc(content: &str) -> f64 {
164        let lines: Vec<&str> = content.lines().collect();
165
166        // Check for YAML frontmatter (--- ... ---)
167        let has_frontmatter = lines.first().map(|l| *l == "---").unwrap_or(false);
168        let frontmatter_score = if has_frontmatter {
169            // Check for useful frontmatter fields: description, globs, alwaysApply
170            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        // MDC gets bonus for having frontmatter
179        (frontmatter_score * 0.3 + body_score * 0.7).min(1.0)
180    }
181
182    /// Score a generic markdown AI config file.
183    fn score_generic_md(content: &str) -> f64 {
184        let chars = content.len();
185
186        // Length score
187        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        // Actionable keywords
198        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        // Structure
205        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        // Average
210        (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        // CLAUDE.md is NOT detected by ai_configs analyzer (handled by config_quality)
279        // Only non-CLAUDE tools should be in result
280        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        // aggregate_score = average of both quality scores
332        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}