1use anyhow::{Context, Result};
14use serde_json::Value;
15
16use super::model::{CoverageReport, FileCoverage};
17
18#[derive(Debug, Clone, Copy)]
20struct Segment {
21 line: u32,
22 count: u64,
23 has_count: bool,
24 is_region_entry: bool,
25 is_gap: bool,
26}
27
28pub fn parse(content: &str) -> Result<CoverageReport> {
30 let root: Value = serde_json::from_str(content).context("invalid llvm-cov JSON")?;
31 let data = root
32 .get("data")
33 .and_then(Value::as_array)
34 .context("llvm-cov JSON: missing `data` array")?;
35
36 let mut report = CoverageReport::new();
37 for export in data {
38 let Some(files) = export.get("files").and_then(Value::as_array) else {
39 continue;
40 };
41 for file in files {
42 let Some(filename) = file.get("filename").and_then(Value::as_str) else {
43 continue;
44 };
45 let segments = file
46 .get("segments")
47 .and_then(Value::as_array)
48 .map(|s| parse_segments(s))
49 .unwrap_or_default();
50 let coverage = reduce_segments(filename, &segments);
51 if !coverage.lines.is_empty() {
52 report.insert(coverage);
53 }
54 }
55 }
56 Ok(report)
57}
58
59fn parse_segments(raw: &[Value]) -> Vec<Segment> {
61 raw.iter()
62 .filter_map(|seg| {
63 let arr = seg.as_array()?;
64 Some(Segment {
65 line: arr.first()?.as_u64()? as u32,
66 count: count_of(arr.get(2)),
67 has_count: arr.get(3).and_then(Value::as_bool).unwrap_or(false),
68 is_region_entry: arr.get(4).and_then(Value::as_bool).unwrap_or(false),
69 is_gap: arr.get(5).and_then(Value::as_bool).unwrap_or(false),
70 })
71 })
72 .collect()
73}
74
75fn count_of(v: Option<&Value>) -> u64 {
77 match v {
78 Some(Value::Number(n)) => n
79 .as_u64()
80 .or_else(|| n.as_f64().map(|f| f.max(0.0) as u64))
81 .unwrap_or(0),
82 _ => 0,
83 }
84}
85
86fn reduce_segments(filename: &str, segments: &[Segment]) -> FileCoverage {
88 let mut file = FileCoverage::new(filename);
89 if segments.is_empty() {
90 return file;
91 }
92
93 let max_line = segments.iter().map(|s| s.line).max().unwrap_or(0);
94 let min_line = segments.iter().map(|s| s.line).min().unwrap_or(0);
95
96 let mut wrapped: Option<Segment> = None;
99 let mut seg_idx = 0usize;
100
101 for line in min_line..=max_line {
102 let start = seg_idx;
104 while seg_idx < segments.len() && segments[seg_idx].line == line {
105 seg_idx += 1;
106 }
107 let line_segments = &segments[start..seg_idx];
108
109 if let Some(count) = line_stat(wrapped.as_ref(), line_segments) {
110 file.record(line, count);
111 }
112
113 if let Some(last) = line_segments.last() {
114 wrapped = Some(*last);
115 }
116 }
117
118 file
119}
120
121fn line_stat(wrapped: Option<&Segment>, line_segments: &[Segment]) -> Option<u64> {
124 let is_start_of_region = |s: &Segment| !s.is_gap && s.has_count && s.is_region_entry;
125
126 let start_of_skipped = line_segments
127 .first()
128 .is_some_and(|s| !s.has_count && s.is_region_entry);
129
130 let min_region_count = line_segments
131 .iter()
132 .filter(|s| is_start_of_region(s))
133 .count();
134
135 let wrapped_has_count = wrapped.is_some_and(|w| w.has_count);
136 let mapped = !start_of_skipped && (wrapped_has_count || min_region_count > 0);
137 if !mapped {
138 return None;
139 }
140
141 let mut count = 0u64;
142 if let Some(w) = wrapped {
143 if w.has_count {
144 count = w.count;
145 }
146 }
147 for s in line_segments {
148 if is_start_of_region(s) {
149 count = count.max(s.count);
150 }
151 }
152 Some(count)
153}
154
155#[cfg(test)]
156#[allow(clippy::unwrap_used, clippy::expect_used)]
157mod tests {
158 use super::*;
159
160 fn export(files: Value) -> String {
161 serde_json::to_string(&serde_json::json!({
162 "data": [{ "files": files }],
163 "type": "llvm.coverage.json.export",
164 "version": "2.0.1"
165 }))
166 .unwrap()
167 }
168
169 #[test]
170 fn single_region_spans_multiple_lines() {
171 let json = export(serde_json::json!([{
173 "filename": "src/a.rs",
174 "segments": [
175 [1, 1, 3, true, true, false],
176 [4, 2, 0, false, false, false]
177 ]
178 }]));
179 let report = parse(&json).unwrap();
180 assert_eq!(report.hits("src/a.rs", 1), Some(3));
182 assert_eq!(report.hits("src/a.rs", 2), Some(3));
183 assert_eq!(report.hits("src/a.rs", 3), Some(3));
184 }
185
186 #[test]
187 fn uncovered_region() {
188 let json = export(serde_json::json!([{
189 "filename": "src/a.rs",
190 "segments": [
191 [10, 1, 0, true, true, false],
192 [12, 1, 0, false, false, false]
193 ]
194 }]));
195 let report = parse(&json).unwrap();
196 assert_eq!(report.hits("src/a.rs", 10), Some(0));
199 assert_eq!(report.hits("src/a.rs", 11), Some(0));
200 assert_eq!(report.hits("src/a.rs", 12), Some(0));
201 assert_eq!(report.covered_lines(), 0);
202 assert_eq!(report.total_lines(), 3);
203 }
204
205 #[test]
206 fn gap_region_is_not_a_region_start() {
207 let json = export(serde_json::json!([{
210 "filename": "src/a.rs",
211 "segments": [
212 [1, 1, 5, true, true, false],
213 [2, 1, 0, true, true, true],
214 [3, 1, 5, true, true, false],
215 [4, 1, 0, false, false, false]
216 ]
217 }]));
218 let report = parse(&json).unwrap();
219 assert_eq!(report.hits("src/a.rs", 1), Some(5));
220 assert_eq!(report.hits("src/a.rs", 3), Some(5));
221 }
222
223 #[test]
224 fn nested_region_takes_max() {
225 let json = export(serde_json::json!([{
227 "filename": "src/a.rs",
228 "segments": [
229 [1, 1, 2, true, true, false],
230 [2, 5, 7, true, true, false],
231 [2, 20, 2, true, false, false],
232 [3, 1, 0, false, false, false]
233 ]
234 }]));
235 let report = parse(&json).unwrap();
236 assert_eq!(report.hits("src/a.rs", 1), Some(2));
237 assert_eq!(report.hits("src/a.rs", 2), Some(7));
239 }
240
241 #[test]
242 fn empty_segments_yields_no_lines() {
243 let json = export(serde_json::json!([{ "filename": "src/a.rs", "segments": [] }]));
244 let report = parse(&json).unwrap();
245 assert!(report.files.is_empty());
246 }
247
248 #[test]
249 fn skips_files_without_filename_and_malformed_segments() {
250 let json = export(serde_json::json!([
251 { "segments": [[1, 1, 5, true, true, false]] }, {
253 "filename": "src/a.rs",
254 "segments": [
255 [], [1, 1, 5, true, true, false],
257 [2, 1, "x", true, true, false], [3, 1, 0, false, false, false]
259 ]
260 }
261 ]));
262 let report = parse(&json).unwrap();
263 assert_eq!(report.files.len(), 1);
264 assert_eq!(report.hits("src/a.rs", 1), Some(5));
265 assert_eq!(report.hits("src/a.rs", 2), Some(5));
268 }
269
270 #[test]
271 fn invalid_json_errors() {
272 assert!(parse("{ not json").is_err());
273 }
274
275 #[test]
276 fn missing_data_array_errors() {
277 assert!(parse(r#"{"version":"2"}"#).is_err());
278 }
279}