covgate 0.1.4

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

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

pub fn compute_changed_metric(
    report: &CoverageReport,
    diff: &[ChangedFile],
    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;
    let mut uncovered = Vec::new();
    let mut changed_totals_by_file: BTreeMap<PathBuf, FileTotals> = BTreeMap::new();

    let target_kind = metric.to_opportunity_kind();
    for opportunity in &report.opportunities {
        if opportunity.kind != target_kind {
            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: totals_by_file.clone(),
    })
}

#[cfg(test)]
mod tests {
    use std::{collections::BTreeMap, path::PathBuf};

    use crate::coverage::istanbul_json::parse_with_repo_root;
    use crate::model::{
        ChangedFile, CoverageOpportunity, CoverageReport, FileTotals, LineRange, MetricKind,
        OpportunityKind, SourceSpan,
    };

    use super::compute_changed_metric;

    #[test]
    fn computes_changed_region_metric() {
        let report = CoverageReport {
            opportunities: vec![
                CoverageOpportunity {
                    kind: OpportunityKind::Region,
                    span: SourceSpan {
                        path: PathBuf::from("src/lib.rs"),
                        start_line: 2,
                        end_line: 3,
                    },
                    covered: true,
                },
                CoverageOpportunity {
                    kind: OpportunityKind::Region,
                    span: SourceSpan {
                        path: PathBuf::from("src/lib.rs"),
                        start_line: 5,
                        end_line: 6,
                    },
                    covered: false,
                },
            ],
            totals_by_file: BTreeMap::from([(
                MetricKind::Region,
                BTreeMap::from([(
                    PathBuf::from("src/lib.rs"),
                    FileTotals {
                        covered: 1,
                        total: 2,
                    },
                )]),
            )]),
        };
        let diff = vec![ChangedFile {
            path: PathBuf::from("src/lib.rs"),
            changed_lines: vec![LineRange { start: 1, end: 6 }],
        }];

        let metric =
            compute_changed_metric(&report, &diff, MetricKind::Region).expect("metric works");
        assert_eq!(metric.covered, 1);
        assert_eq!(metric.total, 2);
        assert_eq!(metric.uncovered_changed_opportunities.len(), 1);
        let file_totals = metric
            .changed_totals_by_file
            .get(&PathBuf::from("src/lib.rs"))
            .expect("changed totals by file");
        assert_eq!(file_totals.covered, 1);
        assert_eq!(file_totals.total, 2);
    }

    #[test]
    fn metric_with_only_zero_totals_is_treated_as_unavailable() {
        let report = CoverageReport {
            opportunities: Vec::new(),
            totals_by_file: BTreeMap::from([(
                MetricKind::Branch,
                BTreeMap::from([(
                    PathBuf::from("src/lib.rs"),
                    FileTotals {
                        covered: 0,
                        total: 0,
                    },
                )]),
            )]),
        };

        let error = compute_changed_metric(&report, &[], MetricKind::Branch)
            .expect_err("branch metric with only zero totals should be unavailable");

        assert_eq!(
            error.to_string(),
            "requested metric branch is not available in the report"
        );
    }

    #[test]
    fn changed_branch_metric_counts_multiline_vitest_branch_outcomes() {
        let report = parse_with_repo_root(
            include_str!("../tests/fixtures/vitest/empty-branch-locations/coverage.json"),
            std::path::Path::new("."),
        )
        .expect("fixture should parse");
        let diff = vec![ChangedFile {
            path: PathBuf::from("src/auth/authService.ts"),
            changed_lines: vec![LineRange { start: 11, end: 11 }],
        }];

        let metric =
            compute_changed_metric(&report, &diff, MetricKind::Branch).expect("metric works");

        assert_eq!(metric.covered, 1);
        assert_eq!(metric.total, 2);
    }

    #[test]
    fn changed_line_metric_keeps_uncovered_fixture_seed_call_visible() {
        let report = parse_with_repo_root(
            include_str!("../tests/fixtures/vitest/empty-branch-locations/coverage.json"),
            std::path::Path::new("."),
        )
        .expect("fixture should parse");
        let diff = vec![ChangedFile {
            path: PathBuf::from("src/fixtures/fixtureSeed.ts"),
            changed_lines: vec![LineRange { start: 20, end: 20 }],
        }];

        let metric =
            compute_changed_metric(&report, &diff, MetricKind::Line).expect("metric works");

        assert_eq!(metric.covered, 0);
        assert_eq!(metric.total, 1);
        assert_eq!(metric.uncovered_changed_opportunities.len(), 1);
    }
}