Skip to main content

jugar_probar/coverage/formatters/
lcov.rs

1//! LCOV Report Formatter (Feature 11)
2//!
3//! Generates LCOV-format coverage reports for CI integration.
4//!
5//! ## LCOV Format
6//!
7//! ```text
8//! TN:<test name>
9//! SF:<source file>
10//! FN:<line>,<function name>
11//! FNDA:<execution count>,<function name>
12//! FNF:<functions found>
13//! FNH:<functions hit>
14//! DA:<line>,<execution count>
15//! LF:<lines found>
16//! LH:<lines hit>
17//! end_of_record
18//! ```
19
20use crate::coverage::CoverageReport;
21use crate::result::ProbarResult;
22use std::collections::BTreeMap;
23use std::path::Path;
24
25/// LCOV format report generator
26#[derive(Debug)]
27pub struct LcovFormatter<'a> {
28    report: &'a CoverageReport,
29    test_name: Option<String>,
30}
31
32impl<'a> LcovFormatter<'a> {
33    /// Create a new LCOV formatter from coverage data
34    #[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    /// Set the test name for the report
43    #[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    /// Generate LCOV format report as a string
50    #[must_use]
51    pub fn generate(&self) -> String {
52        use std::fmt::Write;
53
54        let mut output = String::new();
55
56        // Test name (TN)
57        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        // Group coverage by source file
64        let files = self.group_by_file();
65
66        for (file, blocks) in &files {
67            // Source file (SF)
68            let _ = writeln!(output, "SF:{file}");
69
70            // Functions (FN, FNDA)
71            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            // Functions summary
83            let _ = writeln!(output, "FNF:{}", functions.len());
84            let _ = writeln!(output, "FNH:{functions_hit}");
85
86            // Line data (DA)
87            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            // Lines summary
98            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    /// Save the LCOV report to a file
108    ///
109    /// # Errors
110    ///
111    /// Returns error if file write fails
112    pub fn save(&self, path: &Path) -> ProbarResult<()> {
113        let content = self.generate();
114        std::fs::write(path, content)?;
115        Ok(())
116    }
117
118    /// Group coverage data by source file
119    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                    // Extract file from "file:line" format
127                    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    /// Extract function coverage from blocks
148    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    /// Extract line coverage from blocks
162    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        // Set up some blocks with coverage
186        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        // Set source locations
193        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        // Set function names
200        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        // Line 10 in game.rs should have 10 hits
290        assert!(output.contains("DA:10,10"));
291        // Line 15 in game.rs should have 5 hits
292        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}