Coverage Report

Created: 2025-09-08 21:26

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/noah/src/ruchy/src/quality/coverage.rs
Line
Count
Source
1
//! Test coverage measurement and integration
2
3
use anyhow::{Context, Result};
4
use serde::{Deserialize, Serialize};
5
use std::collections::HashMap;
6
use std::fmt::Write as _;
7
use std::path::Path;
8
use std::process::Command;
9
10
/// Test coverage metrics for individual files
11
#[derive(Debug, Clone, Serialize, Deserialize)]
12
pub struct FileCoverage {
13
    pub path: String,
14
    pub lines_total: usize,
15
    pub lines_covered: usize,
16
    pub branches_total: usize,
17
    pub branches_covered: usize,
18
    pub functions_total: usize,
19
    pub functions_covered: usize,
20
}
21
22
impl FileCoverage {
23
    #[allow(clippy::cast_precision_loss)]
24
0
    pub fn line_coverage_percentage(&self) -> f64 {
25
0
        if self.lines_total == 0 {
26
0
            100.0
27
        } else {
28
0
            (self.lines_covered as f64 / self.lines_total as f64) * 100.0
29
        }
30
0
    }
31
32
    #[allow(clippy::cast_precision_loss)]
33
0
    pub fn branch_coverage_percentage(&self) -> f64 {
34
0
        if self.branches_total == 0 {
35
0
            100.0
36
        } else {
37
0
            (self.branches_covered as f64 / self.branches_total as f64) * 100.0
38
        }
39
0
    }
40
41
    #[allow(clippy::cast_precision_loss)]
42
0
    pub fn function_coverage_percentage(&self) -> f64 {
43
0
        if self.functions_total == 0 {
44
0
            100.0
45
        } else {
46
0
            (self.functions_covered as f64 / self.functions_total as f64) * 100.0
47
        }
48
0
    }
49
}
50
51
/// Overall test coverage report
52
#[derive(Debug, Clone, Serialize, Deserialize)]
53
pub struct CoverageReport {
54
    pub files: HashMap<String, FileCoverage>,
55
    pub total_lines: usize,
56
    pub covered_lines: usize,
57
    pub total_branches: usize,
58
    pub covered_branches: usize,
59
    pub total_functions: usize,
60
    pub covered_functions: usize,
61
}
62
63
impl CoverageReport {
64
0
    pub fn new() -> Self {
65
0
        Self {
66
0
            files: HashMap::new(),
67
0
            total_lines: 0,
68
0
            covered_lines: 0,
69
0
            total_branches: 0,
70
0
            covered_branches: 0,
71
0
            total_functions: 0,
72
0
            covered_functions: 0,
73
0
        }
74
0
    }
75
76
    #[allow(clippy::cast_precision_loss)]
77
0
    pub fn line_coverage_percentage(&self) -> f64 {
78
0
        if self.total_lines == 0 {
79
0
            100.0
80
        } else {
81
0
            (self.covered_lines as f64 / self.total_lines as f64) * 100.0
82
        }
83
0
    }
84
85
    #[allow(clippy::cast_precision_loss)]
86
0
    pub fn branch_coverage_percentage(&self) -> f64 {
87
0
        if self.total_branches == 0 {
88
0
            100.0
89
        } else {
90
0
            (self.covered_branches as f64 / self.total_branches as f64) * 100.0
91
        }
92
0
    }
93
94
    #[allow(clippy::cast_precision_loss)]
95
0
    pub fn function_coverage_percentage(&self) -> f64 {
96
0
        if self.total_functions == 0 {
97
0
            100.0
98
        } else {
99
0
            (self.covered_functions as f64 / self.total_functions as f64) * 100.0
100
        }
101
0
    }
102
103
0
    pub fn add_file(&mut self, file_coverage: FileCoverage) {
104
0
        self.total_lines += file_coverage.lines_total;
105
0
        self.covered_lines += file_coverage.lines_covered;
106
0
        self.total_branches += file_coverage.branches_total;
107
0
        self.covered_branches += file_coverage.branches_covered;
108
0
        self.total_functions += file_coverage.functions_total;
109
0
        self.covered_functions += file_coverage.functions_covered;
110
111
0
        self.files.insert(file_coverage.path.clone(), file_coverage);
112
0
    }
113
}
114
115
impl Default for CoverageReport {
116
0
    fn default() -> Self {
117
0
        Self::new()
118
0
    }
119
}
120
121
/// Coverage collector that integrates with various coverage tools
122
pub struct CoverageCollector {
123
    tool: CoverageTool,
124
    source_dir: String,
125
}
126
127
#[derive(Debug, Clone)]
128
pub enum CoverageTool {
129
    Tarpaulin,
130
    Llvm,
131
    Grcov,
132
}
133
134
impl CoverageCollector {
135
0
    pub fn new(tool: CoverageTool) -> Self {
136
0
        Self {
137
0
            tool,
138
0
            source_dir: "src".to_string(),
139
0
        }
140
0
    }
141
142
    /// Set the source directory for coverage collection
143
    ///
144
    /// # Examples
145
    ///
146
    /// ```
147
    /// use ruchy::quality::{CoverageCollector, CoverageTool};
148
    ///
149
    /// let collector = CoverageCollector::new(CoverageTool::Tarpaulin)
150
    ///     .with_source_dir("src");
151
    /// ```
152
    #[must_use]
153
0
    pub fn with_source_dir<P: AsRef<Path>>(mut self, path: P) -> Self {
154
0
        self.source_dir = path.as_ref().to_string_lossy().to_string();
155
0
        self
156
0
    }
157
158
    /// Collect test coverage by running the appropriate tool
159
    ///
160
    /// # Examples
161
    ///
162
    /// ```no_run
163
    /// use ruchy::quality::{CoverageCollector, CoverageTool};
164
    ///
165
    /// let collector = CoverageCollector::new(CoverageTool::Tarpaulin);
166
    /// let report = collector.collect().expect("Failed to collect coverage");
167
    /// println!("Line coverage: {:.1}%", report.line_coverage_percentage());
168
    /// ```
169
    ///
170
    /// # Errors
171
    ///
172
    /// Returns an error if:
173
    /// - The coverage tool is not installed
174
    /// - The coverage tool fails to run
175
    /// - The output cannot be parsed
176
0
    pub fn collect(&self) -> Result<CoverageReport> {
177
0
        match self.tool {
178
0
            CoverageTool::Tarpaulin => Self::collect_tarpaulin(),
179
0
            CoverageTool::Llvm => Self::collect_llvm(),
180
0
            CoverageTool::Grcov => Self::collect_grcov(),
181
        }
182
0
    }
183
184
0
    fn collect_tarpaulin() -> Result<CoverageReport> {
185
        // Run cargo tarpaulin with JSON output
186
0
        let output = Command::new("cargo")
187
0
            .args([
188
0
                "tarpaulin",
189
0
                "--out",
190
0
                "Json",
191
0
                "--output-dir",
192
0
                "target/coverage",
193
0
            ])
194
0
            .output()
195
0
            .context("Failed to run cargo tarpaulin")?;
196
197
0
        if !output.status.success() {
198
0
            let stderr = String::from_utf8_lossy(&output.stderr);
199
0
            return Err(anyhow::anyhow!("Tarpaulin failed: {}", stderr));
200
0
        }
201
202
0
        let stdout = String::from_utf8_lossy(&output.stdout);
203
0
        Self::parse_tarpaulin_json(&stdout)
204
0
    }
205
206
    #[allow(clippy::unnecessary_wraps)]
207
0
    fn collect_llvm() -> Result<CoverageReport> {
208
        // LLVM-cov workflow would go here
209
        // For now, return a placeholder
210
0
        let mut report = CoverageReport::new();
211
212
        // Add some example coverage data
213
0
        let file_coverage = FileCoverage {
214
0
            path: "src/lib.rs".to_string(),
215
0
            lines_total: 100,
216
0
            lines_covered: 85,
217
0
            branches_total: 20,
218
0
            branches_covered: 16,
219
0
            functions_total: 10,
220
0
            functions_covered: 9,
221
0
        };
222
223
0
        report.add_file(file_coverage);
224
0
        Ok(report)
225
0
    }
226
227
    #[allow(clippy::unnecessary_wraps)]
228
0
    fn collect_grcov() -> Result<CoverageReport> {
229
        // Grcov workflow would go here
230
        // For now, return a placeholder
231
0
        let mut report = CoverageReport::new();
232
233
        // Add some example coverage data
234
0
        let file_coverage = FileCoverage {
235
0
            path: "src/lib.rs".to_string(),
236
0
            lines_total: 100,
237
0
            lines_covered: 90,
238
0
            branches_total: 20,
239
0
            branches_covered: 18,
240
0
            functions_total: 10,
241
0
            functions_covered: 10,
242
0
        };
243
244
0
        report.add_file(file_coverage);
245
0
        Ok(report)
246
0
    }
247
248
    #[allow(clippy::unnecessary_wraps)]
249
0
    fn parse_tarpaulin_json(_json_output: &str) -> Result<CoverageReport> {
250
        // Parse tarpaulin JSON output format
251
        // This is a simplified parser - real implementation would be more robust
252
0
        let mut report = CoverageReport::new();
253
254
        // For now, return a mock report
255
        // Real implementation would parse the actual tarpaulin JSON format
256
0
        let file_coverage = FileCoverage {
257
0
            path: "src/lib.rs".to_string(),
258
0
            lines_total: 100,
259
0
            lines_covered: 82,
260
0
            branches_total: 20,
261
0
            branches_covered: 15,
262
0
            functions_total: 10,
263
0
            functions_covered: 8,
264
0
        };
265
266
0
        report.add_file(file_coverage);
267
0
        Ok(report)
268
0
    }
269
270
    /// Check if the coverage tool is available
271
0
    pub fn is_available(&self) -> bool {
272
0
        match self.tool {
273
0
            CoverageTool::Tarpaulin => Command::new("cargo")
274
0
                .args(["tarpaulin", "--help"])
275
0
                .output()
276
0
                .map(|output| output.status.success())
277
0
                .unwrap_or(false),
278
0
            CoverageTool::Llvm => Command::new("llvm-profdata")
279
0
                .arg("--help")
280
0
                .output()
281
0
                .map(|output| output.status.success())
282
0
                .unwrap_or(false),
283
0
            CoverageTool::Grcov => Command::new("grcov")
284
0
                .arg("--help")
285
0
                .output()
286
0
                .map(|output| output.status.success())
287
0
                .unwrap_or(false),
288
        }
289
0
    }
290
}
291
292
/// HTML coverage report generator
293
pub struct HtmlReportGenerator {
294
    output_dir: String,
295
}
296
297
impl HtmlReportGenerator {
298
0
    pub fn new<P: AsRef<Path>>(output_dir: P) -> Self {
299
0
        Self {
300
0
            output_dir: output_dir.as_ref().to_string_lossy().to_string(),
301
0
        }
302
0
    }
303
304
    /// Generate HTML coverage report
305
    ///
306
    /// # Errors
307
    ///
308
    /// Returns an error if directory creation or file writing fails
309
0
    pub fn generate(&self, report: &CoverageReport) -> Result<()> {
310
0
        std::fs::create_dir_all(&self.output_dir).context("Failed to create output directory")?;
311
312
0
        let html_content = Self::generate_html(report)?;
313
0
        let output_path = format!("{}/coverage.html", self.output_dir);
314
315
0
        std::fs::write(&output_path, html_content).context("Failed to write HTML report")?;
316
317
0
        tracing::info!("Coverage report generated: {output_path}");
318
0
        Ok(())
319
0
    }
320
321
0
    fn generate_html(report: &CoverageReport) -> Result<String> {
322
0
        let mut html = String::new();
323
324
0
        html.push_str("<!DOCTYPE html>\n<html>\n<head>\n");
325
0
        html.push_str("<title>Ruchy Test Coverage Report</title>\n");
326
0
        html.push_str("<style>\n");
327
0
        html.push_str("body { font-family: Arial, sans-serif; margin: 20px; }\n");
328
0
        html.push_str("table { border-collapse: collapse; width: 100%; }\n");
329
0
        html.push_str("th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }\n");
330
0
        html.push_str("th { background-color: #f2f2f2; }\n");
331
0
        html.push_str(".high { color: green; }\n");
332
0
        html.push_str(".medium { color: orange; }\n");
333
0
        html.push_str(".low { color: red; }\n");
334
0
        html.push_str("</style>\n");
335
0
        html.push_str("</head>\n<body>\n");
336
337
0
        html.push_str("<h1>Ruchy Test Coverage Report</h1>\n");
338
339
        // Overall coverage
340
0
        html.push_str("<h2>Overall Coverage</h2>\n");
341
0
        html.push_str("<table>\n");
342
0
        html.push_str("<tr><th>Metric</th><th>Coverage</th></tr>\n");
343
0
        writeln!(
344
0
            html,
345
0
            "<tr><td>Lines</td><td class=\"{}\">{:.1}% ({}/{})</td></tr>",
346
0
            Self::coverage_class(report.line_coverage_percentage()),
347
0
            report.line_coverage_percentage(),
348
            report.covered_lines,
349
            report.total_lines
350
0
        )?;
351
0
        write!(
352
0
            html,
353
0
            "<tr><td>Functions</td><td class=\"{}\">{:.1}% ({}/{})</td></tr>",
354
0
            Self::coverage_class(report.function_coverage_percentage()),
355
0
            report.function_coverage_percentage(),
356
            report.covered_functions,
357
            report.total_functions
358
0
        )?;
359
0
        html.push_str("</table>\n");
360
361
        // File-by-file coverage
362
0
        html.push_str("<h2>File Coverage</h2>\n");
363
0
        html.push_str("<table>\n");
364
0
        html.push_str("<tr><th>File</th><th>Line Coverage</th><th>Function Coverage</th></tr>\n");
365
366
0
        for (path, file_coverage) in &report.files {
367
0
            write!(
368
0
                html,
369
0
                "<tr><td>{}</td><td class=\"{}\">{:.1}%</td><td class=\"{}\">{:.1}%</td></tr>",
370
                path,
371
0
                Self::coverage_class(file_coverage.line_coverage_percentage()),
372
0
                file_coverage.line_coverage_percentage(),
373
0
                Self::coverage_class(file_coverage.function_coverage_percentage()),
374
0
                file_coverage.function_coverage_percentage()
375
0
            )?;
376
        }
377
378
0
        html.push_str("</table>\n");
379
0
        html.push_str("</body>\n</html>\n");
380
381
0
        Ok(html)
382
0
    }
383
384
0
    fn coverage_class(percentage: f64) -> &'static str {
385
0
        if percentage >= 80.0 {
386
0
            "high"
387
0
        } else if percentage >= 60.0 {
388
0
            "medium"
389
        } else {
390
0
            "low"
391
        }
392
0
    }
393
}
394
395
#[cfg(test)]
396
mod tests {
397
    use super::*;
398
399
    #[test]
400
    fn test_file_coverage_percentages() {
401
        let coverage = FileCoverage {
402
            path: "test.rs".to_string(),
403
            lines_total: 100,
404
            lines_covered: 80,
405
            branches_total: 20,
406
            branches_covered: 16,
407
            functions_total: 10,
408
            functions_covered: 9,
409
        };
410
411
        assert!((coverage.line_coverage_percentage() - 80.0).abs() < f64::EPSILON);
412
        assert!((coverage.branch_coverage_percentage() - 80.0).abs() < f64::EPSILON);
413
        assert!((coverage.function_coverage_percentage() - 90.0).abs() < f64::EPSILON);
414
    }
415
416
    #[test]
417
    fn test_coverage_report_aggregation() {
418
        let mut report = CoverageReport::new();
419
420
        let file1 = FileCoverage {
421
            path: "file1.rs".to_string(),
422
            lines_total: 100,
423
            lines_covered: 80,
424
            branches_total: 20,
425
            branches_covered: 16,
426
            functions_total: 10,
427
            functions_covered: 8,
428
        };
429
430
        let file2 = FileCoverage {
431
            path: "file2.rs".to_string(),
432
            lines_total: 50,
433
            lines_covered: 45,
434
            branches_total: 10,
435
            branches_covered: 9,
436
            functions_total: 5,
437
            functions_covered: 5,
438
        };
439
440
        report.add_file(file1);
441
        report.add_file(file2);
442
443
        assert_eq!(report.total_lines, 150);
444
        assert_eq!(report.covered_lines, 125);
445
        let expected = 83.333_333_333_333_34;
446
        assert!((report.line_coverage_percentage() - expected).abs() < f64::EPSILON);
447
    }
448
449
    #[test]
450
    fn test_coverage_collector_creation() {
451
        let collector = CoverageCollector::new(CoverageTool::Tarpaulin).with_source_dir("src");
452
453
        assert_eq!(collector.source_dir, "src");
454
        assert!(matches!(collector.tool, CoverageTool::Tarpaulin));
455
    }
456
457
    #[test]
458
    fn test_html_report_generator() -> Result<(), Box<dyn std::error::Error>> {
459
        let mut report = CoverageReport::new();
460
        let file_coverage = FileCoverage {
461
            path: "src/lib.rs".to_string(),
462
            lines_total: 100,
463
            lines_covered: 85,
464
            branches_total: 20,
465
            branches_covered: 17,
466
            functions_total: 10,
467
            functions_covered: 9,
468
        };
469
        report.add_file(file_coverage);
470
471
        let _generator = HtmlReportGenerator::new("target/coverage");
472
        let html = HtmlReportGenerator::generate_html(&report)?;
473
474
        assert!(html.contains("Ruchy Test Coverage Report"));
475
        assert!(html.contains("85.0%"));
476
        assert!(html.contains("src/lib.rs"));
477
        Ok(())
478
    }
479
}