Skip to main content

claude_native/scoring/
mod.rs

1pub mod weights;
2
3use std::collections::HashMap;
4
5use crate::detection::ProjectType;
6use crate::rules::{Dimension, Effort, RuleResult, RuleStatus, Severity, Suggestion, SuggestionPriority};
7
8/// Grade from A+ to F
9#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
10pub enum Grade {
11    F,
12    D,
13    C,
14    B,
15    A,
16    APlus,
17}
18
19impl Grade {
20    pub fn from_score(score: f64) -> Self {
21        match score as u32 {
22            90..=100 => Grade::APlus,
23            80..=89 => Grade::A,
24            70..=79 => Grade::B,
25            60..=69 => Grade::C,
26            40..=59 => Grade::D,
27            _ => Grade::F,
28        }
29    }
30}
31
32impl std::fmt::Display for Grade {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Grade::APlus => write!(f, "A+"),
36            Grade::A => write!(f, "A"),
37            Grade::B => write!(f, "B"),
38            Grade::C => write!(f, "C"),
39            Grade::D => write!(f, "D"),
40            Grade::F => write!(f, "F"),
41        }
42    }
43}
44
45impl Grade {
46    pub fn description(&self) -> &str {
47        match self {
48            Grade::APlus => "Fully Claude Native — optimized for AI-assisted development",
49            Grade::A => "Claude Native — well set up with minor improvements possible",
50            Grade::B => "Claude Friendly — good foundation, notable gaps",
51            Grade::C => "Claude Compatible — works but significant optimization possible",
52            Grade::D => "Claude Hostile — major friction, high token waste",
53            Grade::F => "Not Claude Native — needs fundamental restructuring",
54        }
55    }
56}
57
58/// Score for a single dimension
59#[derive(Debug, Clone)]
60pub struct DimensionScore {
61    pub dimension: Dimension,
62    pub score: f64,
63    pub weight: f64,
64    pub rules_passed: usize,
65    pub rules_failed: usize,
66    pub rules_warned: usize,
67    pub rules_skipped: usize,
68    pub capped: bool,
69}
70
71/// The complete scorecard
72#[derive(Debug, Clone)]
73pub struct Scorecard {
74    pub project_type: ProjectType,
75    pub dimensions: Vec<DimensionScore>,
76    pub total_score: f64,
77    pub grade: Grade,
78    pub rule_results: Vec<RuleResult>,
79    pub suggestions: Vec<Suggestion>,
80}
81
82const ALL_DIMENSIONS: [Dimension; 5] = [
83    Dimension::Foundation,
84    Dimension::ContextEfficiency,
85    Dimension::Navigation,
86    Dimension::Tooling,
87    Dimension::CodeQuality,
88];
89
90/// Calculate the scorecard from rule results.
91pub fn calculate(results: Vec<RuleResult>, project_type: &ProjectType) -> Scorecard {
92    let weight_table = weights::get_weights(project_type);
93    let mut by_dimension: HashMap<Dimension, Vec<&RuleResult>> = HashMap::new();
94    for r in &results {
95        by_dimension.entry(r.dimension).or_default().push(r);
96    }
97
98    let dim_scores: Vec<DimensionScore> = ALL_DIMENSIONS.iter()
99        .map(|dim| score_dimension(*dim, &by_dimension, &weight_table))
100        .collect();
101
102    let total_score = dim_scores.iter().map(|d| d.score * d.weight).sum::<f64>().clamp(0.0, 100.0);
103    let grade = Grade::from_score(total_score);
104    let suggestions = collect_suggestions(&results, total_score);
105
106    Scorecard { project_type: project_type.clone(), dimensions: dim_scores, total_score, grade, rule_results: results, suggestions }
107}
108
109fn score_dimension(
110    dim: Dimension,
111    by_dimension: &HashMap<Dimension, Vec<&RuleResult>>,
112    weight_table: &HashMap<Dimension, f64>,
113) -> DimensionScore {
114    let dim_results = by_dimension.get(&dim).cloned().unwrap_or_default();
115    let weight = weight_table.get(&dim).copied().unwrap_or(0.2);
116    let mut score = 100.0_f64;
117    let mut capped = false;
118    let (mut passed, mut failed, mut warned, mut skipped) = (0, 0, 0, 0);
119
120    for r in &dim_results {
121        match &r.status {
122            RuleStatus::Pass => passed += 1,
123            RuleStatus::Skip => skipped += 1,
124            RuleStatus::Warn(_) => { warned += 1; score -= r.severity.deduction() / 2.0; }
125            RuleStatus::Fail(_) => {
126                failed += 1;
127                if r.severity == Severity::Critical { capped = true; }
128                else { score -= r.severity.deduction(); }
129            }
130        }
131    }
132
133    if capped { score = score.min(30.0); }
134    score = score.clamp(0.0, 100.0);
135
136    DimensionScore { dimension: dim, score, weight, rules_passed: passed, rules_failed: failed, rules_warned: warned, rules_skipped: skipped, capped }
137}
138
139fn collect_suggestions(results: &[RuleResult], total_score: f64) -> Vec<Suggestion> {
140    let mut suggestions: Vec<Suggestion> = results.iter()
141        .filter_map(|r| r.suggestion.clone())
142        .collect();
143    suggestions.sort_by_key(|s| s.priority);
144
145    // When score is below C grade, prepend --init as the #1 suggestion
146    if total_score < 60.0 {
147        suggestions.insert(0, Suggestion {
148            priority: SuggestionPriority::QuickWin,
149            title: "Run `claude-native --init` to bootstrap your project".into(),
150            description: "This single command generates CLAUDE.md, .claudeignore, and .claude/settings.json tailored to your detected project type. Typically jumps score by 20-30 points.".into(),
151            effort: Effort::Minutes,
152        });
153    }
154
155    suggestions
156}