Skip to main content

oratos_core/
score.rs

1use serde::{Deserialize, Serialize};
2
3use crate::finding::{Category, Finding, Severity};
4
5/// Category scores are 0–100. Higher is better.
6#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
7pub struct CategoryScores {
8    pub seo: f64,
9    pub accessibility: f64,
10    pub structured_data: f64,
11    pub llm_readiness: f64,
12    pub overall: f64,
13}
14
15impl CategoryScores {
16    pub fn from_findings(findings: &[Finding]) -> Self {
17        let seo = score_category(findings, Category::Seo);
18        let accessibility = score_category(findings, Category::Accessibility);
19        let structured_data = score_category(findings, Category::StructuredData);
20        let llm_readiness = score_category(findings, Category::LlmReadiness);
21
22        // Weighted overall: SEO 30%, A11y 25%, Structured 25%, LLM 20%
23        let overall =
24            seo * 0.30 + accessibility * 0.25 + structured_data * 0.25 + llm_readiness * 0.20;
25
26        Self {
27            seo,
28            accessibility,
29            structured_data,
30            llm_readiness,
31            overall,
32        }
33    }
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct ScoreBreakdown {
38    pub scores: CategoryScores,
39    pub penalty_weights: PenaltyWeights,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct PenaltyWeights {
44    pub error: u32,
45    pub warning: u32,
46    pub info: u32,
47}
48
49impl Default for PenaltyWeights {
50    fn default() -> Self {
51        Self {
52            error: 10,
53            warning: 5,
54            info: 1,
55        }
56    }
57}
58
59fn score_category(findings: &[Finding], category: Category) -> f64 {
60    let weights = PenaltyWeights::default();
61    let penalty: u32 = findings
62        .iter()
63        .filter(|f| f.category == category)
64        .map(|f| match f.severity {
65            Severity::Error => weights.error,
66            Severity::Warning => weights.warning,
67            Severity::Info => weights.info,
68        })
69        .sum();
70
71    (100.0_f64 - f64::from(penalty)).clamp(0.0, 100.0)
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use crate::finding::Finding;
78
79    #[test]
80    fn perfect_score_with_no_findings() {
81        let scores = CategoryScores::from_findings(&[]);
82        assert!((scores.overall - 100.0).abs() < f64::EPSILON);
83    }
84
85    #[test]
86    fn errors_reduce_score() {
87        let findings = vec![Finding::new(
88            "test",
89            Severity::Error,
90            Category::Seo,
91            "test error",
92        )];
93        let scores = CategoryScores::from_findings(&findings);
94        assert!((scores.seo - 90.0).abs() < f64::EPSILON);
95    }
96
97    #[test]
98    fn performance_hint_does_not_affect_scores() {
99        let findings = vec![Finding::new(
100            "perf-hint",
101            Severity::Info,
102            Category::PerformanceHint,
103            "perf hint",
104        )];
105        let scores = CategoryScores::from_findings(&findings);
106        assert!((scores.overall - 100.0).abs() < f64::EPSILON);
107    }
108}