cargo-crap 0.2.0

Change Risk Anti-Patterns (CRAP) metric for Rust projects
Documentation
name: CI

on:
  push:
    branches: [ main ]
  pull_request:

env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: "-D warnings"

jobs:
  test:
    name: Test (${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ ubuntu-latest, macos-latest, windows-latest ]
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@cargo-nextest
      - name: Test
        run: cargo nextest run --all-targets
      - name: Doc tests
        run: cargo test --doc

  lints:
    name: Lints
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: rustfmt, clippy
      - uses: Swatinem/rust-cache@v2
      - name: rustfmt
        run: cargo fmt --all -- --check
      - name: clippy
        run: cargo clippy --all-targets -- -D warnings

  msrv:
    name: MSRV (Rust 1.88)
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@1.88
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@cargo-nextest
      - name: Test (MSRV)
        run: cargo nextest run --all-targets
      - name: Doc tests (MSRV)
        run: cargo test --doc

  audit:
    name: Security audit
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - name: Install cargo-audit
        uses: taiki-e/install-action@cargo-audit

      - name: Run security audit
        run: cargo audit

  mutants:
    name: Mutation tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
        with:
          fetch-depth: 0
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-mutants,cargo-nextest
      - name: Run mutation tests (changed files only, PRs)
        if: github.event_name == 'pull_request'
        run: |
          files=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- 'src/*.rs' 2>/dev/null || true)
          if [ -z "$files" ]; then
            echo "No changed src/*.rs files — skipping mutants."
            exit 0
          fi
          echo "Running mutants on: $files"
          cargo mutants $(echo "$files" | sed 's/^/--file /' | tr '\n' ' ')
      - name: Run mutation tests (full, main)
        if: github.event_name == 'push'
        run: cargo mutants
      - name: Upload mutants output
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: mutants-out
          path: mutants.out/

  # Dogfood: run cargo-crap against its own source, gate on threshold 15.
  # On main: also save a JSON baseline as a CI artifact for PR regression gating.
  # On PRs: download the main baseline and fail if any score regressed.
  self_score:
    name: Self-score
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
      # Needed to list previous main runs and download their artifacts via
      # `gh run list` and `actions/download-artifact@v4 run-id:`.
      actions: read
    steps:
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
        with:
          components: llvm-tools-preview
      - uses: taiki-e/install-action@v2
        with:
          tool: cargo-llvm-cov
      - uses: Swatinem/rust-cache@v2

      - name: Generate coverage
        run: cargo llvm-cov --lcov --output-path lcov.info --workspace

      # --- Absolute threshold gate (always) ---
      - name: Score (threshold 15, exclude fixtures)
        run: |
          cargo run --release -- \
            --lcov lcov.info \
            --workspace \
            --exclude 'tests/fixtures/**' \
            --threshold 15 \
            --fail-above \
            --format json \
            --output crap-current.json

      # --- On main: publish baseline for future PRs to compare against ---
      - name: Upload baseline (main only)
        if: github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v4
        with:
          name: crap-baseline
          path: crap-current.json
          retention-days: 90

      # --- On PRs: download the baseline uploaded by the latest successful
      # main run. `actions/download-artifact@v4` only sees the *current*
      # workflow run by default, so we resolve main's latest successful
      # run-id with the gh CLI and pass it explicitly.
      - name: Resolve baseline run-id (PRs only)
        if: github.event_name == 'pull_request'
        id: baseline_run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          RUN_ID=$(gh run list \
            --repo "${{ github.repository }}" \
            --workflow ci.yml \
            --branch main \
            --status success \
            --limit 1 \
            --json databaseId \
            --jq '.[0].databaseId // empty')
          if [ -z "$RUN_ID" ]; then
            echo "No successful main run yet — baseline will be skipped."
          else
            echo "Found main run: $RUN_ID"
          fi
          echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT"

      - name: Download baseline (PRs only)
        if: github.event_name == 'pull_request' && steps.baseline_run.outputs.run_id != ''
        uses: actions/download-artifact@v4
        with:
          name: crap-baseline
          path: baseline
          run-id: ${{ steps.baseline_run.outputs.run_id }}
          github-token: ${{ secrets.GITHUB_TOKEN }}
        continue-on-error: true # artifact may have expired (90-day retention)

      # The `grep '"version"'` guard distinguishes a usable baseline (current
      # envelope schema) from a stale one that cargo-crap would reject at
      # parse time. When the format evolves the next merge to main re-uploads
      # the new shape; PRs in the gap just skip the comparison rather than
      # failing CI.
      - name: Regression check (PRs only)
        if: github.event_name == 'pull_request'
        run: |
          if [ -f baseline/crap-current.json ] && grep -q '"version"' baseline/crap-current.json; then
            cargo run --release -- \
              --lcov lcov.info \
              --workspace \
              --exclude 'tests/fixtures/**' \
              --baseline baseline/crap-current.json \
              --fail-regression
          else
            echo "No usable baseline available — skipping regression check."
          fi

      # --- On PRs: generate markdown comment and post/update on the PR ---
      # `always()` runs these steps even when the Regression check above
      # exits non-zero — that's exactly when we want the comment to land,
      # because it tells the reviewer *what* regressed.
      # `pull_request.head.sha` is the actual commit users browse on the PR
      # branch; `github.sha` on a pull_request event is the synthetic merge
      # commit and won't resolve in `/blob/<sha>/...` URLs. Both vars are
      # GitHub-managed (not user input) — safe to interpolate via env:.
      - name: Generate PR comment
        if: always() && github.event_name == 'pull_request'
        env:
          REPO_URL: ${{ github.server_url }}/${{ github.repository }}
          COMMIT_REF: ${{ github.event.pull_request.head.sha }}
        run: |
          if [ -f baseline/crap-current.json ] && grep -q '"version"' baseline/crap-current.json; then
            cargo run --release -- \
              --lcov lcov.info \
              --workspace \
              --exclude 'tests/fixtures/**' \
              --baseline baseline/crap-current.json \
              --format pr-comment \
              --repo-url "$REPO_URL" \
              --commit-ref "$COMMIT_REF" \
              --output crap-comment.md || true
          else
            cargo run --release -- \
              --lcov lcov.info \
              --workspace \
              --exclude 'tests/fixtures/**' \
              --format pr-comment \
              --repo-url "$REPO_URL" \
              --commit-ref "$COMMIT_REF" \
              --output crap-comment.md || true
            printf '\n_No baseline available — showing absolute scores only._\n' >> crap-comment.md
          fi

      - name: Post or update PR comment
        if: always() && github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            if (!fs.existsSync('crap-comment.md')) return;
            const body = fs.readFileSync('crap-comment.md', 'utf8');
            const marker = '<!-- cargo-crap-report -->';
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const existing = comments.find(c => c.body.startsWith(marker));
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }