commit 50d83b8b6ba8c96ad01908ff87fe5f31f169a823
parent dbeb8dd01dbf19b95ea7359b5c6de3ca253f3696
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Feb 2026 16:50:10 +0000
xtask: add coverage summary and lcov parsers
- add llvm-cov summary json parser for functions lines and regions metrics
- add lcov parser for executable line branch counts and derived percentages
- normalize executable source selection between da and lf-lh records
- run cargo check -q -p xtask and cargo test -q -p xtask
Diffstat:
1 file changed, 155 insertions(+), 0 deletions(-)
diff --git a/crates/xtask/src/coverage.rs b/crates/xtask/src/coverage.rs
@@ -1,5 +1,160 @@
#![forbid(unsafe_code)]
+use std::fs;
+use std::path::Path;
+
+use serde::Deserialize;
+
+#[derive(Debug, Clone)]
+pub struct CoverageSummary {
+ pub functions_percent: f64,
+ pub summary_lines_percent: f64,
+ pub summary_regions_percent: f64,
+}
+
+#[derive(Debug, Clone, Copy)]
+pub enum ExecutableSource {
+ Da,
+ LfLh,
+}
+
+#[derive(Debug, Clone)]
+pub struct LcovCoverage {
+ pub executable_total: u64,
+ pub executable_covered: u64,
+ pub executable_percent: f64,
+ pub executable_source: ExecutableSource,
+ pub branch_total: u64,
+ pub branch_covered: u64,
+ pub branches_available: bool,
+ pub branch_percent: Option<f64>,
+}
+
+#[derive(Debug, Deserialize)]
+struct LlvmCovSummaryRoot {
+ data: Vec<LlvmCovSummaryData>,
+}
+
+#[derive(Debug, Deserialize)]
+struct LlvmCovSummaryData {
+ totals: LlvmCovSummaryTotals,
+}
+
+#[derive(Debug, Deserialize)]
+struct LlvmCovSummaryTotals {
+ functions: LlvmCovSummaryMetric,
+ lines: LlvmCovSummaryMetric,
+ regions: LlvmCovSummaryMetric,
+}
+
+#[derive(Debug, Deserialize)]
+struct LlvmCovSummaryMetric {
+ percent: f64,
+}
+
+pub fn read_summary(path: &Path) -> Result<CoverageSummary, String> {
+ let raw = fs::read_to_string(path)
+ .map_err(|err| format!("failed to read summary {}: {err}", path.display()))?;
+ let parsed: LlvmCovSummaryRoot = serde_json::from_str(&raw)
+ .map_err(|err| format!("failed to parse summary {}: {err}", path.display()))?;
+ let totals = parsed
+ .data
+ .first()
+ .map(|entry| &entry.totals)
+ .ok_or_else(|| format!("summary data is empty in {}", path.display()))?;
+
+ Ok(CoverageSummary {
+ functions_percent: totals.functions.percent,
+ summary_lines_percent: totals.lines.percent,
+ summary_regions_percent: totals.regions.percent,
+ })
+}
+
+pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> {
+ let raw = fs::read_to_string(path)
+ .map_err(|err| format!("failed to read lcov {}: {err}", path.display()))?;
+
+ let mut da_total: u64 = 0;
+ let mut da_covered: u64 = 0;
+ let mut executable_total: u64 = 0;
+ let mut executable_covered: u64 = 0;
+ let mut branch_total: u64 = 0;
+ let mut branch_covered: u64 = 0;
+
+ for line in raw.lines() {
+ if let Some(value) = line.strip_prefix("DA:") {
+ let Some((_, hit)) = value.split_once(',') else {
+ return Err(format!("invalid DA record in {}", path.display()));
+ };
+ let hit_count: u64 = hit
+ .parse()
+ .map_err(|err| format!("invalid DA hit count `{hit}` in {}: {err}", path.display()))?;
+ da_total = da_total.saturating_add(1);
+ if hit_count > 0 {
+ da_covered = da_covered.saturating_add(1);
+ }
+ continue;
+ }
+ if let Some(value) = line.strip_prefix("LF:") {
+ let parsed: u64 = value
+ .parse()
+ .map_err(|err| format!("invalid LF value `{value}` in {}: {err}", path.display()))?;
+ executable_total = executable_total.saturating_add(parsed);
+ continue;
+ }
+ if let Some(value) = line.strip_prefix("LH:") {
+ let parsed: u64 = value
+ .parse()
+ .map_err(|err| format!("invalid LH value `{value}` in {}: {err}", path.display()))?;
+ executable_covered = executable_covered.saturating_add(parsed);
+ continue;
+ }
+ if let Some(value) = line.strip_prefix("BRF:") {
+ let parsed: u64 = value
+ .parse()
+ .map_err(|err| format!("invalid BRF value `{value}` in {}: {err}", path.display()))?;
+ branch_total = branch_total.saturating_add(parsed);
+ continue;
+ }
+ if let Some(value) = line.strip_prefix("BRH:") {
+ let parsed: u64 = value
+ .parse()
+ .map_err(|err| format!("invalid BRH value `{value}` in {}: {err}", path.display()))?;
+ branch_covered = branch_covered.saturating_add(parsed);
+ }
+ }
+
+ let mut executable_source = ExecutableSource::Da;
+ let mut executable_percent = 100.0_f64;
+
+ if da_total > 0 {
+ executable_total = da_total;
+ executable_covered = da_covered;
+ executable_percent = (da_covered as f64 / da_total as f64) * 100.0_f64;
+ } else if executable_total > 0 {
+ executable_source = ExecutableSource::LfLh;
+ executable_percent = (executable_covered as f64 / executable_total as f64) * 100.0_f64;
+ }
+
+ let branches_available = branch_total > 0;
+ let branch_percent = if branches_available {
+ Some((branch_covered as f64 / branch_total as f64) * 100.0_f64)
+ } else {
+ None
+ };
+
+ Ok(LcovCoverage {
+ executable_total,
+ executable_covered,
+ executable_percent,
+ executable_source,
+ branch_total,
+ branch_covered,
+ branches_available,
+ branch_percent,
+ })
+}
+
pub fn run(args: &[String]) -> Result<(), String> {
match args.first().map(String::as_str) {
Some("help") => Ok(()),