covgate 0.2.0-rc0

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

use crate::model::{ComputedMetric, GateResult, GateScopeResult, MetricKind, SpanKey};
use crate::render::title_case;

#[must_use]
pub fn render(result: &GateResult, _diff_description: &str) -> String {
    let mut out = String::new();
    let multiple_scopes = result.scopes.len() > 1;

    out.push_str("## Covgate\n\n");
    out.push_str("### Diff Coverage\n\n");
    if multiple_scopes {
        out.push_str("| Gate | Result | Rule | Observed | Configured |\n");
        out.push_str("| --- | --- | --- | ---: | ---: |\n");
        for scope in &result.scopes {
            let gate_label = scope_label(scope, true);
            for outcome in &scope.rules {
                write_rule_row(&mut out, Some(gate_label.as_str()), outcome);
            }
        }
    } else if let Some(scope) = result.scopes.first() {
        out.push_str("| Result | Rule | Observed | Configured |\n");
        out.push_str("| --- | --- | ---: | ---: |\n");
        for outcome in &scope.rules {
            write_rule_row(&mut out, None, outcome);
        }
    }
    out.push('\n');

    for metric_kind in metric_kinds(result) {
        out.push_str(&format!("#### {}\n\n", title_case(metric_kind.as_str())));
        if multiple_scopes {
            render_changed_metric_table_multi_scope(&mut out, result, metric_kind);
        } else if let Some(scope) = result.scopes.first()
            && let Some(metric) = scope
                .metrics
                .iter()
                .find(|metric| metric.metric == metric_kind)
        {
            render_changed_metric_table_single_scope(&mut out, metric);
        }
        out.push('\n');
    }

    out.push_str("### Overall Coverage\n\n");
    for metric in &result.overall_metrics {
        out.push_str(&format!("#### {}\n\n", title_case(metric.metric.as_str())));
        render_overall_metric_table_single_scope(&mut out, metric);
        out.push('\n');
    }

    out
}

fn write_rule_row(out: &mut String, gate_label: Option<&str>, outcome: &crate::model::RuleOutcome) {
    let status = if outcome.passed { "✅PASS" } else { "❌FAIL" };
    match &outcome.rule {
        crate::model::GateRule::Percent {
            metric: _,
            minimum_percent,
        } => {
            if let Some(gate_label) = gate_label {
                out.push_str(&format!(
                    "| `{gate_label}` | {} | `{}` | {:.2}% | ≥ {:.2}% |\n",
                    status,
                    outcome.rule.label(),
                    outcome.observed_percent,
                    minimum_percent
                ));
            } else {
                out.push_str(&format!(
                    "| {} | `{}` | {:.2}% | ≥ {:.2}% |\n",
                    status,
                    outcome.rule.label(),
                    outcome.observed_percent,
                    minimum_percent
                ));
            }
        }
        crate::model::GateRule::UncoveredCount {
            metric: _,
            maximum_count,
        } => {
            if let Some(gate_label) = gate_label {
                out.push_str(&format!(
                    "| `{gate_label}` | {} | `{}` | {} | ≤ {} |\n",
                    status,
                    outcome.rule.label(),
                    outcome.observed_uncovered_count,
                    maximum_count
                ));
            } else {
                out.push_str(&format!(
                    "| {} | `{}` | {} | ≤ {} |\n",
                    status,
                    outcome.rule.label(),
                    outcome.observed_uncovered_count,
                    maximum_count
                ));
            }
        }
    }
}

fn render_changed_metric_table_single_scope(out: &mut String, metric: &ComputedMetric) {
    let metric_label = title_case(metric.metric.label());
    out.push_str(&format!(
        "| File | Covered Changed {metric_label} | Changed {metric_label} | Coverage | Missed Changed Spans |\n"
    ));
    out.push_str("| --- | ---: | ---: | ---: | --- |\n");
    let missed_by_file = missed_by_file(metric);
    for (path, totals) in &metric.changed_totals_by_file {
        let percent = percent(totals.covered, totals.total);
        let missed = format_missed_spans(missed_by_file.get(&path.display().to_string()));
        out.push_str(&format!(
            "| `{}` | {} | {} | {:.2}% {} | {} |\n",
            path.display(),
            totals.covered,
            totals.total,
            percent,
            coverage_circle(percent),
            missed
        ));
    }
    out.push_str(&format!(
        "| **Total** | **{}** | **{}** | **{:.2}% {}** |  |\n",
        metric.covered,
        metric.total,
        metric.percent,
        coverage_circle(metric.percent)
    ));
}

fn render_changed_metric_table_multi_scope(
    out: &mut String,
    result: &GateResult,
    metric_kind: MetricKind,
) {
    let metric_label = title_case(metric_kind.label());
    out.push_str(&format!(
        "| Gate | File | Covered Changed {metric_label} | Changed {metric_label} | Coverage | Missed Changed Spans |\n"
    ));
    out.push_str("| --- | --- | ---: | ---: | ---: | --- |\n");
    for scope in &result.scopes {
        let Some(metric) = scope
            .metrics
            .iter()
            .find(|metric| metric.metric == metric_kind)
        else {
            continue;
        };
        let gate_label = scope_label(scope, true);
        let missed_by_file = missed_by_file(metric);
        for (path, totals) in &metric.changed_totals_by_file {
            let percent = percent(totals.covered, totals.total);
            let missed = format_missed_spans(missed_by_file.get(&path.display().to_string()));
            out.push_str(&format!(
                "| `{gate_label}` | `{}` | {} | {} | {:.2}% {} | {} |\n",
                path.display(),
                totals.covered,
                totals.total,
                percent,
                coverage_circle(percent),
                missed
            ));
        }
        out.push_str(&format!(
            "| **{} Total** |  | **{}** | **{}** | **{:.2}% {}** |  |\n",
            gate_label,
            metric.covered,
            metric.total,
            metric.percent,
            coverage_circle(metric.percent)
        ));
    }
}

fn render_overall_metric_table_single_scope(out: &mut String, metric: &ComputedMetric) {
    let metric_label = title_case(metric.metric.label());
    out.push_str(&format!(
        "| File | Covered {metric_label} | {metric_label} | Missed {metric_label} | Coverage |\n"
    ));
    out.push_str("| --- | ---: | ---: | ---: | ---: |\n");
    for (path, totals) in &metric.totals_by_file {
        let percent = percent(totals.covered, totals.total);
        let missed = totals.total.saturating_sub(totals.covered);
        out.push_str(&format!(
            "| `{}` | {} | {} | {} | {:.2}% {} |\n",
            path.display(),
            totals.covered,
            totals.total,
            missed,
            percent,
            coverage_circle(percent)
        ));
    }
    let overall_covered: usize = metric
        .totals_by_file
        .values()
        .map(|totals| totals.covered)
        .sum();
    let overall_total: usize = metric
        .totals_by_file
        .values()
        .map(|totals| totals.total)
        .sum();
    let overall_percent = percent(overall_covered, overall_total);
    let overall_missed = overall_total.saturating_sub(overall_covered);
    out.push_str(&format!(
        "| **Total** | **{}** | **{}** | **{}** | **{:.2}% {}** |\n",
        overall_covered,
        overall_total,
        overall_missed,
        overall_percent,
        coverage_circle(overall_percent)
    ));
}

fn metric_kinds(result: &GateResult) -> Vec<MetricKind> {
    let mut kinds = BTreeSet::new();
    for scope in &result.scopes {
        for metric in &scope.metrics {
            kinds.insert(metric.metric);
        }
    }
    kinds.into_iter().collect()
}

fn missed_by_file(metric: &ComputedMetric) -> BTreeMap<String, BTreeMap<SpanKey, usize>> {
    let mut missed_by_file = BTreeMap::<String, BTreeMap<SpanKey, usize>>::new();
    for opportunity in &metric.uncovered_changed_opportunities {
        missed_by_file
            .entry(opportunity.span.path.display().to_string())
            .or_default()
            .entry(opportunity.span.key())
            .and_modify(|count| *count += 1)
            .or_insert(1);
    }
    missed_by_file
}

fn format_missed_spans(spans: Option<&BTreeMap<SpanKey, usize>>) -> String {
    spans
        .map(|values| {
            values
                .iter()
                .map(|(key, count)| {
                    let label = key.format_span();
                    if *count > 1 {
                        format!("`{label}({count})`")
                    } else {
                        format!("`{label}`")
                    }
                })
                .collect::<Vec<_>>()
                .join(", ")
        })
        .unwrap_or_default()
}

fn percent(covered: usize, total: usize) -> f64 {
    if total == 0 {
        100.0
    } else {
        (covered as f64 / total as f64) * 100.0
    }
}

fn scope_label(scope: &GateScopeResult, multiple_scopes: bool) -> String {
    if let Some(label) = &scope.label {
        return label.clone();
    }

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

    String::new()
}

fn coverage_circle(percent: f64) -> &'static str {
    if percent < 50.0 {
        "🔴"
    } else if percent < 80.0 {
        "🟡"
    } else {
        "🟢"
    }
}