covgate 0.1.4

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 crate::{config::Config, diff::DiffSource, model::ChangedFile};

pub fn run(config: Config) -> Result<i32> {
    let report = coverage::load_from_path(&config.coverage_report)?;
    let diff = load_changed_lines_with_warnings(&config.diff_source)?;

    let mut metrics = Vec::new();
    let mut requested_metrics = config.rules.iter().map(|r| r.metric()).collect::<Vec<_>>();
    requested_metrics.sort();
    requested_metrics.dedup();

    let available_metrics = report
        .totals_by_file
        .iter()
        .filter(|(_, totals)| totals.values().any(|file_totals| file_totals.total > 0))
        .map(|(metric, _)| *metric)
        .filter(|metric| !requested_metrics.contains(metric))
        .collect::<Vec<_>>();

    requested_metrics.extend(available_metrics);

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

    let gate_result = gate::evaluate(metrics, &config.rules)?;

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

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

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

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('\'', "'\''"))
}