rust-doctor 0.2.0

A unified code health tool for Rust — scan, score, and fix your codebase
Documentation
name: 'Rust Doctor'
description: 'Scan your Rust project for health issues and get a score'
author: 'rust-doctor'

branding:
  icon: 'activity'
  color: 'green'

inputs:
  directory:
    description: 'Directory to scan'
    required: false
    default: '.'
  fail-on:
    description: 'Exit with failure when this severity is reached (error, warning, none)'
    required: false
    default: 'none'
  token:
    description: 'GitHub token for posting PR comments'
    required: false
    default: ''
  diff:
    description: 'Run in diff mode on PRs (scan only changed files). Auto-detects: enabled on pull_request, disabled on push.'
    required: false
    default: 'auto'
  sarif:
    description: 'Upload SARIF results to GitHub Code Scanning (requires write permissions)'
    required: false
    default: 'false'

outputs:
  score:
    description: 'Health score (0-100)'
    value: ${{ steps.scan.outputs.score }}
  errors:
    description: 'Number of errors found'
    value: ${{ steps.scan.outputs.errors }}
  warnings:
    description: 'Number of warnings found'
    value: ${{ steps.scan.outputs.warnings }}
  sarif-file:
    description: 'Path to the generated SARIF file (if sarif input is true)'
    value: ${{ steps.sarif-scan.outputs.sarif_file }}

runs:
  using: 'composite'
  steps:
    - name: Install rust-doctor
      shell: bash
      run: |
        # Try cargo-binstall first (fast, pre-built binary)
        if command -v cargo-binstall &> /dev/null; then
          echo "Installing rust-doctor via cargo-binstall..."
          cargo binstall rust-doctor --no-confirm --log-level warn 2>/dev/null || {
            echo "cargo-binstall failed, falling back to cargo install..."
            cargo install rust-doctor --locked
          }
        elif command -v cargo &> /dev/null; then
          echo "Installing rust-doctor via cargo install..."
          cargo install rust-doctor --locked
        else
          echo "::error::Neither cargo-binstall nor cargo found. Please ensure Rust toolchain is installed."
          exit 1
        fi

    - name: Run rust-doctor scan
      id: scan
      shell: bash
      env:
        INPUT_DIRECTORY: ${{ inputs.directory }}
        INPUT_FAIL_ON: ${{ inputs.fail-on }}
        INPUT_DIFF: ${{ inputs.diff }}
        EVENT_NAME: ${{ github.event_name }}
      run: |
        ARGS=("${INPUT_DIRECTORY}" "--json" "--fail-on" "${INPUT_FAIL_ON}")

        # Resolve diff mode: 'auto' enables on PRs only, 'true'/'false' are explicit
        USE_DIFF="false"
        if [ "${INPUT_DIFF}" = "auto" ]; then
          if [ "${EVENT_NAME}" = "pull_request" ] || [ "${EVENT_NAME}" = "pull_request_target" ]; then
            USE_DIFF="true"
          fi
        elif [ "${INPUT_DIFF}" = "true" ]; then
          USE_DIFF="true"
        fi

        if [ "${USE_DIFF}" = "true" ]; then
          ARGS+=("--diff")
        fi

        # Run scan and capture output
        set +e
        RESULT=$(rust-doctor "${ARGS[@]}" 2>/dev/null)
        EXIT_CODE=$?
        set -e

        if [ -z "$RESULT" ]; then
          echo "::warning::rust-doctor produced no output"
          echo "score=0" >> "$GITHUB_OUTPUT"
          echo "errors=0" >> "$GITHUB_OUTPUT"
          echo "warnings=0" >> "$GITHUB_OUTPUT"
          exit $EXIT_CODE
        fi

        # Extract values from JSON and validate they are integers
        SCORE=$(echo "$RESULT" | jq -r '.score // 0')
        ERRORS=$(echo "$RESULT" | jq -r '.error_count // 0')
        WARNINGS=$(echo "$RESULT" | jq -r '.warning_count // 0')
        [[ "$SCORE" =~ ^[0-9]+$ ]] || SCORE=0
        [[ "$ERRORS" =~ ^[0-9]+$ ]] || ERRORS=0
        [[ "$WARNINGS" =~ ^[0-9]+$ ]] || WARNINGS=0

        echo "score=${SCORE}" >> "$GITHUB_OUTPUT"
        echo "errors=${ERRORS}" >> "$GITHUB_OUTPUT"
        echo "warnings=${WARNINGS}" >> "$GITHUB_OUTPUT"

        # Save full JSON for comment step using a unique temp file
        # Use RUNNER_TEMP (job-isolated on GitHub-hosted runners) instead of shared /tmp
        RESULT_FILE=$(mktemp "${RUNNER_TEMP:-/tmp}/rust-doctor-result.XXXXXX.json")
        echo "$RESULT" > "$RESULT_FILE"
        echo "result_file=${RESULT_FILE}" >> "$GITHUB_OUTPUT"

        exit $EXIT_CODE

    - name: Post PR comment
      if: inputs.token != '' && github.event_name == 'pull_request'
      shell: bash
      env:
        GH_TOKEN: ${{ inputs.token }}
        SCORE: ${{ steps.scan.outputs.score }}
        ERRORS: ${{ steps.scan.outputs.errors }}
        WARNINGS: ${{ steps.scan.outputs.warnings }}
        RESULT_FILE: ${{ steps.scan.outputs.result_file }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
        REPO: ${{ github.repository }}
      run: |
        # Validate inputs
        [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || { echo "::warning::Invalid PR number, skipping comment"; exit 0; }

        # Determine emoji based on score
        if [ "$SCORE" -ge 75 ]; then
          EMOJI="✅"
          LABEL="Great"
        elif [ "$SCORE" -ge 50 ]; then
          EMOJI="⚠️"
          LABEL="Needs work"
        else
          EMOJI="❌"
          LABEL="Critical"
        fi

        # Build comment body
        COMMENT_BODY="${EMOJI} **Rust Doctor: ${SCORE}/100 — ${LABEL}**

| Metric | Value |
|--------|-------|
| Score | ${SCORE}/100 |
| Errors | ${ERRORS} |
| Warnings | ${WARNINGS} |"

        # Add top diagnostics if available
        if [ -n "$RESULT_FILE" ] && [ -f "$RESULT_FILE" ]; then
          TOP_DIAGS=$(jq -r '[.diagnostics[:5][] | "| \(.severity) | \(.rule | gsub("[|`<>]"; "")) | \(.message | gsub("[|`<>]"; "") | .[0:120]) |"] | join("\n")' "$RESULT_FILE" 2>/dev/null || true)
          if [ -n "$TOP_DIAGS" ]; then
            COMMENT_BODY="${COMMENT_BODY}

<details>
<summary>Top diagnostics</summary>

| Severity | Rule | Message |
|----------|------|---------|
${TOP_DIAGS}

</details>"
          fi
          rm -f "$RESULT_FILE"
        fi

        COMMENT_BODY="${COMMENT_BODY}

---
*Generated by [rust-doctor](https://github.com/ArthurDEV44/rust-doctor)*"

        # Write comment body to temp file to avoid shell metacharacter issues
        COMMENT_FILE=$(mktemp "${RUNNER_TEMP:-/tmp}/rust-doctor-comment.XXXXXX.md")
        printf '%s' "${COMMENT_BODY}" > "$COMMENT_FILE"

        # Find existing comment to update, or create new one
        EXISTING_COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
          --jq '.[] | select(.body | contains("Rust Doctor:")) | .id' 2>/dev/null | head -1)

        if [ -n "$EXISTING_COMMENT_ID" ]; then
          gh api "repos/${REPO}/issues/comments/${EXISTING_COMMENT_ID}" \
            -X PATCH -F body=@"${COMMENT_FILE}" > /dev/null
          echo "Updated existing PR comment"
        else
          gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \
            -F body=@"${COMMENT_FILE}" > /dev/null
          echo "Posted new PR comment"
        fi

        rm -f "$COMMENT_FILE"

    - name: Generate SARIF report
      id: sarif-scan
      if: inputs.sarif == 'true'
      shell: bash
      env:
        INPUT_DIRECTORY: ${{ inputs.directory }}
      run: |
        SARIF_FILE="${RUNNER_TEMP:-/tmp}/rust-doctor-results.sarif"
        rust-doctor "${INPUT_DIRECTORY}" --sarif > "$SARIF_FILE" 2>/dev/null || true
        echo "sarif_file=${SARIF_FILE}" >> "$GITHUB_OUTPUT"

    - name: Upload SARIF to GitHub Code Scanning
      if: inputs.sarif == 'true' && steps.sarif-scan.outputs.sarif_file != ''
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: ${{ steps.sarif-scan.outputs.sarif_file }}
        category: rust-doctor