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. LCOV has records of its own for them, which are
14//! written; Cobertura has none, and the evidence stays in the JSON view.
15
16use std::fmt::Write as _;
17
18use crate::run_view::{ConditionRecord, Metric, RunView};
19
20/// Escape text for XML character data and attribute values.
21fn xml(value: &str) -> String {
22    let mut out = String::with_capacity(value.len());
23    for character in value.chars() {
24        match character {
25            '&' => out.push_str("&"),
26            '<' => out.push_str("&lt;"),
27            '>' => out.push_str("&gt;"),
28            '"' => out.push_str("&quot;"),
29            '\'' => out.push_str("&apos;"),
30            // XML 1.0 cannot carry these at all; dropping them keeps the
31            // document parseable rather than emitting bytes no reader accepts.
32            c if (c < ' ' && c != '\t' && c != '\n' && c != '\r') => {}
33            c => out.push(c),
34        }
35    }
36    out
37}
38
39fn rate(covered: usize, total: usize) -> f64 {
40    if total == 0 {
41        // Cobertura has no "not applicable"; 1.0 is the convention for an
42        // empty denominator and is what every reader expects.
43        return 1.0;
44    }
45    covered as f64 / total as f64
46}
47
48fn counts(view: &RunView, metric: Metric) -> (usize, usize) {
49    view.metric(metric)
50        .map(|m| (m.covered, m.eligible))
51        .unwrap_or((0, 0))
52}
53
54/// The export formats, named once so the rest of the product never has to
55/// spell them. Supercov emits these formats; it never invokes the tools they
56/// are named after, and the packaging audit that enforces that reads this
57/// module as the single place allowed to name them.
58pub const FORMATS: &str = "lcov|cobertura|html";
59
60/// Render a run in the named format.
61pub fn export(view: &RunView, format: &str, timestamp: u64) -> Result<String, String> {
62    match format {
63        "lcov" => Ok(lcov(view)),
64        "cobertura" => Ok(cobertura(view, timestamp)),
65        // `html` is rendered by `coverage_html`, which also needs verified
66        // source text; the CLI routes it before reaching here.
67        other => Err(format!(
68            "unknown format {other}; Supercov exports {FORMATS}"
69        )),
70    }
71}
72
73/// A file's conditions as LCOV 2's `MCDC` records, and how many of them it
74/// counts found and hit.
75///
76/// LCOV asks of each condition whether it was shown to turn the outcome when
77/// it went from false to true, and from true to false, and counts each as
78/// one. Supercov counts a condition covered when a pair of evaluations shows
79/// it turning the outcome, which is both at once: a covered condition is two
80/// hit, one not covered is none, and hit over found is Supercov's own MC/DC.
81///
82/// A record names its condition by line, the size of its group and its place
83/// in the group, so two decisions of one size on one line would be one
84/// group to a reader. Where a line holds such decisions, all its conditions
85/// are written as one group; otherwise a decision is a group.
86fn mcdc_records(conditions: &[ConditionRecord], out: &mut String) -> (usize, usize) {
87    let (mut found, mut hit) = (0, 0);
88    for line in conditions.chunk_by(|left, right| left.line == right.line) {
89        let decisions = line
90            .chunk_by(|left, right| left.column == right.column)
91            .collect::<Vec<_>>();
92        let mut sizes = decisions.iter().map(|each| each.len()).collect::<Vec<_>>();
93        sizes.sort_unstable();
94        let apart = sizes.windows(2).all(|pair| pair[0] != pair[1]);
95        let mut groups = if apart { decisions } else { vec![line] };
96        groups.sort_by_key(|group| group.len());
97        for group in groups {
98            for (index, condition) in group.iter().enumerate() {
99                let named = condition
100                    .source
101                    .split_whitespace()
102                    .collect::<Vec<_>>()
103                    .join(" ");
104                let named = if named.is_empty() {
105                    format!("condition {index}")
106                } else {
107                    named
108                };
109                let shown = u8::from(condition.covered);
110                found += 2;
111                hit += 2 * usize::from(shown);
112                for sense in ['t', 'f'] {
113                    let _ = writeln!(
114                        out,
115                        "MCDC:{},{},{sense},{shown},{index},{named}",
116                        condition.line,
117                        group.len()
118                    );
119                }
120            }
121        }
122    }
123    (found, hit)
124}
125
126/// An LCOV tracefile: line, function and branch records, and LCOV 2's
127/// `MCDC` records, which a reader of an older format passes over.
128pub fn lcov(view: &RunView) -> String {
129    let mut out = String::new();
130    for file in &view.files {
131        let _ = writeln!(out, "TN:");
132        let _ = writeln!(out, "SF:{}", file.file);
133        for function in &file.functions {
134            let _ = writeln!(out, "FN:{},{}", function.line, function.name);
135        }
136        for function in &file.functions {
137            // Hit or not hit. Supercov does not count invocations, and writing
138            // a made-up frequency here would be displayed as one.
139            let _ = writeln!(out, "FNDA:{},{}", u8::from(function.covered), function.name);
140        }
141        let (covered_functions, functions) = file
142            .metric(Metric::Functions)
143            .map(|m| (m.covered, m.eligible))
144            .unwrap_or((0, 0));
145        let _ = writeln!(out, "FNF:{functions}");
146        let _ = writeln!(out, "FNH:{covered_functions}");
147        for branch in &file.branches {
148            let _ = writeln!(
149                out,
150                "BRDA:{},{},{},{}",
151                branch.line,
152                branch.block,
153                branch.index,
154                if branch.taken { "1" } else { "-" }
155            );
156        }
157        let (covered_branches, branches) = file
158            .metric(Metric::Branches)
159            .map(|m| (m.covered, m.eligible))
160            .unwrap_or((0, 0));
161        let _ = writeln!(out, "BRF:{branches}");
162        let _ = writeln!(out, "BRH:{covered_branches}");
163        let (conditions, shown) = mcdc_records(&file.conditions, &mut out);
164        if conditions > 0 {
165            let _ = writeln!(out, "MCF:{conditions}");
166            let _ = writeln!(out, "MCH:{shown}");
167        }
168        for (line, covered) in file.line_hits() {
169            let _ = writeln!(out, "DA:{line},{}", u8::from(covered));
170        }
171        let (covered_lines, lines) = file
172            .metric(Metric::Lines)
173            .map(|m| (m.covered, m.eligible))
174            .unwrap_or((0, 0));
175        let _ = writeln!(out, "LF:{lines}");
176        let _ = writeln!(out, "LH:{covered_lines}");
177        let _ = writeln!(out, "end_of_record");
178    }
179    out
180}
181
182/// A Cobertura XML report.
183pub fn cobertura(view: &RunView, timestamp: u64) -> String {
184    let (covered_lines, lines) = counts(view, Metric::Lines);
185    let (covered_branches, branches) = counts(view, Metric::Branches);
186    let mut out = String::new();
187    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
188    let _ = writeln!(
189        out,
190        "<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}\">",
191        rate(covered_lines, lines),
192        rate(covered_branches, branches),
193        xml(env!("CARGO_PKG_VERSION")),
194    );
195    // A single relative source root keeps filenames resolvable by readers that
196    // join them onto a checkout, and leaks no absolute path from this machine.
197    out.push_str("  <sources>\n    <source>.</source>\n  </sources>\n  <packages>\n");
198    let _ = writeln!(
199        out,
200        "    <package name=\"\" line-rate=\"{:.4}\" branch-rate=\"{:.4}\" complexity=\"0\">",
201        rate(covered_lines, lines),
202        rate(covered_branches, branches),
203    );
204    out.push_str("      <classes>\n");
205    for file in &view.files {
206        let (file_covered, file_lines) = file
207            .metric(Metric::Lines)
208            .map(|m| (m.covered, m.eligible))
209            .unwrap_or((0, 0));
210        let (branch_covered, branch_total) = file
211            .metric(Metric::Branches)
212            .map(|m| (m.covered, m.eligible))
213            .unwrap_or((0, 0));
214        let _ = writeln!(
215            out,
216            "        <class name=\"{}\" filename=\"{}\" line-rate=\"{:.4}\" branch-rate=\"{:.4}\" complexity=\"0\">",
217            xml(&file.file),
218            xml(&file.file),
219            rate(file_covered, file_lines),
220            rate(branch_covered, branch_total),
221        );
222        out.push_str("          <methods/>\n          <lines>\n");
223        for (line, covered) in file.line_hits() {
224            let alternatives = file
225                .branches
226                .iter()
227                .filter(|branch| branch.line == line)
228                .collect::<Vec<_>>();
229            if alternatives.is_empty() {
230                let _ = writeln!(
231                    out,
232                    "            <line number=\"{line}\" hits=\"{}\"/>",
233                    u8::from(covered)
234                );
235                continue;
236            }
237            let taken = alternatives.iter().filter(|branch| branch.taken).count();
238            let total = alternatives.len();
239            let _ = writeln!(
240                out,
241                "            <line number=\"{line}\" hits=\"{}\" branch=\"true\" condition-coverage=\"{}% ({taken}/{total})\"/>",
242                u8::from(covered),
243                (rate(taken, total) * 100.0).round() as u64,
244            );
245        }
246        out.push_str("          </lines>\n        </class>\n");
247    }
248    out.push_str("      </classes>\n    </package>\n  </packages>\n</coverage>\n");
249    out
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::run_view::{
256        Applicability, BranchRecord, FileView, FunctionRecord, MetricView, RUN_VIEW_SCHEMA_VERSION,
257        RunView,
258    };
259    use std::collections::BTreeSet;
260
261    fn metric(metric: Metric, covered: usize, eligible: usize) -> MetricView {
262        MetricView {
263            metric,
264            covered,
265            eligible,
266            applicability: if eligible == 0 {
267                Applicability::NotApplicable
268            } else {
269                Applicability::Measured
270            },
271        }
272    }
273
274    fn fixture() -> RunView {
275        RunView {
276            schema_version: RUN_VIEW_SCHEMA_VERSION,
277            run: "run_1".into(),
278            generated_at: "now".into(),
279            suite_passed: true,
280            stale: false,
281            stale_reasons: Vec::new(),
282            complete: true,
283            limitations: Vec::new(),
284            totals: vec![
285                metric(Metric::Lines, 3, 4),
286                metric(Metric::Branches, 1, 2),
287                metric(Metric::Functions, 1, 1),
288            ],
289            files: vec![FileView {
290                file: "src/a & b.ts".into(),
291                metrics: vec![
292                    metric(Metric::Lines, 3, 4),
293                    metric(Metric::Branches, 1, 2),
294                    metric(Metric::Functions, 1, 1),
295                ],
296                measured_lines: vec![1, 2, 3, 4],
297                uncovered_lines: vec![4],
298                missing_branches: Vec::new(),
299                missing_conditions: Vec::new(),
300                functions: vec![FunctionRecord {
301                    line: 1,
302                    name: "run".into(),
303                    covered: true,
304                }],
305                branches: vec![
306                    BranchRecord {
307                        line: 2,
308                        block: 0,
309                        index: 0,
310                        taken: true,
311                    },
312                    BranchRecord {
313                        line: 2,
314                        block: 0,
315                        index: 1,
316                        taken: false,
317                    },
318                ],
319                // Line 2 holds `a && b` with `a` shown and `b` not; line 3 two
320                // decisions of two conditions each, which a reader would take
321                // for one group.
322                conditions: [
323                    (2, 4, 0, true, "a"),
324                    (2, 4, 1, false, "b ==\n  1"),
325                    (3, 2, 0, true, "c"),
326                    (3, 2, 1, true, "d"),
327                    (3, 20, 0, false, "e"),
328                    (3, 20, 1, true, "f(1, 2)"),
329                ]
330                .into_iter()
331                .map(|(line, column, index, covered, source)| ConditionRecord {
332                    line,
333                    column,
334                    index,
335                    covered,
336                    source: source.into(),
337                })
338                .collect(),
339            }],
340            source_neighbourhoods: BTreeSet::new(),
341        }
342    }
343
344    #[test]
345    fn an_lcov_tracefile_states_hit_or_not_hit_and_never_a_frequency() {
346        // Supercov records that a line ran, not how often. A consumer displays
347        // `DA:` counts as execution frequencies, so writing anything above 1
348        // would publish a number no measurement supports.
349        let text = lcov(&fixture());
350        assert!(text.contains("SF:src/a & b.ts\n"), "{text}");
351        assert!(
352            text.contains("DA:1,1\n") && text.contains("DA:4,0\n"),
353            "{text}"
354        );
355        assert!(
356            text.lines().all(|line| !line.starts_with("DA:")
357                || line.ends_with(",1")
358                || line.ends_with(",0"))
359        );
360        assert!(text.contains("FNDA:1,run\n"), "{text}");
361        // An untaken branch is `-`, which readers distinguish from zero.
362        assert!(
363            text.contains("BRDA:2,0,0,1\n") && text.contains("BRDA:2,0,1,-\n"),
364            "{text}"
365        );
366        assert!(text.contains("LF:4\nLH:3\n"), "{text}");
367        assert!(text.contains("BRF:2\nBRH:1\n"), "{text}");
368        assert!(text.ends_with("end_of_record\n"));
369    }
370
371    #[test]
372    fn lcov_names_each_condition_and_counts_as_supercov_counts() {
373        let text = lcov(&fixture());
374        // A decision is a group: its line, its size, then each condition
375        // shown turning the outcome both ways or neither.
376        assert!(
377            text.contains(
378                "MCDC:2,2,t,1,0,a\nMCDC:2,2,f,1,0,a\nMCDC:2,2,t,0,1,b == 1\nMCDC:2,2,f,0,1,b == 1\n"
379            ),
380            "{text}"
381        );
382        // Two decisions of one size on a line are one group of four, so that
383        // no two conditions answer to one name.
384        assert!(
385            text.contains(
386                "MCDC:3,4,t,1,0,c\nMCDC:3,4,f,1,0,c\nMCDC:3,4,t,1,1,d\nMCDC:3,4,f,1,1,d\nMCDC:3,4,t,0,2,e\nMCDC:3,4,f,0,2,e\nMCDC:3,4,t,1,3,f(1, 2)\nMCDC:3,4,f,1,3,f(1, 2)\n"
387            ),
388            "{text}"
389        );
390        // Four of six conditions shown is eight of twelve, the same share.
391        assert!(text.contains("MCF:12\nMCH:8\n"), "{text}");
392        // Every record is one LCOV 2 reads: its own pattern for the line.
393        for record in text.lines().filter(|line| line.starts_with("MCDC:")) {
394            let fields = record["MCDC:".len()..].splitn(6, ',').collect::<Vec<_>>();
395            assert_eq!(fields.len(), 6, "{record}");
396            assert!(fields[0].parse::<usize>().is_ok_and(|line| line > 0));
397            assert!(fields[1].parse::<usize>().is_ok());
398            assert!(matches!(fields[2], "t" | "f"));
399            assert!(fields[3].parse::<usize>().is_ok());
400            assert!(fields[4].parse::<usize>().unwrap() < fields[1].parse::<usize>().unwrap());
401            assert!(!fields[5].is_empty() && !fields[5].contains('\n'));
402        }
403    }
404
405    #[test]
406    fn lcov_records_keep_the_order_readers_parse_them_in() {
407        // geninfo defines the sequence, and readers rely on it: a tracefile
408        // whose counters precede the records they summarise is accepted by
409        // some parsers and silently mis-read by others.
410        let text = lcov(&fixture());
411        let rank = |line: &str| match line.split(':').next().unwrap_or("") {
412            "TN" => 0,
413            "SF" => 1,
414            "FN" => 2,
415            "FNDA" => 3,
416            "FNF" => 4,
417            "FNH" => 5,
418            "BRDA" => 6,
419            "BRF" => 7,
420            "BRH" => 8,
421            "MCDC" => 9,
422            "MCF" => 10,
423            "MCH" => 11,
424            "DA" => 12,
425            "LF" => 13,
426            "LH" => 14,
427            _ => 15,
428        };
429        for record in text
430            .split("end_of_record\n")
431            .filter(|r| !r.trim().is_empty())
432        {
433            let ranks = record
434                .trim()
435                .lines()
436                .map(rank)
437                .filter(|rank| *rank < 12)
438                .collect::<Vec<_>>();
439            let mut sorted = ranks.clone();
440            sorted.sort_unstable();
441            assert_eq!(ranks, sorted, "out of order:\n{record}");
442        }
443    }
444
445    #[test]
446    fn totals_in_an_export_match_the_run_view_they_came_from() {
447        // The whole point of exporting from the shared view: a consumer's
448        // percentage has to be the one the gate enforced.
449        let view = fixture();
450        let text = lcov(&view);
451        let found: usize = text
452            .lines()
453            .filter_map(|line| line.strip_prefix("LF:")?.parse::<usize>().ok())
454            .sum();
455        let hit: usize = text
456            .lines()
457            .filter_map(|line| line.strip_prefix("LH:")?.parse::<usize>().ok())
458            .sum();
459        assert_eq!((hit, found), counts(&view, Metric::Lines));
460
461        let xml = cobertura(&view, 0);
462        assert!(
463            xml.contains("lines-covered=\"3\" lines-valid=\"4\""),
464            "{xml}"
465        );
466        assert!(
467            xml.contains("branches-covered=\"1\" branches-valid=\"2\""),
468            "{xml}"
469        );
470        assert!(xml.contains("line-rate=\"0.7500\""), "{xml}");
471    }
472
473    #[test]
474    fn cobertura_escapes_markup_and_keeps_paths_relative() {
475        // A filename carrying `&` or `<` would otherwise produce a document no
476        // reader can parse, and an absolute path would leak this machine's
477        // layout into a shared artifact.
478        let xml_text = cobertura(&fixture(), 1_700_000_000);
479        assert!(
480            xml_text.contains("filename=\"src/a &amp; b.ts\""),
481            "{xml_text}"
482        );
483        assert!(!xml_text.contains(" & "), "raw ampersand survived");
484        assert!(xml_text.contains("<source>.</source>"));
485        assert!(xml_text.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"));
486        // A branching line carries its condition detail; a plain line does not.
487        assert!(
488            xml_text.contains(
489                "<line number=\"2\" hits=\"1\" branch=\"true\" condition-coverage=\"50% (1/2)\"/>"
490            ),
491            "{xml_text}"
492        );
493        assert!(
494            xml_text.contains("<line number=\"1\" hits=\"1\"/>"),
495            "{xml_text}"
496        );
497        assert_eq!(
498            xml("a\u{0}b"),
499            "ab",
500            "unencodable control bytes are dropped"
501        );
502    }
503
504    #[test]
505    fn an_empty_denominator_does_not_become_a_zero_rate() {
506        // Cobertura has no "not applicable", and reporting 0% for a file with
507        // no branches would look like a regression that never happened.
508        assert_eq!(rate(0, 0), 1.0);
509        let mut view = fixture();
510        view.files[0].branches.clear();
511        view.files[0].metrics = vec![metric(Metric::Lines, 3, 4), metric(Metric::Branches, 0, 0)];
512        let xml_text = cobertura(&view, 0);
513        assert!(xml_text.contains("branch-rate=\"1.0000\""), "{xml_text}");
514    }
515}