Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

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);
    }
}