Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
8e3057a
Add PR code coverage workflow for Rust changes
SteveL-MSFT Jun 26, 2026
cce23dc
Convert PR coverage workflow scripts from bash to PowerShell
SteveL-MSFT Jun 26, 2026
33e0d8d
Use ./build.ps1 -Test for coverage instrumentation
SteveL-MSFT Jun 26, 2026
0b17b1e
Handle test failures gracefully in coverage workflow
SteveL-MSFT Jun 26, 2026
0e2b25e
Skip Rust toolchain install when no Rust files changed
SteveL-MSFT Jun 26, 2026
2af9a0b
Add -CodeCoverage switch to build.ps1
SteveL-MSFT Jun 26, 2026
86005db
Install cargo-llvm-cov and llvm-tools in build script
SteveL-MSFT Jun 26, 2026
ded37dc
Move Rust toolchain and changed-file detection into build.ps1
SteveL-MSFT Jun 26, 2026
7909122
Add code coverage artifacts to .gitignore
SteveL-MSFT Jun 26, 2026
553f4c3
Skip PR comment steps for fork PRs
SteveL-MSFT Jun 26, 2026
6a0a751
Fix rustup component name to llvm-tools-preview
SteveL-MSFT Jun 26, 2026
a818069
Pass -UseCFS through coverage setup for ADO compatibility
SteveL-MSFT Jun 26, 2026
8a269b4
Only count executable lines in coverage analysis
SteveL-MSFT Jun 26, 2026
f75832f
Determine has_rust_changes from git diff, not lcov.info presence
SteveL-MSFT Jun 26, 2026
5f6c328
Change Test-RustProject to throw on test failure
SteveL-MSFT Jun 26, 2026
2324ff2
Fix Install-CargoLlvmCov help to say llvm-tools-preview
SteveL-MSFT Jun 26, 2026
d850b1e
Fix build.ps1 help to say llvm-tools-preview
SteveL-MSFT Jun 26, 2026
428f2ef
Add temporary base64 function for coverage validation
SteveL-MSFT Jun 26, 2026
cce46ec
Fix coverage setup: verbose output, binstall, scope bug
SteveL-MSFT Jun 26, 2026
cb5c4a1
Fix clippy lint: use div_ceil instead of manual reimplementation
SteveL-MSFT Jun 26, 2026
7075a35
Move coverage analysis into Get-CodeCoverageReport function
SteveL-MSFT Jun 27, 2026
7d20d88
Auto-run coverage analysis when SHAs are available or discoverable
SteveL-MSFT Jun 27, 2026
d67a9aa
Use Write-Host for coverage report output
SteveL-MSFT Jun 27, 2026
8d8e0e7
Fix coverage env vars not visible to child processes
SteveL-MSFT Jun 27, 2026
90fd9ce
Fix 0% coverage: use cargo llvm-cov build/test instead of show-env
SteveL-MSFT Jun 27, 2026
f48470d
Make -CodeCoverage imply -Test in build.ps1
SteveL-MSFT Jun 27, 2026
7300ab3
fix tests to skip on non-windows
SteveL-MSFT Jun 27, 2026
fd1b21c
some fixes to output and deleting the old coverage data file
SteveL-MSFT Jun 27, 2026
b6b1d1a
Handle LLVM sentinel hit counts that overflow Int64
SteveL-MSFT Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions .github/workflows/pr-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: PR Code Coverage

on:
pull_request:
branches: [ "main", "release/*" ]
paths-ignore:
- "docs/**"
- "*.md"
- ".vscode/*.json"
- ".github/ISSUE_TEMPLATE/**"

env:
CARGO_TERM_COLOR: always

defaults:
run:
shell: pwsh

jobs:
coverage:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Build and test with coverage
id: build-test
run: |-
$baseSha = '${{ github.event.pull_request.base.sha }}'
$headSha = '${{ github.event.pull_request.head.sha }}'

# Determine if any Rust files changed from git diff
$changedFiles = git diff --name-only --diff-filter=ACMR "$baseSha...$headSha" -- '*.rs' | Where-Object { $_ }
if (-not $changedFiles) {
"has_rust_changes=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
return
}
"has_rust_changes=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT

$testsFailed = $false
try {
./build.ps1 -Test -CodeCoverage -CodeCoverageBaseSha $baseSha -CodeCoverageHeadSha $headSha -Verbose
if ($LASTEXITCODE -ne 0) {
$testsFailed = $true
}
} catch {
$testsFailed = $true
}

if ($testsFailed) {
Write-Warning 'One or more tests failed. Producing coverage report from partial results.'
"tests_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
} else {
"tests_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
}

# Treat missing lcov.info as a failure when Rust changes exist
if (-not (Test-Path 'lcov.info')) {
Write-Error 'Coverage report (lcov.info) was not generated despite Rust file changes.'
"coverage_failed=true" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
} else {
"coverage_failed=false" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
}

- name: Analyze coverage on changed files
if: steps.build-test.outputs.has_rust_changes == 'true' && steps.build-test.outputs.coverage_failed != 'true'
id: coverage
run: |-
Import-Module ./helpers.build.psm1 -Force
$baseSha = '${{ github.event.pull_request.base.sha }}'
$headSha = '${{ github.event.pull_request.head.sha }}'

$report = Get-CodeCoverageReport -LcovPath 'lcov.info' -BaseSha $baseSha -HeadSha $headSha -Verbose

"percentage=$($report.Percentage)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
"covered=$($report.CoveredLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
"total=$($report.TotalLines)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
"emoji=$($report.Emoji)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT
"label=$($report.Label)" | Out-File -Append -Encoding utf8 -FilePath $env:GITHUB_OUTPUT

- name: Post coverage comment
if: >-
steps.build-test.outputs.has_rust_changes == 'true'
&& steps.build-test.outputs.coverage_failed != 'true'
&& steps.build-test.outputs.tests_failed == 'false'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage-report
message: |
## ${{ steps.coverage.outputs.emoji }} Code Coverage Report

**Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }})

| Metric | Value |
|--------|-------|
| Changed lines analyzed | ${{ steps.coverage.outputs.total }} |
| Lines covered by tests | ${{ steps.coverage.outputs.covered }} |
| Coverage percentage | ${{ steps.coverage.outputs.percentage }}% |

> Coverage is measured only on changed Rust code in this PR.

- name: Post coverage comment (tests failed)
if: >-
steps.build-test.outputs.has_rust_changes == 'true'
&& steps.build-test.outputs.coverage_failed != 'true'
&& steps.build-test.outputs.tests_failed == 'true'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage-report
message: |
## ${{ steps.coverage.outputs.emoji }} Code Coverage Report

> :warning: **One or more tests failed.** Coverage data below may be incomplete.

**Changed code coverage: ${{ steps.coverage.outputs.percentage }}%** (${{ steps.coverage.outputs.label }})

| Metric | Value |
|--------|-------|
| Changed lines analyzed | ${{ steps.coverage.outputs.total }} |
| Lines covered by tests | ${{ steps.coverage.outputs.covered }} |
| Coverage percentage | ${{ steps.coverage.outputs.percentage }}% |

> Coverage is measured only on changed Rust code in this PR.

- name: Post coverage comment (report failed)
if: >-
steps.build-test.outputs.has_rust_changes == 'true'
&& steps.build-test.outputs.coverage_failed == 'true'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage-report
message: |
## :x: Code Coverage Report

**Coverage report could not be generated.** The build or test run failed before
producing coverage data. Check the workflow logs for details.

- name: Post no-changes comment
if: >-
steps.build-test.outputs.has_rust_changes == 'false'
&& github.event.pull_request.head.repo.full_name == github.repository
uses: marocchino/sticky-pull-request-comment@v2
with:
header: coverage-report
message: |
## Code Coverage Report

No Rust files were changed in this PR. Coverage analysis skipped.
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,8 @@ grammars/**/src/
grammars/**/parser.*
tree-sitter-ssh-server-config/
tree-sitter-dscexpression/

# Code coverage artifacts
lcov.info
*.profraw
*.profdata
94 changes: 90 additions & 4 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ using module ./helpers.build.psm1
.PARAMETER Test
Determines whether to run Rust and Pester tests for the project.

.PARAMETER CodeCoverage
Enables code coverage instrumentation using cargo-llvm-cov. When specified, the build and
tests run with coverage instrumentation enabled and an LCOV report is generated at the path
specified by `-CodeCoverageOutputPath` (defaults to `lcov.info` in the repository root).
Installs cargo-llvm-cov and the llvm-tools-preview rustup component automatically if not present.

When `-CodeCoverageBaseSha` and `-CodeCoverageHeadSha` are provided, the script first checks
whether any `.rs` files were changed between those commits. If no Rust files were changed,
coverage is skipped entirely and the script exits early.

.PARAMETER CodeCoverageOutputPath
Specifies the output path for the LCOV code coverage report. Only used when `-CodeCoverage`
is specified. Defaults to `lcov.info` in the repository root.

.PARAMETER CodeCoverageBaseSha
The base commit SHA to compare against when detecting changed Rust files. When specified
along with `-CodeCoverageHeadSha`, coverage is skipped if no `.rs` files were modified.

.PARAMETER CodeCoverageHeadSha
The head commit SHA to compare when detecting changed Rust files. When specified along with
`-CodeCoverageBaseSha`, coverage is skipped if no `.rs` files were modified.

.PARAMETER GetPackageVersion
Short circuits the build to return the current version of the DSC CLI crate.

Expand Down Expand Up @@ -87,6 +109,10 @@ param(
)]
$PackageType,
[switch]$Test,
[switch]$CodeCoverage,
[string]$CodeCoverageOutputPath = (Join-Path $PSScriptRoot 'lcov.info'),
[string]$CodeCoverageBaseSha,
[string]$CodeCoverageHeadSha,
[string[]]$Project,
[switch]$ExcludeRustTests,
[string]$RustTestFilter,
Expand Down Expand Up @@ -153,13 +179,13 @@ begin {
$params.Remove('Quiet') > $null
Write-Progress @params
} elseif ($Completed) {
Write-Information "Finished build script"
Write-Host "Finished build script" -ForegroundColor Green
} else {
$message = "BUILD: $Activity"
if (-not [string]::IsNullOrEmpty($Status)) {
$message += "::$Status"
}
Write-Information $message
Write-Host $message -ForegroundColor Cyan
}
}
}
Expand Down Expand Up @@ -220,6 +246,28 @@ process {

#endregion Setup

#region Code coverage instrumentation
if ($CodeCoverage) {
# Code coverage requires tests to run
$Test = $true
$progressParams.Activity = 'Setting up code coverage'
Write-BuildProgress @progressParams

if ($CodeCoverageBaseSha -and $CodeCoverageHeadSha) {
Write-BuildProgress @progressParams -Status 'Checking for changed Rust files'
$changedRustFiles = Get-ChangedRustFile -BaseSha $CodeCoverageBaseSha -HeadSha $CodeCoverageHeadSha @VerboseParam
if ($changedRustFiles.Count -eq 0) {
Write-Warning 'No Rust files changed between the specified commits. Skipping code coverage.'
return
}
}

Write-BuildProgress @progressParams -Status 'Configuring cargo-llvm-cov environment'
Remove-Item $CodeCoverageOutputPath -Force -ErrorAction Ignore
Initialize-CodeCoverage -UseCFS:$UseCFS @VerboseParam
}
#endregion Code coverage instrumentation

if (!$SkipBuild) {
if ($UpdateLockFile) {
$lockFile = Join-Path $PSScriptRoot "Cargo.lock"
Expand Down Expand Up @@ -249,7 +297,7 @@ process {
Clean = $Clean
}
Write-BuildProgress @progressParams -Status 'Compiling Rust'
Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy @VerboseParam
Build-RustProject @buildParams -Audit:$Audit -Clippy:$Clippy -CodeCoverage:$CodeCoverage @VerboseParam
Write-BuildProgress @progressParams -Status "Copying build artifacts"
Copy-BuildArtifact @buildParams -ExecutableFile $BuildData.PackageFiles.Executable @VerboseParam
}
Expand All @@ -276,7 +324,7 @@ process {
$rustTestParams.TestFilter = $RustTestFilter
}
Write-BuildProgress @progressParams -Status "Testing Rust projects"
Test-RustProject @rustTestParams @VerboseParam
Test-RustProject @rustTestParams -CodeCoverage:$CodeCoverage @VerboseParam
}
if ($RustDocs) {
$docTestParams = @{
Expand Down Expand Up @@ -305,6 +353,44 @@ process {
}
}

#region Code coverage report
if ($CodeCoverage) {
$progressParams.Activity = 'Generating code coverage report'
Write-BuildProgress @progressParams
Write-BuildProgress @progressParams -Status "Writing LCOV report to $CodeCoverageOutputPath"
Export-CodeCoverageReport -OutputPath $CodeCoverageOutputPath @VerboseParam

# Determine base and head SHAs for analysis
$baseSha = $CodeCoverageBaseSha
$headSha = $CodeCoverageHeadSha

if (-not $baseSha -or -not $headSha) {
# Try to discover from current branch vs its upstream/default
$currentBranch = git rev-parse --abbrev-ref HEAD 2>$null
$defaultBranch = git rev-parse --abbrev-ref origin/HEAD 2>$null
if (-not $defaultBranch) {
$defaultBranch = 'origin/main'
}
$discoveredBase = git merge-base $defaultBranch $currentBranch 2>$null
$discoveredHead = git rev-parse HEAD 2>$null

if ($discoveredBase -and $discoveredHead -and $discoveredBase -ne $discoveredHead) {
$baseSha = $discoveredBase
$headSha = $discoveredHead
Write-Verbose -Verbose "Discovered coverage comparison: $baseSha...$headSha"
}
}

if ($baseSha -and $headSha) {
$progressParams.Activity = 'Analyzing code coverage'
Write-BuildProgress @progressParams
$report = Get-CodeCoverageReport -LcovPath $CodeCoverageOutputPath -BaseSha $baseSha -HeadSha $headSha @VerboseParam
Write-Host "$($report.Emoji) Changed code coverage: $($report.Percentage)% ($($report.Label))"
Write-Host " Lines analyzed: $($report.TotalLines) | Lines covered: $($report.CoveredLines)"
}
}
#endregion Code coverage report

if (-not [string]::IsNullOrEmpty($PackageType)) {
$progressParams.Activity = "Packaging"
$packageParams = @{
Expand Down
63 changes: 63 additions & 0 deletions dsc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ fn main() {
}

fn dsc_main() {
// Temporary: validate code coverage instrumentation
let _encoded = base64_encode("coverage-test");

#[cfg(debug_assertions)]
check_debug();

Expand Down Expand Up @@ -221,3 +224,63 @@ fn check_store() {
exit(util::EXIT_INVALID_ARGS);
}
}

/// Temporary function for validating code coverage instrumentation.
/// Encodes a string as base64 without external crates.
fn base64_encode(input: &str) -> String {
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

let bytes = input.as_bytes();
let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);

for chunk in bytes.chunks(3) {
let b0 = chunk[0] as u32;
let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 };
let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 };

let triple = (b0 << 16) | (b1 << 8) | b2;

output.push(ALPHABET[((triple >> 18) & 0x3F) as usize] as char);
output.push(ALPHABET[((triple >> 12) & 0x3F) as usize] as char);

if chunk.len() > 1 {
output.push(ALPHABET[((triple >> 6) & 0x3F) as usize] as char);
} else {
output.push('=');
}

if chunk.len() > 2 {
output.push(ALPHABET[(triple & 0x3F) as usize] as char);
} else {
output.push('=');
}
}

output
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_base64_encode_empty() {
assert_eq!(base64_encode(""), "");
}

#[test]
fn test_base64_encode_basic() {
assert_eq!(base64_encode("Hello"), "SGVsbG8=");
}

#[test]
fn test_base64_encode_padding() {
assert_eq!(base64_encode("Hi"), "SGk=");
assert_eq!(base64_encode("Hey"), "SGV5");
}

#[test]
fn test_base64_encode_coverage_string() {
assert_eq!(base64_encode("coverage-test"), "Y292ZXJhZ2UtdGVzdA==");
}
}
Loading
Loading