gruff-rs 0.4.0

Rust static analyzer and quality linter for CI: dead-code, complexity, security, secrets, and architecture rules with deterministic SARIF/JSON output and baseline support.
use super::*;

pub(crate) fn summarize(findings: &[Finding]) -> Summary {
    let advisory = findings
        .iter()
        .filter(|finding| finding.severity == Severity::Advisory)
        .count();
    let warning = findings
        .iter()
        .filter(|finding| finding.severity == Severity::Warning)
        .count();
    let error = findings
        .iter()
        .filter(|finding| finding.severity == Severity::Error)
        .count();
    Summary {
        advisory,
        warning,
        error,
        total: findings.len(),
    }
}

/// Cross-port canonical composite-score block. Shared verbatim by the `analyse`
/// text header (`render::text`) and the `summary` text view (`summary`) so the two
/// surfaces render byte-identical score lines (they previously diverged on
/// separator, severity order, decimals, and the `/100` denominator). Emits exactly
/// two lines, each terminated with `\n`:
///
/// ```text
/// Composite: <GRADE> (<score> / 100)
/// Findings: <total> total · <e> error · <w> warning · <a> advisory
/// ```
///
/// The score carries two decimals, the findings tally is error-first, and the
/// separator is the literal middot `·` (U+00B7).
pub(crate) fn render_composite_block(out: &mut String, report: &AnalysisReport) {
    use std::fmt::Write as _;
    let _ = writeln!(
        out,
        "Composite: {} ({:.2} / 100)",
        report.score.grade, report.score.composite,
    );
    let _ = writeln!(
        out,
        "Findings: {} total · {} error · {} warning · {} advisory",
        report.summary.total, report.summary.error, report.summary.warning, report.summary.advisory,
    );
}

pub(crate) fn score_report(findings: &[Finding], config: &Config) -> ScoreReport {
    let pillars = pillar_scores(findings, config);
    let composite = composite_score(&pillars);
    let top_offenders = top_file_scores(findings);

    ScoreReport {
        composite,
        grade: grade(composite),
        pillars,
        top_offenders,
    }
}

pub(crate) fn pillar_scores(findings: &[Finding], config: &Config) -> Vec<PillarScore> {
    let mut by_pillar: BTreeMap<Pillar, Vec<&Finding>> = BTreeMap::new();
    for finding in findings {
        by_pillar.entry(finding.pillar).or_default().push(finding);
    }

    let mut pillar_order: Vec<Pillar> = SCORE_PILLARS.to_vec();
    for pillar in by_pillar.keys() {
        if !pillar_order.contains(pillar) {
            pillar_order.push(*pillar);
        }
    }

    pillar_order
        .into_iter()
        .map(|pillar| pillar_score_row(pillar, by_pillar.get(&pillar), config))
        .collect()
}

fn pillar_score_row(
    pillar: Pillar,
    pillar_findings: Option<&Vec<&Finding>>,
    config: &Config,
) -> PillarScore {
    let findings = pillar_findings.map(Vec::as_slice).unwrap_or(&[]);
    let penalty: f64 = findings
        .iter()
        .filter(|finding| !config.is_rule_excluded_from_score(&finding.rule_id))
        .map(|finding| finding_penalty(finding))
        .sum();
    PillarScore {
        pillar,
        score: (100.0 - penalty).max(0.0),
        penalty,
        findings: findings.len(),
    }
}

pub(crate) fn composite_score(pillars: &[PillarScore]) -> f64 {
    let scored: Vec<&PillarScore> = pillars
        .iter()
        .filter(|pillar| SCORE_PILLARS.contains(&pillar.pillar))
        .collect();
    if scored.is_empty() {
        100.0
    } else {
        scored.iter().map(|pillar| pillar.score).sum::<f64>() / scored.len() as f64
    }
}

pub(crate) fn top_file_scores(findings: &[Finding]) -> Vec<FileScore> {
    top_file_scores_with_limit(findings, 10)
}

pub(crate) fn top_file_scores_with_limit(findings: &[Finding], limit: usize) -> Vec<FileScore> {
    let mut file_counts: BTreeMap<String, (usize, f64)> = BTreeMap::new();
    for finding in findings {
        let entry = file_counts
            .entry(finding.file_path.clone())
            .or_insert((0, 0.0));
        entry.0 += 1;
        entry.1 += finding_penalty(finding);
    }
    let mut top_offenders: Vec<FileScore> = file_counts
        .into_iter()
        .map(|(file_path, (findings, penalty))| FileScore {
            file_path,
            score: (100.0 - penalty).max(0.0),
            findings,
        })
        .collect();
    top_offenders.sort_by(|left, right| {
        left.score
            .total_cmp(&right.score)
            .then_with(|| right.findings.cmp(&left.findings))
            .then_with(|| left.file_path.cmp(&right.file_path))
    });
    top_offenders.truncate(limit);
    top_offenders
}

pub(crate) fn finding_penalty(finding: &Finding) -> f64 {
    severity_penalty(finding.severity) * confidence_weight(finding.confidence)
}

pub(crate) fn severity_penalty(severity: Severity) -> f64 {
    match severity {
        Severity::Advisory => 1.5,
        Severity::Warning => 4.0,
        Severity::Error => 8.0,
    }
}

pub(crate) fn confidence_weight(confidence: Confidence) -> f64 {
    match confidence {
        Confidence::Low => 0.5,
        Confidence::Medium => 0.75,
        Confidence::High => 1.0,
    }
}

pub(crate) fn grade(score: f64) -> String {
    match score {
        value if value >= 90.0 => "A",
        value if value >= 80.0 => "B",
        value if value >= 70.0 => "C",
        value if value >= 60.0 => "D",
        _ => "F",
    }
    .to_string()
}