cargo-crap 0.3.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:
  # Detect whether any code-relevant files changed.
  # All other jobs are gated on this output so that markdown-only PRs
  # skip CI entirely — but the workflow still runs and GitHub sees the
  # jobs as "skipped" (which satisfies required status checks).
  changes:
    name: Detect changes
    runs-on: ubuntu-latest
    outputs:
      code: ${{ steps.filter.outputs.code }}
    steps:
      - uses: actions/checkout@v6
      - uses: dorny/paths-filter@v4
        id: filter
        with:
          filters: |
            code:
              - '**/*.rs'
              - 'Cargo.toml'
              - 'Cargo.lock'
              - '.cargo/**'
              - '.github/workflows/**'
              - 'tests/fixtures/**'
              - 'justfile'
              - 'rust-toolchain.toml'
              - 'deny.toml'

  test:
    name: Test (${{ matrix.os }})
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    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
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    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)
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    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
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    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
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    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
    needs: [changes]
    if: needs.changes.outputs.code == 'true'
    runs-on: ubuntu-latest
    permissions:
      # 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

      # --- On main: generate the README badge JSON (spec 15). Runs *before*
      # the threshold gate so a failing gate still publishes a yellow/red
      # badge instead of leaving a stale green one. The `badge` job
      # downloads the artifact and pushes it to the `badges` branch — this
      # job deliberately has no `contents: write`.
      - name: Generate badge JSON (main only)
        if: github.ref == 'refs/heads/main'
        run: |
          cargo run --release -- \
            --lcov lcov.info \
            --workspace \
            --exclude 'tests/fixtures/**' \
            --threshold 15 \
            --format shields \
            --output crap-badge.json
      - name: Upload badge artifact (main only)
        if: github.ref == 'refs/heads/main'
        uses: actions/upload-artifact@v4
        with:
          name: crap-badge
          path: crap-badge.json

      # --- 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.
      # Scan recent successful main runs for the newest one that actually
      # carries a non-expired crap-baseline artifact. Taking the latest
      # successful run blindly breaks after docs-only merges: their runs
      # succeed but skip self_score (changes gate), so they upload nothing.
      - 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=""
          for id in $(gh run list \
            --repo "${{ github.repository }}" \
            --workflow ci.yml \
            --branch main \
            --status success \
            --limit 20 \
            --json databaseId \
            --jq '.[].databaseId'); do
            count=$(gh api "repos/${{ github.repository }}/actions/runs/$id/artifacts" \
              --jq '[.artifacts[] | select(.name == "crap-baseline" and .expired == false)] | length')
            if [ "$count" -gt 0 ]; then
              RUN_ID="$id"
              break
            fi
          done
          if [ -z "$RUN_ID" ]; then
            echo "No main run with a usable crap-baseline artifact — baseline will be skipped."
          else
            echo "Found main run with baseline: $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/**' \
              --threshold 15 \
              --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/**' \
              --threshold 15 \
              --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

      # Upload the comment + PR number as artifacts so the pr-comment workflow
      # (triggered via workflow_run) can post them with write permissions even
      # on fork PRs, where GITHUB_TOKEN here has no write access.
      - name: Upload PR comment artifact
        if: always() && github.event_name == 'pull_request'
        env:
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: echo "$PR_NUMBER" > pr-number.txt
      - name: Upload PR comment artifact files
        if: always() && github.event_name == 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: crap-pr-comment
          path: |
            crap-comment.md
            pr-number.txt
          if-no-files-found: ignore

  # Publish the Shields.io endpoint badge generated by self_score to the
  # orphan `badges` branch, which the README embeds via img.shields.io.
  # Kept separate from self_score so `contents: write` is scoped to the one
  # job that pushes, and only ever runs on main.
  badge:
    name: Publish CRAP badge
    needs: [self_score]
    # `always()` + result filter: publish on self_score success *and*
    # failure (a failing threshold gate is exactly when the badge must turn
    # yellow/red), but not when self_score was skipped by the changes gate.
    if: >-
      always() && github.event_name == 'push' && github.ref == 'refs/heads/main'
      && contains(fromJSON('["success", "failure"]'), needs.self_score.result)
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v6
      # The artifact is missing when self_score failed before the badge was
      # generated (e.g. a broken coverage build) — skip publishing then.
      - name: Download badge artifact
        id: download
        uses: actions/download-artifact@v4
        with:
          name: crap-badge
          path: .
        continue-on-error: true
      # Build a single-file orphan commit with git plumbing and force-push it
      # to `badges` — no checkout dance, and the branch never accumulates
      # history.
      - name: Push badge to the badges branch
        run: |
          if [ ! -f crap-badge.json ]; then
            echo "No badge artifact — self_score failed before generating it; skipping."
            exit 0
          fi
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          blob=$(git hash-object -w crap-badge.json)
          tree=$(printf '100644 blob %s\tcrap-badge.json\n' "$blob" | git mktree)
          commit=$(git commit-tree "$tree" -m "chore: update CRAP badge")
          git push --force origin "$commit:refs/heads/badges"