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