1use std::fmt;
4
5use anyhow::{Context, Result};
6
7use super::model::CoverageReport;
8use super::{cobertura, lcov, llvm_json};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Format {
13 Lcov,
15 LlvmCovJson,
17 Cobertura,
19}
20
21impl fmt::Display for Format {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 let name = match self {
24 Self::Lcov => "lcov",
25 Self::LlvmCovJson => "llvm-cov-json",
26 Self::Cobertura => "cobertura",
27 };
28 f.write_str(name)
29 }
30}
31
32impl Format {
33 pub fn detect(content: &str) -> Result<Self> {
38 let trimmed = content.trim_start();
39 let first = trimmed
40 .chars()
41 .next()
42 .context("coverage report is empty; cannot detect format")?;
43 match first {
44 '<' => Ok(Self::Cobertura),
45 '{' | '[' => Ok(Self::LlvmCovJson),
46 _ if trimmed.starts_with("TN:")
47 || trimmed.starts_with("SF:")
48 || trimmed.starts_with("DA:") =>
49 {
50 Ok(Self::Lcov)
51 }
52 _ => anyhow::bail!(
53 "could not auto-detect coverage report format; pass an explicit --report-format \
54 (lcov, llvm-cov-json, or cobertura)"
55 ),
56 }
57 }
58
59 pub fn parse(self, content: &str) -> Result<CoverageReport> {
61 match self {
62 Self::Lcov => lcov::parse(content),
63 Self::LlvmCovJson => llvm_json::parse(content),
64 Self::Cobertura => cobertura::parse(content),
65 }
66 }
67}
68
69pub fn parse(content: &str, format: Option<Format>) -> Result<CoverageReport> {
71 let format = match format {
72 Some(f) => f,
73 None => Format::detect(content).context("coverage report format auto-detection failed")?,
74 };
75 format
76 .parse(content)
77 .with_context(|| format!("failed to parse {format} coverage report"))
78}
79
80#[cfg(test)]
81#[allow(clippy::unwrap_used, clippy::expect_used)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn detects_lcov() {
87 assert_eq!(
88 Format::detect("SF:src/a.rs\nDA:1,1\n").unwrap(),
89 Format::Lcov
90 );
91 assert_eq!(Format::detect("TN:\nSF:x\n").unwrap(), Format::Lcov);
92 }
93
94 #[test]
95 fn detects_json() {
96 assert_eq!(
97 Format::detect(" {\"data\":[]}").unwrap(),
98 Format::LlvmCovJson
99 );
100 }
101
102 #[test]
103 fn detects_cobertura() {
104 assert_eq!(
105 Format::detect("<?xml version=\"1.0\"?><coverage/>").unwrap(),
106 Format::Cobertura
107 );
108 }
109
110 #[test]
111 fn unknown_format_errors() {
112 assert!(Format::detect("hello world").is_err());
113 assert!(Format::detect("").is_err());
114 }
115
116 #[test]
117 fn parse_dispatches_by_detection() {
118 let report = parse("SF:src/a.rs\nDA:1,2\nend_of_record\n", None).unwrap();
119 assert_eq!(report.hits("src/a.rs", 1), Some(2));
120 }
121
122 #[test]
123 fn display_names() {
124 assert_eq!(Format::Lcov.to_string(), "lcov");
125 assert_eq!(Format::LlvmCovJson.to_string(), "llvm-cov-json");
126 assert_eq!(Format::Cobertura.to_string(), "cobertura");
127 }
128
129 #[test]
130 fn parse_with_explicit_format_dispatches_each_parser() {
131 let lcov = parse("SF:a.rs\nDA:1,1\nend_of_record\n", Some(Format::Lcov)).unwrap();
132 assert_eq!(lcov.hits("a.rs", 1), Some(1));
133
134 let json = parse(
135 r#"{"data":[{"files":[{"filename":"a.rs","segments":[[1,1,3,true,true,false],[2,1,0,false,false,false]]}]}]}"#,
136 Some(Format::LlvmCovJson),
137 )
138 .unwrap();
139 assert_eq!(json.hits("a.rs", 1), Some(3));
140
141 let xml = parse(
142 r#"<coverage><packages><package><classes><class filename="a.rs"><lines><line number="1" hits="2"/></lines></class></classes></package></packages></coverage>"#,
143 Some(Format::Cobertura),
144 )
145 .unwrap();
146 assert_eq!(xml.hits("a.rs", 1), Some(2));
147 }
148
149 #[test]
150 fn parse_propagates_parser_errors() {
151 assert!(parse("{ not json", None).is_err());
153 }
154
155 const LLVM_COV_GAP_LCOV: &str = "\
203SF:/repo/src/lib.rs
204DA:1,2
205DA:2,2
206DA:3,1
207DA:5,1
208DA:7,2
209DA:9,1
210DA:10,1
211DA:11,1
212DA:18,1
213DA:19,1
214DA:20,1
215DA:21,1
216DA:22,1
217LF:14
218LH:13
219end_of_record";
220
221 const LLVM_COV_GAP_JSON: &str = r#"{"data":[{"files":[{"filename":"/repo/src/lib.rs",
223"segments":[
224[1,1,2,true,true,false],[1,51,0,false,false,false],[2,8,2,true,true,false],
225[2,25,0,false,false,false],[3,9,1,true,true,false],[3,10,0,false,false,false],
226[5,9,1,true,true,false],[5,10,0,false,false,false],[7,1,2,true,true,false],
227[7,2,0,false,false,false],[9,1,1,true,true,false],[9,33,0,false,false,false],
228[10,5,1,true,true,false],[10,6,0,false,false,false],[10,7,1,true,true,false],
229[10,11,0,false,false,false],[10,14,1,true,true,false],[10,17,0,false,false,false],
230[10,22,1,true,true,false],[10,23,0,false,false,false],[10,29,1,true,true,false],
231[10,36,0,false,false,false],[11,1,1,true,true,false],[11,2,0,false,false,false],
232[18,5,1,true,true,false],[18,11,0,false,false,false],[19,9,1,true,true,false],
233[19,19,0,false,false,false],[19,20,1,true,true,false],[19,27,0,false,false,false],
234[20,9,1,true,true,false],[20,19,0,false,false,false],[20,20,1,true,true,false],
235[20,27,0,false,false,false],[21,9,1,true,true,false],[21,19,0,false,false,false],
236[21,20,1,true,true,false],[21,24,0,false,false,false],[21,25,1,true,true,false],
237[21,29,0,false,false,false],[21,32,1,true,true,false],[21,36,0,false,false,false],
238[22,5,1,true,true,false],[22,6,0,false,false,false]],
239"summary":{"lines":{"count":14,"covered":13,"percent":92.85714285714286}}}],
240"totals":{"lines":{"count":14,"covered":13,"percent":92.85714285714286}}}],
241"type":"llvm.coverage.json.export","version":"3.1.0"}"#;
242
243 #[test]
248 fn lcov_total_counts_da_records_not_lf_and_lh() {
249 assert!(LLVM_COV_GAP_LCOV.contains("\nLF:14\nLH:13\n"));
250
251 let report = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
252
253 assert_eq!(report.total_lines(), 13);
254 assert_eq!(report.covered_lines(), 13);
255 }
256
257 #[test]
260 fn llvm_json_total_is_rebuilt_from_segments_not_taken_from_summary() {
261 assert!(LLVM_COV_GAP_JSON.contains(r#""lines":{"count":14,"covered":13"#));
262
263 let report = parse(LLVM_COV_GAP_JSON, Some(Format::LlvmCovJson)).unwrap();
264
265 assert_eq!(report.total_lines(), 13);
266 assert_eq!(report.covered_lines(), 13);
267 }
268
269 #[test]
274 fn written_lcov_keeps_the_per_line_total() {
275 let report = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
276
277 let written = lcov::write(&report).unwrap();
278 let reread = parse(&written, Some(Format::Lcov)).unwrap();
279
280 assert_eq!(reread, report);
281 assert_eq!(reread.total_lines(), 13);
282 assert_eq!(reread.covered_lines(), 13);
283 assert!(written.contains("\nLF:13\nLH:13\n"), "{written}");
284 assert!(!written.contains("LF:14"), "{written}");
285 }
286
287 #[test]
292 fn lcov_and_llvm_json_agree_line_for_line() {
293 let lcov = parse(LLVM_COV_GAP_LCOV, Some(Format::Lcov)).unwrap();
294 let json = parse(LLVM_COV_GAP_JSON, Some(Format::LlvmCovJson)).unwrap();
295
296 assert_eq!(
297 lcov.files["/repo/src/lib.rs"].lines,
298 json.files["/repo/src/lib.rs"].lines
299 );
300 }
301}