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}