covgate 0.2.0-rc0

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

use anyhow::{Context, Result};
use serde::Deserialize;

use crate::model::{
    CoverageOpportunity, CoverageReport, FileTotals, MetricKind, OpportunityKind, SourceSpan,
};

use super::path::{lexical_normalize, relativize_absolute_path};

pub(crate) fn parse_with_repo_root(input: &str, repo_root: &Path) -> Result<CoverageReport> {
    let export: HashMap<String, HashMap<String, serde_json::Value>> =
        serde_json::from_str(input).context("failed to parse coverlet json")?;

    let mut opportunities = Vec::new();
    let mut line_totals_by_file = BTreeMap::new();
    let mut branch_totals_by_file = BTreeMap::new();
    let mut function_totals_by_file = BTreeMap::new();
    let mut named_function_totals_by_file = BTreeMap::new();

    for classes_by_file in export.into_values() {
        for (file_name, class_value) in classes_by_file {
            let path = normalize_path(&file_name, repo_root);
            let mut line_hits_by_line = BTreeMap::<u32, bool>::new();
            let mut branch_records = Vec::<BranchRecord>::new();
            let mut function_records = Vec::<FunctionRecord>::new();

            let Some(classes) = class_value.as_object() else {
                continue;
            };

            for methods_value in classes.values() {
                let Some(methods) = methods_value.as_object() else {
                    continue;
                };
                for (method_key, method_value) in methods {
                    let Ok(method) = serde_json::from_value::<CoverletMethod>(method_value.clone())
                    else {
                        continue;
                    };

                    let is_named = is_coverlet_method_named(method_key);

                    for (&line_number, &hits) in &method.lines {
                        let covered = hits > 0;
                        line_hits_by_line
                            .entry(line_number)
                            .and_modify(|seen| *seen = *seen || covered)
                            .or_insert(covered);
                    }

                    branch_records.extend(method.branches);

                    let start_line = method.lines.keys().copied().min();
                    let end_line = method.lines.keys().copied().max();
                    if let (Some(start_line), Some(end_line)) = (start_line, end_line) {
                        let covered = method.lines.values().any(|hits| *hits > 0);
                        function_records.push(FunctionRecord {
                            start_line,
                            end_line,
                            covered,
                            is_named,
                        });
                    }
                }
            }

            if !line_hits_by_line.is_empty() {
                let total = line_hits_by_line.len();
                let mut covered = 0usize;
                for (line_number, is_covered) in line_hits_by_line {
                    if is_covered {
                        covered += 1;
                    }
                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::Line,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: line_number,
                            end_line: line_number,
                            start_col: None,
                            end_col: None,
                        },
                        covered: is_covered,
                        is_named_function: None,
                    });
                }
                line_totals_by_file.insert(path.clone(), FileTotals { covered, total });
            }

            if !branch_records.is_empty() {
                let mut covered = 0usize;
                let total = branch_records.len();
                for branch in branch_records {
                    let is_covered = branch.hits > 0;
                    if is_covered {
                        covered += 1;
                    }
                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::BranchOutcome,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: branch.line,
                            end_line: branch.line,
                            start_col: None,
                            end_col: None,
                        },
                        covered: is_covered,
                        is_named_function: None,
                    });
                }
                branch_totals_by_file.insert(path.clone(), FileTotals { covered, total });
            }

            if !function_records.is_empty() {
                let mut covered = 0usize;
                let total = function_records.len();

                let mut named_covered = 0usize;
                let mut named_total = 0usize;

                for function in function_records {
                    if function.covered {
                        covered += 1;
                        if function.is_named {
                            named_covered += 1;
                        }
                    }
                    if function.is_named {
                        named_total += 1;
                    }

                    opportunities.push(CoverageOpportunity {
                        kind: OpportunityKind::Function,
                        span: SourceSpan {
                            path: path.clone(),
                            start_line: function.start_line,
                            end_line: function.end_line,
                            start_col: None,
                            end_col: None,
                        },
                        covered: function.covered,
                        is_named_function: Some(function.is_named),
                    });
                }
                function_totals_by_file.insert(path.clone(), FileTotals { covered, total });
                if named_total > 0 {
                    named_function_totals_by_file.insert(
                        path,
                        FileTotals {
                            covered: named_covered,
                            total: named_total,
                        },
                    );
                }
            }
        }
    }

    let mut totals_by_file = BTreeMap::new();
    if !line_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Line, line_totals_by_file);
    }
    if !branch_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Branch, branch_totals_by_file);
    }
    if !function_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::Function, function_totals_by_file);
    }
    if !named_function_totals_by_file.is_empty() {
        totals_by_file.insert(MetricKind::NamedFunction, named_function_totals_by_file);
    }

    Ok(CoverageReport {
        opportunities,
        totals_by_file,
    })
}

fn is_coverlet_method_named(key: &str) -> bool {
    let Some(method_part) = key.split("::").nth(1) else {
        return false;
    };
    let name_part = method_part.split('(').next().unwrap_or("");
    !name_part.contains('<') && !name_part.contains('>')
}

fn normalize_path(value: &str, repo_root: &Path) -> PathBuf {
    let normalized_value = value.replace('\\', "/");
    let repo_root_string = repo_root.to_string_lossy().replace('\\', "/");

    if let Some(stripped) = normalized_value
        .strip_prefix(&format!("{repo_root_string}/"))
        .or_else(|| normalized_value.strip_prefix(&repo_root_string))
    {
        let trimmed = stripped.trim_start_matches('/');
        return lexical_normalize(Path::new(trimmed));
    }

    relativize_absolute_path(Path::new(&normalized_value), repo_root)
}

#[derive(Debug, Deserialize)]
struct CoverletMethod {
    #[serde(rename = "Lines", deserialize_with = "deserialize_line_hits")]
    lines: HashMap<u32, u64>,
    #[serde(rename = "Branches", default)]
    branches: Vec<BranchRecord>,
}

#[derive(Debug, Deserialize)]
struct BranchRecord {
    #[serde(rename = "Line")]
    line: u32,
    #[serde(rename = "Hits")]
    hits: u64,
}

#[derive(Debug)]
struct FunctionRecord {
    start_line: u32,
    end_line: u32,
    covered: bool,
    is_named: bool,
}

fn deserialize_line_hits<'de, D>(deserializer: D) -> Result<HashMap<u32, u64>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    let parsed: HashMap<String, u64> = HashMap::deserialize(deserializer)?;
    parsed
        .into_iter()
        .map(|(line, hits)| {
            line.parse::<u32>()
                .map(|line_number| (line_number, hits))
                .map_err(serde::de::Error::custom)
        })
        .collect()
}

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

    use super::{is_coverlet_method_named, normalize_path};

    #[test]
    fn normalizes_windows_path_separators() {
        let repo_root = Path::new("C:/workspace/covgate");
        let normalized = normalize_path("C:\\workspace\\covgate\\src\\lib.cs", repo_root);
        assert_eq!(normalized, PathBuf::from("src/lib.cs"));
    }

    #[test]
    fn identifies_named_functions_correctly() {
        assert!(is_coverlet_method_named(
            "System.Int32 Demo.MathOps::Add(System.Int32)"
        ));
        assert!(!is_coverlet_method_named(
            "System.Void Demo.MathOps::<Add>b__0_0()"
        ));
    }

    #[test]
    fn verifies_named_function_totals() {
        let json = r#"{
            "Demo.Tests.dll": {
                "C:\\src\\MathOps.cs": {
                    "Demo.MathOps": {
                        "System.Int32 Demo.MathOps::Add(System.Int32)": {
                            "Lines": { "1": 1 },
                            "Branches": []
                        },
                        "System.Void Demo.MathOps::<Add>b__0_0()": {
                            "Lines": { "2": 1 },
                            "Branches": []
                        }
                    }
                }
            }
        }"#;

        let repo_root = Path::new("C:/");
        let report = super::parse_with_repo_root(json, repo_root).unwrap();

        let path = PathBuf::from("src/MathOps.cs");
        let function_totals = report
            .totals_by_file
            .get(&crate::model::MetricKind::Function)
            .and_then(|t| t.get(&path))
            .unwrap();
        assert_eq!(function_totals.total, 2);
        assert_eq!(function_totals.covered, 2);

        let named_function_totals = report
            .totals_by_file
            .get(&crate::model::MetricKind::NamedFunction)
            .and_then(|t| t.get(&path))
            .unwrap();
        assert_eq!(named_function_totals.total, 1);
        assert_eq!(named_function_totals.covered, 1);
    }
}