use crate::facts::{Facts, Severity};
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq)]
pub struct Signal {
pub name: &'static str,
pub score: f64,
pub weight: f64,
pub detail: String,
}
impl Signal {
fn new(name: &'static str, score: f64, weight: f64, detail: impl Into<String>) -> Self {
Signal {
name,
score: score.clamp(0.0, 1.0),
weight,
detail: detail.into(),
}
}
}
fn ramp(x: f64, lo: f64, hi: f64) -> f64 {
if (hi - lo).abs() < f64::EPSILON {
return if x >= lo { 1.0 } else { 0.0 };
}
((x - lo) / (hi - lo)).clamp(0.0, 1.0)
}
pub fn staleness(facts: &Facts, now: DateTime<Utc>) -> Option<Signal> {
let last = facts.latest_published?;
let days = (now - last).num_days().max(0) as f64;
let score = 1.0 - ramp(days, 90.0, 730.0);
let detail = format!("last release {} days ago", days as i64);
Some(Signal::new("staleness", score, 2.0, detail))
}
pub fn cadence(facts: &Facts) -> Option<Signal> {
let n = facts.releases_last_year?;
let score = ramp(n as f64, 0.0, 4.0);
let detail = format!("{n} release(s) in the last 12 months");
Some(Signal::new("cadence", score, 1.0, detail))
}
pub fn deprecation(facts: &Facts) -> Option<Signal> {
if !facts.deprecated {
return None;
}
let detail = facts
.deprecated_reason
.clone()
.filter(|r| !r.is_empty())
.map(|r| format!("deprecated: {r}"))
.unwrap_or_else(|| "package is deprecated".to_string());
Some(Signal::new("deprecation", 0.0, 4.0, detail))
}
pub fn vulnerabilities(facts: &Facts) -> Option<Signal> {
if facts.vulns.is_empty() {
if facts.is_unresolved() {
return None;
}
return Some(Signal::new(
"vulnerabilities",
1.0,
2.0,
"no known advisories".to_string(),
));
}
let worst = facts
.vulns
.iter()
.max_by(|a, b| a.severity().cmp(&b.severity()))
.expect("non-empty checked above");
let score = match worst.severity() {
Severity::Critical => 0.0,
Severity::High => 0.15,
Severity::Medium => 0.5,
Severity::Low => 0.75,
};
let detail = format!(
"{} known advisory(ies); worst {} ({})",
facts.vulns.len(),
worst.id,
match worst.severity() {
Severity::Critical => "critical",
Severity::High => "high",
Severity::Medium => "medium",
Severity::Low => "low",
}
);
Some(Signal::new("vulnerabilities", score, 3.0, detail))
}
pub fn license(facts: &Facts) -> Option<Signal> {
if facts.licenses.is_empty() {
return Some(Signal::new(
"license",
0.3,
1.0,
"no license declared".to_string(),
));
}
let permissive = [
"MIT",
"APACHE-2.0",
"BSD-2-CLAUSE",
"BSD-3-CLAUSE",
"ISC",
"0BSD",
"UNLICENSE",
];
let copyleft = ["GPL", "LGPL", "AGPL", "MPL"];
let joined = facts.licenses.join(", ");
let tokens: Vec<String> = facts
.licenses
.iter()
.flat_map(|l| {
l.to_uppercase()
.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '.'))
.filter(|t| !t.is_empty() && *t != "OR" && *t != "AND" && *t != "WITH")
.map(|t| t.to_string())
.collect::<Vec<_>>()
})
.collect();
let is_permissive = tokens.iter().any(|t| permissive.contains(&t.as_str()));
let is_copyleft = tokens
.iter()
.any(|t| copyleft.iter().any(|c| t.contains(c)));
let (score, note) = if is_permissive {
(1.0, "permissive")
} else if is_copyleft {
(0.6, "copyleft — review obligations")
} else {
(0.5, "non-standard — review terms")
};
Some(Signal::new(
"license",
score,
1.0,
format!("{joined} ({note})"),
))
}
pub fn scorecard(facts: &Facts) -> Option<Signal> {
let (val, which) = match (facts.scorecard_overall, facts.scorecard_maintained) {
(Some(o), _) => (o, "overall"),
(None, Some(m)) => (m, "maintained"),
(None, None) => return None,
};
let score = (val / 10.0).clamp(0.0, 1.0);
Some(Signal::new(
"scorecard",
score,
1.5,
format!("OpenSSF Scorecard {which} {val:.1}/10"),
))
}
pub fn bus_factor(facts: &Facts) -> Option<Signal> {
let share = facts.top_contributor_share?;
let score = 1.0 - ramp(share, 0.5, 0.95);
Some(Signal::new(
"bus_factor",
score,
1.5,
format!(
"top contributor authored {:.0}% of recent commits",
share * 100.0
),
))
}
pub fn archived(facts: &Facts) -> Option<Signal> {
if !facts.archived {
return None;
}
Some(Signal::new(
"archived",
0.0,
3.0,
"source repository is archived".to_string(),
))
}
pub fn all(facts: &Facts, now: DateTime<Utc>) -> Vec<Signal> {
[
deprecation(facts),
archived(facts),
vulnerabilities(facts),
staleness(facts, now),
cadence(facts),
scorecard(facts),
bus_factor(facts),
license(facts),
]
.into_iter()
.flatten()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn facts_with_licenses(l: &[&str]) -> Facts {
Facts {
licenses: l.iter().map(|s| s.to_string()).collect(),
..Default::default()
}
}
#[test]
fn spdx_or_expression_is_permissive() {
let sig = license(&facts_with_licenses(&["Apache-2.0 OR MIT"])).unwrap();
assert_eq!(sig.score, 1.0, "{}", sig.detail);
}
#[test]
fn copyleft_is_penalized() {
let sig = license(&facts_with_licenses(&["GPL-3.0-only"])).unwrap();
assert!(sig.score < 0.75);
}
#[test]
fn missing_license_is_low() {
let sig = license(&facts_with_licenses(&[])).unwrap();
assert!(sig.score <= 0.3);
}
}