## 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
    }