1use crate::facts::{Facts, Severity};
9use chrono::{DateTime, Utc};
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct Signal {
14 pub name: &'static str,
16 pub score: f64,
18 pub weight: f64,
20 pub detail: String,
22}
23
24impl Signal {
25 fn new(name: &'static str, score: f64, weight: f64, detail: impl Into<String>) -> Self {
26 Signal {
27 name,
28 score: score.clamp(0.0, 1.0),
29 weight,
30 detail: detail.into(),
31 }
32 }
33}
34
35fn ramp(x: f64, lo: f64, hi: f64) -> f64 {
39 if (hi - lo).abs() < f64::EPSILON {
40 return if x >= lo { 1.0 } else { 0.0 };
41 }
42 ((x - lo) / (hi - lo)).clamp(0.0, 1.0)
43}
44
45pub fn staleness(facts: &Facts, now: DateTime<Utc>) -> Option<Signal> {
48 let last = facts.latest_published?;
49 let days = (now - last).num_days().max(0) as f64;
50 let score = 1.0 - ramp(days, 90.0, 730.0);
52 let detail = format!("last release {} days ago", days as i64);
53 Some(Signal::new("staleness", score, 2.0, detail))
54}
55
56pub fn cadence(facts: &Facts) -> Option<Signal> {
59 let n = facts.releases_last_year?;
60 let score = ramp(n as f64, 0.0, 4.0);
61 let detail = format!("{n} release(s) in the last 12 months");
62 Some(Signal::new("cadence", score, 1.0, detail))
63}
64
65pub fn deprecation(facts: &Facts) -> Option<Signal> {
67 if !facts.deprecated {
68 return None;
69 }
70 let detail = facts
71 .deprecated_reason
72 .clone()
73 .filter(|r| !r.is_empty())
74 .map(|r| format!("deprecated: {r}"))
75 .unwrap_or_else(|| "package is deprecated".to_string());
76 Some(Signal::new("deprecation", 0.0, 4.0, detail))
77}
78
79pub fn vulnerabilities(facts: &Facts) -> Option<Signal> {
81 if facts.vulns.is_empty() {
82 if facts.is_unresolved() {
87 return None;
88 }
89 return Some(Signal::new(
91 "vulnerabilities",
92 1.0,
93 2.0,
94 "no known advisories".to_string(),
95 ));
96 }
97 let worst = facts
98 .vulns
99 .iter()
100 .max_by(|a, b| a.severity().cmp(&b.severity()))
101 .expect("non-empty checked above");
102 let score = match worst.severity() {
103 Severity::Critical => 0.0,
104 Severity::High => 0.15,
105 Severity::Medium => 0.5,
106 Severity::Low => 0.75,
107 };
108 let detail = format!(
109 "{} known advisory(ies); worst {} ({})",
110 facts.vulns.len(),
111 worst.id,
112 match worst.severity() {
113 Severity::Critical => "critical",
114 Severity::High => "high",
115 Severity::Medium => "medium",
116 Severity::Low => "low",
117 }
118 );
119 Some(Signal::new("vulnerabilities", score, 3.0, detail))
120}
121
122pub fn license(facts: &Facts) -> Option<Signal> {
125 if facts.licenses.is_empty() {
126 return Some(Signal::new(
127 "license",
128 0.3,
129 1.0,
130 "no license declared".to_string(),
131 ));
132 }
133 let permissive = [
134 "MIT",
135 "APACHE-2.0",
136 "BSD-2-CLAUSE",
137 "BSD-3-CLAUSE",
138 "ISC",
139 "0BSD",
140 "UNLICENSE",
141 ];
142 let copyleft = ["GPL", "LGPL", "AGPL", "MPL"];
143 let joined = facts.licenses.join(", ");
144 let tokens: Vec<String> = facts
147 .licenses
148 .iter()
149 .flat_map(|l| {
150 l.to_uppercase()
151 .split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '.'))
152 .filter(|t| !t.is_empty() && *t != "OR" && *t != "AND" && *t != "WITH")
153 .map(|t| t.to_string())
154 .collect::<Vec<_>>()
155 })
156 .collect();
157 let is_permissive = tokens.iter().any(|t| permissive.contains(&t.as_str()));
158 let is_copyleft = tokens
159 .iter()
160 .any(|t| copyleft.iter().any(|c| t.contains(c)));
161 let (score, note) = if is_permissive {
162 (1.0, "permissive")
163 } else if is_copyleft {
164 (0.6, "copyleft — review obligations")
165 } else {
166 (0.5, "non-standard — review terms")
167 };
168 Some(Signal::new(
169 "license",
170 score,
171 1.0,
172 format!("{joined} ({note})"),
173 ))
174}
175
176pub fn scorecard(facts: &Facts) -> Option<Signal> {
179 let (val, which) = match (facts.scorecard_overall, facts.scorecard_maintained) {
180 (Some(o), _) => (o, "overall"),
181 (None, Some(m)) => (m, "maintained"),
182 (None, None) => return None,
183 };
184 let score = (val / 10.0).clamp(0.0, 1.0);
185 Some(Signal::new(
186 "scorecard",
187 score,
188 1.5,
189 format!("OpenSSF Scorecard {which} {val:.1}/10"),
190 ))
191}
192
193pub fn bus_factor(facts: &Facts) -> Option<Signal> {
196 let share = facts.top_contributor_share?;
197 let score = 1.0 - ramp(share, 0.5, 0.95);
199 Some(Signal::new(
200 "bus_factor",
201 score,
202 1.5,
203 format!(
204 "top contributor authored {:.0}% of recent commits",
205 share * 100.0
206 ),
207 ))
208}
209
210pub fn archived(facts: &Facts) -> Option<Signal> {
212 if !facts.archived {
213 return None;
214 }
215 Some(Signal::new(
216 "archived",
217 0.0,
218 3.0,
219 "source repository is archived".to_string(),
220 ))
221}
222
223pub fn all(facts: &Facts, now: DateTime<Utc>) -> Vec<Signal> {
225 [
226 deprecation(facts),
227 archived(facts),
228 vulnerabilities(facts),
229 staleness(facts, now),
230 cadence(facts),
231 scorecard(facts),
232 bus_factor(facts),
233 license(facts),
234 ]
235 .into_iter()
236 .flatten()
237 .collect()
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 fn facts_with_licenses(l: &[&str]) -> Facts {
245 Facts {
246 licenses: l.iter().map(|s| s.to_string()).collect(),
247 ..Default::default()
248 }
249 }
250
251 #[test]
252 fn spdx_or_expression_is_permissive() {
253 let sig = license(&facts_with_licenses(&["Apache-2.0 OR MIT"])).unwrap();
254 assert_eq!(sig.score, 1.0, "{}", sig.detail);
255 }
256
257 #[test]
258 fn copyleft_is_penalized() {
259 let sig = license(&facts_with_licenses(&["GPL-3.0-only"])).unwrap();
260 assert!(sig.score < 0.75);
261 }
262
263 #[test]
264 fn missing_license_is_low() {
265 let sig = license(&facts_with_licenses(&[])).unwrap();
266 assert!(sig.score <= 0.3);
267 }
268}