src/rollup.rs
//! **Aggregate / rollup views** — higher-level folds over the fleet-scan + advisory
//! data so a consumer can *see modgunn at altitude* (org-wide severity, license
//! distribution, per-repo health, advisory-DB composition) instead of one raw row
//! at a time.
//!
//! Everything here is **pure** (no warehouse / Iceberg / network) and operates on
//! generic iterators or small owned input rows, so the folds are unit-tested with
//! the lean default feature set — no `warehouse` build — and are deterministic
//! given the same input. The `warehouse` layer maps its
//! [`FleetFinding`](crate::warehouse::FleetFinding) rows into [`FindingRow`] and
//! calls these; the same structs serialize straight into a CLI `rollup` verb or a
//! nornir facett view (`facett-stat` chips, `facett-matrix` health grid,
//! `facett-plot` distributions).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::core::Severity;
/// A count of findings bucketed by [`Severity`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SeverityHistogram {
pub info: usize,
pub low: usize,
pub medium: usize,
pub high: usize,
pub critical: usize,
pub total: usize,
}
impl SeverityHistogram {
/// Fold any stream of severities into the histogram.
pub fn of(severities: impl IntoIterator<Item = Severity>) -> Self {
let mut h = SeverityHistogram::default();
for s in severities {
match s {
Severity::Info => h.info += 1,
Severity::Low => h.low += 1,
Severity::Medium => h.medium += 1,
Severity::High => h.high += 1,
Severity::Critical => h.critical += 1,
}
h.total += 1;
}
h
}
/// The single worst severity present, or `None` when there are no findings.
#[must_use]
pub fn worst(&self) -> Option<Severity> {
if self.critical > 0 {
Some(Severity::Critical)
} else if self.high > 0 {
Some(Severity::High)
} else if self.medium > 0 {
Some(Severity::Medium)
} else if self.low > 0 {
Some(Severity::Low)
} else if self.info > 0 {
Some(Severity::Info)
} else {
None
}
}
/// A 0–100 **health score** (100 = clean): weighted penalties per severity,
/// clamped. Critical hurts most. Monotone — adding a finding never raises it.
#[must_use]
pub fn health_score(&self) -> f32 {
let penalty = self.critical as f32 * 25.0
+ self.high as f32 * 10.0
+ self.medium as f32 * 3.0
+ self.low as f32 * 1.0;
(100.0 - penalty).clamp(0.0, 100.0)
}
}
/// A generic descending-count tally: `(key, count)` sorted by count desc, key asc
/// (deterministic). Use it for licenses, ecosystems, advisory severities, tripwire
/// rules — anything you want a "top N / distribution" of.
#[must_use]
pub fn tally<K, I>(items: I) -> Vec<(K, usize)>
where
K: Ord + Clone,
I: IntoIterator<Item = K>,
{
let mut counts: BTreeMap<K, usize> = BTreeMap::new();
for k in items {
*counts.entry(k).or_insert(0) += 1;
}
let mut out: Vec<(K, usize)> = counts.into_iter().collect();
// BTreeMap gives key-ascending; stable-sort by count descending keeps key asc
// as the tie-break.
out.sort_by(|a, b| b.1.cmp(&a.1));
out
}
/// One fleet finding reduced to the fields the rollups need (the pure mirror of the
/// warehouse `FleetFinding`, so these folds don't need the `warehouse` feature).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FindingRow {
pub repo: String,
/// Vulnerable component name (for distinct-component counts).
pub component: String,
pub severity: Severity,
}
/// Per-repo security rollup — the row a `facett-matrix` health grid / `facett-grid`
/// table renders.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RepoHealth {
pub repo: String,
/// Distinct components in this repo that have at least one finding.
pub vulnerable_components: usize,
/// Total findings against the repo.
pub findings: usize,
pub critical: usize,
pub high: usize,
pub worst: Option<Severity>,
/// 0–100 health score (100 = clean).
pub score: f32,
}
/// Roll up findings into one [`RepoHealth`] per repo, **worst-first** (lowest score
/// first), repo name as the stable tie-break. Deterministic.
#[must_use]
pub fn repo_health(findings: &[FindingRow]) -> Vec<RepoHealth> {
let mut by_repo: BTreeMap<&str, Vec<&FindingRow>> = BTreeMap::new();
for f in findings {
by_repo.entry(f.repo.as_str()).or_default().push(f);
}
let mut out: Vec<RepoHealth> = by_repo
.into_iter()
.map(|(repo, rows)| {
let hist = SeverityHistogram::of(rows.iter().map(|r| r.severity));
let mut comps: Vec<&str> = rows.iter().map(|r| r.component.as_str()).collect();
comps.sort_unstable();
comps.dedup();
RepoHealth {
repo: repo.to_string(),
vulnerable_components: comps.len(),
findings: hist.total,
critical: hist.critical,
high: hist.high,
worst: hist.worst(),
score: hist.health_score(),
}
})
.collect();
// Worst (lowest score) first; repo name asc on ties.
out.sort_by(|a, b| {
a.score
.partial_cmp(&b.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.repo.cmp(&b.repo))
});
out
}
/// A fleet-wide rollup: the org severity histogram + per-repo health + the top
/// affected components. The single object a "🛡 Fleet" overview pane renders.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FleetRollup {
pub severity: SeverityHistogram,
pub repos: Vec<RepoHealth>,
/// Most-frequently-flagged components fleet-wide, `(component, hits)` desc.
pub top_components: Vec<(String, usize)>,
/// Number of repos with at least one finding.
pub affected_repos: usize,
}
/// Compute the whole-fleet rollup from the flat finding rows.
#[must_use]
pub fn fleet_rollup(findings: &[FindingRow]) -> FleetRollup {
let severity = SeverityHistogram::of(findings.iter().map(|f| f.severity));
let repos = repo_health(findings);
let mut top_components = tally(findings.iter().map(|f| f.component.clone()));
top_components.truncate(20);
FleetRollup {
severity,
affected_repos: repos.len(),
repos,
top_components,
}
}
// ── bridge from the warehouse FleetFinding (only when that feature is on) ──────
#[cfg(feature = "warehouse")]
impl FindingRow {
/// Map a warehouse [`FleetFinding`](crate::warehouse::FleetFinding) into the
/// pure rollup input.
#[must_use]
pub fn from_fleet(f: &crate::warehouse::FleetFinding) -> Self {
FindingRow {
repo: f.repo.clone(),
component: f.name.clone(),
severity: f.severity,
}
}
}
#[cfg(feature = "warehouse")]
#[must_use]
pub fn rollup_fleet_findings(findings: &[crate::warehouse::FleetFinding]) -> FleetRollup {
let rows: Vec<FindingRow> = findings.iter().map(FindingRow::from_fleet).collect();
fleet_rollup(&rows)
}
/// A short, stable lower-case label for a [`Severity`] (for summary lines — the CLI
/// prints these, so they stay ASCII + fixed-width friendly).
#[must_use]
pub fn severity_label(s: Severity) -> &'static str {
match s {
Severity::Info => "info",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
/// Render a [`FleetRollup`] into deterministic, human-readable summary lines — the
/// per-repo health / severity-histogram block the `warehouse scan` and `warehouse
/// rollup` CLI verbs print under (or instead of) the raw finding rows.
///
/// Pure + newline-joined (no trailing newline) so it is unit-tested without the
/// `warehouse` feature and is byte-identical given the same rollup. A clean fleet
/// (no findings) renders a single "fleet clean" line.
#[must_use]
pub fn render_fleet_summary(rollup: &FleetRollup) -> String {
let h = &rollup.severity;
if h.total == 0 {
return "fleet clean — 0 findings across all repos".to_string();
}
let mut lines = Vec::with_capacity(rollup.repos.len() + 2);
lines.push(format!(
"fleet: {} repo(s) affected, {} finding(s) [crit {} · high {} · med {} · low {} · info {}]",
rollup.affected_repos, h.total, h.critical, h.high, h.medium, h.low, h.info,
));
// Per-repo health, worst-first (rollup.repos is already ordered lowest-score-first).
for r in &rollup.repos {
lines.push(format!(
" {:<14} health {:>5.1} worst {:<8} {} finding(s) / {} component(s)",
r.repo,
r.score,
r.worst.map(severity_label).unwrap_or("none"),
r.findings,
r.vulnerable_components,
));
}
if !rollup.top_components.is_empty() {
let top: Vec<String> = rollup
.top_components
.iter()
.take(5)
.map(|(c, n)| format!("{c}×{n}"))
.collect();
lines.push(format!(" top components: {}", top.join(", ")));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
fn rows() -> Vec<FindingRow> {
let r = |repo: &str, comp: &str, sev: Severity| FindingRow {
repo: repo.into(),
component: comp.into(),
severity: sev,
};
vec![
r("alpha", "openssl", Severity::Critical),
r("alpha", "openssl", Severity::High), // same component, 2 findings
r("alpha", "zlib", Severity::Low),
r("beta", "log4j", Severity::Medium),
r("gamma", "serde", Severity::Info),
]
}
/// Histogram counts each bucket + total, and worst() picks the max present.
#[test]
fn histogram_counts_and_worst() {
let h = SeverityHistogram::of(rows().iter().map(|r| r.severity));
assert_eq!(
(h.critical, h.high, h.medium, h.low, h.info, h.total),
(1, 1, 1, 1, 1, 5)
);
assert_eq!(h.worst(), Some(Severity::Critical));
assert_eq!(
SeverityHistogram::default().worst(),
None,
"empty → no worst"
);
}
/// Health score is 100 when clean, monotone-decreasing as findings worsen.
#[test]
fn health_score_is_monotone() {
assert_eq!(SeverityHistogram::default().health_score(), 100.0);
let one_low = SeverityHistogram::of([Severity::Low]);
let one_crit = SeverityHistogram::of([Severity::Critical]);
assert!(one_low.health_score() < 100.0);
assert!(
one_crit.health_score() < one_low.health_score(),
"critical hurts more than low"
);
// floors at 0, never negative
let many = SeverityHistogram::of(std::iter::repeat(Severity::Critical).take(100));
assert_eq!(many.health_score(), 0.0);
}
/// tally is count-descending with a stable key tie-break (deterministic).
#[test]
fn tally_is_count_desc_key_asc() {
let t = tally(["b", "a", "b", "c", "a", "b"]);
assert_eq!(t, vec![("b", 3), ("a", 2), ("c", 1)]);
// tie on count → key ascending
let t2 = tally(["y", "x"]);
assert_eq!(t2, vec![("x", 1), ("y", 1)]);
}
/// repo_health groups per repo, counts DISTINCT vulnerable components, picks the
/// worst severity, and orders worst-score-first.
#[test]
fn repo_health_rolls_up_per_repo() {
let h = repo_health(&rows());
assert_eq!(h.len(), 3);
// alpha is worst (a critical) → first.
assert_eq!(h[0].repo, "alpha");
assert_eq!(h[0].worst, Some(Severity::Critical));
assert_eq!(h[0].findings, 3);
assert_eq!(
h[0].vulnerable_components, 2,
"openssl counted once though it has 2 findings"
);
// gamma only has an Info finding → highest score → last.
assert_eq!(h.last().unwrap().repo, "gamma");
assert!(h[0].score < h.last().unwrap().score);
}
/// The rendered fleet summary names each affected repo worst-first, carries its
/// health score + worst severity, and is byte-deterministic.
#[test]
fn render_fleet_summary_lists_repos_worst_first() {
let fr = fleet_rollup(&rows());
let text = render_fleet_summary(&fr);
let lines: Vec<&str> = text.lines().collect();
// Header line names the affected-repo count + the histogram.
assert!(
lines[0].contains("3 repo(s) affected"),
"header: {}",
lines[0]
);
assert!(lines[0].contains("crit 1") && lines[0].contains("high 1"));
// First repo line is the worst-scoring one (alpha, which has a critical).
assert!(lines[1].contains("alpha"), "worst repo first: {}", lines[1]);
assert!(
lines[1].contains("critical"),
"alpha's worst severity is critical"
);
// gamma (info-only) sorts last among the repo lines.
let repo_lines: Vec<&&str> = lines
.iter()
.filter(|l| l.starts_with(" ") && !l.contains("top"))
.collect();
assert!(
repo_lines.last().unwrap().contains("gamma"),
"gamma (best score) last"
);
// The top-components line names the most-flagged component.
assert!(text.contains("openssl×2"), "top components names openssl×2");
// Deterministic.
assert_eq!(render_fleet_summary(&fleet_rollup(&rows())), text);
}
/// A clean fleet renders the single "fleet clean" line, not an empty string.
#[test]
fn render_fleet_summary_clean_fleet() {
let text = render_fleet_summary(&fleet_rollup(&[]));
assert_eq!(text, "fleet clean — 0 findings across all repos");
}
/// The fleet rollup aggregates everything + names the top components, and is
/// deterministic (serializes bit-identically twice).
#[test]
fn fleet_rollup_aggregates_and_is_deterministic() {
let fr = fleet_rollup(&rows());
assert_eq!(fr.severity.total, 5);
assert_eq!(fr.affected_repos, 3);
assert_eq!(
fr.top_components[0],
("openssl".to_string(), 2),
"openssl is most-flagged"
);
let a = serde_json::to_string(&fleet_rollup(&rows())).unwrap();
let b = serde_json::to_string(&fleet_rollup(&rows())).unwrap();
assert_eq!(a, b);
}
}