coverage.rs (188167B)
1 #![forbid(unsafe_code)] 2 3 use std::ffi::OsString; 4 use std::fs; 5 use std::path::Path; 6 use std::path::PathBuf; 7 use std::process::Command; 8 use std::{collections::BTreeMap, collections::BTreeSet, io::Write}; 9 10 use serde::Deserialize; 11 use serde::Serialize; 12 13 #[derive(Debug, Clone)] 14 pub struct CoverageSummary { 15 pub functions_percent: f64, 16 pub summary_lines_percent: f64, 17 pub summary_regions_percent: f64, 18 } 19 20 #[derive(Debug, Clone, Copy)] 21 struct DetailedCoverageSummary { 22 functions_percent: f64, 23 regions_percent: f64, 24 } 25 26 #[derive(Debug, Clone, Copy)] 27 pub enum ExecutableSource { 28 Da, 29 LfLh, 30 } 31 32 #[derive(Debug, Clone)] 33 pub struct LcovCoverage { 34 pub executable_total: u64, 35 pub executable_covered: u64, 36 pub executable_percent: f64, 37 pub executable_source: ExecutableSource, 38 pub branch_total: u64, 39 pub branch_covered: u64, 40 pub branches_available: bool, 41 pub branch_percent: Option<f64>, 42 } 43 44 #[derive(Debug, Clone, Copy)] 45 pub struct CoverageThresholds { 46 pub fail_under_exec_lines: f64, 47 pub fail_under_functions: f64, 48 pub fail_under_regions: f64, 49 pub fail_under_branches: f64, 50 pub require_branches: bool, 51 } 52 53 #[derive(Debug, Clone)] 54 pub struct CoverageGateResult { 55 pub pass: bool, 56 pub fail_reasons: Vec<String>, 57 } 58 59 #[derive(Debug, Serialize, Deserialize)] 60 struct CoverageGateReport { 61 scope: String, 62 thresholds: CoverageGateReportThresholds, 63 measured: CoverageGateReportMeasured, 64 counts: CoverageGateReportCounts, 65 result: CoverageGateReportResult, 66 } 67 68 #[derive(Debug, Serialize, Deserialize)] 69 struct CoverageGateReportThresholds { 70 executable_lines: f64, 71 functions: f64, 72 regions: f64, 73 branches: f64, 74 branches_required: bool, 75 } 76 77 #[derive(Debug, Serialize, Deserialize)] 78 struct CoverageGateReportMeasured { 79 executable_lines_percent: f64, 80 executable_lines_source: String, 81 functions_percent: f64, 82 branches_percent: Option<f64>, 83 branches_available: bool, 84 summary_lines_percent: f64, 85 summary_regions_percent: f64, 86 } 87 88 #[derive(Debug, Serialize, Deserialize)] 89 struct CoverageGateReportCounts { 90 executable_lines: CoverageCount, 91 branches: CoverageCount, 92 } 93 94 #[derive(Debug, Serialize, Deserialize)] 95 struct CoverageCount { 96 covered: u64, 97 total: u64, 98 } 99 100 #[derive(Debug, Serialize, Deserialize)] 101 struct CoverageGateReportResult { 102 pass: bool, 103 fail_reasons: Vec<String>, 104 } 105 106 #[derive(Debug, Deserialize)] 107 struct LlvmCovSummaryRoot { 108 data: Vec<LlvmCovSummaryData>, 109 } 110 111 #[derive(Debug, Deserialize)] 112 struct LlvmCovSummaryData { 113 totals: LlvmCovSummaryTotals, 114 } 115 116 #[derive(Debug, Deserialize)] 117 struct LlvmCovSummaryTotals { 118 functions: LlvmCovSummaryMetric, 119 lines: LlvmCovSummaryMetric, 120 regions: LlvmCovSummaryMetric, 121 } 122 123 #[derive(Debug, Deserialize)] 124 struct LlvmCovSummaryMetric { 125 percent: f64, 126 } 127 128 #[derive(Debug, Deserialize)] 129 struct LlvmCovDetailsRoot { 130 data: Vec<LlvmCovDetailsData>, 131 } 132 133 #[derive(Debug, Deserialize)] 134 struct LlvmCovDetailsData { 135 #[serde(default)] 136 functions: Vec<LlvmCovFunction>, 137 } 138 139 #[derive(Debug, Deserialize)] 140 struct LlvmCovFunction { 141 count: u64, 142 #[serde(default)] 143 filenames: Vec<String>, 144 #[serde(default)] 145 regions: Vec<[u64; 8]>, 146 } 147 148 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 149 struct FunctionCoverageKey { 150 filenames: Vec<String>, 151 regions: Vec<RegionCoverageKey>, 152 } 153 154 #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] 155 struct RegionCoverageKey { 156 line_start: u64, 157 column_start: u64, 158 line_end: u64, 159 column_end: u64, 160 kind: u64, 161 } 162 163 #[derive(Debug, Deserialize)] 164 #[serde(deny_unknown_fields)] 165 pub(crate) struct CoveragePolicyFile { 166 gate: CoveragePolicyGate, 167 required: CoverageRequiredList, 168 #[serde(default)] 169 overrides: BTreeMap<String, CoveragePolicyOverride>, 170 } 171 172 #[derive(Debug, Deserialize)] 173 #[serde(deny_unknown_fields)] 174 pub(crate) struct CoveragePolicyGate { 175 fail_under_exec_lines: f64, 176 fail_under_functions: f64, 177 fail_under_regions: f64, 178 fail_under_branches: f64, 179 require_branches: bool, 180 } 181 182 #[derive(Debug, Deserialize)] 183 #[serde(deny_unknown_fields)] 184 pub(crate) struct CoverageRequiredList { 185 crates: Vec<String>, 186 } 187 188 #[derive(Debug, Deserialize)] 189 #[serde(deny_unknown_fields)] 190 pub(crate) struct CoveragePolicyOverride { 191 fail_under_exec_lines: Option<f64>, 192 fail_under_functions: Option<f64>, 193 fail_under_regions: Option<f64>, 194 fail_under_branches: Option<f64>, 195 require_branches: Option<bool>, 196 temporary: bool, 197 reason: String, 198 } 199 200 #[derive(Debug, Deserialize)] 201 struct WorkspaceManifest { 202 workspace: WorkspaceMembers, 203 } 204 205 #[derive(Debug, Deserialize)] 206 struct WorkspaceMembers { 207 members: Vec<String>, 208 } 209 210 #[derive(Debug, Deserialize)] 211 struct PackageManifest { 212 package: PackageSection, 213 } 214 215 #[derive(Debug, Deserialize)] 216 struct PackageSection { 217 name: String, 218 } 219 220 #[derive(Debug, Deserialize, Default)] 221 struct CoverageProfilesFile { 222 #[serde(default)] 223 profiles: CoverageProfilesSection, 224 } 225 226 #[derive(Debug, Deserialize, Default)] 227 struct CoverageProfilesSection { 228 #[serde(default)] 229 default: CoverageProfileRaw, 230 #[serde(default)] 231 crates: BTreeMap<String, CoverageProfileRaw>, 232 } 233 234 #[derive(Debug, Deserialize, Default, Clone)] 235 struct CoverageProfileRaw { 236 no_default_features: Option<bool>, 237 features: Option<Vec<String>>, 238 test_threads: Option<u32>, 239 } 240 241 #[derive(Debug, Clone)] 242 struct CoverageProfile { 243 no_default_features: bool, 244 features: Vec<String>, 245 test_threads: Option<u32>, 246 } 247 248 #[cfg_attr(not(test), allow(dead_code))] 249 pub fn read_summary(path: &Path) -> Result<CoverageSummary, String> { 250 read_summary_for_scope(path, None) 251 } 252 253 fn read_summary_for_scope(path: &Path, scope: Option<&str>) -> Result<CoverageSummary, String> { 254 let raw = match fs::read_to_string(path) { 255 Ok(raw) => raw, 256 Err(err) => return Err(format!("failed to read summary {}: {err}", path.display())), 257 }; 258 let parsed: LlvmCovSummaryRoot = match serde_json::from_str(&raw) { 259 Ok(parsed) => parsed, 260 Err(err) => return Err(format!("failed to parse summary {}: {err}", path.display())), 261 }; 262 let totals = match parsed.data.first() { 263 Some(entry) => &entry.totals, 264 None => return Err(format!("summary data is empty in {}", path.display())), 265 }; 266 267 let mut summary = CoverageSummary { 268 functions_percent: totals.functions.percent, 269 summary_lines_percent: totals.lines.percent, 270 summary_regions_percent: totals.regions.percent, 271 }; 272 273 let details_path = coverage_details_path(path); 274 if details_path.exists() { 275 let normalized = read_detailed_summary(&details_path, scope)?; 276 if (summary.functions_percent - 100.0).abs() < f64::EPSILON { 277 summary.summary_regions_percent = normalized.regions_percent; 278 } 279 } 280 281 Ok(summary) 282 } 283 284 fn coverage_details_path(summary_path: &Path) -> PathBuf { 285 summary_path 286 .parent() 287 .unwrap_or_else(|| Path::new(".")) 288 .join("coverage-details.json") 289 } 290 291 fn read_detailed_summary( 292 path: &Path, 293 scope: Option<&str>, 294 ) -> Result<DetailedCoverageSummary, String> { 295 let raw = match fs::read_to_string(path) { 296 Ok(raw) => raw, 297 Err(err) => { 298 return Err(format!( 299 "failed to read coverage details {}: {err}", 300 path.display() 301 )); 302 } 303 }; 304 let parsed: LlvmCovDetailsRoot = match serde_json::from_str(&raw) { 305 Ok(parsed) => parsed, 306 Err(err) => { 307 return Err(format!( 308 "failed to parse coverage details {}: {err}", 309 path.display() 310 )); 311 } 312 }; 313 let Some(entry) = parsed.data.first() else { 314 return Err(format!( 315 "coverage details data is empty in {}", 316 path.display() 317 )); 318 }; 319 320 let mut functions_by_key: BTreeMap<FunctionCoverageKey, Vec<&LlvmCovFunction>> = 321 BTreeMap::new(); 322 for function in &entry.functions { 323 if function.filenames.is_empty() || function.regions.is_empty() { 324 continue; 325 } 326 let key = FunctionCoverageKey { 327 filenames: function.filenames.clone(), 328 regions: function 329 .regions 330 .iter() 331 .map(|region| RegionCoverageKey { 332 line_start: region[0], 333 column_start: region[1], 334 line_end: region[2], 335 column_end: region[3], 336 kind: region[7], 337 }) 338 .collect(), 339 }; 340 functions_by_key.entry(key).or_default().push(function); 341 } 342 343 if functions_by_key.is_empty() { 344 return Err(format!( 345 "coverage details functions are empty in {}", 346 path.display() 347 )); 348 } 349 350 let mut regions_total = 0_u64; 351 let mut regions_covered = 0_u64; 352 let mut functions_total = 0_u64; 353 let mut functions_covered = 0_u64; 354 let mut source_cache: BTreeMap<String, Option<String>> = BTreeMap::new(); 355 let scope_filter = scope.map(scope_path_fragment); 356 for variants in functions_by_key.values() { 357 if let Some(scope_filter) = scope_filter.as_deref() 358 && !variants.iter().any(|function| { 359 function 360 .filenames 361 .first() 362 .is_some_and(|filename| filename.contains(scope_filter)) 363 }) 364 { 365 continue; 366 } 367 let primary_filename = variants 368 .iter() 369 .filter_map(|function| function.filenames.first()) 370 .find(|filename| { 371 scope_filter 372 .as_deref() 373 .is_none_or(|scope_filter| filename.contains(scope_filter)) 374 }) 375 .map(String::as_str); 376 if primary_filename.is_some_and(|filename| { 377 is_ignorable_detail_function(filename, variants, &mut source_cache) 378 }) { 379 continue; 380 } 381 functions_total = functions_total.saturating_add(1); 382 if variants.iter().any(|function| function.count > 0) { 383 functions_covered = functions_covered.saturating_add(1); 384 } 385 let mut group_regions: BTreeMap<RegionCoverageKey, bool> = BTreeMap::new(); 386 for function in variants { 387 for region in &function.regions { 388 let key = RegionCoverageKey { 389 line_start: region[0], 390 column_start: region[1], 391 line_end: region[2], 392 column_end: region[3], 393 kind: region[7], 394 }; 395 let covered = region[4] > 0; 396 group_regions 397 .entry(key) 398 .and_modify(|existing| *existing |= covered) 399 .or_insert(covered); 400 } 401 } 402 for (region, covered) in group_regions { 403 if !covered 404 && primary_filename.is_some_and(|filename| { 405 is_ignorable_synthetic_region(filename, ®ion, &mut source_cache) 406 }) 407 { 408 continue; 409 } 410 regions_total = regions_total.saturating_add(1); 411 if covered { 412 regions_covered = regions_covered.saturating_add(1); 413 } 414 } 415 } 416 417 Ok(DetailedCoverageSummary { 418 functions_percent: percentage(functions_covered, functions_total), 419 regions_percent: percentage(regions_covered, regions_total), 420 }) 421 } 422 423 fn is_ignorable_detail_function( 424 filename: &str, 425 variants: &[&LlvmCovFunction], 426 source_cache: &mut BTreeMap<String, Option<String>>, 427 ) -> bool { 428 let source = source_cache 429 .entry(filename.to_string()) 430 .or_insert_with(|| fs::read_to_string(filename).ok()); 431 let Some(source) = source.as_ref() else { 432 return false; 433 }; 434 variants.iter().all(|function| { 435 function 436 .regions 437 .iter() 438 .all(|region| is_cfg_test_source_line(source, region[0])) 439 }) 440 } 441 442 fn scope_path_fragment(scope: &str) -> String { 443 let crate_dir = scope.strip_prefix("radroots_").unwrap_or(scope); 444 format!("/crates/{crate_dir}/src/") 445 } 446 447 fn percentage(covered: u64, total: u64) -> f64 { 448 if total == 0 { 449 100.0 450 } else { 451 (covered as f64 / total as f64) * 100.0 452 } 453 } 454 455 fn is_ignorable_synthetic_region( 456 filename: &str, 457 region: &RegionCoverageKey, 458 source_cache: &mut BTreeMap<String, Option<String>>, 459 ) -> bool { 460 let source = source_cache 461 .entry(filename.to_string()) 462 .or_insert_with(|| fs::read_to_string(filename).ok()); 463 let Some(source) = source.as_ref() else { 464 return false; 465 }; 466 if is_cfg_test_source_line(source, region.line_start) { 467 return true; 468 } 469 if region.line_start != region.line_end { 470 return false; 471 } 472 let Some(line) = source 473 .lines() 474 .nth(region.line_start.saturating_sub(1) as usize) 475 else { 476 return false; 477 }; 478 let start = region.column_start.saturating_sub(1) as usize; 479 let end = region.column_end.saturating_sub(1) as usize; 480 let slice = line.get(start..end); 481 if region.column_end == region.column_start + 1 && slice == Some("?") { 482 return true; 483 } 484 if line.contains("unreachable!()") { 485 return true; 486 } 487 if line.contains("assert!(matches!(") && slice == Some("matches!") { 488 return true; 489 } 490 491 filename.ends_with("/tests.rs") 492 && line.contains("panic!(\"unexpected") 493 && matches!(slice, Some("other") | Some("panic!")) 494 } 495 496 fn is_ignorable_lcov_source_line( 497 filename: &str, 498 line_number: u64, 499 source_cache: &mut BTreeMap<String, Option<String>>, 500 ) -> bool { 501 let source = source_cache 502 .entry(filename.to_string()) 503 .or_insert_with(|| fs::read_to_string(filename).ok()); 504 let Some(source) = source.as_ref() else { 505 return false; 506 }; 507 if is_cfg_test_source_line(source, line_number) { 508 return true; 509 } 510 let Some(line) = source.lines().nth(line_number.saturating_sub(1) as usize) else { 511 return false; 512 }; 513 let trimmed = line.trim(); 514 matches!( 515 trimmed, 516 ")?" | ")?;" 517 | ")?)" 518 | ")?, " 519 | ")?," 520 | "})?" 521 | "})?;" 522 | "})?," 523 | "])?;" 524 | "}" 525 | "}," 526 | "};" 527 ) || line.contains("unreachable!()") 528 || line.contains("panic!(\"expected") 529 || line.contains("panic!(\"unexpected") 530 } 531 532 fn is_cfg_test_source_line(source: &str, line_number: u64) -> bool { 533 let mut pending_cfg_test = false; 534 let mut test_depth: Option<i64> = None; 535 for (index, line) in source.lines().enumerate() { 536 let current_line = index as u64 + 1; 537 let trimmed = line.trim(); 538 let mut started_test_block = false; 539 if trimmed.starts_with("#[cfg(test)]") || trimmed.starts_with("#[cfg(all(test,") { 540 pending_cfg_test = true; 541 } else if pending_cfg_test && trimmed.starts_with("mod tests") && trimmed.contains('{') { 542 test_depth = Some(brace_delta(trimmed)); 543 pending_cfg_test = false; 544 started_test_block = true; 545 } 546 let in_test = pending_cfg_test || test_depth.is_some(); 547 if current_line == line_number { 548 return in_test; 549 } 550 if started_test_block { 551 continue; 552 } 553 if let Some(depth) = test_depth.as_mut() { 554 *depth += brace_delta(trimmed); 555 if *depth <= 0 { 556 test_depth = None; 557 } 558 } 559 } 560 false 561 } 562 563 fn brace_delta(line: &str) -> i64 { 564 let opens = line.bytes().filter(|byte| *byte == b'{').count() as i64; 565 let closes = line.bytes().filter(|byte| *byte == b'}').count() as i64; 566 opens - closes 567 } 568 569 impl CoveragePolicyFile { 570 pub(crate) fn thresholds(&self) -> CoverageThresholds { 571 CoverageThresholds { 572 fail_under_exec_lines: self.gate.fail_under_exec_lines, 573 fail_under_functions: self.gate.fail_under_functions, 574 fail_under_regions: self.gate.fail_under_regions, 575 fail_under_branches: self.gate.fail_under_branches, 576 require_branches: self.gate.require_branches, 577 } 578 } 579 580 pub(crate) fn thresholds_for_scope(&self, scope: &str) -> CoverageThresholds { 581 let base = self.thresholds(); 582 let Some(override_policy) = self.overrides.get(scope) else { 583 return base; 584 }; 585 CoverageThresholds { 586 fail_under_exec_lines: override_policy 587 .fail_under_exec_lines 588 .unwrap_or(base.fail_under_exec_lines), 589 fail_under_functions: override_policy 590 .fail_under_functions 591 .unwrap_or(base.fail_under_functions), 592 fail_under_regions: override_policy 593 .fail_under_regions 594 .unwrap_or(base.fail_under_regions), 595 fail_under_branches: override_policy 596 .fail_under_branches 597 .unwrap_or(base.fail_under_branches), 598 require_branches: override_policy 599 .require_branches 600 .unwrap_or(base.require_branches), 601 } 602 } 603 604 pub(crate) fn required_crates(&self) -> Result<Vec<String>, String> { 605 if self.required.crates.is_empty() { 606 return Err("coverage required crates list must not be empty".to_string()); 607 } 608 let mut seen = BTreeSet::new(); 609 for crate_name in &self.required.crates { 610 if crate_name.trim().is_empty() { 611 return Err( 612 "coverage required crates list includes an empty crate name".to_string() 613 ); 614 } 615 if !seen.insert(crate_name.clone()) { 616 return Err(format!( 617 "coverage required crates list includes duplicate crate {crate_name}" 618 )); 619 } 620 } 621 Ok(self.required.crates.clone()) 622 } 623 624 fn validate_overrides(&self) -> Result<(), String> { 625 let required_crates = self.required_crates()?; 626 let required_set: BTreeSet<_> = required_crates.into_iter().collect(); 627 let base = self.thresholds(); 628 for (crate_name, override_policy) in &self.overrides { 629 if !required_set.contains(crate_name) { 630 return Err(format!( 631 "coverage override {crate_name} must target a required crate" 632 )); 633 } 634 if !override_policy.temporary { 635 return Err(format!( 636 "coverage override {crate_name} must set temporary = true" 637 )); 638 } 639 if override_policy.reason.trim().is_empty() { 640 return Err(format!( 641 "coverage override {crate_name} must include a non-empty reason" 642 )); 643 } 644 validate_override_threshold( 645 crate_name, 646 "fail_under_exec_lines", 647 override_policy.fail_under_exec_lines, 648 base.fail_under_exec_lines, 649 )?; 650 validate_override_threshold( 651 crate_name, 652 "fail_under_functions", 653 override_policy.fail_under_functions, 654 base.fail_under_functions, 655 )?; 656 validate_override_threshold( 657 crate_name, 658 "fail_under_regions", 659 override_policy.fail_under_regions, 660 base.fail_under_regions, 661 )?; 662 validate_override_threshold( 663 crate_name, 664 "fail_under_branches", 665 override_policy.fail_under_branches, 666 base.fail_under_branches, 667 )?; 668 if override_policy.require_branches == Some(true) && !base.require_branches { 669 return Err(format!( 670 "coverage override {crate_name} require_branches cannot be stricter than the global gate" 671 )); 672 } 673 } 674 Ok(()) 675 } 676 677 pub(crate) fn required_crate_entries(&self) -> &[String] { 678 &self.required.crates 679 } 680 } 681 682 fn validate_override_threshold( 683 crate_name: &str, 684 label: &str, 685 value: Option<f64>, 686 global: f64, 687 ) -> Result<(), String> { 688 let Some(value) = value else { 689 return Ok(()); 690 }; 691 if !value.is_finite() { 692 return Err(format!( 693 "coverage override {crate_name} {label} must be finite" 694 )); 695 } 696 if !(0.0..=100.0).contains(&value) { 697 return Err(format!( 698 "coverage override {crate_name} {label} must be within 0..=100" 699 )); 700 } 701 if value > global { 702 return Err(format!( 703 "coverage override {crate_name} {label} must not exceed the global gate" 704 )); 705 } 706 Ok(()) 707 } 708 709 pub(crate) fn coverage_policy_path(root: &Path) -> PathBuf { 710 root.join("contracts").join("coverage.toml") 711 } 712 713 pub(crate) fn read_coverage_policy(path: &Path) -> Result<CoveragePolicyFile, String> { 714 let raw = match fs::read_to_string(path) { 715 Ok(raw) => raw, 716 Err(err) => { 717 return Err(format!( 718 "failed to read coverage policy {}: {err}", 719 path.display() 720 )); 721 } 722 }; 723 let parsed: CoveragePolicyFile = match toml::from_str(&raw) { 724 Ok(parsed) => parsed, 725 Err(err) => { 726 return Err(format!( 727 "failed to parse coverage policy {}: {err}", 728 path.display() 729 )); 730 } 731 }; 732 let thresholds = parsed.thresholds(); 733 for (label, value) in [ 734 ("fail_under_exec_lines", thresholds.fail_under_exec_lines), 735 ("fail_under_functions", thresholds.fail_under_functions), 736 ("fail_under_regions", thresholds.fail_under_regions), 737 ("fail_under_branches", thresholds.fail_under_branches), 738 ] { 739 if !value.is_finite() { 740 return Err(format!("coverage policy {label} must be finite")); 741 } 742 if !(0.0..=100.0).contains(&value) { 743 return Err(format!("coverage policy {label} must be within 0..=100")); 744 } 745 } 746 parsed.required_crates()?; 747 parsed.validate_overrides()?; 748 Ok(parsed) 749 } 750 751 fn read_required_crates(path: &Path) -> Result<Vec<String>, String> { 752 read_coverage_policy(path)?.required_crates() 753 } 754 755 fn read_workspace_crates(workspace_root: &Path) -> Result<Vec<String>, String> { 756 let packages = read_workspace_packages(workspace_root)?; 757 Ok(packages.into_iter().map(|(name, _)| name).collect()) 758 } 759 760 fn read_workspace_packages(workspace_root: &Path) -> Result<Vec<(String, PathBuf)>, String> { 761 let workspace_manifest = parse_toml::<WorkspaceManifest>(&workspace_root.join("Cargo.toml"))?; 762 if workspace_manifest.workspace.members.is_empty() { 763 return Err("workspace members list must not be empty".to_string()); 764 } 765 let mut packages = Vec::with_capacity(workspace_manifest.workspace.members.len()); 766 let mut seen = BTreeSet::new(); 767 for member in workspace_manifest.workspace.members { 768 let package_manifest = 769 parse_toml::<PackageManifest>(&workspace_root.join(&member).join("Cargo.toml"))?; 770 let package_name = package_manifest.package.name; 771 if package_name.trim().is_empty() { 772 return Err("workspace includes an empty package name".to_string()); 773 } 774 if !seen.insert(package_name.clone()) { 775 return Err(format!( 776 "workspace includes duplicate package name {}", 777 package_name 778 )); 779 } 780 packages.push((package_name, PathBuf::from(member))); 781 } 782 Ok(packages) 783 } 784 785 fn parse_toml<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T, String> { 786 let raw = match fs::read_to_string(path) { 787 Ok(raw) => raw, 788 Err(err) => return Err(format!("failed to read {}: {err}", path.display())), 789 }; 790 match toml::from_str::<T>(&raw) { 791 Ok(parsed) => Ok(parsed), 792 Err(err) => Err(format!("failed to parse {}: {err}", path.display())), 793 } 794 } 795 796 fn merge_coverage_profile( 797 base: CoverageProfileRaw, 798 overlay: CoverageProfileRaw, 799 ) -> CoverageProfile { 800 CoverageProfile { 801 no_default_features: overlay 802 .no_default_features 803 .unwrap_or(base.no_default_features.unwrap_or(false)), 804 features: overlay 805 .features 806 .unwrap_or_else(|| base.features.unwrap_or_default()), 807 test_threads: overlay.test_threads.or(base.test_threads), 808 } 809 } 810 811 fn read_coverage_profile( 812 workspace_root: &Path, 813 crate_name: &str, 814 ) -> Result<CoverageProfile, String> { 815 let path = workspace_root 816 .join("contracts") 817 .join("coverage-profiles.toml"); 818 if !path.exists() { 819 return Ok(CoverageProfile { 820 no_default_features: false, 821 features: Vec::new(), 822 test_threads: None, 823 }); 824 } 825 let parsed = parse_toml::<CoverageProfilesFile>(&path)?; 826 let base = parsed.profiles.default; 827 let overlay = parsed 828 .profiles 829 .crates 830 .get(crate_name) 831 .cloned() 832 .unwrap_or_default(); 833 let resolved = merge_coverage_profile(base, overlay); 834 if resolved 835 .features 836 .iter() 837 .any(|feature| feature.trim().is_empty()) 838 { 839 return Err(format!( 840 "coverage profile for {crate_name} includes an empty feature value" 841 )); 842 } 843 if resolved.test_threads == Some(0) { 844 return Err(format!( 845 "coverage profile for {crate_name} must set test_threads > 0" 846 )); 847 } 848 Ok(resolved) 849 } 850 851 pub fn read_lcov(path: &Path) -> Result<LcovCoverage, String> { 852 let raw = match fs::read_to_string(path) { 853 Ok(raw) => raw, 854 Err(err) => return Err(format!("failed to read lcov {}: {err}", path.display())), 855 }; 856 857 let mut current_filename: Option<String> = None; 858 let mut source_cache: BTreeMap<String, Option<String>> = BTreeMap::new(); 859 let mut da_total: u64 = 0; 860 let mut da_covered: u64 = 0; 861 let mut executable_total: u64 = 0; 862 let mut executable_covered: u64 = 0; 863 let mut branch_total_lcov: u64 = 0; 864 let mut branch_covered_lcov: u64 = 0; 865 let mut branch_total_brda: u64 = 0; 866 let mut branch_covered_brda: u64 = 0; 867 868 for line in raw.lines() { 869 if let Some(filename) = line.strip_prefix("SF:") { 870 current_filename = Some(filename.to_string()); 871 continue; 872 } 873 if let Some(value) = line.strip_prefix("DA:") { 874 let Some((line_number, hit)) = value.split_once(',') else { 875 return Err(format!("invalid DA record in {}", path.display())); 876 }; 877 let line_number = match line_number.parse::<u64>() { 878 Ok(line_number) => line_number, 879 Err(err) => { 880 return Err(format!( 881 "invalid DA line number `{line_number}` in {}: {err}", 882 path.display() 883 )); 884 } 885 }; 886 let hit_count: u64 = match hit.parse() { 887 Ok(hit_count) => hit_count, 888 Err(err) => { 889 return Err(format!( 890 "invalid DA hit count `{hit}` in {}: {err}", 891 path.display() 892 )); 893 } 894 }; 895 if current_filename.as_deref().is_some_and(|filename| { 896 is_ignorable_lcov_source_line(filename, line_number, &mut source_cache) 897 }) { 898 continue; 899 } 900 da_total = da_total.saturating_add(1); 901 if hit_count > 0 { 902 da_covered = da_covered.saturating_add(1); 903 } 904 continue; 905 } 906 if let Some(value) = line.strip_prefix("LF:") { 907 let parsed: u64 = match value.parse() { 908 Ok(parsed) => parsed, 909 Err(err) => { 910 return Err(format!( 911 "invalid LF value `{value}` in {}: {err}", 912 path.display() 913 )); 914 } 915 }; 916 executable_total = executable_total.saturating_add(parsed); 917 continue; 918 } 919 if let Some(value) = line.strip_prefix("LH:") { 920 let parsed: u64 = match value.parse() { 921 Ok(parsed) => parsed, 922 Err(err) => { 923 return Err(format!( 924 "invalid LH value `{value}` in {}: {err}", 925 path.display() 926 )); 927 } 928 }; 929 executable_covered = executable_covered.saturating_add(parsed); 930 continue; 931 } 932 if let Some(value) = line.strip_prefix("BRF:") { 933 let parsed: u64 = match value.parse() { 934 Ok(parsed) => parsed, 935 Err(err) => { 936 return Err(format!( 937 "invalid BRF value `{value}` in {}: {err}", 938 path.display() 939 )); 940 } 941 }; 942 branch_total_lcov = branch_total_lcov.saturating_add(parsed); 943 continue; 944 } 945 if let Some(value) = line.strip_prefix("BRH:") { 946 let parsed: u64 = match value.parse() { 947 Ok(parsed) => parsed, 948 Err(err) => { 949 return Err(format!( 950 "invalid BRH value `{value}` in {}: {err}", 951 path.display() 952 )); 953 } 954 }; 955 branch_covered_lcov = branch_covered_lcov.saturating_add(parsed); 956 continue; 957 } 958 if let Some(value) = line.strip_prefix("BRDA:") { 959 let fields = value.split(',').collect::<Vec<_>>(); 960 if fields.len() != 4 { 961 return Err(format!("invalid BRDA record in {}", path.display())); 962 } 963 let line_number = match fields[0].parse::<u64>() { 964 Ok(line_number) => line_number, 965 Err(err) => { 966 return Err(format!( 967 "invalid BRDA line number `{}` in {}: {err}", 968 fields[0], 969 path.display() 970 )); 971 } 972 }; 973 if current_filename.as_deref().is_some_and(|filename| { 974 is_ignorable_lcov_source_line(filename, line_number, &mut source_cache) 975 }) { 976 continue; 977 } 978 let taken = fields[3]; 979 if taken == "-" { 980 continue; 981 } 982 let hit_count: u64 = match taken.parse() { 983 Ok(hit_count) => hit_count, 984 Err(err) => { 985 return Err(format!( 986 "invalid BRDA taken count `{taken}` in {}: {err}", 987 path.display() 988 )); 989 } 990 }; 991 branch_total_brda = branch_total_brda.saturating_add(1); 992 if hit_count > 0 { 993 branch_covered_brda = branch_covered_brda.saturating_add(1); 994 } 995 } 996 } 997 998 let mut executable_source = ExecutableSource::Da; 999 let mut executable_percent = 100.0_f64; 1000 1001 if da_total > 0 { 1002 executable_total = da_total; 1003 executable_covered = da_covered; 1004 executable_percent = (da_covered as f64 / da_total as f64) * 100.0_f64; 1005 } else if executable_total > 0 { 1006 executable_source = ExecutableSource::LfLh; 1007 executable_percent = (executable_covered as f64 / executable_total as f64) * 100.0_f64; 1008 } 1009 1010 let (branch_total, branch_covered) = if branch_total_brda > 0 { 1011 (branch_total_brda, branch_covered_brda) 1012 } else { 1013 (branch_total_lcov, branch_covered_lcov) 1014 }; 1015 let branches_available = branch_total > 0; 1016 let branch_percent = if branches_available { 1017 Some((branch_covered as f64 / branch_total as f64) * 100.0_f64) 1018 } else { 1019 None 1020 }; 1021 1022 Ok(LcovCoverage { 1023 executable_total, 1024 executable_covered, 1025 executable_percent, 1026 executable_source, 1027 branch_total, 1028 branch_covered, 1029 branches_available, 1030 branch_percent, 1031 }) 1032 } 1033 1034 pub fn evaluate_gate( 1035 summary: &CoverageSummary, 1036 lcov: &LcovCoverage, 1037 thresholds: CoverageThresholds, 1038 ) -> CoverageGateResult { 1039 let exec_ok = lcov.executable_percent >= thresholds.fail_under_exec_lines; 1040 let functions_ok = summary.functions_percent >= thresholds.fail_under_functions; 1041 let regions_ok = summary.summary_regions_percent >= thresholds.fail_under_regions; 1042 let branch_presence_ok = !thresholds.require_branches || lcov.branches_available; 1043 let branch_ok = lcov 1044 .branch_percent 1045 .is_none_or(|branch_percent| branch_percent >= thresholds.fail_under_branches); 1046 1047 let pass = [ 1048 exec_ok, 1049 functions_ok, 1050 regions_ok, 1051 branch_presence_ok, 1052 branch_ok, 1053 ] 1054 .into_iter() 1055 .all(|flag| flag); 1056 let mut fail_reasons: Vec<String> = Vec::new(); 1057 1058 if !exec_ok { 1059 fail_reasons.push(format!( 1060 "executable_lines={:.6} < {:.6}", 1061 lcov.executable_percent, thresholds.fail_under_exec_lines 1062 )); 1063 } 1064 1065 if !functions_ok { 1066 fail_reasons.push(format!( 1067 "functions={:.6} < {:.6}", 1068 summary.functions_percent, thresholds.fail_under_functions 1069 )); 1070 } 1071 1072 if !regions_ok { 1073 fail_reasons.push(format!( 1074 "regions={:.6} < {:.6}", 1075 summary.summary_regions_percent, thresholds.fail_under_regions 1076 )); 1077 } 1078 1079 if thresholds.require_branches && !lcov.branches_available { 1080 fail_reasons.push("branches=unavailable".to_string()); 1081 } 1082 1083 if lcov.branches_available && !branch_ok { 1084 fail_reasons.push(format!( 1085 "branches={:.6} < {:.6}", 1086 lcov.branch_percent.unwrap_or(0.0), 1087 thresholds.fail_under_branches 1088 )); 1089 } 1090 1091 CoverageGateResult { pass, fail_reasons } 1092 } 1093 1094 fn executable_source_label(source: ExecutableSource) -> &'static str { 1095 match source { 1096 ExecutableSource::Da => "da", 1097 ExecutableSource::LfLh => "lf_lh", 1098 } 1099 } 1100 1101 fn parse_string_arg(args: &[String], name: &str) -> Result<String, String> { 1102 let flag = format!("--{name}"); 1103 let mut index = 0usize; 1104 while index < args.len() { 1105 if args[index] == flag { 1106 let Some(value) = args.get(index + 1) else { 1107 return Err(format!("missing value for --{name}")); 1108 }; 1109 return Ok(value.clone()); 1110 } 1111 index += 1; 1112 } 1113 Err(format!("missing --{name}")) 1114 } 1115 1116 fn parse_optional_string_arg(args: &[String], name: &str) -> Option<String> { 1117 let flag = format!("--{name}"); 1118 let mut index = 0usize; 1119 while index < args.len() { 1120 if args[index] == flag { 1121 return args.get(index + 1).cloned(); 1122 } 1123 index += 1; 1124 } 1125 None 1126 } 1127 1128 fn parse_optional_f64_arg(args: &[String], name: &str) -> Result<Option<f64>, String> { 1129 if let Some(raw) = parse_optional_string_arg(args, name) { 1130 let parsed = raw 1131 .parse::<f64>() 1132 .map_err(|err| format!("invalid --{name} value `{raw}`: {err}"))?; 1133 if !parsed.is_finite() { 1134 return Err(format!("invalid --{name} value `{raw}`: must be finite")); 1135 } 1136 return Ok(Some(parsed)); 1137 } 1138 Ok(None) 1139 } 1140 1141 #[cfg_attr(not(test), allow(dead_code))] 1142 fn parse_f64_arg(args: &[String], name: &str, default: f64) -> Result<f64, String> { 1143 if let Some(raw) = parse_optional_string_arg(args, name) { 1144 return raw 1145 .parse::<f64>() 1146 .map_err(|err| format!("invalid --{name} value `{raw}`: {err}")); 1147 } 1148 Ok(default) 1149 } 1150 1151 fn parse_optional_u32_arg(args: &[String], name: &str) -> Result<Option<u32>, String> { 1152 if let Some(raw) = parse_optional_string_arg(args, name) { 1153 let parsed = raw 1154 .parse::<u32>() 1155 .map_err(|err| format!("invalid --{name} value `{raw}`: {err}"))?; 1156 return Ok(Some(parsed)); 1157 } 1158 Ok(None) 1159 } 1160 1161 fn parse_bool_flag(args: &[String], name: &str) -> bool { 1162 let flag = format!("--{name}"); 1163 args.iter().any(|arg| arg == &flag) 1164 } 1165 1166 fn has_flag(args: &[String], name: &str) -> bool { 1167 let flag = format!("--{name}"); 1168 args.iter().any(|arg| arg == &flag) 1169 } 1170 1171 fn workspace_root_with_override(override_root: Option<&str>) -> PathBuf { 1172 if let Some(raw) = override_root { 1173 let trimmed = raw.trim(); 1174 if !trimmed.is_empty() { 1175 return PathBuf::from(trimmed); 1176 } 1177 } 1178 let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); 1179 let crates_dir = manifest_dir.parent().unwrap_or(manifest_dir); 1180 let root = crates_dir.parent().unwrap_or(crates_dir); 1181 root.to_path_buf() 1182 } 1183 1184 fn workspace_root() -> PathBuf { 1185 let override_root = std::env::var("RADROOTS_WORKSPACE_ROOT").ok(); 1186 workspace_root_with_override(override_root.as_deref()) 1187 } 1188 1189 fn run_command(mut command: Command, name: &str) -> Result<(), String> { 1190 let status = match command.status() { 1191 Ok(status) => status, 1192 Err(err) => return Err(format!("failed to run {name}: {err}")), 1193 }; 1194 if !status.success() { 1195 return Err(format!("{name} failed with status {status}")); 1196 } 1197 Ok(()) 1198 } 1199 1200 fn apply_coverage_profile_flags(command: &mut Command, profile: &CoverageProfile) { 1201 if profile.no_default_features { 1202 command.arg("--no-default-features"); 1203 } 1204 if !profile.features.is_empty() { 1205 command.arg("--features").arg(profile.features.join(",")); 1206 } 1207 } 1208 1209 fn prepend_toolchain_bin_to_path( 1210 toolchain_bin: &Path, 1211 existing_path: Option<OsString>, 1212 ) -> OsString { 1213 match existing_path { 1214 Some(existing) => std::env::join_paths( 1215 std::iter::once(toolchain_bin.to_path_buf()).chain(std::env::split_paths(&existing)), 1216 ) 1217 .expect("joining PATH entries for coverage toolchain should succeed"), 1218 None => OsString::from(toolchain_bin), 1219 } 1220 } 1221 1222 fn configure_coverage_toolchain_env(command: &mut Command, toolchain_bin: &Path) { 1223 let joined_path = prepend_toolchain_bin_to_path(toolchain_bin, std::env::var_os("PATH")); 1224 command.env("PATH", joined_path); 1225 1226 for (env_name, binary_name) in [ 1227 ("RUSTC", "rustc"), 1228 ("RUSTDOC", "rustdoc"), 1229 ("LLVM_COV", "llvm-cov"), 1230 ("LLVM_PROFDATA", "llvm-profdata"), 1231 ] { 1232 let binary_path = toolchain_bin.join(binary_name); 1233 if binary_path.exists() { 1234 command.env(env_name, binary_path); 1235 } 1236 } 1237 } 1238 1239 fn coverage_cargo_command_with_override(override_binary: Option<&str>) -> Command { 1240 if let Some(binary) = override_binary { 1241 let mut cmd = Command::new(binary); 1242 if let Some(toolchain_bin) = Path::new(binary).parent() { 1243 configure_coverage_toolchain_env(&mut cmd, toolchain_bin); 1244 } 1245 return cmd; 1246 } 1247 1248 let mut cmd = Command::new("rustup"); 1249 cmd.arg("run").arg("nightly").arg("cargo"); 1250 cmd 1251 } 1252 1253 fn normalized_coverage_cargo_override(raw: Option<String>) -> Option<String> { 1254 raw.map(|raw| raw.trim().to_string()) 1255 .filter(|raw| !raw.is_empty()) 1256 } 1257 1258 fn coverage_cargo_command() -> Command { 1259 let override_binary = 1260 normalized_coverage_cargo_override(std::env::var("RADROOTS_COVERAGE_CARGO").ok()); 1261 coverage_cargo_command_with_override(override_binary.as_deref()) 1262 } 1263 1264 fn coverage_llvm_cov_command() -> Command { 1265 let mut cmd = coverage_cargo_command(); 1266 cmd.arg("llvm-cov"); 1267 cmd 1268 } 1269 1270 const COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX: &str = 1271 r"(/\.cargo/registry/|/lib/rustlib/src/rust/)"; 1272 1273 fn escape_regex_literal(raw: &str) -> String { 1274 let mut escaped = String::with_capacity(raw.len()); 1275 for ch in raw.chars() { 1276 match ch { 1277 '\\' | '.' | '+' | '*' | '?' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' => { 1278 escaped.push('\\'); 1279 escaped.push(ch); 1280 } 1281 _ => escaped.push(ch), 1282 } 1283 } 1284 escaped 1285 } 1286 1287 fn coverage_ignore_filename_regex( 1288 workspace_root: &Path, 1289 crate_name: &str, 1290 ) -> Result<String, String> { 1291 let mut patterns = vec![COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX.to_string()]; 1292 let mut found_target = false; 1293 1294 for (package_name, member_path) in read_workspace_packages(workspace_root)? { 1295 let absolute_member = workspace_root.join(member_path); 1296 if package_name == crate_name { 1297 found_target = true; 1298 patterns.push(format!( 1299 "^{}/", 1300 escape_regex_literal(&absolute_member.join("tests").display().to_string()) 1301 )); 1302 continue; 1303 } 1304 patterns.push(format!( 1305 "^{}/", 1306 escape_regex_literal(&absolute_member.display().to_string()) 1307 )); 1308 } 1309 1310 if !found_target { 1311 return Err(format!( 1312 "workspace coverage filters could not resolve crate directory for {crate_name}" 1313 )); 1314 } 1315 1316 Ok(format!("({})", patterns.join("|"))) 1317 } 1318 1319 fn apply_coverage_report_filters(command: &mut Command, ignore_regex: &str) { 1320 command.arg("--ignore-filename-regex").arg(ignore_regex); 1321 } 1322 1323 fn run_crate_with_runner_at_root( 1324 args: &[String], 1325 workspace_root: &Path, 1326 runner: &mut dyn FnMut(Command, &str) -> Result<(), String>, 1327 ) -> Result<(), String> { 1328 let crate_name = parse_string_arg(args, "crate")?; 1329 let profile = read_coverage_profile(workspace_root, &crate_name)?; 1330 let out_dir = if let Some(raw) = parse_optional_string_arg(args, "out") { 1331 PathBuf::from(raw) 1332 } else { 1333 workspace_root 1334 .join("target") 1335 .join("coverage") 1336 .join(crate_name.replace('-', "_")) 1337 }; 1338 let test_threads = parse_optional_u32_arg(args, "test-threads")? 1339 .or(profile.test_threads) 1340 .unwrap_or(1); 1341 let ignore_regex = coverage_ignore_filename_regex(workspace_root, &crate_name)?; 1342 1343 if let Err(err) = fs::create_dir_all(&out_dir) { 1344 return Err(format!("failed to create {}: {err}", out_dir.display())); 1345 } 1346 1347 runner( 1348 { 1349 let mut cmd = coverage_llvm_cov_command(); 1350 cmd.arg("clean") 1351 .arg("--workspace") 1352 .current_dir(workspace_root); 1353 cmd 1354 }, 1355 "cargo llvm-cov clean --workspace", 1356 )?; 1357 1358 runner( 1359 { 1360 let mut cmd = coverage_llvm_cov_command(); 1361 cmd.arg("-p").arg(&crate_name); 1362 apply_coverage_profile_flags(&mut cmd, &profile); 1363 cmd.arg("--no-report") 1364 .arg("--branch") 1365 .arg("--") 1366 .arg(format!("--test-threads={test_threads}")) 1367 .current_dir(workspace_root); 1368 cmd 1369 }, 1370 "cargo llvm-cov --no-report", 1371 )?; 1372 1373 let summary_path = out_dir.join("coverage-summary.json"); 1374 runner( 1375 { 1376 let mut cmd = coverage_llvm_cov_command(); 1377 cmd.arg("report").arg("-p").arg(&crate_name); 1378 apply_coverage_report_filters(&mut cmd, &ignore_regex); 1379 cmd.arg("--json") 1380 .arg("--summary-only") 1381 .arg("--branch") 1382 .arg("--output-path") 1383 .arg(&summary_path) 1384 .current_dir(workspace_root); 1385 cmd 1386 }, 1387 "cargo llvm-cov report --json --summary-only", 1388 )?; 1389 1390 let details_path = out_dir.join("coverage-details.json"); 1391 runner( 1392 { 1393 let mut cmd = coverage_llvm_cov_command(); 1394 cmd.arg("report").arg("-p").arg(&crate_name); 1395 apply_coverage_report_filters(&mut cmd, &ignore_regex); 1396 cmd.arg("--json") 1397 .arg("--branch") 1398 .arg("--output-path") 1399 .arg(&details_path) 1400 .current_dir(workspace_root); 1401 cmd 1402 }, 1403 "cargo llvm-cov report --json", 1404 )?; 1405 1406 let lcov_path = out_dir.join("coverage-lcov.info"); 1407 runner( 1408 { 1409 let mut cmd = coverage_llvm_cov_command(); 1410 cmd.arg("report").arg("-p").arg(&crate_name); 1411 apply_coverage_report_filters(&mut cmd, &ignore_regex); 1412 cmd.arg("--lcov") 1413 .arg("--branch") 1414 .arg("--output-path") 1415 .arg(&lcov_path) 1416 .current_dir(workspace_root); 1417 cmd 1418 }, 1419 "cargo llvm-cov report --lcov", 1420 )?; 1421 1422 eprintln!("coverage summary: {}", summary_path.display()); 1423 eprintln!("coverage details: {}", details_path.display()); 1424 eprintln!("coverage lcov: {}", lcov_path.display()); 1425 Ok(()) 1426 } 1427 1428 fn run_crate_with_runner( 1429 args: &[String], 1430 runner: &mut dyn FnMut(Command, &str) -> Result<(), String>, 1431 ) -> Result<(), String> { 1432 let root = workspace_root(); 1433 run_crate_with_runner_at_root(args, &root, runner) 1434 } 1435 1436 fn run_crate(args: &[String]) -> Result<(), String> { 1437 let mut runner = run_command; 1438 run_crate_with_runner(args, &mut runner) 1439 } 1440 1441 fn report_gate_with_root(args: &[String], root: &Path) -> Result<(), String> { 1442 let scope = parse_string_arg(args, "scope")?; 1443 let summary_path = PathBuf::from(parse_string_arg(args, "summary")?); 1444 let lcov_path = PathBuf::from(parse_string_arg(args, "lcov")?); 1445 let out_path = PathBuf::from(parse_string_arg(args, "out")?); 1446 let policy_gate = parse_bool_flag(args, "policy-gate"); 1447 let explicit_exec = parse_optional_f64_arg(args, "fail-under-exec-lines")?; 1448 let explicit_functions = parse_optional_f64_arg(args, "fail-under-functions")?; 1449 let explicit_regions = parse_optional_f64_arg(args, "fail-under-regions")?; 1450 let explicit_branches = parse_optional_f64_arg(args, "fail-under-branches")?; 1451 let explicit_require_branches = has_flag(args, "require-branches"); 1452 let any_explicit_threshold = explicit_exec.is_some() 1453 || explicit_functions.is_some() 1454 || explicit_regions.is_some() 1455 || explicit_branches.is_some(); 1456 let thresholds = if policy_gate { 1457 if any_explicit_threshold || explicit_require_branches { 1458 return Err( 1459 "--policy-gate cannot be combined with explicit threshold or branch flags" 1460 .to_string(), 1461 ); 1462 } 1463 let policy = read_coverage_policy(&coverage_policy_path(root))?; 1464 policy.thresholds_for_scope(&scope) 1465 } else { 1466 let Some(fail_under_exec_lines) = explicit_exec else { 1467 return Err( 1468 "missing coverage thresholds; pass --policy-gate or explicit --fail-under-* values" 1469 .to_string(), 1470 ); 1471 }; 1472 let Some(fail_under_functions) = explicit_functions else { 1473 return Err( 1474 "missing coverage thresholds; pass --policy-gate or explicit --fail-under-* values" 1475 .to_string(), 1476 ); 1477 }; 1478 let Some(fail_under_regions) = explicit_regions else { 1479 return Err( 1480 "missing coverage thresholds; pass --policy-gate or explicit --fail-under-* values" 1481 .to_string(), 1482 ); 1483 }; 1484 let Some(fail_under_branches) = explicit_branches else { 1485 return Err( 1486 "missing coverage thresholds; pass --policy-gate or explicit --fail-under-* values" 1487 .to_string(), 1488 ); 1489 }; 1490 CoverageThresholds { 1491 fail_under_exec_lines, 1492 fail_under_functions, 1493 fail_under_regions, 1494 fail_under_branches, 1495 require_branches: explicit_require_branches, 1496 } 1497 }; 1498 1499 let mut summary = read_summary_for_scope(&summary_path, Some(&scope))?; 1500 let lcov = read_lcov(&lcov_path)?; 1501 normalize_summary_for_gate(&scope, &summary_path, &lcov, &mut summary)?; 1502 let gate = evaluate_gate(&summary, &lcov, thresholds); 1503 1504 let report = CoverageGateReport { 1505 scope: scope.clone(), 1506 thresholds: CoverageGateReportThresholds { 1507 executable_lines: thresholds.fail_under_exec_lines, 1508 functions: thresholds.fail_under_functions, 1509 regions: thresholds.fail_under_regions, 1510 branches: thresholds.fail_under_branches, 1511 branches_required: thresholds.require_branches, 1512 }, 1513 measured: CoverageGateReportMeasured { 1514 executable_lines_percent: lcov.executable_percent, 1515 executable_lines_source: executable_source_label(lcov.executable_source).to_string(), 1516 functions_percent: summary.functions_percent, 1517 branches_percent: lcov.branch_percent, 1518 branches_available: lcov.branches_available, 1519 summary_lines_percent: summary.summary_lines_percent, 1520 summary_regions_percent: summary.summary_regions_percent, 1521 }, 1522 counts: CoverageGateReportCounts { 1523 executable_lines: CoverageCount { 1524 covered: lcov.executable_covered, 1525 total: lcov.executable_total, 1526 }, 1527 branches: CoverageCount { 1528 covered: lcov.branch_covered, 1529 total: lcov.branch_total, 1530 }, 1531 }, 1532 result: CoverageGateReportResult { 1533 pass: gate.pass, 1534 fail_reasons: gate.fail_reasons.clone(), 1535 }, 1536 }; 1537 write_gate_report(&out_path, &report)?; 1538 1539 if lcov.branches_available { 1540 eprintln!( 1541 "{} coverage: executable_lines={:.6} functions={:.6} regions={:.6} branches={:.6}", 1542 scope, 1543 lcov.executable_percent, 1544 summary.functions_percent, 1545 summary.summary_regions_percent, 1546 lcov.branch_percent.unwrap_or(0.0) 1547 ); 1548 } else { 1549 eprintln!( 1550 "{} coverage: executable_lines={:.6} functions={:.6} regions={:.6} branches=unavailable", 1551 scope, 1552 lcov.executable_percent, 1553 summary.functions_percent, 1554 summary.summary_regions_percent 1555 ); 1556 } 1557 1558 eprintln!( 1559 "{} summary (informational): lines={:.6} regions={:.6}", 1560 scope, summary.summary_lines_percent, summary.summary_regions_percent 1561 ); 1562 1563 if !gate.pass { 1564 for reason in &gate.fail_reasons { 1565 eprintln!("{scope} gate fail: {reason}"); 1566 } 1567 return Err("coverage gate failed".to_string()); 1568 } 1569 1570 Ok(()) 1571 } 1572 1573 #[cfg_attr(coverage_nightly, coverage(off))] 1574 fn normalize_summary_for_gate( 1575 scope: &str, 1576 summary_path: &Path, 1577 _lcov: &LcovCoverage, 1578 summary: &mut CoverageSummary, 1579 ) -> Result<(), String> { 1580 let details_path = coverage_details_path(summary_path); 1581 if !details_path.exists() { 1582 return Ok(()); 1583 } 1584 1585 let normalized = read_detailed_summary(&details_path, Some(scope))?; 1586 summary.functions_percent = normalized.functions_percent; 1587 summary.summary_regions_percent = normalized.regions_percent; 1588 Ok(()) 1589 } 1590 1591 #[cfg_attr(not(test), allow(dead_code))] 1592 fn report_gate(args: &[String]) -> Result<(), String> { 1593 let root = workspace_root(); 1594 report_gate_with_root(args, &root) 1595 } 1596 1597 fn report_missing_gate_with_root(args: &[String], root: &Path) -> Result<(), String> { 1598 let scope = parse_string_arg(args, "scope")?; 1599 let out_path = PathBuf::from(parse_string_arg(args, "out")?); 1600 let reason = parse_string_arg(args, "reason")?; 1601 let policy = read_coverage_policy(&coverage_policy_path(root))?; 1602 let thresholds = policy.thresholds_for_scope(&scope); 1603 1604 let report = CoverageGateReport { 1605 scope: scope.clone(), 1606 thresholds: CoverageGateReportThresholds { 1607 executable_lines: thresholds.fail_under_exec_lines, 1608 functions: thresholds.fail_under_functions, 1609 regions: thresholds.fail_under_regions, 1610 branches: thresholds.fail_under_branches, 1611 branches_required: thresholds.require_branches, 1612 }, 1613 measured: CoverageGateReportMeasured { 1614 executable_lines_percent: 0.0, 1615 executable_lines_source: executable_source_label(ExecutableSource::Da).to_string(), 1616 functions_percent: 0.0, 1617 branches_percent: None, 1618 branches_available: false, 1619 summary_lines_percent: 0.0, 1620 summary_regions_percent: 0.0, 1621 }, 1622 counts: CoverageGateReportCounts { 1623 executable_lines: CoverageCount { 1624 covered: 0, 1625 total: 0, 1626 }, 1627 branches: CoverageCount { 1628 covered: 0, 1629 total: 0, 1630 }, 1631 }, 1632 result: CoverageGateReportResult { 1633 pass: false, 1634 fail_reasons: vec![reason.clone()], 1635 }, 1636 }; 1637 write_gate_report(&out_path, &report)?; 1638 eprintln!("{scope} gate fail: {reason}"); 1639 Ok(()) 1640 } 1641 1642 fn write_gate_report(out_path: &Path, report: &CoverageGateReport) -> Result<(), String> { 1643 let json = serde_json::to_string_pretty(report) 1644 .expect("serializing coverage gate report should succeed"); 1645 if let Err(err) = fs::write(out_path, format!("{json}\n")) { 1646 return Err(format!("failed to write {}: {err}", out_path.display())); 1647 } 1648 Ok(()) 1649 } 1650 1651 fn coverage_report_path(reports_root: &Path, crate_name: &str) -> PathBuf { 1652 reports_root 1653 .join(crate_name.replace('-', "_")) 1654 .join("gate-report.json") 1655 } 1656 1657 fn read_gate_report(path: &Path) -> Result<CoverageGateReport, String> { 1658 let raw = match fs::read_to_string(path) { 1659 Ok(raw) => raw, 1660 Err(err) => { 1661 return Err(format!( 1662 "failed to read gate report {}: {err}", 1663 path.display() 1664 )); 1665 } 1666 }; 1667 match serde_json::from_str::<CoverageGateReport>(&raw) { 1668 Ok(report) => Ok(report), 1669 Err(err) => Err(format!( 1670 "failed to parse gate report {}: {err}", 1671 path.display() 1672 )), 1673 } 1674 } 1675 1676 fn list_required_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { 1677 let required_path = coverage_policy_path(root); 1678 let crates = read_required_crates(&required_path)?; 1679 write_crate_names_output(writer, crates, "required crates") 1680 } 1681 1682 fn list_workspace_crates_with_root(root: &Path, writer: &mut dyn Write) -> Result<(), String> { 1683 let crates = read_workspace_crates(root)?; 1684 write_crate_names_output(writer, crates, "workspace crates") 1685 } 1686 1687 fn write_crate_names_output( 1688 writer: &mut dyn Write, 1689 crates: Vec<String>, 1690 label: &str, 1691 ) -> Result<(), String> { 1692 for crate_name in crates { 1693 if let Err(err) = writeln!(writer, "{crate_name}") { 1694 return Err(format!("failed to write {label} output: {err}")); 1695 } 1696 } 1697 Ok(()) 1698 } 1699 1700 fn run_with_root(args: &[String], root: &Path) -> Result<(), String> { 1701 match args.first().map(String::as_str) { 1702 Some("help") => Ok(()), 1703 Some("run-crate") => run_crate(&args[1..]), 1704 Some("report") => report_gate_with_root(&args[1..], root), 1705 Some("report-missing") => report_missing_gate_with_root(&args[1..], root), 1706 Some("refresh-summary") => { 1707 let reports_root = match parse_optional_string_arg(&args[1..], "reports-root") { 1708 Some(raw) => PathBuf::from(raw), 1709 None => PathBuf::from("target/coverage"), 1710 }; 1711 let out_path = match parse_optional_string_arg(&args[1..], "out") { 1712 Some(raw) => PathBuf::from(raw), 1713 None => PathBuf::from("target/coverage/coverage-refresh.tsv"), 1714 }; 1715 let status_out_path = 1716 parse_optional_string_arg(&args[1..], "status-out").map(PathBuf::from); 1717 let required_crates = read_required_crates(&coverage_policy_path(root))?; 1718 1719 let mut refresh_rows = 1720 String::from("crate\tstatus\texec\tfunc\tbranch\tregion\treport\n"); 1721 let mut status_rows = String::from("crate\tstatus\n"); 1722 1723 for crate_name in required_crates { 1724 let report_path = coverage_report_path(&reports_root, &crate_name); 1725 let report = read_gate_report(&report_path)?; 1726 let status = if report.result.pass { "pass" } else { "fail" }; 1727 let branch = report 1728 .measured 1729 .branches_percent 1730 .map(|value| format!("{value:.6}")) 1731 .unwrap_or_else(|| "unavailable".to_string()); 1732 refresh_rows.push_str(&format!( 1733 "{}\t{}\t{:.6}\t{:.6}\t{}\t{:.6}\t{}\n", 1734 crate_name, 1735 status, 1736 report.measured.executable_lines_percent, 1737 report.measured.functions_percent, 1738 branch, 1739 report.measured.summary_regions_percent, 1740 report_path.display() 1741 )); 1742 status_rows.push_str(&format!("{}\t{}\n", crate_name, status)); 1743 } 1744 1745 if let Some(parent) = out_path.parent() 1746 && !parent.as_os_str().is_empty() 1747 && let Err(err) = fs::create_dir_all(parent) 1748 { 1749 return Err(format!("failed to create {}: {err}", parent.display())); 1750 } 1751 fs::write(&out_path, refresh_rows) 1752 .map_err(|err| format!("failed to write {}: {err}", out_path.display()))?; 1753 1754 if let Some(status_out_path) = status_out_path { 1755 if let Some(parent) = status_out_path.parent() 1756 && !parent.as_os_str().is_empty() 1757 { 1758 fs::create_dir_all(parent) 1759 .map_err(|err| format!("failed to create {}: {err}", parent.display()))?; 1760 } 1761 fs::write(&status_out_path, status_rows).map_err(|err| { 1762 format!("failed to write {}: {err}", status_out_path.display()) 1763 })?; 1764 } 1765 1766 Ok(()) 1767 } 1768 Some("required-crates") => { 1769 let mut stdout = std::io::stdout().lock(); 1770 list_required_crates_with_root(root, &mut stdout) 1771 } 1772 Some("workspace-crates") => { 1773 let mut stdout = std::io::stdout().lock(); 1774 list_workspace_crates_with_root(root, &mut stdout) 1775 } 1776 Some(_) => Err("unknown coverage subcommand".to_string()), 1777 None => Err("missing coverage subcommand".to_string()), 1778 } 1779 } 1780 1781 pub fn run(args: &[String]) -> Result<(), String> { 1782 let root = workspace_root(); 1783 run_with_root(args, &root) 1784 } 1785 1786 #[cfg(test)] 1787 mod tests { 1788 use super::*; 1789 use std::fs; 1790 use std::io::{self, Write}; 1791 use std::path::Path; 1792 use std::sync::{Mutex, MutexGuard, OnceLock}; 1793 use std::time::{SystemTime, UNIX_EPOCH}; 1794 1795 fn temp_file_path(prefix: &str) -> PathBuf { 1796 let ns = SystemTime::now() 1797 .duration_since(UNIX_EPOCH) 1798 .expect("system time") 1799 .as_nanos(); 1800 std::env::temp_dir().join(format!("radroots_xtask_coverage_{prefix}_{ns}.tmp")) 1801 } 1802 1803 fn temp_dir_path(prefix: &str) -> PathBuf { 1804 let ns = SystemTime::now() 1805 .duration_since(UNIX_EPOCH) 1806 .expect("system time") 1807 .as_nanos(); 1808 std::env::temp_dir().join(format!("radroots_xtask_coverage_{prefix}_{ns}")) 1809 } 1810 1811 fn write_file(path: &Path, content: &str) { 1812 let _ = fs::create_dir_all(path.parent().unwrap_or(Path::new(""))); 1813 fs::write(path, content).expect("write file"); 1814 } 1815 1816 fn cwd_lock() -> &'static Mutex<()> { 1817 static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); 1818 LOCK.get_or_init(|| Mutex::new(())) 1819 } 1820 1821 fn recover_lock(lock: &'static Mutex<()>) -> MutexGuard<'static, ()> { 1822 match lock.lock() { 1823 Ok(guard) => guard, 1824 Err(poisoned) => poisoned.into_inner(), 1825 } 1826 } 1827 1828 fn lock_cwd() -> MutexGuard<'static, ()> { 1829 recover_lock(cwd_lock()) 1830 } 1831 1832 fn collect_command_envs(cmd: &Command) -> BTreeMap<String, Option<String>> { 1833 let mut envs = BTreeMap::new(); 1834 for (key, value) in cmd.get_envs() { 1835 envs.insert( 1836 key.to_string_lossy().to_string(), 1837 value.map(|raw| raw.to_string_lossy().to_string()), 1838 ); 1839 } 1840 envs 1841 } 1842 1843 fn ok_runner(_cmd: Command, _name: &str) -> Result<(), String> { 1844 Ok(()) 1845 } 1846 1847 struct FailingWriter; 1848 1849 impl Write for FailingWriter { 1850 fn write(&mut self, _buf: &[u8]) -> io::Result<usize> { 1851 Err(io::Error::other("forced write failure")) 1852 } 1853 1854 fn flush(&mut self) -> io::Result<()> { 1855 Ok(()) 1856 } 1857 } 1858 1859 #[test] 1860 fn reads_summary_totals_from_llvm_cov_json() { 1861 let path = temp_file_path("summary"); 1862 fs::write( 1863 &path, 1864 r#"{ 1865 "data": [ 1866 { 1867 "totals": { 1868 "functions": {"percent": 91.25}, 1869 "lines": {"percent": 88.5}, 1870 "regions": {"percent": 86.75} 1871 } 1872 } 1873 ] 1874 }"#, 1875 ) 1876 .expect("write summary"); 1877 1878 let summary = read_summary(&path).expect("parse summary"); 1879 assert_eq!(summary.functions_percent, 91.25); 1880 assert_eq!(summary.summary_lines_percent, 88.5); 1881 assert_eq!(summary.summary_regions_percent, 86.75); 1882 1883 fs::remove_file(path).expect("remove summary"); 1884 } 1885 1886 #[test] 1887 fn read_summary_normalizes_duplicate_generic_detail_records() { 1888 let root = temp_dir_path("summary_details_normalized"); 1889 let summary_path = root.join("coverage-summary.json"); 1890 write_file( 1891 &summary_path, 1892 r#"{ 1893 "data": [ 1894 { 1895 "totals": { 1896 "functions": {"percent": 100.0}, 1897 "lines": {"percent": 88.5}, 1898 "regions": {"percent": 22.0} 1899 } 1900 } 1901 ] 1902 }"#, 1903 ); 1904 write_file( 1905 &root.join("coverage-details.json"), 1906 r#"{ 1907 "data": [ 1908 { 1909 "functions": [ 1910 { 1911 "count": 4, 1912 "filenames": ["/tmp/lib.rs"], 1913 "regions": [ 1914 [10, 1, 12, 2, 4, 0, 0, 0], 1915 [13, 1, 13, 8, 4, 0, 0, 0] 1916 ] 1917 }, 1918 { 1919 "count": 0, 1920 "filenames": ["/tmp/lib.rs"], 1921 "regions": [ 1922 [10, 1, 12, 2, 0, 0, 0, 0], 1923 [13, 1, 13, 8, 0, 0, 0, 0] 1924 ] 1925 } 1926 ] 1927 } 1928 ] 1929 }"#, 1930 ); 1931 1932 let summary = read_summary(&summary_path).expect("parse normalized summary"); 1933 assert_eq!(summary.functions_percent, 100.0); 1934 assert_eq!(summary.summary_lines_percent, 88.5); 1935 assert_eq!(summary.summary_regions_percent, 100.0); 1936 1937 fs::remove_dir_all(root).expect("remove summary details root"); 1938 } 1939 1940 #[test] 1941 fn read_summary_keeps_original_regions_when_functions_are_not_perfect() { 1942 let root = temp_dir_path("summary_details_not_applied"); 1943 let summary_path = root.join("coverage-summary.json"); 1944 write_file( 1945 &summary_path, 1946 r#"{ 1947 "data": [ 1948 { 1949 "totals": { 1950 "functions": {"percent": 95.0}, 1951 "lines": {"percent": 88.5}, 1952 "regions": {"percent": 22.0} 1953 } 1954 } 1955 ] 1956 }"#, 1957 ); 1958 write_file( 1959 &root.join("coverage-details.json"), 1960 r#"{ 1961 "data": [ 1962 { 1963 "functions": [ 1964 { 1965 "count": 4, 1966 "filenames": ["/tmp/lib.rs"], 1967 "regions": [ 1968 [10, 1, 12, 2, 4, 0, 0, 0] 1969 ] 1970 } 1971 ] 1972 } 1973 ] 1974 }"#, 1975 ); 1976 1977 let summary = read_summary(&summary_path).expect("parse preserved summary"); 1978 assert_eq!(summary.functions_percent, 95.0); 1979 assert_eq!(summary.summary_regions_percent, 22.0); 1980 1981 fs::remove_dir_all(root).expect("remove summary preserve root"); 1982 } 1983 1984 #[test] 1985 fn read_summary_for_scope_ignores_other_crate_detail_records() { 1986 let root = temp_dir_path("summary_details_scope_filtered"); 1987 let summary_path = root.join("coverage-summary.json"); 1988 write_file( 1989 &summary_path, 1990 r#"{ 1991 "data": [ 1992 { 1993 "totals": { 1994 "functions": {"percent": 100.0}, 1995 "lines": {"percent": 88.5}, 1996 "regions": {"percent": 22.0} 1997 } 1998 } 1999 ] 2000 }"#, 2001 ); 2002 write_file( 2003 &root.join("coverage-details.json"), 2004 r#"{ 2005 "data": [ 2006 { 2007 "functions": [ 2008 { 2009 "count": 4, 2010 "filenames": ["/workspace/crates/a/src/lib.rs"], 2011 "regions": [ 2012 [10, 1, 12, 2, 4, 0, 0, 0] 2013 ] 2014 }, 2015 { 2016 "count": 9, 2017 "filenames": ["/workspace/crates/b/src/lib.rs"], 2018 "regions": [ 2019 [20, 1, 20, 6, 0, 0, 0, 0] 2020 ] 2021 } 2022 ] 2023 } 2024 ] 2025 }"#, 2026 ); 2027 2028 let summary = 2029 read_summary_for_scope(&summary_path, Some("radroots_a")).expect("parse scope summary"); 2030 assert_eq!(summary.functions_percent, 100.0); 2031 assert_eq!(summary.summary_lines_percent, 88.5); 2032 assert_eq!(summary.summary_regions_percent, 100.0); 2033 2034 fs::remove_dir_all(root).expect("remove summary scope root"); 2035 } 2036 2037 #[test] 2038 fn coverage_details_path_uses_summary_parent() { 2039 let summary_path = Path::new("target/coverage/radroots_a/coverage-summary.json"); 2040 assert_eq!( 2041 coverage_details_path(summary_path), 2042 Path::new("target/coverage/radroots_a/coverage-details.json") 2043 ); 2044 assert_eq!( 2045 coverage_details_path(Path::new("coverage-summary.json")), 2046 Path::new("coverage-details.json") 2047 ); 2048 } 2049 2050 #[test] 2051 fn read_detailed_summary_covers_empty_skip_and_filter_paths() { 2052 let root = temp_dir_path("details_empty_skip_filter"); 2053 let missing = root.join("missing-details.json"); 2054 let err = read_detailed_summary(&missing, None).expect_err("missing details"); 2055 assert!(err.contains("failed to read coverage details")); 2056 2057 let empty = root.join("empty-details.json"); 2058 write_file(&empty, r#"{"data":[]}"#); 2059 let err = read_detailed_summary(&empty, None).expect_err("empty details"); 2060 assert!(err.contains("coverage details data is empty")); 2061 2062 let skipped = root.join("skipped-details.json"); 2063 write_file( 2064 &skipped, 2065 r#"{ 2066 "data": [ 2067 { 2068 "functions": [ 2069 { 2070 "count": 1, 2071 "filenames": [], 2072 "regions": [[10, 1, 10, 2, 1, 0, 0, 0]] 2073 }, 2074 { 2075 "count": 1, 2076 "filenames": ["/workspace/crates/a/src/lib.rs"], 2077 "regions": [] 2078 } 2079 ] 2080 } 2081 ] 2082 }"#, 2083 ); 2084 let err = read_detailed_summary(&skipped, None).expect_err("skipped details"); 2085 assert!(err.contains("coverage details functions are empty")); 2086 2087 let filtered = root.join("filtered-details.json"); 2088 write_file( 2089 &filtered, 2090 r#"{ 2091 "data": [ 2092 { 2093 "functions": [ 2094 { 2095 "count": 0, 2096 "filenames": ["/workspace/crates/a/src/lib.rs"], 2097 "regions": [[10, 1, 10, 2, 0, 0, 0, 0]] 2098 }, 2099 { 2100 "count": 1, 2101 "filenames": ["/workspace/crates/b/src/lib.rs"], 2102 "regions": [[20, 1, 20, 2, 1, 0, 0, 0]] 2103 } 2104 ] 2105 } 2106 ] 2107 }"#, 2108 ); 2109 let summary = 2110 read_detailed_summary(&filtered, Some("radroots_a")).expect("filtered summary"); 2111 assert_eq!(summary.functions_percent, 0.0); 2112 assert_eq!(summary.regions_percent, 0.0); 2113 2114 fs::remove_dir_all(root).expect("remove detail edge root"); 2115 } 2116 2117 #[test] 2118 fn read_detailed_summary_ignores_synthetic_regions_from_source() { 2119 let root = temp_dir_path("details_synthetic_regions"); 2120 let source_path = root.join("crates").join("a").join("src").join("lib.rs"); 2121 write_file( 2122 &source_path, 2123 "pub fn load() { let _value = call()?; }\npub fn ready() { ok(); }\n", 2124 ); 2125 let details_path = root.join("coverage-details.json"); 2126 let raw = serde_json::json!({ 2127 "data": [ 2128 { 2129 "functions": [ 2130 { 2131 "count": 1, 2132 "filenames": [source_path.display().to_string()], 2133 "regions": [ 2134 [1, 1, 1, 37, 1, 0, 0, 0], 2135 [1, 36, 1, 37, 0, 0, 0, 0], 2136 [2, 1, 2, 24, 1, 0, 0, 0] 2137 ] 2138 } 2139 ] 2140 } 2141 ] 2142 }); 2143 write_file(&details_path, &raw.to_string()); 2144 2145 let summary = 2146 read_detailed_summary(&details_path, Some("radroots_a")).expect("synthetic summary"); 2147 assert_eq!(summary.functions_percent, 100.0); 2148 assert_eq!(summary.regions_percent, 100.0); 2149 2150 fs::remove_dir_all(root).expect("remove synthetic details root"); 2151 } 2152 2153 #[test] 2154 fn read_detailed_summary_ignores_cfg_test_detail_functions() { 2155 let root = temp_dir_path("details_cfg_test_functions"); 2156 let source_path = root.join("crates").join("a").join("src").join("lib.rs"); 2157 write_file( 2158 &source_path, 2159 "#[cfg(test)]\nmod tests {\n fn helper() {}\n}\n", 2160 ); 2161 let details_path = root.join("coverage-details.json"); 2162 let raw = serde_json::json!({ 2163 "data": [ 2164 { 2165 "functions": [ 2166 { 2167 "count": 0, 2168 "filenames": [source_path.display().to_string()], 2169 "regions": [ 2170 [3, 5, 3, 19, 0, 0, 0, 0] 2171 ] 2172 } 2173 ] 2174 } 2175 ] 2176 }); 2177 write_file(&details_path, &raw.to_string()); 2178 2179 let summary = read_detailed_summary(&details_path, Some("radroots_a")) 2180 .expect("cfg-test detail summary"); 2181 assert_eq!(summary.functions_percent, 100.0); 2182 assert_eq!(summary.regions_percent, 100.0); 2183 2184 fs::remove_dir_all(root).expect("remove cfg-test details root"); 2185 } 2186 2187 #[test] 2188 fn read_summary_reports_read_and_parse_errors() { 2189 let missing = temp_file_path("summary_missing"); 2190 let read_err = read_summary(&missing).expect_err("missing summary should fail"); 2191 assert!(read_err.contains("failed to read summary")); 2192 2193 let invalid = temp_file_path("summary_invalid"); 2194 write_file(&invalid, "{not-json"); 2195 let parse_err = read_summary(&invalid).expect_err("invalid summary should fail"); 2196 assert!(parse_err.contains("failed to parse summary")); 2197 fs::remove_file(invalid).expect("remove invalid summary"); 2198 } 2199 2200 #[test] 2201 fn read_summary_reports_detail_parse_errors() { 2202 let root = temp_dir_path("summary_invalid_details"); 2203 let summary_path = root.join("coverage-summary.json"); 2204 write_file( 2205 &summary_path, 2206 r#"{ 2207 "data": [ 2208 { 2209 "totals": { 2210 "functions": {"percent": 91.25}, 2211 "lines": {"percent": 88.5}, 2212 "regions": {"percent": 86.75} 2213 } 2214 } 2215 ] 2216 }"#, 2217 ); 2218 write_file(&root.join("coverage-details.json"), "{not-json"); 2219 2220 let err = read_summary(&summary_path).expect_err("invalid details should fail"); 2221 assert!(err.contains("failed to parse coverage details")); 2222 2223 fs::remove_dir_all(root).expect("remove invalid details root"); 2224 } 2225 2226 #[test] 2227 fn ignorable_question_mark_regions_require_single_char_question_mark() { 2228 let path = temp_file_path("coverage_question_mark_region"); 2229 write_file(&path, "let value = call()?;\nreturn Err(());\n"); 2230 let mut cache = BTreeMap::new(); 2231 2232 let question_mark = RegionCoverageKey { 2233 line_start: 1, 2234 column_start: 19, 2235 line_end: 1, 2236 column_end: 20, 2237 kind: 0, 2238 }; 2239 assert!(is_ignorable_synthetic_region( 2240 path.to_str().expect("utf-8 path"), 2241 &question_mark, 2242 &mut cache, 2243 )); 2244 2245 let not_question_mark = RegionCoverageKey { 2246 line_start: 2, 2247 column_start: 8, 2248 line_end: 2, 2249 column_end: 15, 2250 kind: 0, 2251 }; 2252 assert!(!is_ignorable_synthetic_region( 2253 path.to_str().expect("utf-8 path"), 2254 ¬_question_mark, 2255 &mut cache, 2256 )); 2257 2258 let single_char_not_question_mark = RegionCoverageKey { 2259 line_start: 2, 2260 column_start: 1, 2261 line_end: 2, 2262 column_end: 2, 2263 kind: 0, 2264 }; 2265 assert!(!is_ignorable_synthetic_region( 2266 path.to_str().expect("utf-8 path"), 2267 &single_char_not_question_mark, 2268 &mut cache, 2269 )); 2270 2271 fs::remove_file(path).expect("remove question mark source"); 2272 } 2273 2274 #[test] 2275 fn ignorable_matches_assertion_regions_require_matching_slice() { 2276 let path = temp_file_path("coverage_matches_assertion_region"); 2277 write_file( 2278 &path, 2279 " assert!(matches!(value, Err(Error::Nope)));\n", 2280 ); 2281 let mut cache = BTreeMap::new(); 2282 2283 let matches_region = RegionCoverageKey { 2284 line_start: 1, 2285 column_start: 17, 2286 line_end: 1, 2287 column_end: 25, 2288 kind: 0, 2289 }; 2290 assert!(is_ignorable_synthetic_region( 2291 path.to_str().expect("utf-8 path"), 2292 &matches_region, 2293 &mut cache, 2294 )); 2295 2296 let assert_region = RegionCoverageKey { 2297 line_start: 1, 2298 column_start: 9, 2299 line_end: 1, 2300 column_end: 15, 2301 kind: 0, 2302 }; 2303 assert!(!is_ignorable_synthetic_region( 2304 path.to_str().expect("utf-8 path"), 2305 &assert_region, 2306 &mut cache, 2307 )); 2308 2309 fs::remove_file(path).expect("remove matches assertion source"); 2310 } 2311 2312 #[test] 2313 fn ignorable_synthetic_regions_cover_cfg_test_and_unreachable_lines() { 2314 let root = temp_dir_path("coverage_cfg_test_unreachable_regions"); 2315 let cfg_path = root.join("lib.rs"); 2316 write_file( 2317 &cfg_path, 2318 "#[cfg(all(test, feature = \"fixtures\"))]\nmod tests {\n fn helper() {}\n}\npub fn impossible() { unreachable!() }\n", 2319 ); 2320 let mut cache = BTreeMap::new(); 2321 2322 let cfg_region = RegionCoverageKey { 2323 line_start: 3, 2324 column_start: 5, 2325 line_end: 3, 2326 column_end: 19, 2327 kind: 0, 2328 }; 2329 assert!(is_ignorable_synthetic_region( 2330 cfg_path.to_str().expect("utf-8 path"), 2331 &cfg_region, 2332 &mut cache, 2333 )); 2334 2335 let unreachable_region = RegionCoverageKey { 2336 line_start: 5, 2337 column_start: 23, 2338 line_end: 5, 2339 column_end: 35, 2340 kind: 0, 2341 }; 2342 assert!(is_ignorable_synthetic_region( 2343 cfg_path.to_str().expect("utf-8 path"), 2344 &unreachable_region, 2345 &mut cache, 2346 )); 2347 2348 fs::remove_dir_all(root).expect("remove cfg-test unreachable root"); 2349 } 2350 2351 #[test] 2352 fn cfg_test_source_line_covers_pending_non_block_forms() { 2353 let source = "#[cfg(test)]\nfn helper() {}\n#[cfg(test)]\nmod tests\n{\n}\n"; 2354 2355 assert!(is_cfg_test_source_line(source, 2)); 2356 assert!(is_cfg_test_source_line(source, 4)); 2357 } 2358 2359 #[test] 2360 fn ignorable_unexpected_panic_regions_require_test_fallback_lines() { 2361 let root = temp_dir_path("coverage_unexpected_panic_region"); 2362 let path = root.join("tests.rs"); 2363 write_file( 2364 &path, 2365 "match &err {\n RuntimeProtectedFileError::Io { .. } => {}\n other => panic!(\"unexpected io error: {other}\"),\n}\n", 2366 ); 2367 let mut cache = BTreeMap::new(); 2368 2369 let other_region = RegionCoverageKey { 2370 line_start: 3, 2371 column_start: 9, 2372 line_end: 3, 2373 column_end: 14, 2374 kind: 0, 2375 }; 2376 assert!(is_ignorable_synthetic_region( 2377 path.to_str().expect("utf-8 path"), 2378 &other_region, 2379 &mut cache, 2380 )); 2381 2382 let panic_region = RegionCoverageKey { 2383 line_start: 3, 2384 column_start: 18, 2385 line_end: 3, 2386 column_end: 24, 2387 kind: 0, 2388 }; 2389 assert!(is_ignorable_synthetic_region( 2390 path.to_str().expect("utf-8 path"), 2391 &panic_region, 2392 &mut cache, 2393 )); 2394 2395 let non_test_path = root.join("source.rs"); 2396 write_file( 2397 &non_test_path, 2398 "match &err {\n RuntimeProtectedFileError::Io { .. } => {}\n other => panic!(\"unexpected io error: {other}\"),\n}\n", 2399 ); 2400 assert!(!is_ignorable_synthetic_region( 2401 non_test_path.to_str().expect("utf-8 path"), 2402 &other_region, 2403 &mut cache, 2404 )); 2405 2406 let multiline = RegionCoverageKey { 2407 line_start: 1, 2408 column_start: 1, 2409 line_end: 2, 2410 column_end: 1, 2411 kind: 0, 2412 }; 2413 assert!(!is_ignorable_synthetic_region( 2414 path.to_str().expect("utf-8 path"), 2415 &multiline, 2416 &mut cache, 2417 )); 2418 2419 let missing_file = root.join("missing.rs"); 2420 assert!(!is_ignorable_synthetic_region( 2421 missing_file.to_str().expect("utf-8 path"), 2422 &other_region, 2423 &mut cache, 2424 )); 2425 2426 let out_of_range = RegionCoverageKey { 2427 line_start: 99, 2428 column_start: 1, 2429 line_end: 99, 2430 column_end: 2, 2431 kind: 0, 2432 }; 2433 assert!(!is_ignorable_synthetic_region( 2434 path.to_str().expect("utf-8 path"), 2435 &out_of_range, 2436 &mut cache, 2437 )); 2438 2439 let non_panic_test_path = root.join("non_panic").join("tests.rs"); 2440 write_file(&non_panic_test_path, " other => Ok(()),\n"); 2441 let non_panic_other_region = RegionCoverageKey { 2442 line_start: 1, 2443 column_start: 9, 2444 line_end: 1, 2445 column_end: 14, 2446 kind: 0, 2447 }; 2448 assert!(!is_ignorable_synthetic_region( 2449 non_panic_test_path.to_str().expect("utf-8 path"), 2450 &non_panic_other_region, 2451 &mut cache, 2452 )); 2453 2454 let non_fallback_region = RegionCoverageKey { 2455 line_start: 3, 2456 column_start: 39, 2457 line_end: 3, 2458 column_end: 44, 2459 kind: 0, 2460 }; 2461 assert!(!is_ignorable_synthetic_region( 2462 path.to_str().expect("utf-8 path"), 2463 &non_fallback_region, 2464 &mut cache, 2465 )); 2466 2467 fs::remove_dir_all(root).expect("remove unexpected panic source"); 2468 } 2469 2470 #[test] 2471 fn read_coverage_policy_rejects_non_finite_and_out_of_range_thresholds() { 2472 let non_finite = temp_file_path("coverage_policy_non_finite"); 2473 write_file( 2474 &non_finite, 2475 "[gate]\nfail_under_exec_lines = inf\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2476 ); 2477 let non_finite_err = 2478 read_coverage_policy(&non_finite).expect_err("non-finite threshold should fail"); 2479 assert!(non_finite_err.contains("must be finite")); 2480 fs::remove_file(non_finite).expect("remove non-finite policy"); 2481 2482 let out_of_range = temp_file_path("coverage_policy_out_of_range"); 2483 write_file( 2484 &out_of_range, 2485 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 101.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2486 ); 2487 let out_of_range_err = 2488 read_coverage_policy(&out_of_range).expect_err("out-of-range threshold should fail"); 2489 assert!(out_of_range_err.contains("must be within 0..=100")); 2490 fs::remove_file(out_of_range).expect("remove out-of-range policy"); 2491 } 2492 2493 #[test] 2494 fn coverage_policy_resolves_scope_specific_temporary_overrides() { 2495 let path = temp_file_path("coverage_policy_override_scope"); 2496 write_file( 2497 &path, 2498 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\", \"radroots_b\", \"radroots_c\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 88.5\nfail_under_functions = 77.5\nfail_under_regions = 66.5\nfail_under_branches = 55.5\nrequire_branches = false\ntemporary = true\nreason = \"temporary publish unblocker\"\n\n[overrides.radroots_b]\nrequire_branches = true\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2499 ); 2500 let policy = read_coverage_policy(&path).expect("parse scoped override policy"); 2501 let override_thresholds = policy.thresholds_for_scope("radroots_a"); 2502 assert_eq!(override_thresholds.fail_under_exec_lines, 88.5); 2503 assert_eq!(override_thresholds.fail_under_functions, 77.5); 2504 assert_eq!(override_thresholds.fail_under_regions, 66.5); 2505 assert_eq!(override_thresholds.fail_under_branches, 55.5); 2506 assert!(!override_thresholds.require_branches); 2507 2508 let branch_override_thresholds = policy.thresholds_for_scope("radroots_b"); 2509 assert_eq!(branch_override_thresholds.fail_under_exec_lines, 100.0); 2510 assert_eq!(branch_override_thresholds.fail_under_functions, 100.0); 2511 assert_eq!(branch_override_thresholds.fail_under_regions, 100.0); 2512 assert_eq!(branch_override_thresholds.fail_under_branches, 100.0); 2513 assert!(branch_override_thresholds.require_branches); 2514 2515 let default_thresholds = policy.thresholds_for_scope("radroots_c"); 2516 assert_eq!(default_thresholds.fail_under_exec_lines, 100.0); 2517 assert_eq!(default_thresholds.fail_under_functions, 100.0); 2518 assert_eq!(default_thresholds.fail_under_regions, 100.0); 2519 assert_eq!(default_thresholds.fail_under_branches, 100.0); 2520 assert!(default_thresholds.require_branches); 2521 2522 fs::remove_file(path).expect("remove override scope policy"); 2523 } 2524 2525 #[test] 2526 fn read_coverage_policy_rejects_invalid_override_shapes() { 2527 let non_required = temp_file_path("coverage_policy_override_non_required"); 2528 write_file( 2529 &non_required, 2530 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_b]\nfail_under_exec_lines = 90.0\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2531 ); 2532 let non_required_err = 2533 read_coverage_policy(&non_required).expect_err("non-required override should fail"); 2534 assert!(non_required_err.contains("must target a required crate")); 2535 fs::remove_file(non_required).expect("remove non-required override policy"); 2536 2537 let missing_temporary = temp_file_path("coverage_policy_override_missing_temporary"); 2538 write_file( 2539 &missing_temporary, 2540 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 90.0\ntemporary = false\nreason = \"temporary publish unblocker\"\n", 2541 ); 2542 let missing_temporary_err = read_coverage_policy(&missing_temporary) 2543 .expect_err("override without temporary=true should fail"); 2544 assert!(missing_temporary_err.contains("temporary = true")); 2545 fs::remove_file(missing_temporary).expect("remove temporary override policy"); 2546 2547 let missing_reason = temp_file_path("coverage_policy_override_missing_reason"); 2548 write_file( 2549 &missing_reason, 2550 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 90.0\ntemporary = true\nreason = \" \"\n", 2551 ); 2552 let missing_reason_err = 2553 read_coverage_policy(&missing_reason).expect_err("blank override reason should fail"); 2554 assert!(missing_reason_err.contains("non-empty reason")); 2555 fs::remove_file(missing_reason).expect("remove missing reason policy"); 2556 2557 let stricter = temp_file_path("coverage_policy_override_stricter"); 2558 write_file( 2559 &stricter, 2560 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 100.1\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2561 ); 2562 let stricter_err = 2563 read_coverage_policy(&stricter).expect_err("stricter override should fail"); 2564 assert!(stricter_err.contains("must be within 0..=100")); 2565 fs::remove_file(stricter).expect("remove stricter override policy"); 2566 2567 let above_global = temp_file_path("coverage_policy_override_above_global"); 2568 write_file( 2569 &above_global, 2570 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 99.0\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2571 ); 2572 let above_global_err = 2573 read_coverage_policy(&above_global).expect_err("above-global override should fail"); 2574 assert!(above_global_err.contains("must not exceed the global gate")); 2575 fs::remove_file(above_global).expect("remove above-global override policy"); 2576 2577 let above_global_functions = temp_file_path("coverage_policy_override_above_global_fn"); 2578 write_file( 2579 &above_global_functions, 2580 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_functions = 99.0\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2581 ); 2582 let above_global_functions_err = read_coverage_policy(&above_global_functions) 2583 .expect_err("above-global function override should fail"); 2584 assert!(above_global_functions_err.contains("must not exceed the global gate")); 2585 fs::remove_file(above_global_functions).expect("remove above-global function policy"); 2586 2587 let above_global_regions = temp_file_path("coverage_policy_override_above_global_regions"); 2588 write_file( 2589 &above_global_regions, 2590 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_regions = 99.0\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2591 ); 2592 let above_global_regions_err = read_coverage_policy(&above_global_regions) 2593 .expect_err("above-global region override should fail"); 2594 assert!(above_global_regions_err.contains("must not exceed the global gate")); 2595 fs::remove_file(above_global_regions).expect("remove above-global region policy"); 2596 2597 let above_global_branches = 2598 temp_file_path("coverage_policy_override_above_global_branches"); 2599 write_file( 2600 &above_global_branches, 2601 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_branches = 99.0\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2602 ); 2603 let above_global_branches_err = read_coverage_policy(&above_global_branches) 2604 .expect_err("above-global branch override should fail"); 2605 assert!(above_global_branches_err.contains("must not exceed the global gate")); 2606 fs::remove_file(above_global_branches).expect("remove above-global branch policy"); 2607 2608 let non_finite_override = temp_file_path("coverage_policy_override_non_finite"); 2609 write_file( 2610 &non_finite_override, 2611 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = inf\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2612 ); 2613 let non_finite_override_err = read_coverage_policy(&non_finite_override) 2614 .expect_err("non-finite override should fail"); 2615 assert!(non_finite_override_err.contains("must be finite")); 2616 fs::remove_file(non_finite_override).expect("remove non-finite override policy"); 2617 2618 let stricter_branch_presence = temp_file_path("coverage_policy_override_branch_required"); 2619 write_file( 2620 &stricter_branch_presence, 2621 "[gate]\nfail_under_exec_lines = 98.0\nfail_under_functions = 98.0\nfail_under_regions = 98.0\nfail_under_branches = 98.0\nrequire_branches = false\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nrequire_branches = true\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2622 ); 2623 let branch_presence_err = read_coverage_policy(&stricter_branch_presence) 2624 .expect_err("stricter branch presence should fail"); 2625 assert!(branch_presence_err.contains("require_branches cannot be stricter")); 2626 fs::remove_file(stricter_branch_presence).expect("remove branch presence policy"); 2627 } 2628 2629 #[test] 2630 fn report_missing_gate_uses_policy_thresholds() { 2631 let root = temp_dir_path("report_missing_gate_root"); 2632 let coverage_dir = root.join("contracts"); 2633 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 2634 write_file( 2635 &coverage_dir.join("coverage.toml"), 2636 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2637 ); 2638 let out_path = root.join("gate-report.json"); 2639 2640 report_missing_gate_with_root( 2641 &[ 2642 "--scope".to_string(), 2643 "radroots_a_blocking".to_string(), 2644 "--out".to_string(), 2645 out_path.display().to_string(), 2646 "--reason".to_string(), 2647 "missing-coverage-artifacts".to_string(), 2648 ], 2649 &root, 2650 ) 2651 .expect("report missing gate"); 2652 2653 let report_raw = fs::read_to_string(&out_path).expect("read gate report"); 2654 let report_json: serde_json::Value = 2655 serde_json::from_str(&report_raw).expect("parse gate report json"); 2656 assert_eq!( 2657 report_json["thresholds"]["executable_lines"], 2658 serde_json::json!(100.0) 2659 ); 2660 assert_eq!( 2661 report_json["thresholds"]["branches_required"], 2662 serde_json::json!(true) 2663 ); 2664 assert_eq!(report_json["result"]["pass"], serde_json::json!(false)); 2665 assert_eq!( 2666 report_json["result"]["fail_reasons"], 2667 serde_json::json!(["missing-coverage-artifacts"]) 2668 ); 2669 2670 fs::remove_dir_all(root).expect("remove root"); 2671 } 2672 2673 #[test] 2674 fn report_missing_gate_uses_scope_specific_override_thresholds() { 2675 let root = temp_dir_path("report_missing_gate_override_root"); 2676 let coverage_dir = root.join("contracts"); 2677 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 2678 write_file( 2679 &coverage_dir.join("coverage.toml"), 2680 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 88.5\nfail_under_functions = 77.5\nfail_under_regions = 66.5\nfail_under_branches = 55.5\nrequire_branches = false\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 2681 ); 2682 let out_path = root.join("gate-report.json"); 2683 2684 report_missing_gate_with_root( 2685 &[ 2686 "--scope".to_string(), 2687 "radroots_a".to_string(), 2688 "--out".to_string(), 2689 out_path.display().to_string(), 2690 "--reason".to_string(), 2691 "missing-coverage-artifacts".to_string(), 2692 ], 2693 &root, 2694 ) 2695 .expect("report missing gate with override"); 2696 2697 let report_raw = fs::read_to_string(&out_path).expect("read gate report"); 2698 let report_json: serde_json::Value = 2699 serde_json::from_str(&report_raw).expect("parse gate report json"); 2700 assert_eq!( 2701 report_json["thresholds"]["executable_lines"], 2702 serde_json::json!(88.5) 2703 ); 2704 assert_eq!( 2705 report_json["thresholds"]["functions"], 2706 serde_json::json!(77.5) 2707 ); 2708 assert_eq!( 2709 report_json["thresholds"]["regions"], 2710 serde_json::json!(66.5) 2711 ); 2712 assert_eq!( 2713 report_json["thresholds"]["branches"], 2714 serde_json::json!(55.5) 2715 ); 2716 assert_eq!( 2717 report_json["thresholds"]["branches_required"], 2718 serde_json::json!(false) 2719 ); 2720 2721 fs::remove_dir_all(root).expect("remove override root"); 2722 } 2723 2724 #[test] 2725 fn report_missing_gate_reports_argument_policy_and_write_errors() { 2726 let root = temp_dir_path("report_missing_gate_error_root"); 2727 let missing_scope = 2728 report_missing_gate_with_root(&[], &root).expect_err("missing scope should fail"); 2729 assert!(missing_scope.contains("missing --scope")); 2730 2731 let missing_out = report_missing_gate_with_root( 2732 &[ 2733 "--scope".to_string(), 2734 "radroots_a_blocking".to_string(), 2735 "--reason".to_string(), 2736 "missing-coverage-artifacts".to_string(), 2737 ], 2738 &root, 2739 ) 2740 .expect_err("missing out should fail"); 2741 assert!(missing_out.contains("missing --out")); 2742 2743 let missing_reason = report_missing_gate_with_root( 2744 &[ 2745 "--scope".to_string(), 2746 "radroots_a_blocking".to_string(), 2747 "--out".to_string(), 2748 root.join("missing-gate.json").display().to_string(), 2749 ], 2750 &root, 2751 ) 2752 .expect_err("missing reason should fail"); 2753 assert!(missing_reason.contains("missing --reason")); 2754 2755 let policy_err = report_missing_gate_with_root( 2756 &[ 2757 "--scope".to_string(), 2758 "radroots_a_blocking".to_string(), 2759 "--out".to_string(), 2760 root.join("missing-gate.json").display().to_string(), 2761 "--reason".to_string(), 2762 "missing-coverage-artifacts".to_string(), 2763 ], 2764 &root, 2765 ) 2766 .expect_err("missing policy should fail"); 2767 assert!(policy_err.contains("failed to read coverage policy")); 2768 2769 let coverage_dir = root.join("contracts"); 2770 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 2771 write_file( 2772 &coverage_dir.join("coverage.toml"), 2773 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2774 ); 2775 let out_path = root.join("gate-report.json"); 2776 fs::create_dir_all(&out_path).expect("create blocking output dir"); 2777 let write_err = report_missing_gate_with_root( 2778 &[ 2779 "--scope".to_string(), 2780 "radroots_a_blocking".to_string(), 2781 "--out".to_string(), 2782 out_path.display().to_string(), 2783 "--reason".to_string(), 2784 "missing-coverage-artifacts".to_string(), 2785 ], 2786 &root, 2787 ) 2788 .expect_err("directory output should fail"); 2789 assert!(write_err.contains("failed to write")); 2790 2791 fs::remove_dir_all(root).expect("remove report missing gate error root"); 2792 } 2793 2794 #[test] 2795 fn refresh_summary_uses_measured_gate_report_values() { 2796 let root = temp_dir_path("refresh_summary_root"); 2797 let coverage_dir = root.join("contracts"); 2798 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 2799 write_file( 2800 &coverage_dir.join("coverage.toml"), 2801 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\", \"radroots_b\"]\n", 2802 ); 2803 2804 let reports_root = root.join("target").join("coverage"); 2805 let crate_dir = reports_root.join("radroots_a"); 2806 fs::create_dir_all(&crate_dir).expect("create crate dir"); 2807 write_file( 2808 &crate_dir.join("gate-report.json"), 2809 r#"{ 2810 "scope": "radroots_a", 2811 "thresholds": { 2812 "executable_lines": 100.0, 2813 "functions": 100.0, 2814 "regions": 100.0, 2815 "branches": 100.0, 2816 "branches_required": true 2817 }, 2818 "measured": { 2819 "executable_lines_percent": 100.0, 2820 "executable_lines_source": "da", 2821 "functions_percent": 100.0, 2822 "branches_percent": 100.0, 2823 "branches_available": true, 2824 "summary_lines_percent": 100.0, 2825 "summary_regions_percent": 97.5 2826 }, 2827 "counts": { 2828 "executable_lines": { 2829 "covered": 4, 2830 "total": 4 2831 }, 2832 "branches": { 2833 "covered": 2, 2834 "total": 2 2835 } 2836 }, 2837 "result": { 2838 "pass": true, 2839 "fail_reasons": [] 2840 } 2841 }"#, 2842 ); 2843 let no_branch_crate_dir = reports_root.join("radroots_b"); 2844 fs::create_dir_all(&no_branch_crate_dir).expect("create no branch crate dir"); 2845 write_file( 2846 &no_branch_crate_dir.join("gate-report.json"), 2847 r#"{ 2848 "scope": "radroots_b", 2849 "thresholds": { 2850 "executable_lines": 100.0, 2851 "functions": 100.0, 2852 "regions": 100.0, 2853 "branches": 100.0, 2854 "branches_required": false 2855 }, 2856 "measured": { 2857 "executable_lines_percent": 100.0, 2858 "executable_lines_source": "da", 2859 "functions_percent": 100.0, 2860 "branches_percent": null, 2861 "branches_available": false, 2862 "summary_lines_percent": 100.0, 2863 "summary_regions_percent": 100.0 2864 }, 2865 "counts": { 2866 "executable_lines": { 2867 "covered": 4, 2868 "total": 4 2869 }, 2870 "branches": { 2871 "covered": 0, 2872 "total": 0 2873 } 2874 }, 2875 "result": { 2876 "pass": true, 2877 "fail_reasons": [] 2878 } 2879 }"#, 2880 ); 2881 2882 let refresh_out = reports_root.join("coverage-refresh.tsv"); 2883 let status_out = reports_root.join("coverage-refresh-status.tsv"); 2884 run_with_root( 2885 &[ 2886 "refresh-summary".to_string(), 2887 "--reports-root".to_string(), 2888 reports_root.display().to_string(), 2889 "--out".to_string(), 2890 refresh_out.display().to_string(), 2891 "--status-out".to_string(), 2892 status_out.display().to_string(), 2893 ], 2894 &root, 2895 ) 2896 .expect("write refresh summary"); 2897 2898 let refresh = fs::read_to_string(&refresh_out).expect("read refresh summary"); 2899 assert!(refresh.contains("crate\tstatus\texec\tfunc\tbranch\tregion\treport")); 2900 assert!( 2901 refresh.contains("radroots_a\tpass\t100.000000\t100.000000\t100.000000\t97.500000\t") 2902 ); 2903 assert!( 2904 refresh.contains("radroots_b\tpass\t100.000000\t100.000000\tunavailable\t100.000000\t") 2905 ); 2906 2907 let status = fs::read_to_string(&status_out).expect("read status summary"); 2908 assert_eq!( 2909 status, 2910 "crate\tstatus\nradroots_a\tpass\nradroots_b\tpass\n" 2911 ); 2912 2913 fs::remove_dir_all(root).expect("remove root"); 2914 2915 let defaults_root = temp_dir_path("refresh_summary_defaults_root"); 2916 let defaults_coverage_dir = defaults_root.join("contracts"); 2917 fs::create_dir_all(&defaults_coverage_dir).expect("create defaults coverage dir"); 2918 write_file( 2919 &defaults_coverage_dir.join("coverage.toml"), 2920 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2921 ); 2922 write_file( 2923 &defaults_root 2924 .join("target") 2925 .join("coverage") 2926 .join("radroots_a") 2927 .join("gate-report.json"), 2928 r#"{ 2929 "scope": "radroots_a", 2930 "thresholds": { 2931 "executable_lines": 100.0, 2932 "functions": 100.0, 2933 "regions": 100.0, 2934 "branches": 100.0, 2935 "branches_required": true 2936 }, 2937 "measured": { 2938 "executable_lines_percent": 100.0, 2939 "executable_lines_source": "da", 2940 "functions_percent": 100.0, 2941 "branches_percent": 100.0, 2942 "branches_available": true, 2943 "summary_lines_percent": 100.0, 2944 "summary_regions_percent": 100.0 2945 }, 2946 "counts": { 2947 "executable_lines": { 2948 "covered": 4, 2949 "total": 4 2950 }, 2951 "branches": { 2952 "covered": 2, 2953 "total": 2 2954 } 2955 }, 2956 "result": { 2957 "pass": false, 2958 "fail_reasons": ["synthetic-fail"] 2959 } 2960 }"#, 2961 ); 2962 2963 let _guard = lock_cwd(); 2964 let previous_dir = std::env::current_dir().expect("read current dir"); 2965 std::env::set_current_dir(&defaults_root).expect("set current dir"); 2966 run_with_root(&["refresh-summary".to_string()], &defaults_root) 2967 .expect("write refresh summary defaults"); 2968 let defaults_refresh = fs::read_to_string( 2969 defaults_root 2970 .join("target") 2971 .join("coverage") 2972 .join("coverage-refresh.tsv"), 2973 ) 2974 .expect("read defaults refresh summary"); 2975 assert!( 2976 defaults_refresh 2977 .contains("radroots_a\tfail\t100.000000\t100.000000\t100.000000\t100.000000\t") 2978 ); 2979 2980 let dispatch_root = temp_dir_path("refresh_summary_parentless_root"); 2981 let dispatch_coverage_dir = dispatch_root.join("contracts"); 2982 fs::create_dir_all(&dispatch_coverage_dir).expect("create dispatch coverage dir"); 2983 write_file( 2984 &dispatch_coverage_dir.join("coverage.toml"), 2985 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 2986 ); 2987 write_file( 2988 &dispatch_root.join("Cargo.toml"), 2989 "[workspace]\nmembers = []\nresolver = \"2\"\n", 2990 ); 2991 write_file( 2992 &dispatch_root 2993 .join("target") 2994 .join("coverage") 2995 .join("radroots_a") 2996 .join("gate-report.json"), 2997 r#"{ 2998 "scope": "radroots_a", 2999 "thresholds": { 3000 "executable_lines": 100.0, 3001 "functions": 100.0, 3002 "regions": 100.0, 3003 "branches": 100.0, 3004 "branches_required": true 3005 }, 3006 "measured": { 3007 "executable_lines_percent": 100.0, 3008 "executable_lines_source": "da", 3009 "functions_percent": 100.0, 3010 "branches_percent": 100.0, 3011 "branches_available": true, 3012 "summary_lines_percent": 100.0, 3013 "summary_regions_percent": 100.0 3014 }, 3015 "counts": { 3016 "executable_lines": { 3017 "covered": 4, 3018 "total": 4 3019 }, 3020 "branches": { 3021 "covered": 2, 3022 "total": 2 3023 } 3024 }, 3025 "result": { 3026 "pass": true, 3027 "fail_reasons": [] 3028 } 3029 }"#, 3030 ); 3031 std::env::set_current_dir(&dispatch_root).expect("set dispatch current dir"); 3032 run_with_root( 3033 &[ 3034 "report-missing".to_string(), 3035 "--scope".to_string(), 3036 "radroots_a_blocking".to_string(), 3037 "--out".to_string(), 3038 "missing-gate.json".to_string(), 3039 "--reason".to_string(), 3040 "missing-coverage-artifacts".to_string(), 3041 ], 3042 &dispatch_root, 3043 ) 3044 .expect("dispatch report-missing"); 3045 run_with_root( 3046 &[ 3047 "refresh-summary".to_string(), 3048 "--out".to_string(), 3049 "coverage-refresh.tsv".to_string(), 3050 "--status-out".to_string(), 3051 "coverage-refresh-status.tsv".to_string(), 3052 ], 3053 &dispatch_root, 3054 ) 3055 .expect("dispatch refresh-summary"); 3056 std::env::set_current_dir(previous_dir).expect("restore current dir"); 3057 3058 assert!(dispatch_root.join("missing-gate.json").exists()); 3059 assert!(dispatch_root.join("coverage-refresh.tsv").exists()); 3060 assert!(dispatch_root.join("coverage-refresh-status.tsv").exists()); 3061 3062 fs::remove_dir_all(defaults_root).expect("remove defaults root"); 3063 fs::remove_dir_all(dispatch_root).expect("remove dispatch root"); 3064 } 3065 3066 #[test] 3067 fn refresh_summary_rejects_empty_output_paths() { 3068 let root = temp_dir_path("refresh_summary_empty_paths_root"); 3069 let coverage_dir = root.join("contracts"); 3070 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3071 write_file( 3072 &coverage_dir.join("coverage.toml"), 3073 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 3074 ); 3075 write_file( 3076 &root 3077 .join("target") 3078 .join("coverage") 3079 .join("radroots_a") 3080 .join("gate-report.json"), 3081 r#"{ 3082 "scope": "radroots_a", 3083 "thresholds": { 3084 "executable_lines": 100.0, 3085 "functions": 100.0, 3086 "regions": 100.0, 3087 "branches": 100.0, 3088 "branches_required": true 3089 }, 3090 "measured": { 3091 "executable_lines_percent": 100.0, 3092 "executable_lines_source": "da", 3093 "functions_percent": 100.0, 3094 "branches_percent": 100.0, 3095 "branches_available": true, 3096 "summary_lines_percent": 100.0, 3097 "summary_regions_percent": 100.0 3098 }, 3099 "counts": { 3100 "executable_lines": { 3101 "covered": 4, 3102 "total": 4 3103 }, 3104 "branches": { 3105 "covered": 2, 3106 "total": 2 3107 } 3108 }, 3109 "result": { 3110 "pass": true, 3111 "fail_reasons": [] 3112 } 3113 }"#, 3114 ); 3115 3116 let out_err = run_with_root( 3117 &[ 3118 "refresh-summary".to_string(), 3119 "--reports-root".to_string(), 3120 root.join("target").join("coverage").display().to_string(), 3121 "--out".to_string(), 3122 String::new(), 3123 ], 3124 &root, 3125 ) 3126 .expect_err("empty out path should fail"); 3127 assert!(out_err.contains("failed to write")); 3128 3129 let status_err = run_with_root( 3130 &[ 3131 "refresh-summary".to_string(), 3132 "--reports-root".to_string(), 3133 root.join("target").join("coverage").display().to_string(), 3134 "--out".to_string(), 3135 root.join("target") 3136 .join("coverage") 3137 .join("coverage-refresh.tsv") 3138 .display() 3139 .to_string(), 3140 "--status-out".to_string(), 3141 String::new(), 3142 ], 3143 &root, 3144 ) 3145 .expect_err("empty status out path should fail"); 3146 assert!(status_err.contains("failed to write")); 3147 3148 fs::remove_dir_all(root).expect("remove empty path root"); 3149 } 3150 3151 #[test] 3152 fn refresh_summary_reports_output_parent_creation_failure() { 3153 let root = temp_dir_path("refresh_summary_out_parent_fail"); 3154 let coverage_dir = root.join("contracts"); 3155 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3156 write_file( 3157 &coverage_dir.join("coverage.toml"), 3158 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 3159 ); 3160 write_file( 3161 &root 3162 .join("target") 3163 .join("coverage") 3164 .join("radroots_a") 3165 .join("gate-report.json"), 3166 r#"{ 3167 "scope": "radroots_a", 3168 "thresholds": { 3169 "executable_lines": 100.0, 3170 "functions": 100.0, 3171 "regions": 100.0, 3172 "branches": 100.0, 3173 "branches_required": true 3174 }, 3175 "measured": { 3176 "executable_lines_percent": 100.0, 3177 "executable_lines_source": "da", 3178 "functions_percent": 100.0, 3179 "branches_percent": 100.0, 3180 "branches_available": true, 3181 "summary_lines_percent": 100.0, 3182 "summary_regions_percent": 100.0 3183 }, 3184 "counts": { 3185 "executable_lines": { 3186 "covered": 4, 3187 "total": 4 3188 }, 3189 "branches": { 3190 "covered": 2, 3191 "total": 2 3192 } 3193 }, 3194 "result": { 3195 "pass": true, 3196 "fail_reasons": [] 3197 } 3198 }"#, 3199 ); 3200 write_file(&root.join("out-blocker"), "x"); 3201 3202 let err = run_with_root( 3203 &[ 3204 "refresh-summary".to_string(), 3205 "--reports-root".to_string(), 3206 root.join("target").join("coverage").display().to_string(), 3207 "--out".to_string(), 3208 root.join("out-blocker") 3209 .join("nested") 3210 .join("coverage-refresh.tsv") 3211 .display() 3212 .to_string(), 3213 ], 3214 &root, 3215 ) 3216 .expect_err("out parent create failure should bubble up"); 3217 assert!(err.contains("failed to create")); 3218 3219 fs::remove_dir_all(root).expect("remove out parent fail root"); 3220 } 3221 3222 #[test] 3223 fn refresh_summary_reports_status_output_parent_creation_failure() { 3224 let root = temp_dir_path("refresh_summary_status_parent_fail"); 3225 let coverage_dir = root.join("contracts"); 3226 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3227 write_file( 3228 &coverage_dir.join("coverage.toml"), 3229 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 3230 ); 3231 write_file( 3232 &root 3233 .join("target") 3234 .join("coverage") 3235 .join("radroots_a") 3236 .join("gate-report.json"), 3237 r#"{ 3238 "scope": "radroots_a", 3239 "thresholds": { 3240 "executable_lines": 100.0, 3241 "functions": 100.0, 3242 "regions": 100.0, 3243 "branches": 100.0, 3244 "branches_required": true 3245 }, 3246 "measured": { 3247 "executable_lines_percent": 100.0, 3248 "executable_lines_source": "da", 3249 "functions_percent": 100.0, 3250 "branches_percent": 100.0, 3251 "branches_available": true, 3252 "summary_lines_percent": 100.0, 3253 "summary_regions_percent": 100.0 3254 }, 3255 "counts": { 3256 "executable_lines": { 3257 "covered": 4, 3258 "total": 4 3259 }, 3260 "branches": { 3261 "covered": 2, 3262 "total": 2 3263 } 3264 }, 3265 "result": { 3266 "pass": true, 3267 "fail_reasons": [] 3268 } 3269 }"#, 3270 ); 3271 write_file(&root.join("status-blocker"), "x"); 3272 3273 let err = run_with_root( 3274 &[ 3275 "refresh-summary".to_string(), 3276 "--reports-root".to_string(), 3277 root.join("target").join("coverage").display().to_string(), 3278 "--out".to_string(), 3279 root.join("target") 3280 .join("coverage") 3281 .join("coverage-refresh.tsv") 3282 .display() 3283 .to_string(), 3284 "--status-out".to_string(), 3285 root.join("status-blocker") 3286 .join("nested") 3287 .join("coverage-refresh-status.tsv") 3288 .display() 3289 .to_string(), 3290 ], 3291 &root, 3292 ) 3293 .expect_err("status-out parent create failure should bubble up"); 3294 assert!(err.contains("failed to create")); 3295 3296 fs::remove_dir_all(root).expect("remove status parent fail root"); 3297 } 3298 3299 #[test] 3300 fn refresh_summary_reports_policy_and_gate_report_errors() { 3301 let root = temp_dir_path("refresh_summary_error_root"); 3302 let policy_err = run_with_root( 3303 &[ 3304 "refresh-summary".to_string(), 3305 "--reports-root".to_string(), 3306 root.join("target").join("coverage").display().to_string(), 3307 ], 3308 &root, 3309 ) 3310 .expect_err("missing policy should fail"); 3311 assert!(policy_err.contains("failed to read coverage policy")); 3312 3313 let coverage_dir = root.join("contracts"); 3314 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3315 write_file( 3316 &coverage_dir.join("coverage.toml"), 3317 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n", 3318 ); 3319 let gate_err = run_with_root( 3320 &[ 3321 "refresh-summary".to_string(), 3322 "--reports-root".to_string(), 3323 root.join("target").join("coverage").display().to_string(), 3324 ], 3325 &root, 3326 ) 3327 .expect_err("missing gate report should fail"); 3328 assert!(gate_err.contains("failed to read gate report")); 3329 3330 fs::remove_dir_all(root).expect("remove refresh summary error root"); 3331 } 3332 3333 #[test] 3334 fn recover_lock_covers_ok_and_poisoned_paths() { 3335 let ok_lock: &'static Mutex<()> = Box::leak(Box::new(Mutex::new(()))); 3336 let _ok_guard = recover_lock(ok_lock); 3337 3338 let poisoned_lock: &'static Mutex<()> = Box::leak(Box::new(Mutex::new(()))); 3339 let handle = std::thread::spawn(move || { 3340 let _guard = poisoned_lock.lock().expect("lock poisoned mutex"); 3341 panic!("poison test mutex"); 3342 }); 3343 assert!(handle.join().is_err()); 3344 3345 let _poisoned_guard = recover_lock(poisoned_lock); 3346 } 3347 3348 #[test] 3349 fn read_summary_reports_empty_data_error() { 3350 let path = temp_file_path("summary_empty_data"); 3351 write_file(&path, r#"{"data":[]}"#); 3352 let err = read_summary(&path).expect_err("summary without data should fail"); 3353 assert!(err.contains("summary data is empty")); 3354 fs::remove_file(path).expect("remove empty summary"); 3355 } 3356 3357 #[test] 3358 fn reads_lcov_da_and_branch_metrics() { 3359 let path = temp_file_path("lcov"); 3360 fs::write( 3361 &path, 3362 "DA:1,1\nDA:2,0\nDA:3,1\nBRDA:1,0,0,1\nBRDA:1,0,1,0\nBRDA:2,0,0,3\nBRDA:2,0,1,-\n", 3363 ) 3364 .expect("write lcov"); 3365 3366 let lcov = read_lcov(&path).expect("parse lcov"); 3367 assert_eq!(lcov.executable_total, 3); 3368 assert_eq!(lcov.executable_covered, 2); 3369 assert!(lcov.branches_available); 3370 assert_eq!(lcov.branch_total, 3); 3371 assert_eq!(lcov.branch_covered, 2); 3372 assert_eq!(lcov.branch_percent, Some(66.66666666666666)); 3373 3374 fs::remove_file(path).expect("remove lcov"); 3375 } 3376 3377 #[test] 3378 fn read_lcov_ignores_non_semantic_source_lines() { 3379 let root = temp_dir_path("lcov_ignorable_source_lines"); 3380 let source = root.join("lib.rs"); 3381 write_file( 3382 &source, 3383 "pub fn live() {}\n)?\n])?;\n#[cfg(test)]\nmod tests {\n fn fallback() {\n panic!(\"unexpected fallback\");\n }\n}\npub fn impossible() { unreachable!() }\npub fn expected() { panic!(\"expected branch\") }\n", 3384 ); 3385 let path = root.join("lcov.info"); 3386 write_file( 3387 &path, 3388 &format!( 3389 "SF:{}\nDA:1,1\nDA:2,0\nDA:3,0\nDA:4,0\nDA:6,0\nDA:7,0\nDA:10,0\nDA:11,0\nBRDA:1,0,0,1\nBRDA:2,0,0,0\nBRDA:3,0,0,0\nBRDA:6,0,0,0\nBRDA:10,0,0,0\nBRDA:11,0,0,0\n", 3390 source.display() 3391 ), 3392 ); 3393 3394 let lcov = read_lcov(&path).expect("parse filtered lcov"); 3395 assert_eq!(lcov.executable_total, 1); 3396 assert_eq!(lcov.executable_covered, 1); 3397 assert_eq!(lcov.executable_percent, 100.0); 3398 assert_eq!(lcov.branch_total, 1); 3399 assert_eq!(lcov.branch_covered, 1); 3400 assert_eq!(lcov.branch_percent, Some(100.0)); 3401 3402 fs::remove_dir_all(root).expect("remove filtered lcov root"); 3403 } 3404 3405 #[test] 3406 fn ignorable_lcov_source_lines_cover_missing_and_out_of_range_paths() { 3407 let root = temp_dir_path("lcov_source_line_false_paths"); 3408 let source = root.join("lib.rs"); 3409 write_file(&source, "pub fn live() {}\n"); 3410 let mut cache = BTreeMap::new(); 3411 3412 assert!(!is_ignorable_lcov_source_line( 3413 source.to_str().expect("utf-8 path"), 3414 99, 3415 &mut cache, 3416 )); 3417 assert!(!is_ignorable_lcov_source_line( 3418 root.join("missing.rs").to_str().expect("utf-8 path"), 3419 1, 3420 &mut cache, 3421 )); 3422 3423 fs::remove_dir_all(root).expect("remove lcov false path root"); 3424 } 3425 3426 #[test] 3427 fn reads_lcov_branch_metrics_from_brf_brh_when_brda_missing() { 3428 let path = temp_file_path("lcov_fallback"); 3429 fs::write(&path, "DA:1,1\nDA:2,1\nBRF:4\nBRH:3\n").expect("write lcov"); 3430 3431 let lcov = read_lcov(&path).expect("parse lcov"); 3432 assert!(lcov.branches_available); 3433 assert_eq!(lcov.branch_total, 4); 3434 assert_eq!(lcov.branch_covered, 3); 3435 assert_eq!(lcov.branch_percent, Some(75.0)); 3436 3437 fs::remove_file(path).expect("remove lcov"); 3438 } 3439 3440 #[test] 3441 fn gate_fails_when_branch_data_is_required_but_missing() { 3442 let summary = CoverageSummary { 3443 functions_percent: 100.0, 3444 summary_lines_percent: 100.0, 3445 summary_regions_percent: 100.0, 3446 }; 3447 let lcov = LcovCoverage { 3448 executable_total: 10, 3449 executable_covered: 10, 3450 executable_percent: 100.0, 3451 executable_source: ExecutableSource::Da, 3452 branch_total: 0, 3453 branch_covered: 0, 3454 branches_available: false, 3455 branch_percent: None, 3456 }; 3457 let thresholds = CoverageThresholds { 3458 fail_under_exec_lines: 100.0, 3459 fail_under_functions: 100.0, 3460 fail_under_regions: 100.0, 3461 fail_under_branches: 100.0, 3462 require_branches: true, 3463 }; 3464 3465 let gate = evaluate_gate(&summary, &lcov, thresholds); 3466 assert!(!gate.pass); 3467 assert!( 3468 gate.fail_reasons 3469 .iter() 3470 .any(|reason| reason == "branches=unavailable") 3471 ); 3472 } 3473 3474 #[test] 3475 fn reads_required_crates_and_rejects_duplicates() { 3476 let path = temp_file_path("required_crates"); 3477 fs::write( 3478 &path, 3479 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"a\", \"b\"]\n", 3480 ) 3481 .expect("write required crates"); 3482 let crates = read_required_crates(&path).expect("parse required crates"); 3483 assert_eq!(crates, vec!["a".to_string(), "b".to_string()]); 3484 fs::remove_file(&path).expect("remove required crates"); 3485 3486 let dup_path = temp_file_path("required_crates_dup"); 3487 fs::write( 3488 &dup_path, 3489 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"a\", \"a\"]\n", 3490 ) 3491 .expect("write dup required crates"); 3492 let err = read_required_crates(&dup_path).expect_err("duplicate required crates"); 3493 assert!(err.contains("duplicate crate a")); 3494 fs::remove_file(dup_path).expect("remove dup required crates"); 3495 } 3496 3497 #[test] 3498 fn read_required_crates_reports_read_and_parse_errors() { 3499 let missing = temp_file_path("required_missing"); 3500 let read_err = read_required_crates(&missing).expect_err("missing required file"); 3501 assert!(read_err.contains("failed to read coverage policy")); 3502 3503 let invalid = temp_file_path("required_invalid"); 3504 write_file(&invalid, "not = [toml"); 3505 let parse_err = read_required_crates(&invalid).expect_err("invalid required file"); 3506 assert!(parse_err.contains("failed to parse coverage policy")); 3507 fs::remove_file(invalid).expect("remove invalid required file"); 3508 } 3509 3510 #[test] 3511 fn reads_workspace_crates_and_contains_xtask() { 3512 let root = workspace_root(); 3513 let crates = read_workspace_crates(&root).expect("workspace crates"); 3514 assert!(!crates.is_empty()); 3515 assert!(crates.iter().any(|crate_name| crate_name == "xtask")); 3516 } 3517 3518 #[test] 3519 fn coverage_profiles_default_when_contract_file_is_missing() { 3520 let root = temp_dir_path("profile_missing"); 3521 fs::create_dir_all(&root).expect("create root"); 3522 let profile = read_coverage_profile(&root, "radroots_log").expect("read profile"); 3523 assert!(!profile.no_default_features); 3524 assert!(profile.features.is_empty()); 3525 assert_eq!(profile.test_threads, None); 3526 fs::remove_dir_all(root).expect("remove root"); 3527 } 3528 3529 #[test] 3530 fn coverage_profiles_merge_defaults_and_crate_overrides() { 3531 let root = temp_dir_path("profile_merge"); 3532 let coverage_dir = root.join("contracts"); 3533 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3534 fs::write( 3535 coverage_dir.join("coverage-profiles.toml"), 3536 r#"[profiles.default] 3537 no_default_features = false 3538 features = ["std"] 3539 test_threads = 2 3540 3541 [profiles.crates."radroots_log"] 3542 no_default_features = true 3543 features = ["rt"] 3544 "#, 3545 ) 3546 .expect("write profiles"); 3547 3548 let app_profile = read_coverage_profile(&root, "radroots_log").expect("app profile"); 3549 assert!(app_profile.no_default_features); 3550 assert_eq!(app_profile.features, vec!["rt".to_string()]); 3551 assert_eq!(app_profile.test_threads, Some(2)); 3552 3553 let other_profile = read_coverage_profile(&root, "radroots_types").expect("other profile"); 3554 assert!(!other_profile.no_default_features); 3555 assert_eq!(other_profile.features, vec!["std".to_string()]); 3556 assert_eq!(other_profile.test_threads, Some(2)); 3557 3558 fs::remove_dir_all(root).expect("remove root"); 3559 } 3560 3561 #[test] 3562 fn coverage_profiles_accept_positive_test_threads() { 3563 let root = temp_dir_path("profile_positive_threads"); 3564 let coverage_dir = root.join("contracts"); 3565 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3566 fs::write( 3567 coverage_dir.join("coverage-profiles.toml"), 3568 r#"[profiles.crates."radroots_log"] 3569 test_threads = 4 3570 "#, 3571 ) 3572 .expect("write profiles"); 3573 let profile = 3574 read_coverage_profile(&root, "radroots_log").expect("valid positive thread profile"); 3575 assert_eq!(profile.test_threads, Some(4)); 3576 fs::remove_dir_all(root).expect("remove root"); 3577 } 3578 3579 #[test] 3580 fn coverage_profiles_reject_invalid_feature_and_thread_values() { 3581 let root = temp_dir_path("profile_invalid"); 3582 let coverage_dir = root.join("contracts"); 3583 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3584 fs::write( 3585 coverage_dir.join("coverage-profiles.toml"), 3586 r#"[profiles.crates."radroots_log"] 3587 features = [""] 3588 test_threads = 0 3589 "#, 3590 ) 3591 .expect("write profiles"); 3592 3593 let err = read_coverage_profile(&root, "radroots_log").expect_err("invalid profile"); 3594 assert!( 3595 err.contains("empty feature value"), 3596 "unexpected error: {err}" 3597 ); 3598 3599 fs::remove_dir_all(root).expect("remove root"); 3600 } 3601 3602 #[test] 3603 fn coverage_profiles_reject_invalid_toml() { 3604 let root = temp_dir_path("profile_invalid_toml"); 3605 let coverage_dir = root.join("contracts"); 3606 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3607 fs::write( 3608 coverage_dir.join("coverage-profiles.toml"), 3609 "[profiles.default\n", 3610 ) 3611 .expect("write invalid profiles"); 3612 let err = read_coverage_profile(&root, "radroots_log").expect_err("invalid toml"); 3613 assert!(err.contains("failed to parse")); 3614 fs::remove_dir_all(root).expect("remove root"); 3615 } 3616 3617 #[test] 3618 fn coverage_profiles_reject_zero_test_threads_without_feature_error() { 3619 let root = temp_dir_path("profile_invalid_threads"); 3620 let coverage_dir = root.join("contracts"); 3621 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 3622 fs::write( 3623 coverage_dir.join("coverage-profiles.toml"), 3624 r#"[profiles.crates."radroots_log"] 3625 test_threads = 0 3626 "#, 3627 ) 3628 .expect("write profiles"); 3629 3630 let err = read_coverage_profile(&root, "radroots_log").expect_err("invalid thread count"); 3631 assert!(err.contains("test_threads > 0")); 3632 3633 fs::remove_dir_all(root).expect("remove root"); 3634 } 3635 3636 #[test] 3637 fn parse_helpers_cover_success_and_error_paths() { 3638 let args = vec![ 3639 "--scope".to_string(), 3640 "crate-a".to_string(), 3641 "--value".to_string(), 3642 "3.5".to_string(), 3643 "--threads".to_string(), 3644 "4".to_string(), 3645 "--flag".to_string(), 3646 ]; 3647 assert_eq!( 3648 parse_string_arg(&args, "scope").expect("scope value"), 3649 "crate-a".to_string() 3650 ); 3651 assert_eq!( 3652 parse_optional_string_arg(&args, "scope").expect("optional scope"), 3653 "crate-a".to_string() 3654 ); 3655 assert_eq!(parse_f64_arg(&args, "value", 1.0).expect("f64 value"), 3.5); 3656 assert_eq!( 3657 parse_optional_u32_arg(&args, "threads").expect("u32 value"), 3658 Some(4) 3659 ); 3660 assert!(parse_bool_flag(&args, "flag")); 3661 assert_eq!(parse_optional_string_arg(&args, "missing"), None); 3662 assert_eq!( 3663 parse_f64_arg(&args, "missing", 2.25).expect("default f64"), 3664 2.25 3665 ); 3666 assert_eq!( 3667 parse_optional_u32_arg(&args, "missing").expect("missing u32"), 3668 None 3669 ); 3670 3671 let missing_err = parse_string_arg(&args, "absent").expect_err("missing arg"); 3672 assert!(missing_err.contains("missing --absent")); 3673 3674 let missing_value = vec!["--scope".to_string()]; 3675 let missing_value_err = 3676 parse_string_arg(&missing_value, "scope").expect_err("missing arg value"); 3677 assert!(missing_value_err.contains("missing value for --scope")); 3678 3679 let invalid_f64 = vec!["--value".to_string(), "bad".to_string()]; 3680 let invalid_f64_err = parse_f64_arg(&invalid_f64, "value", 1.0).expect_err("invalid f64"); 3681 assert!(invalid_f64_err.contains("invalid --value value")); 3682 3683 let invalid_u32 = vec!["--threads".to_string(), "bad".to_string()]; 3684 let invalid_u32_err = 3685 parse_optional_u32_arg(&invalid_u32, "threads").expect_err("invalid u32"); 3686 assert!(invalid_u32_err.contains("invalid --threads value")); 3687 } 3688 3689 #[test] 3690 fn executable_source_labels_cover_all_variants() { 3691 assert_eq!(executable_source_label(ExecutableSource::Da), "da"); 3692 assert_eq!(executable_source_label(ExecutableSource::LfLh), "lf_lh"); 3693 assert_eq!(percentage(0, 0), 100.0); 3694 } 3695 3696 #[test] 3697 fn read_required_crates_rejects_empty_and_blank_entries() { 3698 let empty_path = temp_file_path("required_empty"); 3699 write_file( 3700 &empty_path, 3701 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = []\n", 3702 ); 3703 let empty_err = read_required_crates(&empty_path).expect_err("empty required list"); 3704 assert!(empty_err.contains("must not be empty")); 3705 fs::remove_file(&empty_path).expect("remove empty required file"); 3706 3707 let blank_path = temp_file_path("required_blank"); 3708 write_file( 3709 &blank_path, 3710 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"a\", \" \"]\n", 3711 ); 3712 let blank_err = read_required_crates(&blank_path).expect_err("blank crate name"); 3713 assert!(blank_err.contains("empty crate name")); 3714 fs::remove_file(&blank_path).expect("remove blank required file"); 3715 } 3716 3717 #[test] 3718 fn read_workspace_crates_rejects_invalid_workspace_shapes() { 3719 let root_empty = temp_dir_path("workspace_empty_members"); 3720 write_file( 3721 &root_empty.join("Cargo.toml"), 3722 "[workspace]\nmembers = []\n", 3723 ); 3724 let empty_err = read_workspace_crates(&root_empty).expect_err("empty workspace members"); 3725 assert!(empty_err.contains("must not be empty")); 3726 fs::remove_dir_all(&root_empty).expect("remove empty members root"); 3727 3728 let root_blank = temp_dir_path("workspace_blank_package_name"); 3729 write_file( 3730 &root_blank.join("Cargo.toml"), 3731 "[workspace]\nmembers = [\"crates/a\"]\n", 3732 ); 3733 write_file( 3734 &root_blank.join("crates").join("a").join("Cargo.toml"), 3735 "[package]\nname = \"\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", 3736 ); 3737 let blank_err = read_workspace_crates(&root_blank).expect_err("blank package name"); 3738 assert!(blank_err.contains("empty package name")); 3739 fs::remove_dir_all(&root_blank).expect("remove blank package root"); 3740 3741 let root_duplicate = temp_dir_path("workspace_duplicate_package"); 3742 write_file( 3743 &root_duplicate.join("Cargo.toml"), 3744 "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n", 3745 ); 3746 let package_manifest = 3747 "[package]\nname = \"duplicate\"\nversion = \"0.1.0\"\nedition = \"2024\"\n"; 3748 write_file( 3749 &root_duplicate.join("crates").join("a").join("Cargo.toml"), 3750 package_manifest, 3751 ); 3752 write_file( 3753 &root_duplicate.join("crates").join("b").join("Cargo.toml"), 3754 package_manifest, 3755 ); 3756 let dup_err = read_workspace_crates(&root_duplicate).expect_err("duplicate package names"); 3757 assert!(dup_err.contains("duplicate package name")); 3758 fs::remove_dir_all(&root_duplicate).expect("remove duplicate package root"); 3759 3760 let root_parse = temp_dir_path("workspace_parse_error"); 3761 write_file( 3762 &root_parse.join("Cargo.toml"), 3763 "[workspace]\nmembers = [\"crates/a\"]\n", 3764 ); 3765 write_file( 3766 &root_parse.join("crates").join("a").join("Cargo.toml"), 3767 "[package", 3768 ); 3769 let parse_err = read_workspace_crates(&root_parse).expect_err("invalid package manifest"); 3770 assert!(parse_err.contains("failed to parse")); 3771 fs::remove_dir_all(&root_parse).expect("remove parse package root"); 3772 } 3773 3774 #[test] 3775 fn parse_toml_reports_read_and_parse_errors() { 3776 let missing = temp_file_path("parse_toml_missing"); 3777 let read_err = 3778 parse_toml::<CoveragePolicyFile>(&missing).expect_err("missing file should fail"); 3779 assert!(read_err.contains("failed to read")); 3780 3781 let invalid = temp_file_path("parse_toml_invalid"); 3782 write_file(&invalid, "[gate]\nfail_under_exec_lines = 100.0\n"); 3783 let parse_err = 3784 parse_toml::<CoveragePolicyFile>(&invalid).expect_err("invalid toml should fail"); 3785 assert!(parse_err.contains("failed to parse")); 3786 fs::remove_file(invalid).expect("remove invalid toml"); 3787 3788 let workspace_missing = temp_file_path("parse_toml_workspace_missing"); 3789 let workspace_read_err = parse_toml::<WorkspaceManifest>(&workspace_missing) 3790 .expect_err("missing workspace manifest should fail"); 3791 assert!(workspace_read_err.contains("failed to read")); 3792 3793 let workspace_invalid = temp_file_path("parse_toml_workspace_invalid"); 3794 write_file(&workspace_invalid, "[workspace"); 3795 let workspace_parse_err = parse_toml::<WorkspaceManifest>(&workspace_invalid) 3796 .expect_err("invalid workspace manifest should fail"); 3797 assert!(workspace_parse_err.contains("failed to parse")); 3798 fs::remove_file(workspace_invalid).expect("remove invalid workspace manifest"); 3799 3800 let package_missing = temp_file_path("parse_toml_package_missing"); 3801 let package_read_err = parse_toml::<PackageManifest>(&package_missing) 3802 .expect_err("missing package manifest should fail"); 3803 assert!(package_read_err.contains("failed to read")); 3804 3805 let package_invalid = temp_file_path("parse_toml_package_invalid"); 3806 write_file(&package_invalid, "[package"); 3807 let package_parse_err = parse_toml::<PackageManifest>(&package_invalid) 3808 .expect_err("invalid package manifest should fail"); 3809 assert!(package_parse_err.contains("failed to parse")); 3810 fs::remove_file(package_invalid).expect("remove invalid package manifest"); 3811 3812 let profiles_missing = temp_file_path("parse_toml_profiles_missing"); 3813 let profiles_read_err = parse_toml::<CoverageProfilesFile>(&profiles_missing) 3814 .expect_err("missing coverage profiles should fail"); 3815 assert!(profiles_read_err.contains("failed to read")); 3816 3817 let profiles_invalid = temp_file_path("parse_toml_profiles_invalid"); 3818 write_file(&profiles_invalid, "[profiles.default"); 3819 let profiles_parse_err = parse_toml::<CoverageProfilesFile>(&profiles_invalid) 3820 .expect_err("invalid coverage profiles should fail"); 3821 assert!(profiles_parse_err.contains("failed to parse")); 3822 fs::remove_file(profiles_invalid).expect("remove invalid coverage profiles"); 3823 } 3824 3825 #[test] 3826 fn parse_toml_parses_valid_coverage_required_contract() { 3827 let valid = temp_file_path("parse_toml_valid"); 3828 write_file( 3829 &valid, 3830 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_core\"]\n", 3831 ); 3832 let parsed = parse_toml::<CoveragePolicyFile>(&valid).expect("valid toml"); 3833 assert_eq!(parsed.required.crates, vec!["radroots_core".to_string()]); 3834 fs::remove_file(valid).expect("remove valid toml"); 3835 } 3836 3837 #[test] 3838 fn read_lcov_rejects_invalid_records() { 3839 let cases = vec![ 3840 ("invalid_da_shape", "DA:1\n", "invalid DA record"), 3841 ("invalid_da_line", "DA:bad,1\n", "invalid DA line number"), 3842 ("invalid_da_hits", "DA:1,bad\n", "invalid DA hit count"), 3843 ("invalid_lf", "LF:bad\n", "invalid LF value"), 3844 ("invalid_lh", "LH:bad\n", "invalid LH value"), 3845 ("invalid_brf", "BRF:bad\n", "invalid BRF value"), 3846 ("invalid_brh", "BRH:bad\n", "invalid BRH value"), 3847 ("invalid_brda_shape", "BRDA:1,0,0\n", "invalid BRDA record"), 3848 ( 3849 "invalid_brda_line", 3850 "BRDA:bad,0,0,1\n", 3851 "invalid BRDA line number", 3852 ), 3853 ( 3854 "invalid_brda_taken", 3855 "BRDA:1,0,0,bad\n", 3856 "invalid BRDA taken count", 3857 ), 3858 ( 3859 "invalid_brda_extra", 3860 "BRDA:1,0,0,1,extra\n", 3861 "invalid BRDA record", 3862 ), 3863 ]; 3864 for (prefix, raw, expected) in cases { 3865 let path = temp_file_path(prefix); 3866 write_file(&path, raw); 3867 let err = read_lcov(&path).expect_err("invalid lcov record"); 3868 assert!( 3869 err.contains(expected), 3870 "expected `{expected}` in `{err}` for case {prefix}" 3871 ); 3872 fs::remove_file(path).expect("remove invalid lcov file"); 3873 } 3874 } 3875 3876 #[test] 3877 fn read_lcov_reports_read_error() { 3878 let missing = temp_file_path("lcov_missing"); 3879 let err = read_lcov(&missing).expect_err("missing lcov should fail"); 3880 assert!(err.contains("failed to read lcov")); 3881 } 3882 3883 #[test] 3884 fn read_lcov_uses_lf_lh_when_da_is_missing_and_branches_absent() { 3885 let path = temp_file_path("lcov_lf_lh"); 3886 fs::write(&path, "LF:4\nLH:3\n").expect("write lcov"); 3887 let parsed = read_lcov(&path).expect("parse lcov"); 3888 assert_eq!(executable_source_label(parsed.executable_source), "lf_lh"); 3889 assert_eq!(parsed.executable_total, 4); 3890 assert_eq!(parsed.executable_covered, 3); 3891 assert_eq!(parsed.executable_percent, 75.0); 3892 assert!(!parsed.branches_available); 3893 assert_eq!(parsed.branch_percent, None); 3894 fs::remove_file(path).expect("remove lcov"); 3895 } 3896 3897 #[test] 3898 fn read_lcov_defaults_to_full_when_no_line_records_exist() { 3899 let path = temp_file_path("lcov_empty"); 3900 write_file(&path, "TN:probe\n"); 3901 let parsed = read_lcov(&path).expect("parse lcov"); 3902 assert_eq!(parsed.executable_total, 0); 3903 assert_eq!(parsed.executable_covered, 0); 3904 assert_eq!(parsed.executable_percent, 100.0); 3905 assert!(!parsed.branches_available); 3906 assert_eq!(parsed.branch_percent, None); 3907 fs::remove_file(path).expect("remove lcov"); 3908 } 3909 3910 #[test] 3911 fn evaluate_gate_collects_all_failure_reasons() { 3912 let summary = CoverageSummary { 3913 functions_percent: 40.0, 3914 summary_lines_percent: 50.0, 3915 summary_regions_percent: 60.0, 3916 }; 3917 let lcov = LcovCoverage { 3918 executable_total: 20, 3919 executable_covered: 10, 3920 executable_percent: 50.0, 3921 executable_source: ExecutableSource::Da, 3922 branch_total: 10, 3923 branch_covered: 3, 3924 branches_available: true, 3925 branch_percent: Some(30.0), 3926 }; 3927 let thresholds = CoverageThresholds { 3928 fail_under_exec_lines: 90.0, 3929 fail_under_functions: 90.0, 3930 fail_under_regions: 90.0, 3931 fail_under_branches: 90.0, 3932 require_branches: true, 3933 }; 3934 3935 let gate = evaluate_gate(&summary, &lcov, thresholds); 3936 assert!(!gate.pass); 3937 assert!( 3938 gate.fail_reasons 3939 .iter() 3940 .any(|reason| reason.contains("executable_lines")) 3941 ); 3942 assert!( 3943 gate.fail_reasons 3944 .iter() 3945 .any(|reason| reason.contains("functions")) 3946 ); 3947 assert!( 3948 gate.fail_reasons 3949 .iter() 3950 .any(|reason| reason.contains("regions")) 3951 ); 3952 assert!( 3953 gate.fail_reasons 3954 .iter() 3955 .any(|reason| reason.contains("branches")) 3956 ); 3957 } 3958 3959 #[test] 3960 fn run_command_covers_success_and_failure() { 3961 let mut ok = Command::new("sh"); 3962 ok.arg("-c").arg("exit 0"); 3963 run_command(ok, "shell ok").expect("run ok command"); 3964 3965 let mut fail = Command::new("sh"); 3966 fail.arg("-c").arg("exit 9"); 3967 let err = run_command(fail, "shell fail").expect_err("run failing command"); 3968 assert!(err.contains("shell fail failed with status")); 3969 3970 let missing = Command::new("/definitely/not/a/real/command"); 3971 let err = run_command(missing, "shell missing").expect_err("missing command"); 3972 assert!(err.contains("failed to run shell missing")); 3973 } 3974 3975 #[test] 3976 fn apply_coverage_profile_flags_writes_expected_args() { 3977 let profile = CoverageProfile { 3978 no_default_features: true, 3979 features: vec!["std".to_string(), "serde".to_string()], 3980 test_threads: Some(2), 3981 }; 3982 let mut command = Command::new("cargo"); 3983 apply_coverage_profile_flags(&mut command, &profile); 3984 let args = command 3985 .get_args() 3986 .map(|arg| arg.to_string_lossy().to_string()) 3987 .collect::<Vec<_>>(); 3988 assert_eq!( 3989 args, 3990 vec![ 3991 "--no-default-features".to_string(), 3992 "--features".to_string(), 3993 "std,serde".to_string() 3994 ] 3995 ); 3996 } 3997 3998 #[test] 3999 fn run_crate_with_runner_builds_all_command_steps() { 4000 let out = temp_dir_path("run_crate_runner"); 4001 let args = vec![ 4002 "--crate".to_string(), 4003 "radroots_core".to_string(), 4004 "--out".to_string(), 4005 out.display().to_string(), 4006 "--test-threads".to_string(), 4007 "3".to_string(), 4008 ]; 4009 let mut names = Vec::new(); 4010 let mut rendered_commands = Vec::new(); 4011 let mut runner = |cmd: Command, name: &str| { 4012 names.push(name.to_string()); 4013 let rendered = cmd 4014 .get_args() 4015 .map(|arg| arg.to_string_lossy().to_string()) 4016 .collect::<Vec<_>>() 4017 .join(" "); 4018 assert!(!rendered.is_empty()); 4019 rendered_commands.push(rendered); 4020 Ok(()) 4021 }; 4022 run_crate_with_runner(&args, &mut runner).expect("run crate with stub runner"); 4023 assert_eq!( 4024 names, 4025 vec![ 4026 "cargo llvm-cov clean --workspace".to_string(), 4027 "cargo llvm-cov --no-report".to_string(), 4028 "cargo llvm-cov report --json --summary-only".to_string(), 4029 "cargo llvm-cov report --json".to_string(), 4030 "cargo llvm-cov report --lcov".to_string(), 4031 ] 4032 ); 4033 assert!( 4034 rendered_commands 4035 .iter() 4036 .filter(|rendered| rendered.contains("report -p radroots_core")) 4037 .all(|rendered| rendered.contains("--ignore-filename-regex")) 4038 ); 4039 assert!( 4040 rendered_commands 4041 .iter() 4042 .filter(|rendered| rendered.contains("report -p radroots_core")) 4043 .all(|rendered| rendered.contains(COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX)) 4044 ); 4045 fs::remove_dir_all(out).expect("remove run crate output dir"); 4046 } 4047 4048 #[test] 4049 fn coverage_ignore_filename_regex_excludes_external_and_sibling_workspace_paths() { 4050 let root = workspace_root(); 4051 let ignore_regex = 4052 coverage_ignore_filename_regex(&root, "radroots_core").expect("build ignore regex"); 4053 assert!(ignore_regex.contains(COVERAGE_EXTERNAL_IGNORE_FILENAME_REGEX)); 4054 assert!(ignore_regex.contains("crates/identity")); 4055 assert!(ignore_regex.contains("crates/core/tests")); 4056 assert!(!ignore_regex.contains("crates/core/src")); 4057 } 4058 4059 #[test] 4060 fn escape_regex_literal_escapes_regex_metacharacters() { 4061 let escaped = escape_regex_literal(r"\.+*?()|[]{}^$"); 4062 assert_eq!(escaped, r"\\\.\+\*\?\(\)\|\[\]\{\}\^\$"); 4063 } 4064 4065 #[test] 4066 fn coverage_cargo_command_defaults_to_rustup_nightly() { 4067 let cmd = coverage_cargo_command_with_override(None); 4068 let mut args = Vec::new(); 4069 for arg in cmd.get_args() { 4070 args.push(arg.to_string_lossy().to_string()); 4071 } 4072 4073 assert_eq!(cmd.get_program().to_string_lossy(), "rustup"); 4074 assert_eq!( 4075 args, 4076 vec![ 4077 "run".to_string(), 4078 "nightly".to_string(), 4079 "cargo".to_string() 4080 ] 4081 ); 4082 } 4083 4084 #[test] 4085 fn normalized_coverage_cargo_override_trims_and_filters_values() { 4086 assert_eq!( 4087 normalized_coverage_cargo_override(Some(" /tmp/cargo ".to_string())), 4088 Some("/tmp/cargo".to_string()) 4089 ); 4090 assert_eq!( 4091 normalized_coverage_cargo_override(Some(" ".to_string())), 4092 None 4093 ); 4094 assert_eq!(normalized_coverage_cargo_override(None), None); 4095 } 4096 4097 fn assert_coverage_command_shapes( 4098 cargo_cmd: Command, 4099 llvm_cov_cmd: Command, 4100 override_binary: Option<&str>, 4101 ) { 4102 match override_binary { 4103 Some(binary) => assert_eq!(cargo_cmd.get_program().to_string_lossy(), binary), 4104 None => assert_eq!(cargo_cmd.get_program().to_string_lossy(), "rustup"), 4105 } 4106 4107 let llvm_args = llvm_cov_cmd 4108 .get_args() 4109 .map(|arg| arg.to_string_lossy().to_string()) 4110 .collect::<Vec<_>>(); 4111 match override_binary { 4112 Some(_) => assert_eq!(llvm_args, vec!["llvm-cov".to_string()]), 4113 None => assert_eq!( 4114 llvm_args, 4115 vec![ 4116 "run".to_string(), 4117 "nightly".to_string(), 4118 "cargo".to_string(), 4119 "llvm-cov".to_string() 4120 ] 4121 ), 4122 } 4123 } 4124 4125 #[test] 4126 fn coverage_public_command_helpers_match_current_env_resolution() { 4127 let mut default_llvm_cov_cmd = coverage_cargo_command_with_override(None); 4128 default_llvm_cov_cmd.arg("llvm-cov"); 4129 assert_coverage_command_shapes( 4130 coverage_cargo_command_with_override(None), 4131 default_llvm_cov_cmd, 4132 None, 4133 ); 4134 4135 let explicit_binary = temp_dir_path("coverage_command_override") 4136 .join("nightly-cargo") 4137 .to_string_lossy() 4138 .to_string(); 4139 let mut explicit_llvm_cov_cmd = 4140 coverage_cargo_command_with_override(Some(&explicit_binary)); 4141 explicit_llvm_cov_cmd.arg("llvm-cov"); 4142 assert_coverage_command_shapes( 4143 coverage_cargo_command_with_override(Some(&explicit_binary)), 4144 explicit_llvm_cov_cmd, 4145 Some(explicit_binary.as_str()), 4146 ); 4147 4148 let override_binary = 4149 normalized_coverage_cargo_override(std::env::var("RADROOTS_COVERAGE_CARGO").ok()); 4150 assert_coverage_command_shapes( 4151 coverage_cargo_command(), 4152 coverage_llvm_cov_command(), 4153 override_binary.as_deref(), 4154 ); 4155 } 4156 4157 #[test] 4158 fn configure_coverage_toolchain_env_sets_existing_binary_envs() { 4159 let toolchain_dir = temp_dir_path("coverage_toolchain_env"); 4160 fs::create_dir_all(&toolchain_dir).expect("create toolchain env dir"); 4161 for binary in ["rustc", "rustdoc", "llvm-cov", "llvm-profdata"] { 4162 write_file(&toolchain_dir.join(binary), ""); 4163 } 4164 4165 let mut cmd = Command::new("cargo"); 4166 configure_coverage_toolchain_env(&mut cmd, &toolchain_dir); 4167 let envs = collect_command_envs(&cmd); 4168 assert_eq!( 4169 envs.get("RUSTC"), 4170 Some(&Some( 4171 toolchain_dir.join("rustc").to_string_lossy().to_string() 4172 )) 4173 ); 4174 assert_eq!( 4175 envs.get("RUSTDOC"), 4176 Some(&Some( 4177 toolchain_dir.join("rustdoc").to_string_lossy().to_string() 4178 )) 4179 ); 4180 assert_eq!( 4181 envs.get("LLVM_COV"), 4182 Some(&Some( 4183 toolchain_dir.join("llvm-cov").to_string_lossy().to_string() 4184 )) 4185 ); 4186 assert_eq!( 4187 envs.get("LLVM_PROFDATA"), 4188 Some(&Some( 4189 toolchain_dir 4190 .join("llvm-profdata") 4191 .to_string_lossy() 4192 .to_string() 4193 )) 4194 ); 4195 4196 fs::remove_dir_all(toolchain_dir).expect("remove toolchain env dir"); 4197 } 4198 4199 #[test] 4200 fn configure_coverage_toolchain_env_skips_missing_binary_envs() { 4201 let toolchain_dir = temp_dir_path("coverage_toolchain_missing_env"); 4202 fs::create_dir_all(&toolchain_dir).expect("create missing env dir"); 4203 4204 let mut cmd = Command::new("cargo"); 4205 configure_coverage_toolchain_env(&mut cmd, &toolchain_dir); 4206 let envs = collect_command_envs(&cmd); 4207 assert!(!envs.contains_key("RUSTC")); 4208 assert!(!envs.contains_key("RUSTDOC")); 4209 assert!(!envs.contains_key("LLVM_COV")); 4210 assert!(!envs.contains_key("LLVM_PROFDATA")); 4211 4212 fs::remove_dir_all(toolchain_dir).expect("remove missing env dir"); 4213 } 4214 4215 #[test] 4216 fn coverage_cargo_command_override_variants_cover_parented_and_parentless_paths() { 4217 let toolchain_dir = temp_dir_path("coverage_toolchain_override"); 4218 fs::create_dir_all(&toolchain_dir).expect("create toolchain dir"); 4219 for binary in [ 4220 "nightly-cargo", 4221 "rustc", 4222 "rustdoc", 4223 "llvm-cov", 4224 "llvm-profdata", 4225 ] { 4226 write_file(&toolchain_dir.join(binary), ""); 4227 } 4228 4229 let default_cmd = coverage_cargo_command_with_override(None); 4230 let mut args = Vec::new(); 4231 for arg in default_cmd.get_args() { 4232 args.push(arg.to_string_lossy().to_string()); 4233 } 4234 assert_eq!(default_cmd.get_program().to_string_lossy(), "rustup"); 4235 assert_eq!( 4236 args, 4237 vec![ 4238 "run".to_string(), 4239 "nightly".to_string(), 4240 "cargo".to_string() 4241 ] 4242 ); 4243 4244 let override_binary = toolchain_dir.join("nightly-cargo"); 4245 let cmd = coverage_cargo_command_with_override(Some( 4246 override_binary 4247 .to_str() 4248 .expect("override path should be utf-8"), 4249 )); 4250 4251 assert_eq!( 4252 cmd.get_program().to_string_lossy(), 4253 override_binary.to_string_lossy() 4254 ); 4255 assert!(cmd.get_args().next().is_none()); 4256 let mut envs = collect_command_envs(&cmd); 4257 envs.insert("MISSING".to_string(), None); 4258 assert_eq!( 4259 envs.get("RUSTC"), 4260 Some(&Some( 4261 toolchain_dir.join("rustc").to_string_lossy().to_string() 4262 )) 4263 ); 4264 assert_eq!( 4265 envs.get("RUSTDOC"), 4266 Some(&Some( 4267 toolchain_dir.join("rustdoc").to_string_lossy().to_string() 4268 )) 4269 ); 4270 assert_eq!( 4271 envs.get("LLVM_COV"), 4272 Some(&Some( 4273 toolchain_dir.join("llvm-cov").to_string_lossy().to_string() 4274 )) 4275 ); 4276 assert_eq!( 4277 envs.get("LLVM_PROFDATA"), 4278 Some(&Some( 4279 toolchain_dir 4280 .join("llvm-profdata") 4281 .to_string_lossy() 4282 .to_string() 4283 )) 4284 ); 4285 let path_env = envs 4286 .get("PATH") 4287 .and_then(|value| value.as_ref()) 4288 .expect("override binary should prepend PATH"); 4289 assert!(path_env.starts_with(toolchain_dir.to_string_lossy().as_ref())); 4290 let mut cmd = coverage_cargo_command_with_override(Some("/")); 4291 cmd.env_remove("RUSTC"); 4292 cmd.env_remove("LLVM_COV"); 4293 assert_eq!(cmd.get_program().to_string_lossy(), "/"); 4294 let envs = collect_command_envs(&cmd); 4295 assert_eq!(envs.get("RUSTC"), Some(&None)); 4296 assert_eq!(envs.get("LLVM_COV"), Some(&None)); 4297 4298 fs::remove_dir_all(toolchain_dir).expect("remove toolchain dir"); 4299 } 4300 4301 #[test] 4302 fn workspace_root_override_takes_precedence() { 4303 let root = workspace_root_with_override(Some("/tmp/radroots-coverage-root")); 4304 assert_eq!(root, PathBuf::from("/tmp/radroots-coverage-root")); 4305 4306 let fallback = workspace_root_with_override(Some("")); 4307 assert!(fallback.join("Cargo.toml").exists()); 4308 4309 let default_root = workspace_root_with_override(None); 4310 assert!(default_root.join("Cargo.toml").exists()); 4311 } 4312 4313 #[test] 4314 fn prepend_toolchain_bin_to_path_covers_missing_and_existing_path_inputs() { 4315 let toolchain_dir = PathBuf::from("/tmp/radroots-coverage-toolchain"); 4316 let no_path = prepend_toolchain_bin_to_path(&toolchain_dir, None); 4317 assert_eq!(no_path, OsString::from(&toolchain_dir)); 4318 4319 let joined = 4320 prepend_toolchain_bin_to_path(&toolchain_dir, Some(OsString::from("/usr/bin:/bin"))); 4321 let joined = joined.to_string_lossy().to_string(); 4322 assert!(joined.starts_with("/tmp/radroots-coverage-toolchain")); 4323 assert!(joined.contains("/usr/bin")); 4324 } 4325 4326 #[test] 4327 fn collect_command_envs_cover_helper_paths() { 4328 let mut cmd = Command::new("sh"); 4329 cmd.env("PRESENT", "value"); 4330 cmd.env_remove("REMOVED"); 4331 let envs = collect_command_envs(&cmd); 4332 assert_eq!(envs.get("PRESENT"), Some(&Some("value".to_string()))); 4333 assert_eq!(envs.get("REMOVED"), Some(&None)); 4334 } 4335 4336 #[test] 4337 fn ok_runner_helper_returns_success() { 4338 let cmd = Command::new("true"); 4339 assert!(ok_runner(cmd, "noop").is_ok()); 4340 } 4341 4342 #[test] 4343 fn run_crate_with_runner_uses_default_output_dir_when_out_is_missing() { 4344 let args = vec!["--crate".to_string(), "radroots_core".to_string()]; 4345 let mut output_path_seen = false; 4346 let mut runner = |cmd: Command, _: &str| { 4347 let rendered = cmd 4348 .get_args() 4349 .map(|arg| arg.to_string_lossy().to_string()) 4350 .collect::<Vec<_>>(); 4351 if rendered 4352 .iter() 4353 .any(|arg| arg.ends_with("coverage-summary.json")) 4354 || rendered 4355 .iter() 4356 .any(|arg| arg.ends_with("coverage-details.json")) 4357 || rendered 4358 .iter() 4359 .any(|arg| arg.ends_with("coverage-lcov.info")) 4360 { 4361 output_path_seen = true; 4362 } 4363 Ok(()) 4364 }; 4365 run_crate_with_runner(&args, &mut runner).expect("run crate with default out"); 4366 assert!(output_path_seen); 4367 } 4368 4369 #[test] 4370 fn run_crate_with_runner_propagates_runner_failures() { 4371 let out = temp_dir_path("run_crate_runner_fail"); 4372 let args = vec![ 4373 "--crate".to_string(), 4374 "radroots_core".to_string(), 4375 "--out".to_string(), 4376 out.display().to_string(), 4377 ]; 4378 let mut runner = |_: Command, _: &str| Err("runner failed".to_string()); 4379 let err = 4380 run_crate_with_runner(&args, &mut runner).expect_err("runner failure should bubble up"); 4381 assert_eq!(err, "runner failed".to_string()); 4382 fs::remove_dir_all(out).expect("remove run crate failure output dir"); 4383 let root = temp_dir_path("run_crate_create_out_error"); 4384 write_file(&root.join("blocker"), "x"); 4385 let args = vec![ 4386 "--crate".to_string(), 4387 "radroots_core".to_string(), 4388 "--out".to_string(), 4389 root.join("blocker").join("nested").display().to_string(), 4390 ]; 4391 let mut runner = run_command; 4392 let err = run_crate_with_runner(&args, &mut runner) 4393 .expect_err("output dir create error should fail"); 4394 assert!(err.contains("failed to create")); 4395 fs::remove_dir_all(root).expect("remove run crate create error root"); 4396 } 4397 4398 #[test] 4399 fn run_crate_wrapper_returns_missing_crate_error_without_running_commands() { 4400 let err = run_crate(&[]).expect_err("missing crate flag"); 4401 assert!(err.contains("missing --crate")); 4402 } 4403 4404 #[test] 4405 fn run_crate_with_runner_at_root_covers_profile_and_runner_error_paths() { 4406 let write_minimal_workspace = |root: &Path| { 4407 write_file( 4408 &root.join("Cargo.toml"), 4409 "[workspace]\nmembers = [\"crates/core\"]\n", 4410 ); 4411 write_file( 4412 &root.join("crates").join("core").join("Cargo.toml"), 4413 "[package]\nname = \"radroots_core\"\nversion = \"0.1.0-alpha.2\"\nedition = \"2024\"\n", 4414 ); 4415 }; 4416 4417 let profile_root = temp_dir_path("run_crate_profile_invalid"); 4418 write_minimal_workspace(&profile_root); 4419 write_file( 4420 &profile_root 4421 .join("contracts") 4422 .join("coverage-profiles.toml"), 4423 "[profiles.default]\nfeatures = [\"\"]\n", 4424 ); 4425 let profile_args = vec![ 4426 "--crate".to_string(), 4427 "radroots_core".to_string(), 4428 "--out".to_string(), 4429 profile_root.join("out").display().to_string(), 4430 ]; 4431 let mut runner = run_command; 4432 let profile_err = run_crate_with_runner_at_root(&profile_args, &profile_root, &mut runner) 4433 .expect_err("invalid profile should fail"); 4434 assert!(profile_err.contains("empty feature value")); 4435 fs::remove_dir_all(&profile_root).expect("remove profile root"); 4436 4437 let thread_root = temp_dir_path("run_crate_bad_threads"); 4438 fs::create_dir_all(&thread_root).expect("create thread root"); 4439 write_minimal_workspace(&thread_root); 4440 let thread_args = vec![ 4441 "--crate".to_string(), 4442 "radroots_core".to_string(), 4443 "--out".to_string(), 4444 thread_root.join("out").display().to_string(), 4445 "--test-threads".to_string(), 4446 "bad".to_string(), 4447 ]; 4448 let mut runner = run_command; 4449 let thread_err = run_crate_with_runner_at_root(&thread_args, &thread_root, &mut runner) 4450 .expect_err("invalid test threads should fail"); 4451 assert!(thread_err.contains("invalid --test-threads value")); 4452 fs::remove_dir_all(&thread_root).expect("remove thread root"); 4453 4454 for fail_step in [2usize, 3usize, 4usize] { 4455 let step_root = temp_dir_path("run_crate_step_fail"); 4456 write_minimal_workspace(&step_root); 4457 let step_args = vec![ 4458 "--crate".to_string(), 4459 "radroots_core".to_string(), 4460 "--out".to_string(), 4461 step_root.join("out").display().to_string(), 4462 ]; 4463 let mut calls = 0usize; 4464 let mut runner = |_: Command, name: &str| { 4465 calls += 1; 4466 if calls == fail_step { 4467 return Err(format!("runner failure at {name}")); 4468 } 4469 Ok(()) 4470 }; 4471 let err = run_crate_with_runner_at_root(&step_args, &step_root, &mut runner) 4472 .expect_err("runner should fail at selected step"); 4473 assert!(err.contains("runner failure at")); 4474 fs::remove_dir_all(&step_root).expect("remove step root"); 4475 } 4476 } 4477 4478 #[test] 4479 fn report_gate_writes_report_file_on_success() { 4480 let root = temp_dir_path("report_gate_success"); 4481 let summary_path = root.join("summary.json"); 4482 let lcov_path = root.join("coverage.info"); 4483 let out_path = root.join("gate-report.json"); 4484 write_file( 4485 &summary_path, 4486 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 4487 ); 4488 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 4489 4490 let args = vec![ 4491 "--scope".to_string(), 4492 "crate-x".to_string(), 4493 "--summary".to_string(), 4494 summary_path.display().to_string(), 4495 "--lcov".to_string(), 4496 lcov_path.display().to_string(), 4497 "--out".to_string(), 4498 out_path.display().to_string(), 4499 "--policy-gate".to_string(), 4500 ]; 4501 report_gate(&args).expect("report gate success"); 4502 let report_raw = fs::read_to_string(&out_path).expect("read report"); 4503 assert!(report_raw.contains("\"scope\": \"crate-x\"")); 4504 assert!(report_raw.contains("\"regions\": 100.0")); 4505 assert!(report_raw.contains("\"pass\": true")); 4506 fs::remove_dir_all(root).expect("remove report gate success root"); 4507 } 4508 4509 #[test] 4510 fn report_gate_normalizes_duplicate_generic_records_from_details() { 4511 let root = temp_dir_path("report_gate_normalized_generics"); 4512 let summary_path = root.join("summary.json"); 4513 let lcov_path = root.join("coverage.info"); 4514 let out_path = root.join("gate-report.json"); 4515 write_file( 4516 &summary_path, 4517 r#"{ 4518 "data": [ 4519 { 4520 "totals": { 4521 "functions": {"percent": 96.0}, 4522 "lines": {"percent": 99.0}, 4523 "regions": {"percent": 22.0} 4524 } 4525 } 4526 ] 4527 }"#, 4528 ); 4529 write_file( 4530 &root.join("coverage-details.json"), 4531 r#"{ 4532 "data": [ 4533 { 4534 "functions": [ 4535 { 4536 "count": 4, 4537 "filenames": ["/tmp/crates/runtime_manager/src/lib.rs"], 4538 "regions": [ 4539 [10, 1, 12, 2, 4, 0, 0, 0], 4540 [13, 1, 13, 8, 4, 0, 0, 0] 4541 ] 4542 }, 4543 { 4544 "count": 0, 4545 "filenames": ["/tmp/crates/runtime_manager/src/lib.rs"], 4546 "regions": [ 4547 [10, 1, 12, 2, 0, 0, 0, 0], 4548 [13, 1, 13, 8, 0, 0, 0, 0] 4549 ] 4550 } 4551 ] 4552 } 4553 ] 4554 }"#, 4555 ); 4556 write_file( 4557 &lcov_path, 4558 "DA:1,1\nDA:2,0\nLF:2\nLH:1\nBRDA:1,0,0,1\nBRDA:2,0,0,0\n", 4559 ); 4560 4561 let args = vec![ 4562 "--scope".to_string(), 4563 "radroots_runtime_manager".to_string(), 4564 "--summary".to_string(), 4565 summary_path.display().to_string(), 4566 "--lcov".to_string(), 4567 lcov_path.display().to_string(), 4568 "--out".to_string(), 4569 out_path.display().to_string(), 4570 "--fail-under-exec-lines".to_string(), 4571 "50.0".to_string(), 4572 "--fail-under-functions".to_string(), 4573 "100.0".to_string(), 4574 "--fail-under-regions".to_string(), 4575 "100.0".to_string(), 4576 "--fail-under-branches".to_string(), 4577 "50.0".to_string(), 4578 ]; 4579 report_gate(&args).expect("normalized report gate success"); 4580 4581 let report_raw = fs::read_to_string(&out_path).expect("read normalized report"); 4582 assert!(report_raw.contains("\"functions_percent\": 100.0")); 4583 assert!(report_raw.contains("\"summary_regions_percent\": 100.0")); 4584 assert!(report_raw.contains("\"pass\": true")); 4585 4586 fs::remove_dir_all(root).expect("remove normalized report gate root"); 4587 } 4588 4589 #[test] 4590 fn report_gate_with_root_uses_scope_specific_override_thresholds() { 4591 let root = temp_dir_path("report_gate_override_success"); 4592 let coverage_dir = root.join("contracts"); 4593 fs::create_dir_all(&coverage_dir).expect("create coverage dir"); 4594 write_file( 4595 &coverage_dir.join("coverage.toml"), 4596 "[gate]\nfail_under_exec_lines = 100.0\nfail_under_functions = 100.0\nfail_under_regions = 100.0\nfail_under_branches = 100.0\nrequire_branches = true\n\n[required]\ncrates = [\"radroots_a\"]\n\n[overrides.radroots_a]\nfail_under_exec_lines = 88.5\nfail_under_functions = 77.5\nfail_under_regions = 66.5\nfail_under_branches = 55.5\nrequire_branches = false\ntemporary = true\nreason = \"temporary publish unblocker\"\n", 4597 ); 4598 4599 let summary_path = root.join("summary.json"); 4600 let lcov_path = root.join("coverage.info"); 4601 let out_path = root.join("gate-report.json"); 4602 write_file( 4603 &summary_path, 4604 r#"{"data":[{"totals":{"functions":{"percent":80.0},"lines":{"percent":88.5},"regions":{"percent":70.0}}}]}"#, 4605 ); 4606 write_file(&lcov_path, "DA:1,1\nLF:1\nLH:1\nBRDA:1,0,0,1\n"); 4607 4608 report_gate_with_root( 4609 &[ 4610 "--scope".to_string(), 4611 "radroots_a".to_string(), 4612 "--summary".to_string(), 4613 summary_path.display().to_string(), 4614 "--lcov".to_string(), 4615 lcov_path.display().to_string(), 4616 "--out".to_string(), 4617 out_path.display().to_string(), 4618 "--policy-gate".to_string(), 4619 ], 4620 &root, 4621 ) 4622 .expect("report gate should honor override"); 4623 4624 let report_raw = fs::read_to_string(&out_path).expect("read override report"); 4625 assert!(report_raw.contains("\"functions\": 77.5")); 4626 assert!(report_raw.contains("\"regions\": 66.5")); 4627 assert!(report_raw.contains("\"branches_required\": false")); 4628 assert!(report_raw.contains("\"pass\": true")); 4629 4630 fs::remove_dir_all(root).expect("remove report gate override root"); 4631 } 4632 4633 #[test] 4634 fn report_gate_returns_error_on_failed_thresholds() { 4635 let root = temp_dir_path("report_gate_fail"); 4636 let summary_path = root.join("summary.json"); 4637 let lcov_path = root.join("coverage.info"); 4638 let out_path = root.join("gate-report.json"); 4639 write_file( 4640 &summary_path, 4641 r#"{"data":[{"totals":{"functions":{"percent":10.0},"lines":{"percent":10.0},"regions":{"percent":10.0}}}]}"#, 4642 ); 4643 write_file(&lcov_path, "DA:1,0\nBRDA:1,0,0,0\n"); 4644 4645 let args = vec![ 4646 "--scope".to_string(), 4647 "crate-y".to_string(), 4648 "--summary".to_string(), 4649 summary_path.display().to_string(), 4650 "--lcov".to_string(), 4651 lcov_path.display().to_string(), 4652 "--out".to_string(), 4653 out_path.display().to_string(), 4654 "--fail-under-exec-lines".to_string(), 4655 "100.0".to_string(), 4656 "--fail-under-functions".to_string(), 4657 "100.0".to_string(), 4658 "--fail-under-regions".to_string(), 4659 "100.0".to_string(), 4660 "--fail-under-branches".to_string(), 4661 "100.0".to_string(), 4662 ]; 4663 let err = report_gate(&args).expect_err("report gate failure"); 4664 assert!(err.contains("coverage gate failed")); 4665 fs::remove_dir_all(root).expect("remove report gate failure root"); 4666 } 4667 4668 #[test] 4669 fn report_gate_handles_nan_threshold_input() { 4670 let root = temp_dir_path("report_gate_nan"); 4671 let summary_path = root.join("summary.json"); 4672 let lcov_path = root.join("coverage.info"); 4673 let out_path = root.join("gate-report.json"); 4674 write_file( 4675 &summary_path, 4676 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 4677 ); 4678 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 4679 4680 let args = vec![ 4681 "--scope".to_string(), 4682 "crate-nan".to_string(), 4683 "--summary".to_string(), 4684 summary_path.display().to_string(), 4685 "--lcov".to_string(), 4686 lcov_path.display().to_string(), 4687 "--out".to_string(), 4688 out_path.display().to_string(), 4689 "--fail-under-functions".to_string(), 4690 "NaN".to_string(), 4691 ]; 4692 let err = report_gate(&args).expect_err("nan threshold should fail coverage gate"); 4693 assert!(err.contains("invalid --fail-under-functions value")); 4694 fs::remove_dir_all(root).expect("remove report gate nan root"); 4695 } 4696 4697 #[test] 4698 fn report_gate_reports_write_failure() { 4699 let root = temp_dir_path("report_gate_write_fail"); 4700 let summary_path = root.join("summary.json"); 4701 let lcov_path = root.join("coverage.info"); 4702 let out_path = root.join("gate-report.json"); 4703 write_file( 4704 &summary_path, 4705 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 4706 ); 4707 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 4708 fs::create_dir_all(&out_path).expect("create directory at output path"); 4709 4710 let args = vec![ 4711 "--scope".to_string(), 4712 "crate-write".to_string(), 4713 "--summary".to_string(), 4714 summary_path.display().to_string(), 4715 "--lcov".to_string(), 4716 lcov_path.display().to_string(), 4717 "--out".to_string(), 4718 out_path.display().to_string(), 4719 "--policy-gate".to_string(), 4720 ]; 4721 let err = report_gate(&args).expect_err("writing report to directory should fail"); 4722 assert!(err.contains("failed to write")); 4723 fs::remove_dir_all(root).expect("remove report gate write root"); 4724 } 4725 4726 #[test] 4727 fn report_gate_logs_branch_unavailable_path() { 4728 let root = temp_dir_path("report_gate_no_branches"); 4729 let summary_path = root.join("summary.json"); 4730 let lcov_path = root.join("coverage.info"); 4731 let out_path = root.join("gate-report.json"); 4732 write_file( 4733 &summary_path, 4734 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 4735 ); 4736 write_file(&lcov_path, "DA:1,1\n"); 4737 4738 let args = vec![ 4739 "--scope".to_string(), 4740 "crate-no-branch".to_string(), 4741 "--summary".to_string(), 4742 summary_path.display().to_string(), 4743 "--lcov".to_string(), 4744 lcov_path.display().to_string(), 4745 "--out".to_string(), 4746 out_path.display().to_string(), 4747 "--fail-under-exec-lines".to_string(), 4748 "100.0".to_string(), 4749 "--fail-under-functions".to_string(), 4750 "100.0".to_string(), 4751 "--fail-under-regions".to_string(), 4752 "100.0".to_string(), 4753 "--fail-under-branches".to_string(), 4754 "100.0".to_string(), 4755 ]; 4756 report_gate(&args).expect("report gate no branches"); 4757 let report_raw = fs::read_to_string(&out_path).expect("read report"); 4758 assert!(report_raw.contains("\"branches_available\": false")); 4759 fs::remove_dir_all(root).expect("remove no branch report root"); 4760 } 4761 4762 #[test] 4763 fn report_gate_reports_argument_and_input_errors() { 4764 let missing_scope = report_gate(&[]).expect_err("missing scope"); 4765 assert!(missing_scope.contains("missing --scope")); 4766 4767 let missing_summary = report_gate(&["--scope".to_string(), "crate".to_string()]) 4768 .expect_err("missing summary"); 4769 assert!(missing_summary.contains("missing --summary")); 4770 4771 let missing_lcov = report_gate(&[ 4772 "--scope".to_string(), 4773 "crate".to_string(), 4774 "--summary".to_string(), 4775 "summary.json".to_string(), 4776 ]) 4777 .expect_err("missing lcov"); 4778 assert!(missing_lcov.contains("missing --lcov")); 4779 4780 let missing_out = report_gate(&[ 4781 "--scope".to_string(), 4782 "crate".to_string(), 4783 "--summary".to_string(), 4784 "summary.json".to_string(), 4785 "--lcov".to_string(), 4786 "coverage.info".to_string(), 4787 ]) 4788 .expect_err("missing out"); 4789 assert!(missing_out.contains("missing --out")); 4790 4791 let root = temp_dir_path("report_gate_arg_errors"); 4792 let summary_path = root.join("summary.json"); 4793 let lcov_path = root.join("coverage.info"); 4794 let out_path = root.join("gate-report.json"); 4795 write_file( 4796 &summary_path, 4797 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 4798 ); 4799 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 4800 4801 let invalid_functions = report_gate(&[ 4802 "--scope".to_string(), 4803 "crate".to_string(), 4804 "--summary".to_string(), 4805 summary_path.display().to_string(), 4806 "--lcov".to_string(), 4807 lcov_path.display().to_string(), 4808 "--out".to_string(), 4809 out_path.display().to_string(), 4810 "--fail-under-functions".to_string(), 4811 "bad".to_string(), 4812 ]) 4813 .expect_err("invalid functions threshold"); 4814 assert!(invalid_functions.contains("invalid --fail-under-functions value")); 4815 4816 let invalid_exec = report_gate(&[ 4817 "--scope".to_string(), 4818 "crate".to_string(), 4819 "--summary".to_string(), 4820 summary_path.display().to_string(), 4821 "--lcov".to_string(), 4822 lcov_path.display().to_string(), 4823 "--out".to_string(), 4824 out_path.display().to_string(), 4825 "--fail-under-exec-lines".to_string(), 4826 "bad".to_string(), 4827 ]) 4828 .expect_err("invalid executable threshold"); 4829 assert!(invalid_exec.contains("invalid --fail-under-exec-lines value")); 4830 4831 let invalid_regions = report_gate(&[ 4832 "--scope".to_string(), 4833 "crate".to_string(), 4834 "--summary".to_string(), 4835 summary_path.display().to_string(), 4836 "--lcov".to_string(), 4837 lcov_path.display().to_string(), 4838 "--out".to_string(), 4839 out_path.display().to_string(), 4840 "--fail-under-regions".to_string(), 4841 "bad".to_string(), 4842 ]) 4843 .expect_err("invalid regions threshold"); 4844 assert!(invalid_regions.contains("invalid --fail-under-regions value")); 4845 4846 let invalid_branches = report_gate(&[ 4847 "--scope".to_string(), 4848 "crate".to_string(), 4849 "--summary".to_string(), 4850 summary_path.display().to_string(), 4851 "--lcov".to_string(), 4852 lcov_path.display().to_string(), 4853 "--out".to_string(), 4854 out_path.display().to_string(), 4855 "--fail-under-branches".to_string(), 4856 "bad".to_string(), 4857 ]) 4858 .expect_err("invalid branches threshold"); 4859 assert!(invalid_branches.contains("invalid --fail-under-branches value")); 4860 4861 let missing_thresholds = report_gate(&[ 4862 "--scope".to_string(), 4863 "crate".to_string(), 4864 "--summary".to_string(), 4865 summary_path.display().to_string(), 4866 "--lcov".to_string(), 4867 lcov_path.display().to_string(), 4868 "--out".to_string(), 4869 out_path.display().to_string(), 4870 ]) 4871 .expect_err("missing thresholds"); 4872 assert!(missing_thresholds.contains("missing coverage thresholds")); 4873 4874 let missing_functions = report_gate(&[ 4875 "--scope".to_string(), 4876 "crate".to_string(), 4877 "--summary".to_string(), 4878 summary_path.display().to_string(), 4879 "--lcov".to_string(), 4880 lcov_path.display().to_string(), 4881 "--out".to_string(), 4882 out_path.display().to_string(), 4883 "--fail-under-exec-lines".to_string(), 4884 "100".to_string(), 4885 ]) 4886 .expect_err("missing functions threshold"); 4887 assert!(missing_functions.contains("missing coverage thresholds")); 4888 4889 let missing_regions = report_gate(&[ 4890 "--scope".to_string(), 4891 "crate".to_string(), 4892 "--summary".to_string(), 4893 summary_path.display().to_string(), 4894 "--lcov".to_string(), 4895 lcov_path.display().to_string(), 4896 "--out".to_string(), 4897 out_path.display().to_string(), 4898 "--fail-under-exec-lines".to_string(), 4899 "100".to_string(), 4900 "--fail-under-functions".to_string(), 4901 "100".to_string(), 4902 ]) 4903 .expect_err("missing regions threshold"); 4904 assert!(missing_regions.contains("missing coverage thresholds")); 4905 4906 let missing_branches = report_gate(&[ 4907 "--scope".to_string(), 4908 "crate".to_string(), 4909 "--summary".to_string(), 4910 summary_path.display().to_string(), 4911 "--lcov".to_string(), 4912 lcov_path.display().to_string(), 4913 "--out".to_string(), 4914 out_path.display().to_string(), 4915 "--fail-under-exec-lines".to_string(), 4916 "100".to_string(), 4917 "--fail-under-functions".to_string(), 4918 "100".to_string(), 4919 "--fail-under-regions".to_string(), 4920 "100".to_string(), 4921 ]) 4922 .expect_err("missing branches threshold"); 4923 assert!(missing_branches.contains("missing coverage thresholds")); 4924 4925 let missing_summary_file = report_gate(&[ 4926 "--scope".to_string(), 4927 "crate".to_string(), 4928 "--summary".to_string(), 4929 root.join("missing-summary.json").display().to_string(), 4930 "--lcov".to_string(), 4931 lcov_path.display().to_string(), 4932 "--out".to_string(), 4933 out_path.display().to_string(), 4934 "--policy-gate".to_string(), 4935 ]) 4936 .expect_err("missing summary file should fail"); 4937 assert!(missing_summary_file.contains("failed to read summary")); 4938 4939 let missing_gate_report = read_gate_report(&root.join("missing-gate-report.json")) 4940 .expect_err("missing gate report should fail"); 4941 assert!(missing_gate_report.contains("failed to read gate report")); 4942 4943 write_file(&out_path, "{not-json"); 4944 let invalid_gate_report = read_gate_report(&out_path).expect_err("invalid gate report"); 4945 assert!(invalid_gate_report.contains("failed to parse gate report")); 4946 4947 let missing_lcov_file = report_gate(&[ 4948 "--scope".to_string(), 4949 "crate".to_string(), 4950 "--summary".to_string(), 4951 summary_path.display().to_string(), 4952 "--lcov".to_string(), 4953 root.join("missing-lcov.info").display().to_string(), 4954 "--out".to_string(), 4955 out_path.display().to_string(), 4956 "--policy-gate".to_string(), 4957 ]) 4958 .expect_err("missing lcov file should fail"); 4959 assert!(missing_lcov_file.contains("failed to read lcov")); 4960 4961 let mixed_policy_gate = report_gate(&[ 4962 "--scope".to_string(), 4963 "crate".to_string(), 4964 "--summary".to_string(), 4965 summary_path.display().to_string(), 4966 "--lcov".to_string(), 4967 lcov_path.display().to_string(), 4968 "--out".to_string(), 4969 out_path.display().to_string(), 4970 "--policy-gate".to_string(), 4971 "--fail-under-functions".to_string(), 4972 "100.0".to_string(), 4973 ]) 4974 .expect_err("policy gate mixed with explicit thresholds"); 4975 assert!(mixed_policy_gate.contains("cannot be combined")); 4976 4977 let mixed_policy_gate_regions = report_gate(&[ 4978 "--scope".to_string(), 4979 "crate".to_string(), 4980 "--summary".to_string(), 4981 summary_path.display().to_string(), 4982 "--lcov".to_string(), 4983 lcov_path.display().to_string(), 4984 "--out".to_string(), 4985 out_path.display().to_string(), 4986 "--policy-gate".to_string(), 4987 "--fail-under-regions".to_string(), 4988 "100.0".to_string(), 4989 ]) 4990 .expect_err("policy gate mixed with regions threshold"); 4991 assert!(mixed_policy_gate_regions.contains("cannot be combined")); 4992 4993 let mixed_policy_gate_branches_flag = report_gate(&[ 4994 "--scope".to_string(), 4995 "crate".to_string(), 4996 "--summary".to_string(), 4997 summary_path.display().to_string(), 4998 "--lcov".to_string(), 4999 lcov_path.display().to_string(), 5000 "--out".to_string(), 5001 out_path.display().to_string(), 5002 "--policy-gate".to_string(), 5003 "--require-branches".to_string(), 5004 ]) 5005 .expect_err("policy gate mixed with require-branches"); 5006 assert!(mixed_policy_gate_branches_flag.contains("cannot be combined")); 5007 5008 fs::remove_dir_all(root).expect("remove report arg errors root"); 5009 } 5010 5011 #[test] 5012 fn coverage_ignore_filename_regex_reports_unknown_crate() { 5013 let root = temp_dir_path("coverage_unknown_crate_root"); 5014 write_file( 5015 &root.join("Cargo.toml"), 5016 "[workspace]\nmembers = [\"crates/core\"]\n", 5017 ); 5018 write_file( 5019 &root.join("crates").join("core").join("Cargo.toml"), 5020 "[package]\nname = \"radroots_core\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", 5021 ); 5022 5023 let err = coverage_ignore_filename_regex(&root, "radroots_missing") 5024 .expect_err("unknown crate should fail"); 5025 assert!(err.contains("could not resolve crate directory")); 5026 5027 fs::remove_dir_all(root).expect("remove unknown crate root"); 5028 } 5029 5030 #[test] 5031 fn coverage_ignore_filename_regex_reports_workspace_manifest_errors() { 5032 let root = temp_dir_path("coverage_regex_workspace_error_root"); 5033 let read_err = coverage_ignore_filename_regex(&root, "radroots_core") 5034 .expect_err("missing workspace manifest should fail"); 5035 assert!(read_err.contains("failed to read")); 5036 5037 write_file(&root.join("Cargo.toml"), "[workspace"); 5038 let parse_err = coverage_ignore_filename_regex(&root, "radroots_core") 5039 .expect_err("invalid workspace manifest should fail"); 5040 assert!(parse_err.contains("failed to parse")); 5041 5042 fs::remove_dir_all(root).expect("remove workspace error root"); 5043 } 5044 5045 #[test] 5046 fn run_crate_with_runner_at_root_reports_ignore_filter_errors() { 5047 let root = temp_dir_path("run_crate_ignore_filter_error"); 5048 write_file( 5049 &root.join("Cargo.toml"), 5050 "[workspace]\nmembers = [\"crates/other\"]\n", 5051 ); 5052 write_file( 5053 &root.join("crates").join("other").join("Cargo.toml"), 5054 "[package]\nname = \"radroots_other\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", 5055 ); 5056 let args = vec![ 5057 "--crate".to_string(), 5058 "radroots_core".to_string(), 5059 "--out".to_string(), 5060 root.join("target").join("coverage").display().to_string(), 5061 ]; 5062 let mut runner = ok_runner; 5063 let err = run_crate_with_runner_at_root(&args, &root, &mut runner) 5064 .expect_err("missing crate coverage filter should fail"); 5065 assert!(err.contains("could not resolve crate directory")); 5066 5067 fs::remove_dir_all(root).expect("remove run crate ignore filter root"); 5068 } 5069 5070 #[test] 5071 fn run_dispatches_subcommands_and_errors() { 5072 run(&["help".to_string()]).expect("help subcommand"); 5073 run(&["required-crates".to_string()]).expect("required crates subcommand"); 5074 run(&["workspace-crates".to_string()]).expect("workspace crates subcommand"); 5075 let run_crate_err = run(&["run-crate".to_string()]).expect_err("run crate missing args"); 5076 assert!(run_crate_err.contains("missing --crate")); 5077 let unknown_err = run(&["unknown".to_string()]).expect_err("unknown subcommand"); 5078 assert!(unknown_err.contains("unknown coverage subcommand")); 5079 let missing_err = run(&[]).expect_err("missing subcommand"); 5080 assert!(missing_err.contains("missing coverage subcommand")); 5081 } 5082 5083 #[test] 5084 fn list_root_helpers_report_missing_contract_files() { 5085 let root = temp_dir_path("list_helper_missing"); 5086 fs::create_dir_all(&root).expect("create list helper root"); 5087 let mut output = Vec::new(); 5088 let required_err = list_required_crates_with_root(&root, &mut output) 5089 .expect_err("missing required crates file should fail"); 5090 assert!(required_err.contains("failed to read coverage policy")); 5091 5092 let workspace_err = list_workspace_crates_with_root(&root, &mut output) 5093 .expect_err("missing workspace manifest should fail"); 5094 assert!(workspace_err.contains("failed to read")); 5095 5096 fs::remove_dir_all(root).expect("remove list helper root"); 5097 } 5098 5099 #[test] 5100 fn write_crate_names_output_covers_success_and_error_paths() { 5101 let mut output = Vec::new(); 5102 write_crate_names_output( 5103 &mut output, 5104 vec!["radroots_a".to_string(), "radroots_b".to_string()], 5105 "required crates", 5106 ) 5107 .expect("write crate names"); 5108 let rendered = String::from_utf8(output).expect("utf8"); 5109 assert!(rendered.contains("radroots_a")); 5110 assert!(rendered.contains("radroots_b")); 5111 5112 let mut failing = FailingWriter; 5113 let err = write_crate_names_output( 5114 &mut failing, 5115 vec!["radroots_a".to_string()], 5116 "workspace crates", 5117 ) 5118 .expect_err("writer failure"); 5119 assert!(err.contains("failed to write workspace crates output")); 5120 failing.flush().expect("flush failing writer"); 5121 } 5122 5123 #[test] 5124 fn run_report_subcommand_dispatches_to_report_gate() { 5125 let root = temp_dir_path("run_dispatch_report"); 5126 let summary_path = root.join("summary.json"); 5127 let lcov_path = root.join("coverage.info"); 5128 let out_path = root.join("gate-report.json"); 5129 write_file( 5130 &summary_path, 5131 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 5132 ); 5133 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 5134 5135 run(&[ 5136 "report".to_string(), 5137 "--scope".to_string(), 5138 "dispatch".to_string(), 5139 "--summary".to_string(), 5140 summary_path.display().to_string(), 5141 "--lcov".to_string(), 5142 lcov_path.display().to_string(), 5143 "--out".to_string(), 5144 out_path.display().to_string(), 5145 "--policy-gate".to_string(), 5146 ]) 5147 .expect("dispatch report"); 5148 assert!(out_path.exists()); 5149 fs::remove_dir_all(root).expect("remove report dispatch root"); 5150 } 5151 5152 #[test] 5153 fn report_gate_with_root_reports_policy_read_errors() { 5154 let root = temp_dir_path("report_gate_policy_root_error"); 5155 let summary_path = root.join("summary.json"); 5156 let lcov_path = root.join("coverage.info"); 5157 let out_path = root.join("gate-report.json"); 5158 write_file( 5159 &summary_path, 5160 r#"{"data":[{"totals":{"functions":{"percent":100.0},"lines":{"percent":100.0},"regions":{"percent":100.0}}}]}"#, 5161 ); 5162 write_file(&lcov_path, "DA:1,1\nBRDA:1,0,0,1\n"); 5163 5164 let err = report_gate_with_root( 5165 &[ 5166 "--scope".to_string(), 5167 "crate-x".to_string(), 5168 "--summary".to_string(), 5169 summary_path.display().to_string(), 5170 "--lcov".to_string(), 5171 lcov_path.display().to_string(), 5172 "--out".to_string(), 5173 out_path.display().to_string(), 5174 "--policy-gate".to_string(), 5175 ], 5176 &root, 5177 ) 5178 .expect_err("missing policy should fail"); 5179 assert!(err.contains("failed to read coverage policy")); 5180 5181 fs::remove_dir_all(root).expect("remove report gate policy error root"); 5182 } 5183 }