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@v4
        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@v4
        with:
          name: mutants-report-full
          path: mutants.out
          if-no-files-found: ignore
