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;