Skip to main content

harbor_core/
scoring.rs

1use crate::analysis_result::AnalysisResult;
2
3const BASELINE: i32 = 100;
4const MAX_SCORE: i32 = 145;
5const BONUS_THRESHOLD: i32 = 90;
6
7/// The computed score and letter grade for a scan.
8#[derive(Debug, Clone, PartialEq)]
9pub struct ScanScore {
10    pub score: i32,
11    pub grade: &'static str,
12}
13
14impl ScanScore {
15    /// Calculate the score from a set of analysis results using the two-round
16    /// HTTP Observatory method:
17    ///
18    /// - Round 1: penalties are deducted from the baseline of 100.
19    /// - Round 2: bonuses are added only if the round-1 score is >= 90.
20    ///
21    /// Final score is clamped to [0, 145].
22    pub fn calculate(results: &[AnalysisResult]) -> Self {
23        let penalties: i32 = results
24            .iter()
25            .filter(|r| r.score_impact < 0)
26            .map(|r| r.score_impact)
27            .sum();
28
29        let round1 = (BASELINE + penalties).clamp(0, BASELINE);
30
31        let bonuses: i32 = if round1 >= BONUS_THRESHOLD {
32            results
33                .iter()
34                .filter(|r| r.score_impact > 0)
35                .map(|r| r.score_impact)
36                .sum()
37        } else {
38            0
39        };
40
41        let score = (round1 + bonuses).clamp(0, MAX_SCORE);
42        Self {
43            score,
44            grade: Self::grade(score),
45        }
46    }
47
48    pub fn grade(score: i32) -> &'static str {
49        match score {
50            100..=i32::MAX => "A+",
51            90..=99 => "A",
52            85..=89 => "A-",
53            80..=84 => "B+",
54            70..=79 => "B",
55            65..=69 => "B-",
56            60..=64 => "C+",
57            50..=59 => "C",
58            45..=49 => "C-",
59            40..=44 => "D+",
60            30..=39 => "D",
61            25..=29 => "D-",
62            _ => "F",
63        }
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use crate::severity::Severity;
71
72    fn result(score_impact: i32) -> AnalysisResult {
73        AnalysisResult::new(Severity::Ok, "check", "comment").with_score(score_impact)
74    }
75
76    #[test]
77    fn perfect_score_with_no_results() {
78        let score = ScanScore::calculate(&[]);
79        assert_eq!(score.score, 100);
80        assert_eq!(score.grade, "A+");
81    }
82
83    #[test]
84    fn penalty_is_deducted_from_baseline() {
85        let score = ScanScore::calculate(&[result(-25)]);
86        assert_eq!(score.score, 75);
87        assert_eq!(score.grade, "B");
88    }
89
90    #[test]
91    fn multiple_penalties_are_summed() {
92        let score = ScanScore::calculate(&[result(-20), result(-20), result(-5)]);
93        assert_eq!(score.score, 55);
94        assert_eq!(score.grade, "C");
95    }
96
97    #[test]
98    fn score_is_clamped_to_zero() {
99        let score = ScanScore::calculate(&[result(-200)]);
100        assert_eq!(score.score, 0);
101        assert_eq!(score.grade, "F");
102    }
103
104    #[test]
105    fn bonus_applied_when_round1_score_is_90_or_more() {
106        let score = ScanScore::calculate(&[result(10)]);
107        assert_eq!(score.score, 110);
108        assert_eq!(score.grade, "A+");
109    }
110
111    #[test]
112    fn bonus_not_applied_when_round1_score_below_90() {
113        // -20 penalty brings score to 80, bonus should NOT apply
114        let score = ScanScore::calculate(&[result(-20), result(10)]);
115        assert_eq!(score.score, 80);
116        assert_eq!(score.grade, "B+");
117    }
118
119    #[test]
120    fn score_is_clamped_to_max() {
121        let score = ScanScore::calculate(&[result(100)]);
122        assert_eq!(score.score, MAX_SCORE);
123    }
124
125    #[test]
126    fn grade_boundaries() {
127        assert_eq!(ScanScore::grade(100), "A+");
128        assert_eq!(ScanScore::grade(99), "A");
129        assert_eq!(ScanScore::grade(90), "A");
130        assert_eq!(ScanScore::grade(89), "A-");
131        assert_eq!(ScanScore::grade(85), "A-");
132        assert_eq!(ScanScore::grade(84), "B+");
133        assert_eq!(ScanScore::grade(80), "B+");
134        assert_eq!(ScanScore::grade(79), "B");
135        assert_eq!(ScanScore::grade(70), "B");
136        assert_eq!(ScanScore::grade(69), "B-");
137        assert_eq!(ScanScore::grade(65), "B-");
138        assert_eq!(ScanScore::grade(64), "C+");
139        assert_eq!(ScanScore::grade(60), "C+");
140        assert_eq!(ScanScore::grade(59), "C");
141        assert_eq!(ScanScore::grade(50), "C");
142        assert_eq!(ScanScore::grade(49), "C-");
143        assert_eq!(ScanScore::grade(45), "C-");
144        assert_eq!(ScanScore::grade(44), "D+");
145        assert_eq!(ScanScore::grade(40), "D+");
146        assert_eq!(ScanScore::grade(39), "D");
147        assert_eq!(ScanScore::grade(30), "D");
148        assert_eq!(ScanScore::grade(29), "D-");
149        assert_eq!(ScanScore::grade(25), "D-");
150        assert_eq!(ScanScore::grade(24), "F");
151        assert_eq!(ScanScore::grade(0), "F");
152    }
153}