use std::fmt;
use anyhow::{Context, Result};
use super::model::CoverageReport;
use super::{cobertura, go_coverprofile, jacoco, lcov, llvm_json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Format {
Lcov,
LlvmCovJson,
Cobertura,
Jacoco,
GoCoverprofile,
}
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
Self::Lcov => "lcov",
Self::LlvmCovJson => "llvm-cov-json",
Self::Cobertura => "cobertura",
Self::Jacoco => "jacoco",
Self::GoCoverprofile => "go-coverprofile",
};
f.write_str(name)
}
}
impl Format {
pub fn detect(content: &str) -> Result<Self> {
let trimmed = content
.trim_start()
.trim_start_matches('\u{feff}')
.trim_start();
let first = trimmed
.chars()
.next()
.context("coverage report is empty; cannot detect format")?;
match first {
'<' => detect_xml(trimmed),
'{' | '[' => Ok(Self::LlvmCovJson),
_ if trimmed.starts_with("TN:")
|| trimmed.starts_with("SF:")
|| trimmed.starts_with("DA:") =>
{
Ok(Self::Lcov)
}
_ if trimmed.starts_with("mode:") => Ok(Self::GoCoverprofile),
_ => anyhow::bail!(
"could not auto-detect coverage report format; pass an explicit --report-format \
(lcov, llvm-cov-json, cobertura, jacoco, or go-coverprofile)"
),
}
}
pub fn parse(self, content: &str) -> Result<CoverageReport> {
match self {
Self::Lcov => lcov::parse(content),
Self::LlvmCovJson => llvm_json::parse(content),
Self::Cobertura => cobertura::parse(content),
Self::Jacoco => jacoco::parse(content),
Self::GoCoverprofile => go_coverprofile::parse(content),
}
}
}
fn detect_xml(content: &str) -> Result<Format> {
let mut reader = quick_xml::Reader::from_str(content);
loop {
match reader
.read_event()
.context("Failed to detect coverage XML root")?
{
quick_xml::events::Event::Start(e) | quick_xml::events::Event::Empty(e) => {
return match e.name().as_ref() {
b"report" => Ok(Format::Jacoco),
b"coverage" => Ok(Format::Cobertura),
_ => {
anyhow::bail!("Unknown coverage XML root; expected <coverage> or <report>")
}
};
}
quick_xml::events::Event::Eof => anyhow::bail!("Coverage XML has no root element"),
_ => {}
}
}
}
pub fn resolve(content: &str, format: Option<Format>) -> Result<Format> {
match format {
Some(f) => Ok(f),
None => Format::detect(content).context("coverage report format auto-detection failed"),
}
}
pub fn parse(content: &str, format: Option<Format>) -> Result<CoverageReport> {
let format = resolve(content, format)?;
format
.parse(content)
.with_context(|| format!("failed to parse {format} coverage report"))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn detects_lcov() {
assert_eq!(
Format::detect("SF:src/a.rs\nDA:1,1\n").unwrap(),
Format::Lcov
);
assert_eq!(Format::detect("TN:\nSF:x\n").unwrap(), Format::Lcov);
}
#[test]
fn detects_json() {
assert_eq!(
Format::detect(" {\"data\":[]}").unwrap(),
Format::LlvmCovJson
);
}
#[test]
fn detects_cobertura() {
assert_eq!(
Format::detect("<?xml version=\"1.0\"?><coverage/>").unwrap(),
Format::Cobertura
);
}
#[test]
fn detects_jacoco_and_dispatches_after_xml_preamble() {
let content = r#"<?xml version="1.0"?><!-- <coverage/> -->
<!DOCTYPE report PUBLIC "-//JACOCO//DTD Report 1.1//EN" "report.dtd">
<report name="demo"><package name="p"><sourcefile name="A.java">
<line nr="1" mi="1" ci="1"/></sourcefile></package></report>"#;
assert_eq!(Format::detect(content).unwrap(), Format::Jacoco);
assert_eq!(Format::Jacoco.to_string(), "jacoco");
assert_eq!(Format::detect("\u{feff}<report/>").unwrap(), Format::Jacoco);
assert_eq!(parse(content, None).unwrap().hits("p/A.java", 1), Some(1));
assert_eq!(
parse(content, Some(Format::Jacoco)).unwrap(),
parse(content, None).unwrap()
);
assert_eq!(
Format::detect("<!-- <report/> --><coverage/>").unwrap(),
Format::Cobertura
);
for content in ["<other/>", "<?xml version=\"1.0\"?>", "<report"] {
assert!(Format::detect(content).is_err(), "{content}");
}
}
#[test]
fn detects_go_coverprofile_in_every_mode() {
for mode in ["set", "count", "atomic"] {
let content = format!("mode: {mode}\nm/a.go:1.1,1.9 1 1\n");
assert_eq!(Format::detect(&content).unwrap(), Format::GoCoverprofile);
}
assert_eq!(
Format::detect("\n mode: set\n").unwrap(),
Format::GoCoverprofile
);
}
#[test]
fn an_unknown_go_mode_is_detected_so_the_parser_can_name_it() {
assert_eq!(
Format::detect("mode: sometimes\n").unwrap(),
Format::GoCoverprofile
);
let message = format!("{:#}", parse("mode: sometimes\n", None).unwrap_err());
assert!(message.contains("sometimes"), "{message}");
}
#[test]
fn unknown_format_errors() {
assert!(Format::detect("hello world").is_err());
assert!(Format::detect("").is_err());
}
#[test]
fn parse_dispatches_by_detection() {
let report = parse("SF:src/a.rs\nDA:1,2\nend_of_record\n", None).unwrap();
assert_eq!(report.hits("src/a.rs", 1), Some(2));
}
#[test]
fn display_names() {
assert_eq!(Format::Lcov.to_string(), "lcov");
assert_eq!(Format::LlvmCovJson.to_string(), "llvm-cov-json");
assert_eq!(Format::Cobertura.to_string(), "cobertura");
assert_eq!(Format::GoCoverprofile.to_string(), "go-coverprofile");
}
#[test]
fn parse_with_explicit_format_dispatches_each_parser() {
let lcov = parse("SF:a.rs\nDA:1,1\nend_of_record\n", Some(Format::Lcov)).unwrap();
assert_eq!(lcov.hits("a.rs", 1), Some(1));
let json = parse(
r#"{"data":[{"files":[{"filename":"a.rs","segments":[[1,1,3,true,true,false],[2,1,0,false,false,false]]}]}]}"#,
Some(Format::LlvmCovJson),
)
.unwrap();
assert_eq!(json.hits("a.rs", 1), Some(3));
let xml = parse(
r#"<coverage><packages><package><classes><class filename="a.rs"><lines><line number="1" hits="2"/></lines></class></classes></package></packages></coverage>"#,
Some(Format::Cobertura),
)
.unwrap();
assert_eq!(xml.hits("a.rs", 1), Some(2));
let go = parse(
"mode: count\nm/a.go:1.1,2.9 1 4\n",
Some(Format::GoCoverprofile),
)
.unwrap();
assert_eq!(go.hits("m/a.go", 2), Some(4));
}
#[test]
fn resolve_prefers_an_explicit_format_over_detection() {
assert_eq!(
resolve("mode: set\n", Some(Format::Lcov)).unwrap(),
Format::Lcov
);
assert_eq!(
resolve("mode: set\n", None).unwrap(),
Format::GoCoverprofile
);
assert!(resolve("", None).is_err());
}
#[test]
fn parse_propagates_parser_errors() {
assert!(parse("{ not json", None).is_err());
}
const LLVM_COV_GAP_LCOV: &str = "\
SF:/repo/src/lib.rs
DA:1,2
DA:2,2
DA:3,1
DA:5,1
DA:7,2
DA:9,1
DA:10,1
DA:11,1
DA:18,1
DA:19,1
DA:20,1
DA:21,1
DA:22,1
LF:14
LH:13
end_of_record";
const LLVM_COV_GAP_JSON: &str = r#"{"data":[{"files":[{"filename":"/repo/src/lib.rs",
"segments":[
[1,1,2,true,true,false],[1,51,0,false,false,false],[2,8,2,true,true,false],
[2,25,0,false,false,false],[3,9,1,true,true,false],[3,10,0,false,false,false],
[5,9,1,true,true,false],[5,10,0,false,false,false],[7,1,2,true,true,false],
[7,2,0,false,false,false],[9,1,1,true,true,false],[9,33,0,false,false,false],
[10,5,1,true,true,false],[10,6,0,false,false,false],[10,7,1,true,true,false],
[10,11,0,false,false,false],[10,14,1,true,true,false],[10,17,0,false,false,false],
[10,22,1,true,true,false],[10,23,0,false,false,false],[10,29,1,true,true,false],
[10,36,0,false,false,false],[11,1,1,true,true,false],[11,2,0,false,false,false],
[18,5,1,true,true,false],[18,11,0,false,false,false],[19,9,1,true,true,false],
[19,19,0,false,false,false],[19,20,1,true,true,false],[19,27,0,false,false,false],
[20,9,1,true,true,false],[20,19,0,false,false,false],[20,20,1,true,true,false],
[20,27,0,false,false,false],[21,9,1,true,true,false],[21,19,0,false,false,false],
[21,20,1,true,true,false],[21,24,0,false,false,false],[21,25,1,true,true,false],
[21,29,0,false,false,false],[21,32,1,true,true,false],[21,36,0,false,false,false],
[22,5,1,true,true,false],[22,6,0,false,false,false]],
"summary":{"lines":{"count":14,"covered":13,"percent":92.85714285714286}}}],
"totals":{"lines":{"count":14,"covered":13,"percent":92.85714285714286}}}],
"type":"llvm.coverage.json.export","version":"3.1.0"}"#;
#[test]
fn lcov_total_counts_da_records_not_lf_and_lh() {
assert!(LLVM_COV_GAP_LCOV.contains("\nLF:14\nLH:13\n"));
let report = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
assert_eq!(report.total_lines(), 13);
assert_eq!(report.covered_lines(), 13);
}
#[test]
fn llvm_json_total_is_rebuilt_from_segments_not_taken_from_summary() {
assert!(LLVM_COV_GAP_JSON.contains(r#""lines":{"count":14,"covered":13"#));
let report = parse(LLVM_COV_GAP_JSON, Some(Format::LlvmCovJson)).unwrap();
assert_eq!(report.total_lines(), 13);
assert_eq!(report.covered_lines(), 13);
}
#[test]
fn written_lcov_keeps_the_per_line_total() {
let report = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
let written = lcov::write(&report).unwrap();
let reread = parse(&written, Some(Format::Lcov)).unwrap();
assert_eq!(reread, report);
assert_eq!(reread.total_lines(), 13);
assert_eq!(reread.covered_lines(), 13);
assert!(written.contains("\nLF:13\nLH:13\n"), "{written}");
assert!(!written.contains("LF:14"), "{written}");
}
#[test]
fn lcov_and_llvm_json_agree_line_for_line() {
let lcov = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
let json = parse(LLVM_COV_GAP_JSON, Some(Format::LlvmCovJson)).unwrap();
assert_eq!(
lcov.files["/repo/src/lib.rs"].lines,
json.files["/repo/src/lib.rs"].lines
);
}
}