sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

coverage_policy.rs (10191B)


      1 use std::{collections::BTreeMap, fs, path::Path};
      2 
      3 use serde::Deserialize;
      4 
      5 #[derive(Debug, Deserialize)]
      6 #[serde(deny_unknown_fields)]
      7 pub(crate) struct CoverageContract {
      8     policy: CoveragePolicy,
      9     pub(crate) toolchain: CoverageToolchain,
     10     pub(crate) report: CoverageReport,
     11     generated: GeneratedCoveragePolicy,
     12     scopes: BTreeMap<String, CoverageScope>,
     13     exclusions: BTreeMap<String, CoverageExclusion>,
     14 }
     15 
     16 #[derive(Debug, Deserialize)]
     17 #[serde(deny_unknown_fields)]
     18 struct CoveragePolicy {
     19     enforce: bool,
     20     require_regions: bool,
     21     require_functions: bool,
     22     require_lines: bool,
     23 }
     24 
     25 #[derive(Debug, Deserialize)]
     26 #[serde(deny_unknown_fields)]
     27 pub(crate) struct CoverageToolchain {
     28     pub(crate) rust: String,
     29     pub(crate) wasm_target: String,
     30 }
     31 
     32 #[derive(Debug, Deserialize)]
     33 #[serde(deny_unknown_fields)]
     34 pub(crate) struct CoverageReport {
     35     pub(crate) output: String,
     36     pub(crate) ignore_filename_regex: String,
     37 }
     38 
     39 #[derive(Debug, Deserialize)]
     40 #[serde(deny_unknown_fields)]
     41 struct GeneratedCoveragePolicy {
     42     typescript: String,
     43     binding_crates: String,
     44     wasm_glue: String,
     45 }
     46 
     47 #[derive(Debug, Deserialize)]
     48 #[serde(deny_unknown_fields)]
     49 struct CoverageScope {
     50     paths: Vec<String>,
     51     threshold: f64,
     52 }
     53 
     54 #[derive(Debug, Deserialize)]
     55 #[serde(deny_unknown_fields)]
     56 struct CoverageExclusion {
     57     paths: Vec<String>,
     58     reason: String,
     59 }
     60 
     61 #[derive(Debug, Deserialize)]
     62 struct LlvmCovReport {
     63     data: Vec<LlvmCovData>,
     64 }
     65 
     66 #[derive(Debug, Deserialize)]
     67 struct LlvmCovData {
     68     files: Vec<LlvmCovFile>,
     69     totals: LlvmCovSummary,
     70 }
     71 
     72 #[derive(Debug, Deserialize)]
     73 struct LlvmCovFile {
     74     filename: String,
     75     summary: LlvmCovSummary,
     76 }
     77 
     78 #[derive(Debug, Deserialize)]
     79 struct LlvmCovSummary {
     80     lines: LlvmCovMetric,
     81     functions: LlvmCovMetric,
     82     regions: LlvmCovMetric,
     83 }
     84 
     85 #[derive(Debug, Deserialize)]
     86 struct LlvmCovMetric {
     87     count: u64,
     88     covered: u64,
     89     percent: f64,
     90 }
     91 
     92 #[derive(Debug, Default)]
     93 struct MetricAccumulator {
     94     count: u64,
     95     covered: u64,
     96 }
     97 
     98 impl MetricAccumulator {
     99     fn add(&mut self, metric: &LlvmCovMetric) {
    100         self.count += metric.count;
    101         self.covered += metric.covered;
    102     }
    103 
    104     fn metric(&self) -> LlvmCovMetric {
    105         LlvmCovMetric {
    106             count: self.count,
    107             covered: self.covered,
    108             percent: metric_percent(self.count, self.covered),
    109         }
    110     }
    111 }
    112 
    113 #[derive(Debug, Default)]
    114 struct SummaryAccumulator {
    115     lines: MetricAccumulator,
    116     functions: MetricAccumulator,
    117     regions: MetricAccumulator,
    118     matched_files: usize,
    119 }
    120 
    121 impl SummaryAccumulator {
    122     fn add(&mut self, summary: &LlvmCovSummary) {
    123         self.lines.add(&summary.lines);
    124         self.functions.add(&summary.functions);
    125         self.regions.add(&summary.regions);
    126         self.matched_files += 1;
    127     }
    128 
    129     fn summary(&self) -> LlvmCovSummary {
    130         LlvmCovSummary {
    131             lines: self.lines.metric(),
    132             functions: self.functions.metric(),
    133             regions: self.regions.metric(),
    134         }
    135     }
    136 }
    137 
    138 pub(crate) fn validate_contract(contract: &CoverageContract) -> Result<(), String> {
    139     validate_non_empty(&contract.toolchain.rust, "toolchain.rust")?;
    140     validate_non_empty(&contract.toolchain.wasm_target, "toolchain.wasm_target")?;
    141     validate_non_empty(&contract.report.output, "report.output")?;
    142     validate_non_empty(
    143         &contract.report.ignore_filename_regex,
    144         "report.ignore_filename_regex",
    145     )?;
    146     validate_non_empty(&contract.generated.typescript, "generated.typescript")?;
    147     validate_non_empty(
    148         &contract.generated.binding_crates,
    149         "generated.binding_crates",
    150     )?;
    151     validate_non_empty(&contract.generated.wasm_glue, "generated.wasm_glue")?;
    152     if contract.scopes.is_empty() {
    153         return Err("contracts/coverage.toml scopes must not be empty".to_owned());
    154     }
    155     for (name, scope) in &contract.scopes {
    156         validate_non_empty(name, "scope name")?;
    157         validate_threshold(scope.threshold, &format!("scopes.{name}.threshold"))?;
    158         if scope.paths.is_empty() {
    159             return Err(format!("scopes.{name}.paths must not be empty"));
    160         }
    161         for path in &scope.paths {
    162             validate_non_empty(path, &format!("scopes.{name}.paths entry"))?;
    163         }
    164     }
    165     if contract.exclusions.is_empty() {
    166         return Err("contracts/coverage.toml exclusions must not be empty".to_owned());
    167     }
    168     for (name, exclusion) in &contract.exclusions {
    169         validate_non_empty(name, "exclusion name")?;
    170         validate_non_empty(&exclusion.reason, &format!("exclusions.{name}.reason"))?;
    171         if exclusion.paths.is_empty() {
    172             return Err(format!("exclusions.{name}.paths must not be empty"));
    173         }
    174         for path in &exclusion.paths {
    175             validate_non_empty(path, &format!("exclusions.{name}.paths entry"))?;
    176         }
    177     }
    178     Ok(())
    179 }
    180 
    181 fn validate_threshold(threshold: f64, field: &str) -> Result<(), String> {
    182     if (0.0..=100.0).contains(&threshold) {
    183         Ok(())
    184     } else {
    185         Err(format!(
    186             "contracts/coverage.toml {field} must be between 0 and 100"
    187         ))
    188     }
    189 }
    190 
    191 fn validate_non_empty(value: &str, field: &str) -> Result<(), String> {
    192     if value.trim().is_empty() {
    193         Err(format!("contracts/coverage.toml {field} must not be empty"))
    194     } else {
    195         Ok(())
    196     }
    197 }
    198 
    199 pub(crate) fn evaluate_report(
    200     root: &Path,
    201     report_path: &Path,
    202     contract: &CoverageContract,
    203 ) -> Result<(), String> {
    204     let raw = fs::read_to_string(report_path)
    205         .map_err(|error| format!("failed to read {}: {error}", report_path.display()))?;
    206     let report = serde_json::from_str::<LlvmCovReport>(&raw)
    207         .map_err(|error| format!("failed to parse {}: {error}", report_path.display()))?;
    208     let data = report
    209         .data
    210         .first()
    211         .ok_or_else(|| format!("{} did not include coverage data", report_path.display()))?;
    212     validate_metric(
    213         "total lines",
    214         &data.totals.lines,
    215         contract.policy.require_lines,
    216     )?;
    217     validate_metric(
    218         "total functions",
    219         &data.totals.functions,
    220         contract.policy.require_functions,
    221     )?;
    222     validate_metric(
    223         "total regions",
    224         &data.totals.regions,
    225         contract.policy.require_regions,
    226     )?;
    227     let mut failures = Vec::new();
    228     for (scope_name, scope) in &contract.scopes {
    229         let scope_summary = match scope_summary(root, data, scope) {
    230             Ok(summary) => summary,
    231             Err(error) => {
    232                 failures.push(format!("coverage scope {scope_name}: {error}"));
    233                 continue;
    234             }
    235         };
    236         collect_scope_metric_failure(
    237             &mut failures,
    238             scope_name,
    239             "lines",
    240             &scope_summary.lines,
    241             scope.threshold,
    242             contract.policy.require_lines,
    243         );
    244         collect_scope_metric_failure(
    245             &mut failures,
    246             scope_name,
    247             "functions",
    248             &scope_summary.functions,
    249             scope.threshold,
    250             contract.policy.require_functions,
    251         );
    252         collect_scope_metric_failure(
    253             &mut failures,
    254             scope_name,
    255             "regions",
    256             &scope_summary.regions,
    257             scope.threshold,
    258             contract.policy.require_regions,
    259         );
    260     }
    261     if !contract.policy.enforce {
    262         println!(
    263             "coverage policy parsed and measured; enforcement disabled in {}",
    264             report_path.display()
    265         );
    266         return Ok(());
    267     }
    268     if !failures.is_empty() {
    269         return Err(failures.join("\n"));
    270     }
    271     println!("coverage policy passed using {}", report_path.display());
    272     Ok(())
    273 }
    274 
    275 fn scope_summary(
    276     root: &Path,
    277     data: &LlvmCovData,
    278     scope: &CoverageScope,
    279 ) -> Result<LlvmCovSummary, String> {
    280     let mut accumulator = SummaryAccumulator::default();
    281     for file in &data.files {
    282         let filename = report_filename(root, &file.filename);
    283         if scope
    284             .paths
    285             .iter()
    286             .any(|pattern| path_matches(pattern, &filename))
    287         {
    288             accumulator.add(&file.summary);
    289         }
    290     }
    291     if accumulator.matched_files == 0 {
    292         return Err(format!(
    293             "matched no report files for {}",
    294             scope.paths.join(", ")
    295         ));
    296     }
    297     Ok(accumulator.summary())
    298 }
    299 
    300 fn report_filename(root: &Path, filename: &str) -> String {
    301     let path = Path::new(filename);
    302     let relative = path.strip_prefix(root).unwrap_or(path);
    303     relative.to_string_lossy().replace('\\', "/")
    304 }
    305 
    306 fn path_matches(pattern: &str, path: &str) -> bool {
    307     if let Some(prefix) = pattern.strip_suffix("/**") {
    308         path == prefix || path.starts_with(&format!("{prefix}/"))
    309     } else {
    310         path == pattern
    311     }
    312 }
    313 
    314 fn collect_scope_metric_failure(
    315     failures: &mut Vec<String>,
    316     scope_name: &str,
    317     metric_name: &str,
    318     metric: &LlvmCovMetric,
    319     threshold: f64,
    320     required: bool,
    321 ) {
    322     if let Err(error) = validate_metric(metric_name, metric, required) {
    323         failures.push(format!("coverage scope {scope_name}: {error}"));
    324     }
    325     if let Err(error) = enforce_metric(metric_name, metric, threshold) {
    326         failures.push(format!("coverage scope {scope_name}: {error}"));
    327     }
    328 }
    329 
    330 fn validate_metric(name: &str, metric: &LlvmCovMetric, required: bool) -> Result<(), String> {
    331     if required && metric.count == 0 {
    332         return Err(format!(
    333             "coverage report did not include required {name} records"
    334         ));
    335     }
    336     if metric.covered > metric.count {
    337         return Err(format!("coverage report has invalid {name} counts"));
    338     }
    339     Ok(())
    340 }
    341 
    342 fn enforce_metric(name: &str, metric: &LlvmCovMetric, threshold: f64) -> Result<(), String> {
    343     if metric.percent < threshold {
    344         return Err(format!(
    345             "coverage {name} {:.3}% is below required {:.1}%",
    346             metric.percent, threshold
    347         ));
    348     }
    349     Ok(())
    350 }
    351 
    352 fn metric_percent(count: u64, covered: u64) -> f64 {
    353     if count == 0 {
    354         0.0
    355     } else {
    356         covered as f64 * 100.0 / count as f64
    357     }
    358 }
    359 
    360 #[cfg(test)]
    361 #[path = "coverage_policy_tests.rs"]
    362 mod tests;