jugar_probar/coverage/formatters/
lcov.rs1use crate::coverage::CoverageReport;
21use crate::result::ProbarResult;
22use std::collections::BTreeMap;
23use std::path::Path;
24
25#[derive(Debug)]
27pub struct LcovFormatter<'a> {
28 report: &'a CoverageReport,
29 test_name: Option<String>,
30}
31
32impl<'a> LcovFormatter<'a> {
33 #[must_use]
35 pub fn new(report: &'a CoverageReport) -> Self {
36 Self {
37 report,
38 test_name: report.session_name().map(String::from),
39 }
40 }
41
42 #[must_use]
44 pub fn with_test_name(mut self, name: impl Into<String>) -> Self {
45 self.test_name = Some(name.into());
46 self
47 }
48
49 #[must_use]
51 pub fn generate(&self) -> String {
52 use std::fmt::Write;
53
54 let mut output = String::new();
55
56 if let Some(ref name) = self.test_name {
58 let _ = writeln!(output, "TN:{name}");
59 } else {
60 output.push_str("TN:\n");
61 }
62
63 let files = self.group_by_file();
65
66 for (file, blocks) in &files {
67 let _ = writeln!(output, "SF:{file}");
69
70 let functions = Self::extract_functions(blocks);
72 let mut functions_hit = 0;
73
74 for (func_name, (line, count)) in &functions {
75 let _ = writeln!(output, "FN:{line},{func_name}");
76 let _ = writeln!(output, "FNDA:{count},{func_name}");
77 if *count > 0 {
78 functions_hit += 1;
79 }
80 }
81
82 let _ = writeln!(output, "FNF:{}", functions.len());
84 let _ = writeln!(output, "FNH:{functions_hit}");
85
86 let lines = Self::extract_lines(blocks);
88 let mut lines_hit = 0;
89
90 for (line, count) in &lines {
91 let _ = writeln!(output, "DA:{line},{count}");
92 if *count > 0 {
93 lines_hit += 1;
94 }
95 }
96
97 let _ = writeln!(output, "LF:{}", lines.len());
99 let _ = writeln!(output, "LH:{lines_hit}");
100
101 output.push_str("end_of_record\n");
102 }
103
104 output
105 }
106
107 pub fn save(&self, path: &Path) -> ProbarResult<()> {
113 let content = self.generate();
114 std::fs::write(path, content)?;
115 Ok(())
116 }
117
118 fn group_by_file(&self) -> BTreeMap<String, Vec<(u32, u64, Option<String>)>> {
120 let mut files: BTreeMap<String, Vec<(u32, u64, Option<String>)>> = BTreeMap::new();
121
122 for block in self.report.block_coverages() {
123 let file = block.source_location.as_ref().map_or_else(
124 || "unknown".to_string(),
125 |loc| {
126 loc.split(':').next().unwrap_or("unknown").to_string()
128 },
129 );
130
131 let line = block.source_location.as_ref().map_or(0, |loc| {
132 loc.split(':')
133 .nth(1)
134 .and_then(|l| l.parse().ok())
135 .unwrap_or(0)
136 });
137
138 files
139 .entry(file)
140 .or_default()
141 .push((line, block.hit_count, block.function_name));
142 }
143
144 files
145 }
146
147 fn extract_functions(blocks: &[(u32, u64, Option<String>)]) -> BTreeMap<String, (u32, u64)> {
149 let mut functions = BTreeMap::new();
150
151 for (line, count, func_name) in blocks {
152 if let Some(ref name) = func_name {
153 let entry = functions.entry(name.clone()).or_insert((*line, 0));
154 entry.1 += count;
155 }
156 }
157
158 functions
159 }
160
161 fn extract_lines(blocks: &[(u32, u64, Option<String>)]) -> BTreeMap<u32, u64> {
163 let mut lines = BTreeMap::new();
164
165 for (line, count, _) in blocks {
166 if *line > 0 {
167 *lines.entry(*line).or_insert(0) += count;
168 }
169 }
170
171 lines
172 }
173}
174
175#[cfg(test)]
176#[allow(clippy::unwrap_used, clippy::expect_used)]
177mod tests {
178 use super::*;
179 use crate::coverage::BlockId;
180
181 fn create_test_report() -> CoverageReport {
182 let mut report = CoverageReport::new(5);
183 report.set_session_name("test_session");
184
185 report.record_hits(BlockId::new(0), 10);
187 report.record_hits(BlockId::new(1), 5);
188 report.record_hits(BlockId::new(2), 0);
189 report.record_hits(BlockId::new(3), 3);
190 report.record_hits(BlockId::new(4), 0);
191
192 report.set_source_location(BlockId::new(0), "src/game.rs:10");
194 report.set_source_location(BlockId::new(1), "src/game.rs:15");
195 report.set_source_location(BlockId::new(2), "src/game.rs:20");
196 report.set_source_location(BlockId::new(3), "src/player.rs:5");
197 report.set_source_location(BlockId::new(4), "src/player.rs:10");
198
199 report.set_function_name(BlockId::new(0), "main");
201 report.set_function_name(BlockId::new(1), "main");
202 report.set_function_name(BlockId::new(2), "update");
203 report.set_function_name(BlockId::new(3), "move_player");
204 report.set_function_name(BlockId::new(4), "move_player");
205
206 report
207 }
208
209 #[test]
210 fn test_lcov_formatter_new() {
211 let report = CoverageReport::new(10);
212 let formatter = LcovFormatter::new(&report);
213 assert!(formatter.test_name.is_none());
214 }
215
216 #[test]
217 fn test_lcov_formatter_with_test_name() {
218 let report = CoverageReport::new(10);
219 let formatter = LcovFormatter::new(&report).with_test_name("my_test");
220 assert_eq!(formatter.test_name, Some("my_test".to_string()));
221 }
222
223 #[test]
224 fn test_generate_empty_report() {
225 let report = CoverageReport::new(0);
226 let formatter = LcovFormatter::new(&report);
227 let output = formatter.generate();
228
229 assert!(output.contains("TN:"));
230 }
231
232 #[test]
233 fn test_generate_with_test_name() {
234 let report = create_test_report();
235 let formatter = LcovFormatter::new(&report);
236 let output = formatter.generate();
237
238 assert!(output.contains("TN:test_session"));
239 }
240
241 #[test]
242 fn test_generate_contains_source_files() {
243 let report = create_test_report();
244 let formatter = LcovFormatter::new(&report);
245 let output = formatter.generate();
246
247 assert!(output.contains("SF:src/game.rs"));
248 assert!(output.contains("SF:src/player.rs"));
249 }
250
251 #[test]
252 fn test_generate_contains_functions() {
253 let report = create_test_report();
254 let formatter = LcovFormatter::new(&report);
255 let output = formatter.generate();
256
257 assert!(output.contains("FN:"));
258 assert!(output.contains("FNDA:"));
259 assert!(output.contains("FNF:"));
260 assert!(output.contains("FNH:"));
261 }
262
263 #[test]
264 fn test_generate_contains_line_data() {
265 let report = create_test_report();
266 let formatter = LcovFormatter::new(&report);
267 let output = formatter.generate();
268
269 assert!(output.contains("DA:"));
270 assert!(output.contains("LF:"));
271 assert!(output.contains("LH:"));
272 }
273
274 #[test]
275 fn test_generate_contains_end_of_record() {
276 let report = create_test_report();
277 let formatter = LcovFormatter::new(&report);
278 let output = formatter.generate();
279
280 assert!(output.contains("end_of_record"));
281 }
282
283 #[test]
284 fn test_generate_line_hit_counts() {
285 let report = create_test_report();
286 let formatter = LcovFormatter::new(&report);
287 let output = formatter.generate();
288
289 assert!(output.contains("DA:10,10"));
291 assert!(output.contains("DA:15,5"));
293 }
294
295 #[test]
296 fn test_save_creates_file() {
297 let report = create_test_report();
298 let formatter = LcovFormatter::new(&report);
299
300 let temp_dir = tempfile::tempdir().unwrap();
301 let path = temp_dir.path().join("coverage.lcov");
302
303 formatter.save(&path).unwrap();
304
305 assert!(path.exists());
306 let content = std::fs::read_to_string(&path).unwrap();
307 assert!(content.contains("TN:"));
308 }
309
310 #[test]
311 fn test_group_by_file() {
312 let report = create_test_report();
313 let formatter = LcovFormatter::new(&report);
314 let files = formatter.group_by_file();
315
316 assert!(files.contains_key("src/game.rs"));
317 assert!(files.contains_key("src/player.rs"));
318 assert_eq!(files.len(), 2);
319 }
320
321 #[test]
322 fn test_custom_test_name_overrides_session() {
323 let report = create_test_report();
324 let formatter = LcovFormatter::new(&report).with_test_name("custom_name");
325 let output = formatter.generate();
326
327 assert!(output.contains("TN:custom_name"));
328 assert!(!output.contains("TN:test_session"));
329 }
330}