Skip to main content

omni_dev/coverage/
format.rs

1//! Coverage report format detection and parse dispatch.
2
3use std::fmt;
4
5use anyhow::{Context, Result};
6
7use super::model::CoverageReport;
8use super::{cobertura, lcov, llvm_json};
9
10/// A supported per-line coverage report format.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum Format {
13    /// lcov trace file (`DA`/`SF`/`end_of_record`).
14    Lcov,
15    /// llvm-cov JSON export (`cargo llvm-cov report --json`).
16    LlvmCovJson,
17    /// Cobertura XML.
18    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    /// Detects the format from report `content`.
34    ///
35    /// Detection is by leading non-whitespace character/token: XML opens with
36    /// `<`, JSON with `{`, and lcov with a record keyword (`TN:`/`SF:`).
37    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    /// Parses `content` according to this format.
60    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
69/// Parses `content` using `format`, auto-detecting when `format` is `None`.
70pub 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        // Detected as JSON but invalid → parse error with context.
152        assert!(parse("{ not json", None).is_err());
153    }
154
155    /// Real `cargo llvm-cov` 0.8.7 output (rustc 1.98, LLVM 22.1.8, edition 2021)
156    /// for this whole `src/lib.rs`, run under `cargo llvm-cov --no-report` and
157    /// then `cargo llvm-cov report --lcov` / `--json`:
158    ///
159    /// ```text
160    ///  1 pub fn generic<T: Default + PartialEq>(t: T) -> u8 {
161    ///  2     if t == T::default() {
162    ///  3         0
163    ///  4     } else {
164    ///  5         1
165    ///  6     }
166    ///  7 }
167    ///  8
168    ///  9 pub fn wrap(v: &[u8]) -> Vec<u8> {
169    /// 10     v.iter().map(|b| b + 1).collect()
170    /// 11 }
171    /// 12
172    /// 13 #[cfg(test)]
173    /// 14 mod tests {
174    /// 15     use super::*;
175    /// 16
176    /// 17     #[test]
177    /// 18     fn t() {
178    /// 19         assert_eq!(generic(0u8), 0);
179    /// 20         assert_eq!(generic(1u16), 1);
180    /// 21         assert_eq!(wrap(&[1]), vec![2]);
181    /// 22     }
182    /// 23 }
183    /// ```
184    ///
185    /// Trimmed to the records and fields that matter here: the lcov's `FN*`/`BR*`
186    /// records and the JSON's `functions`, `branches` and non-line summaries are
187    /// elided, and the absolute path is replaced by `/repo`.
188    ///
189    /// Two things in it make llvm-cov's own summary disagree with the per-line
190    /// records (#2131):
191    ///
192    /// - the closure `|b| b + 1` is a function record of its own that *also*
193    ///   maps line 10, which `wrap` maps too, so the summary counts line 10
194    ///   twice; and
195    /// - `generic` has two instantiations (`u8` takes line 3, `u16` line 5). The
196    ///   summary merges them by `max` (one line missed); the per-line view unions
197    ///   them (every line covered).
198    ///
199    /// So the summary — and the lcov `LF`/`LH` records, which are written from
200    /// it — say 14 lines, 13 covered, while the `DA` records and the JSON
201    /// `segments` hold 13 lines, all covered.
202    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    /// The JSON half of [`LLVM_COV_GAP_LCOV`].
222    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    /// `coverage diff`'s total is the per-line view — distinct lines — and not
244    /// the figure `llvm-cov report` prints. The lcov carries that figure too, in
245    /// `LF`/`LH`, so reading those instead of counting `DA` records would move
246    /// every `--fail-under-lines` gate; this pins that it does not.
247    #[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    /// The JSON export's `summary`/`totals` carry the summary's figure as well;
258    /// the parser must keep rebuilding lines from `segments` and ignore them.
259    #[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    /// An lcov written from the per-line view carries the same total on the way
270    /// back in, and its `LF`/`LH` agree with its `DA` records — where the real
271    /// output above disagrees with itself. This is what lets `coverage merge`
272    /// promise the total `coverage diff` computes (#2118).
273    #[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    /// Both real-world formats yield the same per-line view — the same
288    /// executable lines with the same hit counts — which is what lets a sharded
289    /// run mix them and a baseline in one format be compared with a head in the
290    /// other.
291    #[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}