1use std::fmt::Write as _;
17
18use crate::run_view::{ConditionRecord, Metric, RunView};
19
20fn 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("<"),
27 '>' => out.push_str(">"),
28 '"' => out.push_str("""),
29 '\'' => out.push_str("'"),
30 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 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
54pub const FORMATS: &str = "lcov|cobertura|html";
59
60pub 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 other => Err(format!(
68 "unknown format {other}; Supercov exports {FORMATS}"
69 )),
70 }
71}
72
73fn 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
126pub 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 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
182pub 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 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 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 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 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 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 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 assert!(text.contains("MCF:12\nMCH:8\n"), "{text}");
392 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 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 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 let xml_text = cobertura(&fixture(), 1_700_000_000);
479 assert!(
480 xml_text.contains("filename=\"src/a & 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 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 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}