1use serde::Serialize;
7use std::fs;
8use std::path::Path;
9
10const 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#[derive(Debug, Clone, Serialize)]
19pub struct AgentSetupResult {
20 pub score: f64,
22 pub claude_md_score: f64,
24 pub claude_dir_score: f64,
26 pub mcp_score: f64,
28 pub cursor_score: f64,
30 pub other_ai_score: f64,
32 pub details: Vec<AgentSetupDetail>,
34}
35
36#[derive(Debug, Clone, Serialize)]
38pub struct AgentSetupDetail {
39 pub check: String,
41 pub found: bool,
43 pub path: Option<String>,
45}
46
47pub struct AgentSetupAnalyzer;
48
49impl AgentSetupAnalyzer {
50 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 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; if len > 2000 {
97 score += 0.3; } else if len > 500 {
99 score += 0.2; }
101
102 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 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 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 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; } else if server_count >= 1 {
187 score += 0.25; }
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 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 } else {
235 0.0
236 }
237 }
238
239 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 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 let ai_dir = root.join(".ai");
285 let has_ai_dir = ai_dir.is_dir();
286 if !has_ai_dir {
287 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 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 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 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 });
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 let content = format!("# Project\n\n## Rules\n\n{}\n", "y".repeat(3000));
447 fs::write(root.join("CLAUDE.md"), content).unwrap();
448
449 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 let mcp = r#"{"mcpServers":{"a":{},"b":{},"c":{}}}"#;
462 fs::write(root.join(".mcp.json"), mcp).unwrap();
463
464 fs::write(root.join(".cursorrules"), "rules").unwrap();
466
467 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}