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 report: HashMap<String, IstanbulFileCoverage> =
serde_json::from_str(input).context("failed to parse istanbul 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();
for (file_name, coverage) in report {
let path = normalize_path(&file_name, repo_root);
let mut lines = BTreeMap::<u32, bool>::new();
for (statement_id, statement) in &coverage.statement_map {
let hits = coverage.s.get(statement_id).copied().unwrap_or(0);
let covered = hits > 0;
lines
.entry(statement.start.line)
.and_modify(|seen| *seen = *seen || covered)
.or_insert(covered);
}
if !lines.is_empty() {
let covered = lines.values().filter(|is_covered| **is_covered).count();
let total = lines.len();
for (line, is_covered) in lines {
opportunities.push(CoverageOpportunity {
kind: OpportunityKind::Line,
span: SourceSpan {
path: path.clone(),
start_line: line,
end_line: line,
},
covered: is_covered,
});
}
line_totals_by_file.insert(path.clone(), FileTotals { covered, total });
}
let mut branch_records = Vec::new();
for (branch_id, branch_map) in &coverage.branch_map {
let outcomes = coverage.b.get(branch_id).cloned().unwrap_or_default();
let fallback_start_line = branch_map
.loc
.as_ref()
.and_then(|span| span.start.line)
.or(branch_map.line)
.or(branch_map.loc.as_ref().and_then(|span| span.end.line));
let fallback_end_line = branch_map
.loc
.as_ref()
.and_then(|span| span.end.line)
.or(fallback_start_line);
for (index, location) in branch_map.locations.iter().enumerate() {
let start_line = location.start.line.or(fallback_start_line);
let end_line = location.end.line.or(fallback_end_line).or(start_line);
let Some(start_line) = start_line else {
continue;
};
let end_line = end_line.unwrap_or(start_line);
branch_records.push(BranchRecord {
start_line,
end_line,
covered: outcomes.get(index).copied().unwrap_or(0) > 0,
});
}
}
if !branch_records.is_empty() {
let covered = branch_records
.iter()
.filter(|record| record.covered)
.count();
let total = branch_records.len();
for record in branch_records {
opportunities.push(CoverageOpportunity {
kind: OpportunityKind::BranchOutcome,
span: SourceSpan {
path: path.clone(),
start_line: record.start_line,
end_line: record.end_line,
},
covered: record.covered,
});
}
branch_totals_by_file.insert(path.clone(), FileTotals { covered, total });
}
let mut function_records = Vec::new();
for (function_id, function_map) in &coverage.fn_map {
let covered = coverage.f.get(function_id).copied().unwrap_or(0) > 0;
function_records.push(FunctionRecord {
start_line: function_map.loc.start.line,
end_line: function_map.loc.end.line,
covered,
});
}
if !function_records.is_empty() {
let covered = function_records
.iter()
.filter(|function| function.covered)
.count();
let total = function_records.len();
for function in function_records {
opportunities.push(CoverageOpportunity {
kind: OpportunityKind::Function,
span: SourceSpan {
path: path.clone(),
start_line: function.start_line,
end_line: function.end_line,
},
covered: function.covered,
});
}
function_totals_by_file.insert(path, FileTotals { covered, 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);
}
Ok(CoverageReport {
opportunities,
totals_by_file,
})
}
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 normalized_value == repo_root_string {
return PathBuf::new();
}
if let Some(stripped) = normalized_value.strip_prefix(&format!("{repo_root_string}/")) {
return lexical_normalize(Path::new(stripped));
}
relativize_absolute_path(Path::new(&normalized_value), repo_root)
}
#[derive(Debug, Deserialize)]
struct IstanbulFileCoverage {
#[serde(rename = "statementMap", default)]
statement_map: HashMap<String, IstanbulSpan>,
#[serde(default)]
s: HashMap<String, u64>,
#[serde(rename = "branchMap", default)]
branch_map: HashMap<String, IstanbulBranchMap>,
#[serde(default)]
b: HashMap<String, Vec<u64>>,
#[serde(rename = "fnMap", default)]
fn_map: HashMap<String, IstanbulFunctionMap>,
#[serde(default)]
f: HashMap<String, u64>,
}
#[derive(Debug, Deserialize)]
struct IstanbulFunctionMap {
loc: IstanbulSpan,
}
#[derive(Debug, Deserialize)]
struct IstanbulBranchMap {
#[serde(default)]
line: Option<u32>,
#[serde(default)]
loc: Option<IstanbulOptionalSpan>,
locations: Vec<IstanbulOptionalSpan>,
}
#[derive(Debug, Deserialize)]
struct IstanbulSpan {
start: IstanbulPosition,
end: IstanbulPosition,
}
#[derive(Debug, Deserialize)]
struct IstanbulPosition {
line: u32,
}
#[derive(Debug, Deserialize)]
struct IstanbulOptionalSpan {
start: IstanbulOptionalPosition,
end: IstanbulOptionalPosition,
}
#[derive(Debug, Deserialize, Default)]
struct IstanbulOptionalPosition {
#[serde(default)]
line: Option<u32>,
}
#[derive(Debug)]
struct FunctionRecord {
start_line: u32,
end_line: u32,
covered: bool,
}
#[derive(Debug)]
struct BranchRecord {
start_line: u32,
end_line: u32,
covered: bool,
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use crate::model::{MetricKind, OpportunityKind};
use super::parse_with_repo_root;
#[test]
fn parses_istanbul_line_branch_and_function_totals() {
let input = r#"
{
"src/math.js": {
"path": "src/math.js",
"statementMap": {
"0": {"start": {"line": 1, "column": 0}, "end": {"line": 1, "column": 10}},
"1": {"start": {"line": 2, "column": 0}, "end": {"line": 2, "column": 10}}
},
"s": {"0": 1, "1": 0},
"branchMap": {
"0": {
"loc": {"start": {"line": 2, "column": 0}, "end": {"line": 2, "column": 10}},
"type": "if",
"locations": [
{"start": {"line": 2, "column": 0}, "end": {"line": 2, "column": 10}},
{"start": {"line": 2, "column": 0}, "end": {"line": 2, "column": 10}}
]
}
},
"b": {"0": [1, 0]},
"fnMap": {
"0": {
"name": "compute",
"decl": {"start": {"line": 1, "column": 0}, "end": {"line": 1, "column": 10}},
"loc": {"start": {"line": 1, "column": 0}, "end": {"line": 3, "column": 1}},
"line": 1
}
},
"f": {"0": 1}
}
}
"#;
let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
.expect("istanbul json should parse");
let line_totals = report
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.get(&PathBuf::from("src/math.js"))
.expect("line totals should include fixture file");
assert_eq!(line_totals.covered, 1);
assert_eq!(line_totals.total, 2);
let branch_totals = report
.totals_by_file
.get(&MetricKind::Branch)
.expect("branch totals should exist")
.get(&PathBuf::from("src/math.js"))
.expect("branch totals should include fixture file");
assert_eq!(branch_totals.covered, 1);
assert_eq!(branch_totals.total, 2);
let function_totals = report
.totals_by_file
.get(&MetricKind::Function)
.expect("function totals should exist")
.get(&PathBuf::from("src/math.js"))
.expect("function totals should include fixture file");
assert_eq!(function_totals.covered, 1);
assert_eq!(function_totals.total, 1);
}
#[test]
fn parse_rejects_invalid_json() {
let error = parse_with_repo_root("{", Path::new("/workspace/covgate"))
.expect_err("invalid json should fail");
assert!(error.to_string().contains("failed to parse istanbul json"));
}
#[test]
fn parses_checked_in_vitest_fixture_with_empty_branch_locations() {
let input =
include_str!("../../tests/fixtures/vitest/empty-branch-locations/coverage.json");
let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
.expect("checked-in vitest fixture should parse");
let branch_totals = report
.totals_by_file
.get(&MetricKind::Branch)
.expect("branch totals should exist");
assert!(
branch_totals.contains_key(&PathBuf::from("src/auth/authService.ts")),
"fixture should include authService branch totals"
);
assert!(
branch_totals.contains_key(&PathBuf::from("src/auth/msalConfig.ts")),
"fixture should include msalConfig branch totals"
);
let auth_service_branches: Vec<_> = report
.opportunities
.iter()
.filter(|opportunity| {
opportunity.kind == OpportunityKind::BranchOutcome
&& opportunity.span.path == Path::new("src/auth/authService.ts")
&& opportunity.span.start_line == 10
&& opportunity.span.end_line == 11
})
.collect();
assert_eq!(
auth_service_branches.len(),
2,
"line 10-11 authService branch should preserve both outcome spans"
);
}
#[test]
fn merges_overlapping_statement_lines_as_covered_when_any_statement_hits() {
let input = r#"
{
"src/math.js": {
"statementMap": {
"0": {"start": {"line": 2}, "end": {"line": 2}},
"1": {"start": {"line": 2}, "end": {"line": 2}}
},
"s": {"0": 0, "1": 1},
"branchMap": {},
"b": {},
"fnMap": {},
"f": {}
}
}
"#;
let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
.expect("istanbul json should parse");
let line_totals = report
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.get(&PathBuf::from("src/math.js"))
.expect("file totals should exist");
assert_eq!(line_totals.covered, 1);
assert_eq!(line_totals.total, 1);
assert!(!report.totals_by_file.contains_key(&MetricKind::Branch));
assert!(!report.totals_by_file.contains_key(&MetricKind::Function));
}
#[test]
fn counts_unique_statement_start_lines_for_line_totals() {
let input = r#"
{
"src/math.js": {
"statementMap": {
"0": {"start": {"line": 19}, "end": {"line": 22}},
"1": {"start": {"line": 20}, "end": {"line": 20}},
"2": {"start": {"line": 22}, "end": {"line": 22}}
},
"s": {"0": 1, "1": 0, "2": 1},
"branchMap": {},
"b": {},
"fnMap": {},
"f": {}
}
}
"#;
let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
.expect("istanbul json should parse");
let line_totals = report
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.get(&PathBuf::from("src/math.js"))
.expect("file totals should exist");
assert_eq!(line_totals.covered, 2);
assert_eq!(line_totals.total, 3);
let line_20 = report
.opportunities
.iter()
.find(|opportunity| {
opportunity.kind == OpportunityKind::Line
&& opportunity.span.path == Path::new("src/math.js")
&& opportunity.span.start_line == 20
&& opportunity.span.end_line == 20
})
.expect("line 20 opportunity should exist");
assert!(!line_20.covered, "line 20 should remain uncovered");
}
#[test]
fn checked_in_vitest_fixture_preserves_uncovered_nested_fixture_seed_line() {
let input =
include_str!("../../tests/fixtures/vitest/empty-branch-locations/coverage.json");
let report = parse_with_repo_root(input, Path::new("/workspace/covgate"))
.expect("checked-in vitest fixture should parse");
let line_20 = report
.opportunities
.iter()
.find(|opportunity| {
opportunity.kind == OpportunityKind::Line
&& opportunity.span.path == Path::new("src/fixtures/fixtureSeed.ts")
&& opportunity.span.start_line == 20
&& opportunity.span.end_line == 20
})
.expect("fixtureSeed line 20 opportunity should exist");
assert!(
!line_20.covered,
"fixtureSeed line 20 should stay uncovered"
);
}
#[test]
fn normalizes_repo_prefixed_and_absolute_paths() {
let prefixed = parse_with_repo_root(
r#"{
"/workspace/covgate/src/math.js": {
"statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
"s": {"0": 1},
"branchMap": {},
"b": {},
"fnMap": {},
"f": {}
}
}"#,
Path::new("/workspace/covgate"),
)
.expect("prefixed path should parse");
assert!(
prefixed
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.contains_key(&PathBuf::from("src/math.js"))
);
let absolute_outside = parse_with_repo_root(
r#"{
"/opt/other/math.js": {
"statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
"s": {"0": 1},
"branchMap": {},
"b": {},
"fnMap": {},
"f": {}
}
}"#,
Path::new("/workspace/covgate"),
)
.expect("absolute outside path should parse");
assert!(
absolute_outside
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.contains_key(&PathBuf::from("/opt/other/math.js"))
);
}
#[test]
fn does_not_strip_repo_root_text_prefix_when_not_path_boundary() {
let report = parse_with_repo_root(
r#"{
"/workspace/covgate-old/src/math.js": {
"statementMap": {"0": {"start": {"line": 1}, "end": {"line": 1}}},
"s": {"0": 1},
"branchMap": {},
"b": {},
"fnMap": {},
"f": {}
}
}"#,
Path::new("/workspace/covgate"),
)
.expect("path should parse");
assert!(
report
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.contains_key(&PathBuf::from("/workspace/covgate-old/src/math.js"))
);
assert!(
!report
.totals_by_file
.get(&MetricKind::Line)
.expect("line totals should exist")
.contains_key(&PathBuf::from("-old/src/math.js"))
);
}
}