use crate::facts::{Facts, Severity};
use crate::signals::{self, Signal};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Grade {
A,
B,
C,
D,
F,
}
impl Grade {
fn from_value(v: u8) -> Grade {
match v {
90..=100 => Grade::A,
75..=89 => Grade::B,
60..=74 => Grade::C,
40..=59 => Grade::D,
_ => Grade::F,
}
}
pub fn as_str(self) -> &'static str {
match self {
Grade::A => "A",
Grade::B => "B",
Grade::C => "C",
Grade::D => "D",
Grade::F => "F",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Tier {
Ok,
Caution,
Risky,
}
impl Tier {
pub fn as_str(self) -> &'static str {
match self {
Tier::Ok => "ok",
Tier::Caution => "caution",
Tier::Risky => "risky",
}
}
pub fn parse(s: &str) -> Option<Tier> {
match s.trim().to_lowercase().as_str() {
"ok" => Some(Tier::Ok),
"caution" => Some(Tier::Caution),
"risky" => Some(Tier::Risky),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Score {
pub value: u8,
pub grade: Grade,
pub tier: Tier,
pub signals: Vec<Signal>,
pub forced_reasons: Vec<String>,
}
const CAUTION_BELOW: u8 = 80;
const RISKY_BELOW: u8 = 55;
const MATURE_FLOOR: u8 = 70;
pub fn score(facts: &Facts, now: DateTime<Utc>) -> Score {
let signals = signals::all(facts, now);
let total_weight: f64 = signals.iter().map(|s| s.weight).sum();
let mut value = if total_weight <= 0.0 {
50 } else {
let weighted: f64 = signals.iter().map(|s| s.score * s.weight).sum();
(weighted / total_weight * 100.0).round() as u8
};
let clean =
!facts.is_unresolved() && !facts.deprecated && !facts.archived && facts.vulns.is_empty();
let mature = facts.total_versions.unwrap_or(0) >= 10 || facts.stars.unwrap_or(0) >= 1_000;
if clean && mature {
value = value.max(MATURE_FLOOR);
}
let mut tier = if value < RISKY_BELOW {
Tier::Risky
} else if value < CAUTION_BELOW {
Tier::Caution
} else {
Tier::Ok
};
let mut forced_reasons = Vec::new();
if facts.is_unresolved() {
forced_reasons.push(
"no registry data — package unknown or lookup failed; risk not assessable".to_string(),
);
}
if facts.deprecated {
forced_reasons.push("package is deprecated".to_string());
}
if facts.archived {
forced_reasons.push("source repository is archived".to_string());
}
if let Some(v) = facts.vulns.iter().find(|v| v.severity() >= Severity::High) {
forced_reasons.push(format!("unresolved high/critical advisory {}", v.id));
}
if !forced_reasons.is_empty() {
tier = Tier::Risky;
}
Score {
value,
grade: Grade::from_value(value),
tier,
signals,
forced_reasons,
}
}