diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
new file mode 100644
index 00000000..5a96b778
--- /dev/null
+++ b/.github/workflows/coverage.yml
@@ -0,0 +1,282 @@
+name: Code Coverage
+
+on:
+ pull_request:
+ branches: [main]
+
+ push:
+ branches: [main]
+
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pull-requests: write
+ pages: write
+ id-token: write
+
+defaults:
+ run:
+ shell: bash
+
+jobs:
+ coverage:
+ name: Generate code coverage with cargo-llvm-cov
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v4
+ with:
+ # Fetch enough history for diff against base branch
+ fetch-depth: 0
+
+ - name: Cache cargo builds
+ uses: Swatinem/rust-cache@v2
+
+ - name: Install Rust toolchain with llvm-tools-preview
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ components: llvm-tools-preview
+
+ - name: Install cargo-llvm-cov
+ uses: taiki-e/install-action@cargo-llvm-cov
+
+ - name: Install Linux deps for winit/wgpu
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ pkg-config libx11-dev libxcb1-dev libxcb-render0-dev \
+ libxcb-shape0-dev libxcb-xfixes0-dev libxkbcommon-dev \
+ libwayland-dev libudev-dev \
+ libvulkan-dev libvulkan1 mesa-vulkan-drivers vulkan-tools
+
+ - name: Install Linux deps for audio
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libasound2-dev
+
+ - name: Configure Vulkan (Ubuntu)
+ run: |
+ echo "WGPU_BACKEND=vulkan" >> "$GITHUB_ENV"
+ # Prefer Mesa's software Vulkan (lavapipe) for headless availability
+ echo "VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json" >> "$GITHUB_ENV"
+ vulkaninfo --summary || true
+
+ - name: Generate full coverage JSON
+ run: |
+ cargo llvm-cov --workspace \
+ --features lambda-rs/with-vulkan,lambda-rs/audio-output-device \
+ --json \
+ --output-path coverage.json
+
+ - name: Generate HTML coverage report
+ run: |
+ cargo llvm-cov --workspace \
+ --features lambda-rs/with-vulkan,lambda-rs/audio-output-device \
+ --html \
+ --output-dir coverage-html \
+ --no-run
+
+ - name: Get changed files in PR
+ if: github.event_name == 'pull_request'
+ id: changed
+ run: |
+ # Use GitHub's provided base/head SHAs for accurate diff
+ base_sha="${{ github.event.pull_request.base.sha }}"
+ head_sha="${{ github.event.pull_request.head.sha }}"
+ changed_files=$(git diff --name-only "$base_sha" "$head_sha" -- '*.rs' | tr '\n' ' ')
+ echo "files=$changed_files" >> "$GITHUB_OUTPUT"
+
+ - name: Generate coverage report data
+ id: cov
+ run: |
+ # Extract total coverage and round to 2 decimal places
+ pct_raw=$(jq -r '(.data[0].totals.lines.percent // 0)' coverage.json)
+ pct=$(printf "%.2f" "$pct_raw")
+ covered=$(jq -r '(.data[0].totals.lines.covered // 0)' coverage.json)
+ total=$(jq -r '(.data[0].totals.lines.count // 0)' coverage.json)
+ echo "pct=$pct" >> "$GITHUB_OUTPUT"
+ echo "covered=$covered" >> "$GITHUB_OUTPUT"
+ echo "total=$total" >> "$GITHUB_OUTPUT"
+
+ # Extract per-file coverage as JSON for changed files
+ jq -r '.data[0].files[] | "\(.filename)|\(.summary.lines.percent // 0)|\(.summary.lines.covered // 0)|\(.summary.lines.count // 0)"' coverage.json > file_coverage.txt
+
+ - name: Build PR coverage comment
+ if: github.event_name == 'pull_request'
+ id: comment
+ env:
+ CHANGED_FILES: ${{ steps.changed.outputs.files }}
+ RUN_ID: ${{ github.run_id }}
+ REPO: ${{ github.repository }}
+ COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
+ run: |
+ # Base URL for GitHub Pages coverage (from main branch)
+ PAGES_BASE="https://lambda-sh.github.io/lambda/coverage"
+ # Get current timestamp in UTC
+ TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
+ # Short commit SHA for display
+ SHORT_SHA="${COMMIT_SHA:0:7}"
+
+ # Build the comment body
+ {
+ echo "### โ
Coverage Report"
+ echo ""
+ echo "๐ [View Full HTML Report](https://github.com/${REPO}/actions/runs/${RUN_ID}) (download artifact)"
+ echo ""
+ echo "#### Overall Coverage"
+ echo ""
+ echo "| Metric | Value |"
+ echo "|--------|-------|"
+ echo "| **Total Line Coverage** | ${{ steps.cov.outputs.pct }}% |"
+ echo "| **Lines Covered** | ${{ steps.cov.outputs.covered }} / ${{ steps.cov.outputs.total }} |"
+ echo ""
+
+ # Calculate coverage for changed files
+ if [ -n "$CHANGED_FILES" ]; then
+ echo "#### Changed Files in This PR"
+ echo ""
+ echo "| File | Coverage | Lines |"
+ echo "|------|----------|-------|"
+
+ pr_covered=0
+ pr_total=0
+
+ for file in $CHANGED_FILES; do
+ # Find this file in coverage data (match by filename ending)
+ match=$(grep -E "/${file}\|" file_coverage.txt || grep -E "^${file}\|" file_coverage.txt || true)
+ if [ -n "$match" ]; then
+ file_pct=$(echo "$match" | cut -d'|' -f2)
+ file_covered=$(echo "$match" | cut -d'|' -f3)
+ file_total=$(echo "$match" | cut -d'|' -f4)
+ # Format percentage to 2 decimal places
+ file_pct_fmt=$(printf "%.2f" "$file_pct")
+ # Create HTML filename (replace / with path structure, add .html)
+ html_file=$(echo "$file" | sed 's|/|/|g').html
+ echo "| [\`${file}\`](${PAGES_BASE}/${html_file}) | ${file_pct_fmt}% | ${file_covered}/${file_total} |"
+ pr_covered=$((pr_covered + file_covered))
+ pr_total=$((pr_total + file_total))
+ else
+ echo "| \`${file}\` | N/A | (no coverage data) |"
+ fi
+ done
+
+ echo ""
+ if [ "$pr_total" -gt 0 ]; then
+ pr_pct=$(echo "scale=2; $pr_covered * 100 / $pr_total" | bc)
+ echo "**PR Files Coverage:** ${pr_pct}% (${pr_covered}/${pr_total} lines)"
+ fi
+ else
+ echo "*No Rust files changed in this PR.*"
+ fi
+
+ echo ""
+ echo "---"
+ echo "*Generated by [cargo-llvm-cov](https://github.com/taiki-e/cargo-llvm-cov) ยท [Latest main coverage](${PAGES_BASE})*"
+ echo ""
+ echo "Last updated: ${TIMESTAMP} ยท Commit: [\`${SHORT_SHA}\`](https://github.com/${REPO}/commit/${COMMIT_SHA})"
+ } > comment_body.md
+
+ # Store as output (handle multiline)
+ {
+ echo "body<> "$GITHUB_OUTPUT"
+
+ - name: Find existing coverage comment
+ if: github.event_name == 'pull_request'
+ id: find_comment
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const issue_number = context.issue.number;
+
+ const comments = await github.rest.issues.listComments({
+ owner,
+ repo,
+ issue_number,
+ });
+
+ const botComment = comments.data.find(comment =>
+ comment.user.type === 'Bot' &&
+ comment.body.includes('### โ
Coverage Report')
+ );
+
+ return botComment ? botComment.id : null;
+ result-encoding: string
+
+ - name: Create or update PR comment
+ if: github.event_name == 'pull_request'
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const fs = require('fs');
+ const body = fs.readFileSync('comment_body.md', 'utf8');
+ const { owner, repo } = context.repo;
+ const issue_number = context.issue.number;
+ const existingCommentId = ${{ steps.find_comment.outputs.result }};
+
+ if (existingCommentId) {
+ await github.rest.issues.updateComment({
+ owner,
+ repo,
+ comment_id: existingCommentId,
+ body,
+ });
+ console.log(`Updated existing comment ${existingCommentId}`);
+ } else {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body,
+ });
+ console.log('Created new coverage comment');
+ }
+
+ - name: Upload coverage HTML as artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-html-report
+ path: coverage-html/
+ retention-days: 30
+
+ - name: Upload coverage JSON as artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: coverage-json
+ path: coverage.json
+ retention-days: 30
+
+ # Deploy HTML report to GitHub Pages on pushes to main
+ deploy-coverage:
+ name: Deploy coverage to GitHub Pages
+ needs: coverage
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Download coverage HTML artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: coverage-html-report
+ path: coverage-html
+
+ - name: Setup Pages
+ uses: actions/configure-pages@v4
+
+ - name: Upload to GitHub Pages
+ uses: actions/upload-pages-artifact@v3
+ with:
+ path: coverage-html
+
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4