Skip to main content

supercov_engine/
coverage_export.rs

1//! LCOV and Cobertura exports, written from the shared run view.
2//!
3//! These exist for compatibility, not as a headline: they let Supercov feed
4//! the viewers, hosted services and CI integrations a team already uses. Both
5//! read the same view the CLI and the gates read, so a consumer's totals match
6//! what `check` enforced.
7//!
8//! Two things are deliberately not done. Supercov records whether a line ran,
9//! not how many times, so an export states `1` or `0` and never invents an
10//! execution frequency a consumer would display as fact. And MC/DC conditions
11//! are not flattened into ordinary branches to fit a simpler model: a consumer
12//! would then report condition obligations as branch coverage, which is a
13//! different measurement. The richer evidence stays in the JSON view.
14
15use std::fmt::Write as _;
16
17use crate::run_view::{Metric, RunView};
18
19/// Escape text for XML character data and attribute values.
20fn xml(value: &str) -> String {
21    let mut out = String::with_capacity(value.len());
22    for character in value.chars() {
23        match character {
24            '&' => out.push_str("&"),
25            '<' => out.push_str("&lt;"),
26            '>' => out.push_str("&gt;"),
27            '"' => out.push_str("&quot;"),
28            '\'' => out.push_str("&apos;"),
29            // XML 1.0 cannot carry these at all; dropping them keeps the
30            // document parseable rather than emitting bytes no reader accepts.
31            c if (c < ' ' && c != '\t' && c != '\n' && c != '\r') => {}
32            c => out.push(c),
33        }
34    }
35    out
36}
37
38fn rate(covered: usize, total: usize) -> f64 {
39    if total == 0 {
40        // Cobertura has no "not applicable"; 1.0 is the convention for an
41        // empty denominator and is what every reader expects.
42        return 1.0;
43    }
44    covered as f64 / total as f64
45}
46
47fn counts(view: &RunView, metric: Metric) -> (usize, usize) {
48    view.metric(metric)
49        .map(|m| (m.covered, m.eligible))
50        .unwrap_or((0, 0))
51}
52
53/// The export formats, named once so the rest of the product never has to
54/// spell them. Supercov emits these formats; it never invokes the tools they
55/// are named after, and the packaging audit that enforces that reads this
56/// module as the single place allowed to name them.
57pub const FORMATS: &str = "lcov|cobertura|html";
58
59/// Render a run in the named format.
60pub fn export(view: &RunView, format: &str, timestamp: u64) -> Result<String, String> {
61    match format {
62        "lcov" => Ok(lcov(view)),
63        "cobertura" => Ok(cobertura(view, timestamp)),
64        // `html` is rendered by `coverage_html`, which also needs verified
65        // source text; the CLI routes it before reaching here.
66        other => Err(format!(
67            "unknown format {other}; Supercov exports {FORMATS}"
68        )),
69    }
70}
71
72/// An LCOV tracefile.
73///
74/// Line, function and branch records only. Modern LCOV does have an `MCDC`
75/// record, but faithful mapping from Supercov's condition evidence and support
76/// in downstream readers both need verifying before claiming it.
77pub fn lcov(view: &RunView) -> String {
78    let mut out = String::new();
79    for file in &view.files {
80        let _ = writeln!(out, "TN:");
81        let _ = writeln!(out, "SF:{}", file.file);
82        for function in &file.functions {
83            let _ = writeln!(out, "FN:{},{}", function.line, function.name);
84        }
85        for function in &file.functions {
86            // Hit or not hit. Supercov does not count invocations, and writing
87            // a made-up frequency here would be displayed as one.
88            let _ = writeln!(out, "FNDA:{},{}", u8::from(function.covered), function.name);
89        }
90        let (covered_functions, functions) = file
91            .metric(Metric::Functions)
92            .map(|m| (m.covered, m.eligible))
93            .unwrap_or((0, 0));
94        let _ = writeln!(out, "FNF:{functions}");
95        let _ = writeln!(out, "FNH:{covered_functions}");
96        for branch in &file.branches {
97            let _ = writeln!(
98                out,
99                "BRDA:{},{},{},{}",
100                branch.line,
101                branch.block,
102                branch.index,
103                if branch.taken { "1" } else { "-" }
104            );
105        }
106        let (covered_branches, branches) = file
107            .metric(Metric::Branches)
108            .map(|m| (m.covered, m.eligible))
109            .unwrap_or((0, 0));
110        let _ = writeln!(out, "BRF:{branches}");
111        let _ = writeln!(out, "BRH:{covered_branches}");
112        for (line, covered) in file.line_hits() {
113            let _ = writeln!(out, "DA:{line},{}", u8::from(covered));
114        }
115        let (covered_lines, lines) = file
116            .metric(Metric::Lines)
117            .map(|m| (m.covered, m.eligible))
118            .unwrap_or((0, 0));
119        let _ = writeln!(out, "LF:{lines}");
120        let _ = writeln!(out, "LH:{covered_lines}");
121        let _ = writeln!(out, "end_of_record");
122    }
123    out
124}
125
126/// A Cobertura XML report.
127pub fn cobertura(view: &RunView, timestamp: u64) -> String {
128    let (covered_lines, lines) = counts(view, Metric::Lines);
129    let (covered_branches, branches) = counts(view, Metric::Branches);
130    let mut out = String::new();
131    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
132    let _ = writeln!(
133        out,
134        "<coverage line-rate=\"{:.4}\" branch-rate=\"{:.4}\" lines-covered=\"{covered_lines}\" lines-valid=\"{lines}\" branches-covered=\"{covered_branches}\" branches-valid=\"{branches}\" complexity=\"0\" version=\"{}\" timestamp=\"{timestamp}\">",
135        rate(covered_lines, lines),
136        rate(covered_branches, branches),
137        xml(env!("CARGO_PKG_VERSION")),
138    );
139    // A single relative source root keeps filenames resolvable by readers that
140    // join them onto a checkout, and leaks no absolute path from this machine.
141    out.push_str("  <sources>\n    <source>.</source>\n  </sources>\n  <packages>\n");
142    let _ = writeln!(
143        out,
144        "    <package name=\"\" line-rate=\"{:.4}\" branch-rate=\"{:.4}\" complexity=\"0\">",
145        rate(covered_lines, lines),
146        rate(covered_branches, branches),
147    );
148    out.push_str("      <classes>\n");
149    for file in &view.files {
150        let (file_covered, file_lines) = file
151            .metric(Metric::Lines)
152            .map(|m| (m.covered, m.eligible))
153            .unwrap_or((0, 0));
154        let (branch_covered, branch_total) = file
155            .metric(Metric::Branches)
156            .map(|m| (m.covered, m.eligible))
157            .unwrap_or((0, 0));
158        let _ = writeln!(
159            out,
160            "        <class name=\"{}\" filename=\"{}\" line-rate=\"{:.4}\" branch-rate=\"{:.4}\" complexity=\"0\">",
161            xml(&file.file),
162            xml(&file.file),
163            rate(file_covered, file_lines),
164            rate(branch_covered, branch_total),
165        );
166        out.push_str("          <methods/>\n          <lines>\n");
167        for (line, covered) in file.line_hits() {
168            let alternatives = file
169                .branches
170                .iter()
171                .filter(|branch| branch.line == line)
172                .collect::<Vec<_>>();
173            if alternatives.is_empty() {
174                let _ = writeln!(
175                    out,
176                    "            <line number=\"{line}\" hits=\"{}\"/>",
177                    u8::from(covered)
178                );
179                continue;
180            }
181            let taken = alternatives.iter().filter(|branch| branch.taken).count();
182            let total = alternatives.len();
183            let _ = writeln!(
184                out,
185                "            <line number=\"{line}\" hits=\"{}\" branch=\"true\" condition-coverage=\"{}% ({taken}/{total})\"/>",
186                u8::from(covered),
187                (rate(taken, total) * 100.0).round() as u64,
188            );
189        }
190        out.push_str("          </lines>\n        </class>\n");
191    }
192    out.push_str("      </classes>\n    </package>\n  </packages>\n</coverage>\n");
193    out
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use crate::run_view::{
200        Applicability, BranchRecord, FileView, FunctionRecord, MetricView, RUN_VIEW_SCHEMA_VERSION,
201        RunView,
202    };
203    use std::collections::BTreeSet;
204
205    fn metric(metric: Metric, covered: usize, eligible: usize) -> MetricView {
206        MetricView {
207            metric,
208            covered,
209            eligible,
210            applicability: if eligible == 0 {
211                Applicability::NotApplicable
212            } else {
213                Applicability::Measured
214            },
215        }
216    }
217
218    fn fixture() -> RunView {
219        RunView {
220            schema_version: RUN_VIEW_SCHEMA_VERSION,
221            run: "run_1".into(),
222            generated_at: "now".into(),
223            suite_passed: true,
224            stale: false,
225            stale_reasons: Vec::new(),
226            complete: true,
227            limitations: Vec::new(),
228            totals: vec![
229                metric(Metric::Lines, 3, 4),
230                metric(Metric::Branches, 1, 2),
231                metric(Metric::Functions, 1, 1),
232            ],
233            files: vec![FileView {
234                file: "src/a & b.ts".into(),
235                metrics: vec![
236                    metric(Metric::Lines, 3, 4),
237                    metric(Metric::Branches, 1, 2),
238                    metric(Metric::Functions, 1, 1),
239                ],
240                measured_lines: vec![1, 2, 3, 4],
241                uncovered_lines: vec![4],
242                missing_branches: Vec::new(),
243                missing_conditions: Vec::new(),
244                functions: vec![FunctionRecord {
245                    line: 1,
246                    name: "run".into(),
247                    covered: true,
248                }],
249                branches: vec![
250                    BranchRecord {
251                        line: 2,
252                        block: 0,
253                        index: 0,
254                        taken: true,
255                    },
256                    BranchRecord {
257                        line: 2,
258                        block: 0,
259                        index: 1,
260                        taken: false,
261                    },
262                ],
263            }],
264            source_neighbourhoods: BTreeSet::new(),
265        }
266    }
267
268    #[test]
269    fn an_lcov_tracefile_states_hit_or_not_hit_and_never_a_frequency() {
270        // Supercov records that a line ran, not how often. A consumer displays
271        // `DA:` counts as execution frequencies, so writing anything above 1
272        // would publish a number no measurement supports.
273        let text = lcov(&fixture());
274        assert!(text.contains("SF:src/a & b.ts\n"), "{text}");
275        assert!(
276            text.contains("DA:1,1\n") && text.contains("DA:4,0\n"),
277            "{text}"
278        );
279        assert!(
280            text.lines().all(|line| !line.starts_with("DA:")
281                || line.ends_with(",1")
282                || line.ends_with(",0"))
283        );
284        assert!(text.contains("FNDA:1,run\n"), "{text}");
285        // An untaken branch is `-`, which readers distinguish from zero.
286        assert!(
287            text.contains("BRDA:2,0,0,1\n") && text.contains("BRDA:2,0,1,-\n"),
288            "{text}"
289        );
290        assert!(text.contains("LF:4\nLH:3\n"), "{text}");
291        assert!(text.contains("BRF:2\nBRH:1\n"), "{text}");
292        assert!(text.ends_with("end_of_record\n"));
293    }
294
295    #[test]
296    fn lcov_records_keep_the_order_readers_parse_them_in() {
297        // geninfo defines the sequence, and readers rely on it: a tracefile
298        // whose counters precede the records they summarise is accepted by
299        // some parsers and silently mis-read by others.
300        let text = lcov(&fixture());
301        let rank = |line: &str| match line.split(':').next().unwrap_or("") {
302            "TN" => 0,
303            "SF" => 1,
304            "FN" => 2,
305            "FNDA" => 3,
306            "FNF" => 4,
307            "FNH" => 5,
308            "BRDA" => 6,
309            "BRF" => 7,
310            "BRH" => 8,
311            "DA" => 9,
312            "LF" => 10,
313            "LH" => 11,
314            _ => 12,
315        };
316        for record in text
317            .split("end_of_record\n")
318            .filter(|r| !r.trim().is_empty())
319        {
320            let ranks = record
321                .trim()
322                .lines()
323                .map(rank)
324                .filter(|rank| *rank < 12)
325                .collect::<Vec<_>>();
326            let mut sorted = ranks.clone();
327            sorted.sort_unstable();
328            assert_eq!(ranks, sorted, "out of order:\n{record}");
329        }
330    }
331
332    #[test]
333    fn totals_in_an_export_match_the_run_view_they_came_from() {
334        // The whole point of exporting from the shared view: a consumer's
335        // percentage has to be the one the gate enforced.
336        let view = fixture();
337        let text = lcov(&view);
338        let found: usize = text
339            .lines()
340            .filter_map(|line| line.strip_prefix("LF:")?.parse::<usize>().ok())
341            .sum();
342        let hit: usize = text
343            .lines()
344            .filter_map(|line| line.strip_prefix("LH:")?.parse::<usize>().ok())
345            .sum();
346        assert_eq!((hit, found), counts(&view, Metric::Lines));
347
348        let xml = cobertura(&view, 0);
349        assert!(
350            xml.contains("lines-covered=\"3\" lines-valid=\"4\""),
351            "{xml}"
352        );
353        assert!(
354            xml.contains("branches-covered=\"1\" branches-valid=\"2\""),
355            "{xml}"
356        );
357        assert!(xml.contains("line-rate=\"0.7500\""), "{xml}");
358    }
359
360    #[test]
361    fn cobertura_escapes_markup_and_keeps_paths_relative() {
362        // A filename carrying `&` or `<` would otherwise produce a document no
363        // reader can parse, and an absolute path would leak this machine's
364        // layout into a shared artifact.
365        let xml_text = cobertura(&fixture(), 1_700_000_000);
366        assert!(
367            xml_text.contains("filename=\"src/a &amp; b.ts\""),
368            "{xml_text}"
369        );
370        assert!(!xml_text.contains(" & "), "raw ampersand survived");
371        assert!(xml_text.contains("<source>.</source>"));
372        assert!(xml_text.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
373        // A branching line carries its condition detail; a plain line does not.
374        assert!(
375            xml_text.contains(
376                "<line number=\"2\" hits=\"1\" branch=\"true\" condition-coverage=\"50% (1/2)\"/>"
377            ),
378            "{xml_text}"
379        );
380        assert!(
381            xml_text.contains("<line number=\"1\" hits=\"1\"/>"),
382            "{xml_text}"
383        );
384        assert_eq!(
385            xml("a\u{0}b"),
386            "ab",
387            "unencodable control bytes are dropped"
388        );
389    }
390
391    #[test]
392    fn an_empty_denominator_does_not_become_a_zero_rate() {
393        // Cobertura has no "not applicable", and reporting 0% for a file with
394        // no branches would look like a regression that never happened.
395        assert_eq!(rate(0, 0), 1.0);
396        let mut view = fixture();
397        view.files[0].branches.clear();
398        view.files[0].metrics = vec![metric(Metric::Lines, 3, 4), metric(Metric::Branches, 0, 0)];
399        let xml_text = cobertura(&view, 0);
400        assert!(xml_text.contains("branch-rate=\"1.0000\""), "{xml_text}");
401    }
402}