use anyhow::{Context, Result};
use curie_deps::resolver::{resolve, DepEntry, ResolveOptions};
use std::path::{Path, PathBuf};
pub const DEFAULT_JACOCO_VERSION: &str = "0.8.13";
const JACOCO_AGENT_COORD: &str = "org.jacoco:org.jacoco.agent";
const JACOCO_CLI_COORD: &str = "org.jacoco:org.jacoco.cli";
pub fn resolve_agent_jar(
default_repos: &[curie_deps::repo::Repository],
extra_repos: &[curie_deps::repo::Repository],
offline: bool,
) -> Result<PathBuf> {
let jars = resolve(
&[DepEntry {
key: JACOCO_AGENT_COORD,
version: DEFAULT_JACOCO_VERSION,
repo_id: None,
exclusions: vec![],
classifier: Some("runtime"),
allow_version_conflict: false,
}],
&ResolveOptions {
default_repos: default_repos.to_vec(),
named_repos: extra_repos.to_vec(),
progress: false,
bom_imports: vec![],
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.context("failed to resolve JaCoCo agent")?;
select_jar_from_resolve(&jars, "jacoco.agent", "runtime")
.with_context(|| "JaCoCo agent JAR not found after resolution")
}
fn select_jar_from_resolve(
jars: &[std::path::PathBuf],
coord_fragment: &str,
classifier: &str,
) -> Result<std::path::PathBuf> {
jars.iter()
.find(|p| jar_filename_matches(p, coord_fragment, Some(classifier)))
.or_else(|| jars.iter().find(|p| jar_filename_matches(p, coord_fragment, None)))
.cloned()
.with_context(|| format!("no jar matched {} with classifier {}", coord_fragment, classifier))
}
fn jar_filename_matches(path: &std::path::Path, coord: &str, classifier: Option<&str>) -> bool {
let name = match path.file_name().and_then(|f| f.to_str()) {
Some(n) => n,
None => return false,
};
if !name.contains(coord) {
return false;
}
match classifier {
Some(c) => name.contains(c),
None => true,
}
}
pub fn resolve_cli_jar(
default_repos: &[curie_deps::repo::Repository],
extra_repos: &[curie_deps::repo::Repository],
offline: bool,
) -> Result<PathBuf> {
let jars = resolve(
&[DepEntry {
key: JACOCO_CLI_COORD,
version: DEFAULT_JACOCO_VERSION,
repo_id: None,
exclusions: vec![],
classifier: Some("nodeps"),
allow_version_conflict: false,
}],
&ResolveOptions {
default_repos: default_repos.to_vec(),
named_repos: extra_repos.to_vec(),
progress: false,
bom_imports: vec![],
offline,
skip_version_ranges: false, error_on_version_conflict: false,
},
)
.context("failed to resolve JaCoCo CLI")?;
select_jar_from_resolve(&jars, "jacoco.cli", "nodeps")
.with_context(|| "JaCoCo CLI JAR not found after resolution")
}
pub fn agent_jvm_arg(agent_jar: &Path, dest_file: &Path) -> String {
format!(
"-javaagent:{}=destfile={}",
agent_jar.display(),
dest_file.display()
)
}
pub fn generate_report(
cli_jar: &Path,
exec_file: &Path,
classes_dir: &Path,
source_dirs: &[PathBuf],
report_dir: &Path,
) -> Result<CoverageSummary> {
let html_dir = report_dir.join("html");
let csv_file = report_dir.join("coverage.csv");
std::fs::create_dir_all(&html_dir)
.with_context(|| format!("failed to create {}", html_dir.display()))?;
let mut cmd = std::process::Command::new("java");
cmd.arg("-jar").arg(cli_jar);
cmd.arg("report").arg(exec_file);
cmd.arg("--classfiles").arg(classes_dir);
for src in source_dirs {
cmd.arg("--sourcefiles").arg(src);
}
cmd.arg("--html").arg(&html_dir);
cmd.arg("--csv").arg(&csv_file);
let status = crate::proc::spawn_cmd(&mut cmd)
.context("failed to invoke JaCoCo CLI for report generation")?;
if !status.success() {
anyhow::bail!("JaCoCo report generation failed");
}
parse_csv_summary(&csv_file)
}
#[derive(Debug, Clone)]
pub struct CoverageSummary {
pub line_covered: u64,
pub line_missed: u64,
pub branch_covered: u64,
pub branch_missed: u64,
}
impl CoverageSummary {
pub fn line_pct(&self) -> f64 {
let total = self.line_covered + self.line_missed;
if total == 0 {
100.0
} else {
(self.line_covered as f64 / total as f64) * 100.0
}
}
pub fn branch_pct(&self) -> f64 {
let total = self.branch_covered + self.branch_missed;
if total == 0 {
100.0
} else {
(self.branch_covered as f64 / total as f64) * 100.0
}
}
pub fn summary_line(&self) -> String {
format!("{:.1}% lines, {:.1}% branches", self.line_pct(), self.branch_pct())
}
}
fn parse_csv_summary(csv_path: &Path) -> Result<CoverageSummary> {
let content = std::fs::read_to_string(csv_path)
.with_context(|| format!("failed to read {}", csv_path.display()))?;
let mut line_covered: u64 = 0;
let mut line_missed: u64 = 0;
let mut branch_covered: u64 = 0;
let mut branch_missed: u64 = 0;
for (i, line) in content.lines().enumerate() {
if i == 0 {
continue; }
let cols: Vec<&str> = line.split(',').collect();
if cols.len() < 9 {
continue;
}
branch_missed += cols[5].parse::<u64>().unwrap_or(0);
branch_covered += cols[6].parse::<u64>().unwrap_or(0);
line_missed += cols[7].parse::<u64>().unwrap_or(0);
line_covered += cols[8].parse::<u64>().unwrap_or(0);
}
Ok(CoverageSummary {
line_covered,
line_missed,
branch_covered,
branch_missed,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn agent_jvm_arg_format() {
let arg = agent_jvm_arg(
Path::new("/cache/jacocoagent.jar"),
Path::new("/project/target/coverage/jacoco.exec"),
);
assert!(arg.starts_with("-javaagent:/cache/jacocoagent.jar=destfile="));
assert!(arg.contains("jacoco.exec"));
}
#[test]
fn coverage_summary_percentages() {
let s = CoverageSummary {
line_covered: 75,
line_missed: 25,
branch_covered: 10,
branch_missed: 10,
};
assert!((s.line_pct() - 75.0).abs() < 0.01);
assert!((s.branch_pct() - 50.0).abs() < 0.01);
}
#[test]
fn coverage_summary_zero_totals_is_100() {
let s = CoverageSummary {
line_covered: 0,
line_missed: 0,
branch_covered: 0,
branch_missed: 0,
};
assert!((s.line_pct() - 100.0).abs() < 0.01);
assert!((s.branch_pct() - 100.0).abs() < 0.01);
}
#[test]
fn coverage_summary_line_format() {
let s = CoverageSummary {
line_covered: 80,
line_missed: 20,
branch_covered: 6,
branch_missed: 4,
};
let line = s.summary_line();
assert!(line.contains("80.0%"), "got: {line}");
assert!(line.contains("60.0%"), "got: {line}");
assert!(line.contains("lines"), "got: {line}");
assert!(line.contains("branches"), "got: {line}");
}
#[test]
fn parse_csv_summary_aggregates_rows() {
let dir = tempfile::tempdir().unwrap();
let csv = dir.path().join("coverage.csv");
std::fs::write(
&csv,
"GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,\
BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,\
COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED\n\
g,p,A,10,20,2,4,3,7,1,2,0,1\n\
g,p,B,5,15,1,3,2,8,0,1,0,1\n",
)
.unwrap();
let summary = parse_csv_summary(&csv).unwrap();
assert_eq!(summary.line_covered, 15);
assert_eq!(summary.line_missed, 5);
assert_eq!(summary.branch_covered, 7);
assert_eq!(summary.branch_missed, 3);
}
#[test]
fn parse_csv_summary_empty_body_returns_zeros() {
let dir = tempfile::tempdir().unwrap();
let csv = dir.path().join("coverage.csv");
std::fs::write(
&csv,
"GROUP,PACKAGE,CLASS,INSTRUCTION_MISSED,INSTRUCTION_COVERED,\
BRANCH_MISSED,BRANCH_COVERED,LINE_MISSED,LINE_COVERED,\
COMPLEXITY_MISSED,COMPLEXITY_COVERED,METHOD_MISSED,METHOD_COVERED\n",
)
.unwrap();
let summary = parse_csv_summary(&csv).unwrap();
assert_eq!(summary.line_covered, 0);
assert_eq!(summary.line_missed, 0);
}
#[test]
fn default_jacoco_version_is_semver() {
let parts: Vec<&str> = DEFAULT_JACOCO_VERSION.split('.').collect();
assert!(parts.len() >= 2, "expected semver, got: {}", DEFAULT_JACOCO_VERSION);
}
}