covgate 0.2.0-rc0

Diff-focused coverage gates for local CI, pull requests, and autonomous coding agents.
Documentation
pub mod cli;
pub mod config;
pub mod coverage;
pub mod diff;
pub mod gate;
pub mod git;
pub mod metrics;
pub mod model;
pub mod render;

use anyhow::Result;
use std::collections::BTreeSet;

use crate::{
    config::{Config, ConfiguredGate},
    diff::DiffSource,
    model::{ChangedFile, GateResult, MetricKind},
};

pub fn run(config: Config) -> Result<i32> {
    let Config {
        coverage_report,
        diff_source,
        gates,
        markdown_output,
        verbose,
    } = config;
    let coverage_report = &coverage_report;
    let diff_source = &diff_source;
    let gates = &gates;
    let markdown_output = &markdown_output;

    let report = coverage::load_from_path(coverage_report)?;
    let diff = load_changed_lines_with_warnings(diff_source)?;

    let mut overall_metrics = Vec::new();
    for kind in [
        MetricKind::Region,
        MetricKind::Line,
        MetricKind::Branch,
        MetricKind::Function,
        MetricKind::NamedFunction,
    ] {
        if let Ok(metric) = metrics::compute_overall_metric(&report, kind) {
            overall_metrics.push(metric);
        }
    }
    let overall_metrics = overall_metrics;

    let gate_inputs = assign_changed_files(&report, &diff, gates)?;
    let mut scopes = Vec::new();

    for gate_input in gate_inputs {
        let mut metrics = Vec::new();
        let mut requested_metrics = gate_input
            .gate
            .rules
            .iter()
            .map(|rule| rule.metric())
            .collect::<Vec<_>>();
        requested_metrics.sort();
        requested_metrics.dedup();

        let available_metrics = report
            .totals_by_file
            .iter()
            .filter(|(metric, totals)| {
                !requested_metrics.contains(metric)
                    && totals.iter().any(|(path, file_totals)| {
                        gate_input.paths.contains(path) && file_totals.total > 0
                    })
            })
            .map(|(metric, _)| *metric)
            .collect::<Vec<_>>();
        requested_metrics.extend(available_metrics);

        for metric_kind in requested_metrics {
            let metric = metrics::compute_changed_metric(
                &report,
                &gate_input.changed_files,
                &gate_input.paths,
                metric_kind,
            )?;
            metrics.push(metric);
        }

        let scope = gate::evaluate(
            gate_input.gate.label.clone(),
            metrics,
            &gate_input.gate.rules,
        )?;
        scopes.push(scope);
    }
    let scopes = scopes;

    let gate_result = GateResult {
        passed: scopes.iter().all(|scope| scope.passed),
        scopes,
        overall_metrics,
    };

    let verbosity = if verbose {
        crate::model::Verbosity::Verbose
    } else {
        crate::model::Verbosity::Normal
    };

    let console = render::console::render(&gate_result, &diff_source.describe(), verbosity);
    println!("{console}");

    if let Some(path) = markdown_output {
        let markdown = render::markdown::render(&gate_result, &diff_source.describe());
        std::fs::write(path, markdown)?;
    }

    Ok(if gate_result.passed { 0 } else { 1 })
}

struct GateRunInput<'a> {
    gate: &'a ConfiguredGate,
    changed_files: Vec<ChangedFile>,
    paths: BTreeSet<std::path::PathBuf>,
}

fn assign_changed_files<'a>(
    report: &crate::model::CoverageReport,
    diff: &[ChangedFile],
    gates: &'a [ConfiguredGate],
) -> Result<Vec<GateRunInput<'a>>> {
    let supported_files = report
        .totals_by_file
        .values()
        .flat_map(|totals| {
            totals
                .iter()
                .filter_map(|(path, totals)| (totals.total > 0).then_some(path.clone()))
        })
        .collect::<BTreeSet<_>>();
    let mut scoped_inputs = gates
        .iter()
        .filter(|gate| !gate.is_fallback())
        .map(|gate| GateRunInput {
            gate,
            changed_files: Vec::new(),
            paths: BTreeSet::new(),
        })
        .collect::<Vec<_>>();
    let has_scoped_gates = !scoped_inputs.is_empty();
    let fallback_gate = gates.iter().find(|gate| gate.is_fallback());
    let mut fallback_changed_files = Vec::new();
    let mut fallback_paths = BTreeSet::new();

    for changed_file in diff {
        if !supported_files.contains(&changed_file.path) {
            continue;
        }

        let mut matching = Vec::new();
        for gate in &scoped_inputs {
            if !gate.gate.matches(&changed_file.path) {
                continue;
            }

            let label = match &gate.gate.label {
                Some(label) => label.clone(),
                None => "unnamed".to_string(),
            };
            matching.push(label);
        }

        if matching.len() > 1 {
            anyhow::bail!(
                "changed file `{}` matches multiple scoped gates: {}",
                changed_file.path.display(),
                matching.join(", ")
            );
        }

        if let Some(input) = scoped_inputs
            .iter_mut()
            .find(|gate| gate.gate.matches(&changed_file.path))
        {
            input.paths.insert(changed_file.path.clone());
            input.changed_files.push(changed_file.clone());
        } else if fallback_gate.is_some() {
            fallback_paths.insert(changed_file.path.clone());
            fallback_changed_files.push(changed_file.clone());
        } else {
            anyhow::bail!(
                "changed file `{}` has supported coverage opportunities but does not match any scoped gate and no fallback gate is configured",
                changed_file.path.display()
            );
        }
    }

    let mut participating = scoped_inputs
        .into_iter()
        .filter(|input| !input.changed_files.is_empty())
        .collect::<Vec<_>>();

    if let Some(gate) = fallback_gate
        && (!fallback_changed_files.is_empty() || participating.is_empty())
    {
        participating.push(GateRunInput {
            gate,
            paths: if !has_scoped_gates
                || (fallback_changed_files.is_empty() && participating.is_empty())
            {
                supported_files
            } else {
                fallback_paths
            },
            changed_files: fallback_changed_files,
        });
    }

    Ok(participating)
}

fn load_changed_lines_with_warnings(source: &DiffSource) -> Result<Vec<ChangedFile>> {
    emit_untracked_files_warning(source)?;
    diff::load_changed_lines(source)
}

fn emit_untracked_files_warning(source: &DiffSource) -> Result<()> {
    if !matches!(source, DiffSource::GitBase(_)) {
        return Ok(());
    }

    let untracked_files = list_untracked_files()?;
    if untracked_files.is_empty() {
        return Ok(());
    }

    let add_command = format_git_add_command(&untracked_files);
    eprintln!(
        "⚠️ Untracked-files warning: untracked files are not included in diff gating and can produce a false pass. Add them with: `{add_command}`."
    );
    Ok(())
}

fn list_untracked_files() -> Result<Vec<String>> {
    crate::git::list_untracked_files()
}

fn format_git_add_command(paths: &[String]) -> String {
    let mut command = String::from("git add -N");
    for path in paths {
        command.push(' ');
        command.push_str(&shell_escape_path(path));
    }
    command
}

fn shell_escape_path(path: &str) -> String {
    if path
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-'))
    {
        return path.to_string();
    }

    format!("'{}'", path.replace('\'', "'\''"))
}