config-disassembler 0.6.0

Disassemble config files into smaller files and reassemble on demand.
Documentation
name: Mutation Testing

on:
  pull_request:
    branches:
      - main
    paths:
      - 'src/**'
      - 'tests/**'
      - 'Cargo.toml'
      - 'Cargo.lock'
      - '.cargo/mutants.toml'
      - '.github/workflows/mutation.yml'
  workflow_dispatch:
    inputs:
      full:
        description: 'Run the full mutation test suite instead of incremental'
        type: boolean
        default: false

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: ${{ github.ref }}-mutation
  cancel-in-progress: true

jobs:
  incremental:
    if: ${{ github.event_name == 'pull_request' || !inputs.full }}
    name: Incremental mutation testing
    runs-on: ubuntu-latest
    timeout-minutes: 90
    steps:
      - name: Checkout
        uses: actions/checkout@v6
        with:
          fetch-depth: 0

      - name: Install Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable
          # Defer caching to the dedicated `Swatinem/rust-cache@v2`
          # step below; this action's bundled cache is coarser-grained
          # and would just race the rust-cache one for the same paths.
          cache: false

      - uses: Swatinem/rust-cache@v2

      # Install cargo-mutants from the merge commit of upstream PR
      # https://github.com/sourcefrog/cargo-mutants/pull/613 (fixes #611
      # "is not a file" mid-run, and rewrites BuildDir::overwrite_file to
      # distinguish ENOENT / symlink / directory / non-regular file in
      # the error message). The latest tagged release on crates.io is
      # v27.0.0 from 2026-03-07 and does *not* contain this fix. Pinned
      # to a specific SHA so the install is reproducible; revisit and
      # switch back to taiki-e/install-action@v2 once a 27.x.x release
      # ships with this fix.
      - name: Install cargo-mutants (from upstream main, post-#613)
        run: cargo install --locked --git https://github.com/sourcefrog/cargo-mutants --rev cbdfe8a574566e01cef9ffaa7475dfaf69c88440 cargo-mutants

      - name: Compute diff vs base
        id: diff
        env:
          BASE_REF: ${{ github.base_ref || 'main' }}
        run: |
          set -euo pipefail
          git fetch --no-tags --depth=1 origin "$BASE_REF"
          MERGE_BASE=$(git merge-base HEAD "origin/$BASE_REF")
          git diff "$MERGE_BASE" HEAD -- src tests Cargo.toml Cargo.lock > mutation.diff
          # Track which Rust files changed so we can fall back to --file
          # when --in-diff finds no mutable lines (e.g. a PR that only edits
          # `#[cfg(test)] mod tests`). cargo-mutants does not mutate test
          # code, so an in-diff filter on a test-only PR would yield no
          # mutants and silently pass.
          git diff --name-only --diff-filter=AM "$MERGE_BASE" HEAD -- 'src/**' 'tests/**' \
            | grep '\.rs$' > changed-rs.txt || true
          if [ -s mutation.diff ]; then
            echo "has_diff=true" >> "$GITHUB_OUTPUT"
            echo "Diff lines: $(wc -l < mutation.diff)"
            if [ -s changed-rs.txt ]; then
              echo "Changed Rust files:"
              cat changed-rs.txt
            fi
          else
            echo "has_diff=false" >> "$GITHUB_OUTPUT"
            echo "No relevant changes; mutation testing will be skipped."
          fi

      - name: Run incremental mutation testing
        if: steps.diff.outputs.has_diff == 'true'
        run: |
          set -euo pipefail
          # Fast path: only mutate the production lines that appear in the
          # PR diff. cargo-mutants prints "No mutants to filter" and exits 0
          # when the diff covers only test code, in which case we fall back
          # to mutating every changed Rust file so the new tests get a
          # chance to kill the mutants they were written to target.
          LOG=$(mktemp)
          cargo mutants --no-shuffle --in-diff mutation.diff 2>&1 | tee "$LOG"
          if grep -q "No mutants to filter" "$LOG"; then
            if [ ! -s changed-rs.txt ]; then
              echo "::notice::No Rust source changes; nothing to mutate."
              exit 0
            fi
            echo "::notice::--in-diff produced no mutants (test-only diff); re-running scoped to changed files."
            FILE_ARGS=()
            while IFS= read -r f; do
              FILE_ARGS+=("--file" "$f")
            done < changed-rs.txt
            cargo mutants --no-shuffle "${FILE_ARGS[@]}"
          fi

      - name: Upload mutation report
        if: always() && steps.diff.outputs.has_diff == 'true'
        uses: actions/upload-artifact@v7
        with:
          name: mutants-report-incremental
          path: mutants.out
          if-no-files-found: ignore

  full:
    if: ${{ github.event_name == 'workflow_dispatch' && inputs.full }}
    name: Full mutation testing
    runs-on: ubuntu-latest
    timeout-minutes: 360
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Install Rust toolchain
        uses: actions-rust-lang/setup-rust-toolchain@v1
        with:
          toolchain: stable
          # Defer caching to the dedicated `Swatinem/rust-cache@v2`
          # step below; this action's bundled cache is coarser-grained
          # and would just race the rust-cache one for the same paths.
          cache: false

      - uses: Swatinem/rust-cache@v2

      # Install cargo-mutants from the merge commit of upstream PR #613;
      # see the matching note on the `incremental` job above.
      - name: Install cargo-mutants (from upstream main, post-#613)
        run: cargo install --locked --git https://github.com/sourcefrog/cargo-mutants --rev cbdfe8a574566e01cef9ffaa7475dfaf69c88440 cargo-mutants

      - name: Run full mutation testing
        run: cargo mutants --no-shuffle

      - name: Upload mutation report
        if: always()
        uses: actions/upload-artifact@v7
        with:
          name: mutants-report-full
          path: mutants.out
          if-no-files-found: ignore

      # Compute the mutation score from `outcomes.json` for the badge.
      # Score is `(caught + timeout) / (caught + missed + timeout)`:
      #   * `caught` and `timeout` are both "detected" semantically --
      #     the test suite signalled the mutant, either by failing or
      #     by hanging long enough to hit the per-mutant deadline.
      #   * `unviable` mutants don't compile and aren't real test
      #     misses, so they're excluded from the denominator.
      #   * Mutants matching `.cargo/mutants.toml` exclusions never
      #     reach the run at all, so they don't appear here either.
      # We surface the score to the job summary regardless of badge
      # configuration so it's always visible from the run page.
      - name: Compute mutation score
        id: score
        if: always()
        run: |
          set -euo pipefail
          if [ ! -f mutants.out/outcomes.json ]; then
            echo "::notice::No mutants.out/outcomes.json -- skipping badge update."
            echo "skip=true" >> "$GITHUB_OUTPUT"
            exit 0
          fi
          CAUGHT=$(jq '.caught // 0' mutants.out/outcomes.json)
          MISSED=$(jq '.missed // 0' mutants.out/outcomes.json)
          TIMEOUT=$(jq '.timeout // 0' mutants.out/outcomes.json)
          UNVIABLE=$(jq '.unviable // 0' mutants.out/outcomes.json)
          DENOM=$(( CAUGHT + MISSED + TIMEOUT ))
          KILLED=$(( CAUGHT + TIMEOUT ))
          if [ "$DENOM" -eq 0 ]; then
            PCT_RAW="0.00"
            MESSAGE="N/A"
            COLOR="lightgrey"
          else
            PCT_RAW=$(awk -v k="$KILLED" -v d="$DENOM" 'BEGIN { printf "%.2f", k/d*100 }')
            # Trim trailing ".00" so a perfect score reads "100%" not "100.00%".
            PCT_TRIMMED="${PCT_RAW%.00}"
            MESSAGE="${PCT_TRIMMED}%"
            INT=${PCT_RAW%.*}
            if [ "$INT" -ge 100 ]; then COLOR=brightgreen
            elif [ "$INT" -ge 95 ]; then COLOR=green
            elif [ "$INT" -ge 90 ]; then COLOR=yellowgreen
            elif [ "$INT" -ge 80 ]; then COLOR=yellow
            elif [ "$INT" -ge 70 ]; then COLOR=orange
            else COLOR=red
            fi
          fi
          {
            echo "## Mutation score"
            echo ""
            echo "**${MESSAGE}** (${KILLED} killed / ${DENOM} viable; ${UNVIABLE} unviable)"
            echo ""
            echo "| Outcome | Count |"
            echo "| --- | ---: |"
            echo "| Caught | ${CAUGHT} |"
            echo "| Timeout (counted as caught) | ${TIMEOUT} |"
            echo "| **Missed** | **${MISSED}** |"
            echo "| Unviable (excluded from score) | ${UNVIABLE} |"
          } >> "$GITHUB_STEP_SUMMARY"
          echo "message=$MESSAGE" >> "$GITHUB_OUTPUT"
          echo "color=$COLOR" >> "$GITHUB_OUTPUT"
          echo "Mutation score: $MESSAGE ($COLOR) -- killed=$KILLED denom=$DENOM"

      # Publish the score to a public gist so `img.shields.io/endpoint`
      # can render it as a badge in the README. The step gracefully
      # no-ops until the maintainer has:
      #
      #   1. Created a public gist containing a placeholder
      #      `mutants-badge.json` (any JSON literal works).
      #   2. Stored the gist ID in the `MUTANTS_BADGE_GIST_ID` repo
      #      variable.
      #   3. Stored a fine-grained PAT with `gist` write access in the
      #      `GIST_TOKEN` repo secret.
      #
      # Until those three are in place the conditional below is false
      # and the step is skipped without failing the workflow. Only
      # publishes on `main` so a `workflow_dispatch` from a feature
      # branch can't poison the public badge.
      - name: Publish mutation score badge
        if: |
          always()
          && steps.score.outputs.skip != 'true'
          && github.ref == 'refs/heads/main'
          && vars.MUTANTS_BADGE_GIST_ID != ''
        uses: schneegans/dynamic-badges-action@v1.8.0
        with:
          auth: ${{ secrets.RELEASE_PLZ_TOKEN }}
          gistID: ${{ vars.MUTANTS_BADGE_GIST_ID }}
          filename: mutants-badge.json
          label: mutants
          message: ${{ steps.score.outputs.message }}
          color: ${{ steps.score.outputs.color }}