Skip to main content

kardo_core/analysis/
agent_setup.rs

1//! Agent setup infrastructure analysis.
2//!
3//! Evaluates the AI agent configuration quality of a project:
4//! CLAUDE.md, .claude/ directory, MCP config, .cursorrules, other AI configs.
5
6use serde::Serialize;
7use std::fs;
8use std::path::Path;
9
10/// Weight constants for each signal group.
11const W_CLAUDE_MD: f64 = 0.30;
12const W_CLAUDE_DIR: f64 = 0.25;
13const W_MCP: f64 = 0.20;
14const W_CURSOR: f64 = 0.10;
15const W_OTHER_AI: f64 = 0.15;
16
17/// Result of agent setup infrastructure analysis.
18#[derive(Debug, Clone, Serialize)]
19pub struct AgentSetupResult {
20    /// Weighted composite agent setup score (0.0-1.0).
21    pub score: f64,
22    /// CLAUDE.md presence and quality score.
23    pub claude_md_score: f64,
24    /// .claude/ directory structure completeness score.
25    pub claude_dir_score: f64,
26    /// MCP configuration (.mcp.json) quality score.
27    pub mcp_score: f64,
28    /// Cursor rules (.cursorrules / .cursor/rules) presence score.
29    pub cursor_score: f64,
30    /// Other AI tool config files (Copilot, Windsurf, Aider, etc.) score.
31    pub other_ai_score: f64,
32    /// Individual check results with pass/fail details.
33    pub details: Vec<AgentSetupDetail>,
34}
35
36/// Individual check detail.
37#[derive(Debug, Clone, Serialize)]
38pub struct AgentSetupDetail {
39    /// Human-readable description of what was checked.
40    pub check: String,
41    /// Whether this check passed (file/directory found and valid).
42    pub found: bool,
43    /// Relative path of the detected file or directory, if found.
44    pub path: Option<String>,
45}
46
47pub struct AgentSetupAnalyzer;
48
49impl AgentSetupAnalyzer {
50    /// Analyze agent setup infrastructure in the project directory.
51    pub fn analyze(project_root: &Path) -> AgentSetupResult {
52        let mut details = Vec::new();
53
54        let claude_md_score = Self::check_claude_md(project_root, &mut details);
55        let claude_dir_score = Self::check_claude_dir(project_root, &mut details);
56        let mcp_score = Self::check_mcp(project_root, &mut details);
57        let cursor_score = Self::check_cursor(project_root, &mut details, claude_md_score > 0.0);
58        let other_ai_score = Self::check_other_ai(project_root, &mut details);
59
60        let score = W_CLAUDE_MD * claude_md_score
61            + W_CLAUDE_DIR * claude_dir_score
62            + W_MCP * mcp_score
63            + W_CURSOR * cursor_score
64            + W_OTHER_AI * other_ai_score;
65
66        AgentSetupResult {
67            score,
68            claude_md_score,
69            claude_dir_score,
70            mcp_score,
71            cursor_score,
72            other_ai_score,
73            details,
74        }
75    }
76
77    /// Check CLAUDE.md presence and quality (weight 0.30).
78    fn check_claude_md(root: &Path, details: &mut Vec<AgentSetupDetail>) -> f64 {
79        let path = root.join("CLAUDE.md");
80        let exists = path.exists();
81        details.push(AgentSetupDetail {
82            check: "CLAUDE.md exists".to_string(),
83            found: exists,
84            path: if exists { Some("CLAUDE.md".to_string()) } else { None },
85        });
86
87        if !exists {
88            return 0.0;
89        }
90
91        let content = fs::read_to_string(&path).unwrap_or_default();
92        let len = content.len();
93
94        let mut score: f64 = 0.5; // base for existence
95
96        if len > 2000 {
97            score += 0.3; // total 0.8 for >2000
98        } else if len > 500 {
99            score += 0.2; // total 0.7 for >500
100        }
101
102        // Structured: has markdown headings
103        let has_headings = content.lines().any(|l| l.starts_with('#'));
104        if has_headings {
105            score = (score + 0.1).min(1.0);
106        }
107
108        details.push(AgentSetupDetail {
109            check: format!("CLAUDE.md length: {} chars", len),
110            found: len > 500,
111            path: Some("CLAUDE.md".to_string()),
112        });
113
114        score
115    }
116
117    /// Check .claude/ directory structure (weight 0.25).
118    fn check_claude_dir(root: &Path, details: &mut Vec<AgentSetupDetail>) -> f64 {
119        let mut score = 0.0;
120
121        let instructions = root.join(".claude/instructions");
122        let has_instructions = instructions.exists();
123        details.push(AgentSetupDetail {
124            check: ".claude/instructions exists".to_string(),
125            found: has_instructions,
126            path: if has_instructions { Some(".claude/instructions".to_string()) } else { None },
127        });
128        if has_instructions {
129            score += 0.3;
130        }
131
132        let subdirs = [
133            ("commands", 0.2),
134            ("agents", 0.2),
135            ("skills", 0.15),
136            ("hooks", 0.15),
137        ];
138
139        for (name, weight) in &subdirs {
140            let dir = root.join(format!(".claude/{}", name));
141            let has_files = dir.is_dir() && Self::dir_has_files(&dir);
142            details.push(AgentSetupDetail {
143                check: format!(".claude/{}/ with files", name),
144                found: has_files,
145                path: if has_files { Some(format!(".claude/{}/", name)) } else { None },
146            });
147            if has_files {
148                score += weight;
149            }
150        }
151
152        score
153    }
154
155    /// Check MCP configuration (weight 0.20).
156    fn check_mcp(root: &Path, details: &mut Vec<AgentSetupDetail>) -> f64 {
157        let mcp_path = root.join(".mcp.json");
158        let mcp_example = root.join(".mcp.json.example");
159
160        if mcp_path.exists() {
161            details.push(AgentSetupDetail {
162                check: ".mcp.json exists".to_string(),
163                found: true,
164                path: Some(".mcp.json".to_string()),
165            });
166
167            let mut score = 0.5;
168
169            // Try to parse and count servers
170            if let Ok(content) = fs::read_to_string(&mcp_path) {
171                if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
172                    let server_count = json
173                        .get("mcpServers")
174                        .and_then(|v| v.as_object())
175                        .map(|obj| obj.len())
176                        .unwrap_or(0);
177
178                    details.push(AgentSetupDetail {
179                        check: format!("MCP servers configured: {}", server_count),
180                        found: server_count > 0,
181                        path: Some(".mcp.json".to_string()),
182                    });
183
184                    if server_count >= 3 {
185                        score += 0.5; // total 1.0
186                    } else if server_count >= 1 {
187                        score += 0.25; // total 0.75
188                    }
189                }
190            }
191
192            score
193        } else if mcp_example.exists() {
194            details.push(AgentSetupDetail {
195                check: ".mcp.json.example exists".to_string(),
196                found: true,
197                path: Some(".mcp.json.example".to_string()),
198            });
199            0.3
200        } else {
201            details.push(AgentSetupDetail {
202                check: ".mcp.json exists".to_string(),
203                found: false,
204                path: None,
205            });
206            0.0
207        }
208    }
209
210    /// Check .cursorrules / .cursor/rules (weight 0.10).
211    fn check_cursor(root: &Path, details: &mut Vec<AgentSetupDetail>, has_claude_md: bool) -> f64 {
212        let cursorrules = root.join(".cursorrules");
213        let cursor_rules = root.join(".cursor/rules");
214
215        let found = cursorrules.exists() || cursor_rules.exists();
216        let path_str = if cursorrules.exists() {
217            Some(".cursorrules".to_string())
218        } else if cursor_rules.exists() {
219            Some(".cursor/rules".to_string())
220        } else {
221            None
222        };
223
224        details.push(AgentSetupDetail {
225            check: ".cursorrules or .cursor/rules exists".to_string(),
226            found,
227            path: path_str,
228        });
229
230        if found {
231            1.0
232        } else if has_claude_md {
233            0.5 // Partial: CLAUDE.md exists but no cursor config
234        } else {
235            0.0
236        }
237    }
238
239    /// Check other AI config files (weight 0.15).
240    fn check_other_ai(root: &Path, details: &mut Vec<AgentSetupDetail>) -> f64 {
241        let mut score = 0.0;
242
243        let checks: &[(&str, f64)] = &[
244            (".github/copilot-instructions.md", 0.3),
245            ("coderabbit.yaml", 0.2),
246            (".windsurfrules", 0.2),
247            (".clinerules", 0.2),
248            ("AGENTS.md", 0.3),
249        ];
250
251        for (path, weight) in checks {
252            let full = root.join(path);
253            let found = full.exists();
254            details.push(AgentSetupDetail {
255                check: format!("{} exists", path),
256                found,
257                path: if found { Some(path.to_string()) } else { None },
258            });
259            if found {
260                score += weight;
261            }
262        }
263
264        // Check aider: .aider.conf.yml or .aider/ directory
265        let aider_conf = root.join(".aider.conf.yml");
266        let aider_dir = root.join(".aider");
267        let has_aider = aider_conf.exists() || aider_dir.is_dir();
268        details.push(AgentSetupDetail {
269            check: ".aider config exists".to_string(),
270            found: has_aider,
271            path: if aider_conf.exists() {
272                Some(".aider.conf.yml".to_string())
273            } else if aider_dir.is_dir() {
274                Some(".aider/".to_string())
275            } else {
276                None
277            },
278        });
279        if has_aider {
280            score += 0.3;
281        }
282
283        // Check any .ai/ or .ai-* config
284        let ai_dir = root.join(".ai");
285        let has_ai_dir = ai_dir.is_dir();
286        if !has_ai_dir {
287            // Check for .ai-* files
288            if let Ok(entries) = fs::read_dir(root) {
289                for entry in entries.flatten() {
290                    let name = entry.file_name();
291                    let name = name.to_string_lossy();
292                    if name.starts_with(".ai-") || name.starts_with(".ai/") {
293                        details.push(AgentSetupDetail {
294                            check: "AI config directory/file exists".to_string(),
295                            found: true,
296                            path: Some(name.to_string()),
297                        });
298                        score += 0.2;
299                        return score.min(1.0);
300                    }
301                }
302            }
303        } else {
304            details.push(AgentSetupDetail {
305                check: ".ai/ directory exists".to_string(),
306                found: true,
307                path: Some(".ai/".to_string()),
308            });
309            score += 0.2;
310        }
311
312        score.min(1.0)
313    }
314
315    /// Check if a directory contains at least one file.
316    fn dir_has_files(dir: &Path) -> bool {
317        fs::read_dir(dir)
318            .map(|entries| entries.flatten().any(|e| e.file_type().map(|t| t.is_file()).unwrap_or(false)))
319            .unwrap_or(false)
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use tempfile::TempDir;
327
328    fn setup_project(setup: impl FnOnce(&Path)) -> (TempDir, AgentSetupResult) {
329        let dir = TempDir::new().unwrap();
330        setup(dir.path());
331        let result = AgentSetupAnalyzer::analyze(dir.path());
332        (dir, result)
333    }
334
335    #[test]
336    fn test_empty_project_zero_score() {
337        let (_dir, result) = setup_project(|_| {});
338        assert!(
339            result.score < 0.01,
340            "Empty project should score ~0, got {}",
341            result.score
342        );
343        assert_eq!(result.claude_md_score, 0.0);
344        assert_eq!(result.claude_dir_score, 0.0);
345        assert_eq!(result.mcp_score, 0.0);
346    }
347
348    #[test]
349    fn test_project_with_claude_md_and_dir() {
350        let (_dir, result) = setup_project(|root| {
351            // Large CLAUDE.md with headings
352            let content = format!(
353                "# Project\n\n## Rules\n\nYou MUST follow these rules.\n\n{}\n",
354                "x".repeat(3000)
355            );
356            fs::write(root.join("CLAUDE.md"), content).unwrap();
357
358            // .claude/ directory with instructions and commands
359            fs::create_dir_all(root.join(".claude/commands")).unwrap();
360            fs::write(root.join(".claude/instructions"), "Instructions here").unwrap();
361            fs::write(root.join(".claude/commands/build.md"), "Build command").unwrap();
362        });
363
364        assert!(
365            result.claude_md_score >= 0.9,
366            "Large structured CLAUDE.md should score >= 0.9, got {}",
367            result.claude_md_score
368        );
369        assert!(
370            result.claude_dir_score >= 0.5,
371            "Dir with instructions + commands should score >= 0.5, got {}",
372            result.claude_dir_score
373        );
374        assert!(
375            result.score > 0.3,
376            "Project with CLAUDE.md + .claude/ should score > 0.3, got {}",
377            result.score
378        );
379    }
380
381    #[test]
382    fn test_project_with_no_ai_config_only_readme() {
383        let (_dir, result) = setup_project(|root| {
384            fs::write(root.join("README.md"), "# My Project\nDescription.").unwrap();
385        });
386
387        assert!(
388            result.score < 0.15,
389            "Project with only README should have low score, got {}",
390            result.score
391        );
392        assert_eq!(result.claude_md_score, 0.0);
393    }
394
395    #[test]
396    fn test_mcp_config_with_servers() {
397        let (_dir, result) = setup_project(|root| {
398            let mcp_json = r#"{
399                "mcpServers": {
400                    "context7": {"command": "npx", "args": ["context7"]},
401                    "exa": {"command": "npx", "args": ["exa"]},
402                    "memory": {"command": "npx", "args": ["memory"]}
403                }
404            }"#;
405            fs::write(root.join(".mcp.json"), mcp_json).unwrap();
406        });
407
408        assert!(
409            (result.mcp_score - 1.0).abs() < 0.01,
410            "MCP with 3+ servers should score 1.0, got {}",
411            result.mcp_score
412        );
413    }
414
415    #[test]
416    fn test_cursorrules_exists() {
417        let (_dir, result) = setup_project(|root| {
418            fs::write(root.join(".cursorrules"), "Cursor rules here").unwrap();
419        });
420
421        assert!(
422            (result.cursor_score - 1.0).abs() < 0.01,
423            ".cursorrules should give cursor_score = 1.0, got {}",
424            result.cursor_score
425        );
426    }
427
428    #[test]
429    fn test_cursorrules_partial_when_claude_md_exists() {
430        let (_dir, result) = setup_project(|root| {
431            fs::write(root.join("CLAUDE.md"), "# Project\nRules.").unwrap();
432            // No .cursorrules
433        });
434
435        assert!(
436            (result.cursor_score - 0.5).abs() < 0.01,
437            "No .cursorrules but CLAUDE.md → cursor_score = 0.5, got {}",
438            result.cursor_score
439        );
440    }
441
442    #[test]
443    fn test_full_ai_setup_high_score() {
444        let (_dir, result) = setup_project(|root| {
445            // CLAUDE.md
446            let content = format!("# Project\n\n## Rules\n\n{}\n", "y".repeat(3000));
447            fs::write(root.join("CLAUDE.md"), content).unwrap();
448
449            // .claude/ full setup
450            fs::create_dir_all(root.join(".claude/commands")).unwrap();
451            fs::create_dir_all(root.join(".claude/agents")).unwrap();
452            fs::create_dir_all(root.join(".claude/skills")).unwrap();
453            fs::create_dir_all(root.join(".claude/hooks")).unwrap();
454            fs::write(root.join(".claude/instructions"), "Full instructions").unwrap();
455            fs::write(root.join(".claude/commands/build.md"), "cmd").unwrap();
456            fs::write(root.join(".claude/agents/reviewer.md"), "agent").unwrap();
457            fs::write(root.join(".claude/skills/debug.md"), "skill").unwrap();
458            fs::write(root.join(".claude/hooks/pre-commit.md"), "hook").unwrap();
459
460            // MCP
461            let mcp = r#"{"mcpServers":{"a":{},"b":{},"c":{}}}"#;
462            fs::write(root.join(".mcp.json"), mcp).unwrap();
463
464            // Cursor
465            fs::write(root.join(".cursorrules"), "rules").unwrap();
466
467            // Other AI
468            fs::create_dir_all(root.join(".github")).unwrap();
469            fs::write(root.join(".github/copilot-instructions.md"), "copilot").unwrap();
470        });
471
472        assert!(
473            result.score > 0.85,
474            "Full AI setup should score > 0.85, got {}",
475            result.score
476        );
477    }
478
479    #[test]
480    fn test_windsurfrules_detected() {
481        let (_dir, result) = setup_project(|root| {
482            fs::write(root.join(".windsurfrules"), "Windsurf rules here").unwrap();
483        });
484        assert!(
485            result.other_ai_score > 0.0,
486            ".windsurfrules should contribute to other_ai_score, got {}",
487            result.other_ai_score
488        );
489    }
490
491    #[test]
492    fn test_agents_md_standalone_detected() {
493        let (_dir, result) = setup_project(|root| {
494            fs::write(root.join("AGENTS.md"), "# Agents\n\n## Agent 1\nAgent config.").unwrap();
495        });
496        assert!(
497            result.other_ai_score > 0.0,
498            "Standalone AGENTS.md should contribute to other_ai_score, got {}",
499            result.other_ai_score
500        );
501    }
502
503    #[test]
504    fn test_clinerules_detected() {
505        let (_dir, result) = setup_project(|root| {
506            fs::write(root.join(".clinerules"), "Cline rules").unwrap();
507        });
508        assert!(
509            result.other_ai_score > 0.0,
510            ".clinerules should contribute to other_ai_score, got {}",
511            result.other_ai_score
512        );
513    }
514
515    #[test]
516    fn test_mcp_example_only() {
517        let (_dir, result) = setup_project(|root| {
518            fs::write(root.join(".mcp.json.example"), "{}").unwrap();
519        });
520
521        assert!(
522            (result.mcp_score - 0.3).abs() < 0.01,
523            ".mcp.json.example should score 0.3, got {}",
524            result.mcp_score
525        );
526    }
527}