covgate 0.2.0

Diff-focused coverage gates for local CI, pull requests, and autonomous coding agents.
Documentation
use std::collections::BTreeMap;

use crate::model::{
    CheckResult, ComputedMetric, GateEvaluation, GateRule, MetricKind, RuleOutcome, SourceSpan,
};
use crate::render::title_case;

#[must_use]
pub fn render(result: &CheckResult, diff_description: &str) -> String {
    let mut out = String::new();

    if !result.passed {
        out.push_str(&format!("Diff: {diff_description}\n\n"));
        out.push_str(&render_failures(result));
    }

    let multiple_gates = result.gates.len() > 1;
    for gate in &result.gates {
        let label = summary_label(gate, multiple_gates);
        for outcome in &gate.rules {
            out.push_str(&render_rule_summary(outcome, label.as_deref()));
            out.push('\n');
        }
    }

    out.trim_end().to_string()
}

fn render_failures(result: &CheckResult) -> String {
    let mut out = String::new();
    let failed_metrics = result
        .gates
        .iter()
        .flat_map(|gate| &gate.rules)
        .filter_map(|outcome| (!outcome.passed).then_some(outcome.rule.metric()))
        .collect::<Vec<_>>();
    render_failure_metrics(&mut out, &result.changed_metrics, &failed_metrics);
    out
}

fn render_failure_metrics(
    out: &mut String,
    metrics: &[ComputedMetric],
    failed_metrics: &[MetricKind],
) {
    let files_with_uncovered = group_uncovered_by_file(metrics, failed_metrics);
    for (path, file_metrics) in files_with_uncovered {
        out.push_str(&render_file_failure_header(&path, metrics, failed_metrics));
        out.push('\n');

        for (metric_kind, spans) in file_metrics {
            let grouped = group_file_spans(&spans);
            out.push_str(&format!(
                "  {}: {}\n",
                metric_kind.label(),
                grouped.join(", ")
            ));
        }
        out.push('\n');
    }
}

fn render_rule_summary(outcome: &RuleOutcome, scope_label: Option<&str>) -> String {
    let status = if outcome.passed { "PASS" } else { "FAIL" };
    let metric_label = title_case(outcome.rule.metric().label());

    let summary = match &outcome.rule {
        GateRule::Percent {
            metric: _,
            minimum_percent,
        } => {
            let comparator = if outcome.passed { "" } else { "" };
            let observed = format_percent(outcome.observed_percent, outcome.observed_total_count);
            let counts = format!(
                "({}/{})",
                outcome.observed_covered_count, outcome.observed_total_count
            );
            format!(
                "{status} {metric_label}: {observed} {counts} {comparator} {minimum_percent:.2}%"
            )
        }
        GateRule::UncoveredCount {
            metric: _,
            maximum_count,
        } => {
            let comparator = if outcome.passed { "" } else { "" };
            format!(
                "{status} {metric_label}: {} uncovered {comparator} {maximum_count}",
                outcome.observed_uncovered_count
            )
        }
    };

    if let Some(scope_label) = scope_label {
        format!("[{scope_label}] {summary}")
    } else {
        summary
    }
}

fn group_uncovered_by_file(
    metrics: &[ComputedMetric],
    failed_metrics: &[MetricKind],
) -> BTreeMap<std::path::PathBuf, BTreeMap<MetricKind, Vec<SourceSpan>>> {
    let mut files_with_uncovered: BTreeMap<
        std::path::PathBuf,
        BTreeMap<MetricKind, Vec<SourceSpan>>,
    > = BTreeMap::new();
    for metric in metrics {
        if !failed_metrics.contains(&metric.metric) {
            continue;
        }

        for opportunity in &metric.uncovered_changed_opportunities {
            files_with_uncovered
                .entry(opportunity.span.path.clone())
                .or_default()
                .entry(metric.metric)
                .or_default()
                .push(opportunity.span.clone());
        }
    }
    files_with_uncovered
}

fn render_file_failure_header(
    path: &std::path::Path,
    metrics: &[ComputedMetric],
    failed_metrics: &[MetricKind],
) -> String {
    let mut header = path.display().to_string();
    let mut stats = Vec::new();

    for metric in metrics {
        if !failed_metrics.contains(&metric.metric) {
            continue;
        }

        if let Some(file_totals) = metric.changed_totals_by_file.get(path) {
            let percent = if file_totals.total == 0 {
                100.0
            } else {
                (file_totals.covered as f64 / file_totals.total as f64) * 100.0
            };
            stats.push(format!("{percent:.2}% {}", metric.metric.as_str()));
        }
    }

    if !stats.is_empty() {
        header.push_str(&format!(" ({})", stats.join(", ")));
    }

    header
}

fn group_file_spans(spans: &[SourceSpan]) -> Vec<String> {
    SourceSpan::group_by_span(spans)
        .into_iter()
        .map(|(key, count)| {
            let label = key.format_span();
            if count > 1 {
                format!("{label}({count})")
            } else {
                label
            }
        })
        .collect()
}

fn summary_label(gate: &GateEvaluation, multiple_scopes: bool) -> Option<String> {
    if let Some(label) = &gate.label {
        return Some(label.clone());
    }

    if multiple_scopes {
        return Some("default".to_string());
    }

    None
}

fn format_percent(percent: f64, total: usize) -> String {
    if total == 0 {
        "N/A".to_string()
    } else {
        format!("{percent:.2}%")
    }
}