//! JaCoCo XML reports: //! //! ```xml //! //! //! //! //! ``` //! //! Source files are found below the report's module in the conventional //! source roots (`src//{java,kotlin,groovy,scala}`), falling back to //! `src//java`. use crate::Accumulator; use quick_xml::Reader; use quick_xml::events::{BytesStart, Event}; use std::path::{Path, PathBuf}; const LANGS: &[&str] = &["java", "kotlin", "groovy", "scala"]; const MAX_DEPTH: usize = 6; /// The module a report belongs to: `/target/site/jacoco/jacoco.xml` (Maven) or /// `/build/reports/jacoco//.xml` (Gradle). pub(crate) fn module_dir(report: &Path) -> PathBuf { for dir in report.ancestors().skip(1) { let name = dir.file_name().and_then(|n| n.to_str()).unwrap_or_default(); if (name == "target" || name == "build") && let Some(parent) = dir.parent() { return parent.to_path_buf(); } } report.parent().unwrap_or(report).to_path_buf() } /// JaCoCo XML reports below `dir` (skipping VCS / dependency directories). pub(crate) fn find_reports(dir: &Path) -> Vec { let mut out = Vec::new(); walk(dir, 0, &mut out); out.sort(); out } fn walk(dir: &Path, depth: usize, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for e in entries.flatten() { let path = e.path(); let name = e.file_name().to_string_lossy().into_owned(); let Ok(ft) = e.file_type() else { continue }; if ft.is_dir() { if name.starts_with('.') || name == "node_modules" || name == "src" { continue; } // `…/target/site/jacoco/` and `…/build/reports/jacoco//`. if name == "jacoco" && path.parent().is_some_and(|p| p.ends_with("target/site") || p.ends_with("build/reports")) { collect_xml(&path, 0, out); continue; } if depth < MAX_DEPTH { walk(&path, depth + 1, out); } } } } fn collect_xml(dir: &Path, depth: usize, out: &mut Vec) { let Ok(entries) = std::fs::read_dir(dir) else { return }; for e in entries.flatten() { let path = e.path(); if path.is_dir() && depth < 1 { collect_xml(&path, depth + 1, out); } else if path.extension().is_some_and(|x| x == "xml") { out.push(path); } } } fn attr(e: &BytesStart, name: &str) -> Option { e.attributes().flatten().find(|a| a.key.as_ref() == name).map(|a| a.value.into_owned()) } fn num(e: &BytesStart, name: &str) -> u32 { attr(e, name).and_then(|v| v.parse().ok()).unwrap_or(0) } /// Source file of `package/name` below `module`. fn locate(module: &Path, package: &str, name: &str) -> PathBuf { let rel = if package.is_empty() { PathBuf::from(name) } else { Path::new(package).join(name) }; let src = module.join("src"); if let Ok(sets) = std::fs::read_dir(&src) { let mut sets: Vec = sets.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); // `main` first, so a class compiled from main sources does not resolve into tests. sets.sort_by_key(|p| (!p.ends_with("main"), p.clone())); for set in sets { for lang in LANGS { let candidate = set.join(lang).join(&rel); if candidate.is_file() { return candidate; } } } } src.join("main").join("java").join(rel) } /// Parses one report of the module at `module` into `acc`. pub(crate) fn parse_into(xml: &str, module: &Path, acc: &mut Accumulator) -> Result<(), quick_xml::Error> { let mut reader = Reader::from_str(xml); let mut package = String::new(); let mut file: Option = None; loop { match reader.read_event()? { Event::Start(e) | Event::Empty(e) => match e.name().as_ref() { "package" => package = attr(&e, "name").unwrap_or_default(), "sourcefile" => { let name = attr(&e, "name").unwrap_or_default(); let path = locate(module, &package, &name); acc.file(&path); file = Some(path); } "line" => { let Some(f) = file.as_deref() else { continue }; let nr = num(&e, "nr"); if nr == 0 { continue; } let (ci, mb, cb) = (num(&e, "ci"), num(&e, "mb"), num(&e, "cb")); acc.line(f, nr - 1, u64::from(ci)); for i in 0..mb + cb { acc.branch(f, nr - 1, i.to_string(), i < cb); } } _ => {} }, Event::End(e) => match e.name().as_ref() { "sourcefile" => file = None, "package" => package.clear(), _ => {} }, Event::Eof => break, _ => {} } } Ok(()) } #[cfg(test)] /// Parses a single report whose sources live below `module`. pub(crate) fn parse(xml: &str, module: &Path) -> Result { let mut acc = Accumulator { has_branches: true, ..Default::default() }; parse_into(xml, module, &mut acc).map_err(|e| e.to_string())?; Ok(acc.finish("JaCoCo")) } #[cfg(test)] mod tests { use super::*; const REPORT: &str = r#""#; #[test] fn parses_lines_and_branches_and_locates_sources() { let dir = tempfile::tempdir().unwrap(); let kt = dir.path().join("src/main/kotlin/com/example"); std::fs::create_dir_all(&kt).unwrap(); std::fs::write(kt.join("Util.kt"), "").unwrap(); let suite = parse(REPORT, dir.path()).unwrap(); assert!(suite.has_branches); assert_eq!(suite.files.len(), 2); let calc = &suite.files[0]; assert_eq!(calc.path.as_ref() as &Path, dir.path().join("src/main/java/com/example/Calc.java")); assert_eq!((calc.lines_total, calc.lines_covered), (3, 2)); assert_eq!((calc.branches_total, calc.branches_covered), (2, 1)); assert!(calc.lines[1].partial()); assert_eq!(calc.lines[2].hits, 0); assert_eq!(suite.files[1].path.as_ref() as &Path, kt.join("Util.kt")); } #[test] fn finds_maven_and_gradle_reports() { let dir = tempfile::tempdir().unwrap(); let maven = dir.path().join("core/target/site/jacoco"); let gradle = dir.path().join("app/build/reports/jacoco/test"); std::fs::create_dir_all(&maven).unwrap(); std::fs::create_dir_all(&gradle).unwrap(); std::fs::write(maven.join("jacoco.xml"), REPORT).unwrap(); std::fs::write(gradle.join("jacocoTestReport.xml"), REPORT).unwrap(); std::fs::write(maven.join("index.html"), "").unwrap(); let found = find_reports(dir.path()); assert_eq!(found.len(), 2); assert_eq!(module_dir(&found[0]), dir.path().join("app")); assert_eq!(module_dir(&found[1]), dir.path().join("core")); } #[test] fn rejects_broken_xml() { assert!(parse("", Path::new("/m")).is_err()); let suite = parse("", Path::new("/m")).unwrap(); assert!(suite.files.is_empty()); } }