covgate 0.2.0-rc0

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

use crate::model::{ChangedFile, ComputedMetric, CoverageReport, FileTotals, MetricKind};

pub fn compute_changed_metric(
    report: &CoverageReport,
    diff: &[ChangedFile],
    included_paths: &BTreeSet<PathBuf>,
    metric: MetricKind,
) -> anyhow::Result<ComputedMetric> {
    let global_totals_by_file = report
        .totals_by_file
        .get(&metric)
        .filter(|totals| totals.values().any(|file_totals| file_totals.total > 0))
        .ok_or_else(|| {
            anyhow::anyhow!(
                "requested metric {} is not available in the report",
                metric.as_str()
            )
        })?;

    let mut covered = 0usize;
    let mut total = 0usize;
    let mut uncovered = Vec::new();
    let mut changed_totals_by_file: BTreeMap<PathBuf, FileTotals> = BTreeMap::new();

    let target_kind = metric.to_opportunity_kind();
    let named_only = matches!(metric, MetricKind::NamedFunction);

    for opportunity in &report.opportunities {
        if opportunity.kind != target_kind {
            continue;
        }
        if named_only && !opportunity.is_named_function.unwrap_or(false) {
            continue;
        }
        if !included_paths.contains(&opportunity.span.path) {
            continue;
        }
        let changed = diff.iter().any(|file| {
            file.path == opportunity.span.path
                && file
                    .changed_lines
                    .iter()
                    .any(|range| opportunity.span.overlaps_line_range(range.start, range.end))
        });
        if !changed {
            continue;
        }
        total += 1;
        let entry = changed_totals_by_file
            .entry(opportunity.span.path.clone())
            .or_insert(FileTotals {
                covered: 0,
                total: 0,
            });
        entry.total += 1;
        if opportunity.covered {
            covered += 1;
            entry.covered += 1;
        } else {
            uncovered.push(opportunity.clone());
        }
    }

    let percent = if total == 0 {
        100.0
    } else {
        (covered as f64 / total as f64) * 100.0
    };

    Ok(ComputedMetric {
        metric,
        covered,
        total,
        percent,
        uncovered_changed_opportunities: uncovered,
        changed_totals_by_file,
        totals_by_file: global_totals_by_file.clone(),
    })
}

pub fn compute_overall_metric(
    report: &CoverageReport,
    metric: MetricKind,
) -> anyhow::Result<ComputedMetric> {
    let totals_by_file = report
        .totals_by_file
        .get(&metric)
        .filter(|totals| totals.values().any(|file_totals| file_totals.total > 0))
        .ok_or_else(|| {
            anyhow::anyhow!(
                "requested metric {} is not available in the report",
                metric.as_str()
            )
        })?;

    let mut covered = 0usize;
    let mut total = 0usize;

    for file_totals in totals_by_file.values() {
        covered += file_totals.covered;
        total += file_totals.total;
    }

    let percent = if total == 0 {
        100.0
    } else {
        (covered as f64 / total as f64) * 100.0
    };

    Ok(ComputedMetric {
        metric,
        covered,
        total,
        percent,
        uncovered_changed_opportunities: Vec::new(),
        changed_totals_by_file: BTreeMap::new(),
        totals_by_file: totals_by_file.clone(),
    })
}