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}**
|
|-
|
|
|
# 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>
|
|-
${TOP_DIAGS}
</details>"
fi
rm -f "$RESULT_FILE"
fi
COMMENT_BODY="${COMMENT_BODY}
---
*Generated by [rust-doctor](https://github.com/ArthurDEV44/rust-doctor)*"
COMMENT_FILE=$(mktemp "${RUNNER_TEMP:-/tmp}/rust-doctor-comment.XXXXXX.md")
printf '%s' "${COMMENT_BODY}" > "$COMMENT_FILE"
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