1use std::fmt::Write as _;
16
17use crate::run_view::{Metric, RunView};
18
19fn 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("<"),
26 '>' => out.push_str(">"),
27 '"' => out.push_str("""),
28 '\'' => out.push_str("'"),
29 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 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
53pub const FORMATS: &str = "lcov|cobertura|html";
58
59pub 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 other => Err(format!(
67 "unknown format {other}; Supercov exports {FORMATS}"
68 )),
69 }
70}
71
72pub 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 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
126pub 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 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 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 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 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 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 let xml_text = cobertura(&fixture(), 1_700_000_000);
366 assert!(
367 xml_text.contains("filename=\"src/a & 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 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 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}