diff --git a/.anvil.lock b/.anvil.lock new file mode 100644 index 00000000..05a6ba57 --- /dev/null +++ b/.anvil.lock @@ -0,0 +1,138 @@ +version = 1 +rendered_by = "cargo-anvil 0.1.0" + +[[file]] +path = ".github/actions/anvil-impact/action.yml" +checksum = "sha256:7d2f0dcafd024407c8e73370c19afdb882e4cb807f6113258561d665bd2b8a0a" + +[[file]] +path = ".github/actions/anvil-pr-fast/action.yml" +checksum = "sha256:6d93a456cdeb9668d713c623cb649732e731ec31a3761b4ad971acb3b9827ea5" + +[[file]] +path = ".github/actions/anvil-pr-mutants/action.yml" +checksum = "sha256:4053a6023ddcd01eee22911e0080031e1bcd8c502ffc75acfb20d4c02a038685" + +[[file]] +path = ".github/actions/anvil-pr-runtime-analysis/action.yml" +checksum = "sha256:326cc30bfb67bc1d23be50d37c57e542466bce813a56b38a0c2a015e8d13a540" + +[[file]] +path = ".github/actions/anvil-pr-test/action.yml" +checksum = "sha256:ba33cd6a826d2a6e63e88fd6e4795496717b697d64bf93f133333d916952cdb5" + +[[file]] +path = ".github/actions/anvil-scheduled-advisories/action.yml" +checksum = "sha256:c05a587116e84f5da346f8faf994503c6b0ab578fb2534d5e586fa4bbb261cd6" + +[[file]] +path = ".github/actions/anvil-scheduled-exhaustive/action.yml" +checksum = "sha256:6a56421f666b92239a952e0ef351c803c59f5d24bd22b34f8695581fa175bdea" + +[[file]] +path = ".github/actions/anvil-scheduled-test/action.yml" +checksum = "sha256:a42cf49d0e203db5545a797888c8c62e1564f5914a2588e2a40cb3b2cf8b0ad3" + +[[file]] +path = ".github/actions/anvil-setup/action.yml" +checksum = "sha256:065181e093ed68c83d5f974ebde2980f26cc70f954d965b2c0ca1b63042d3a1e" + +[[file]] +path = ".github/workflows/anvil-pr-impl.yml" +checksum = "sha256:70b2f188d3cc0fee5c9502534611121db9ff8b2c27cd6dfa176bf9a9ba9d2d2e" + +[[file]] +path = ".github/workflows/anvil-pr.yml" +checksum = "sha256:14c3541e39a7918497cb400ea20232340bc056da6d862a951568c09e32f4f2f7" + +[[file]] +path = ".github/workflows/anvil-scheduled-impl.yml" +checksum = "sha256:bee7c741ee336c43b75754a0a902d6a64f8df971a95121acc7c6d66bbf2bacc8" + +[[file]] +path = ".github/workflows/anvil-scheduled.yml" +checksum = "sha256:a45359b92a6d851fc39bfa1c03aeb489b544ae37a1cea390dbbf860b2def8205" + +[[file]] +path = "justfiles/anvil/checks.just" +checksum = "sha256:d7c4e8c7eb70c4214ae5bd527636c2c865605bd91c82dd32625a35f6f0b203be" + +[[file]] +path = "justfiles/anvil/groups.just" +checksum = "sha256:6fa13e5e760a490c5c466efee538513bc9a73deffd58d8a05f13b71f3b9b4f10" + +[[file]] +path = "justfiles/anvil/mod.just" +checksum = "sha256:06b486f1d154b36cf1c1a43333addd2e3ade8c69646600f7409a0c6a1c08cedb" + +[[file]] +path = "justfiles/anvil/tiers.just" +checksum = "sha256:fd65dc16029c0e347f55f406e005ca1b264db4b482e3f1e8f6ef94aad7c51b64" + +[[file]] +path = "justfiles/anvil/tools.just" +checksum = "sha256:7c92f6cadca16c4e2d6897b1c9304e7a541fb7c7e380e27165304d4071a6d27c" + +[[file]] +path = "justfiles/anvil/versions.just" +checksum = "sha256:42bea708c5dc7fc08847dcf4bfed8941b084ac98dbd43b6c28582c5d59449895" + +[[region]] +host = ".delta.toml" +id = "anvil-delta" +checksum = "sha256:ef049abc4bba5e6dac7dfc07a4852d821de4682fe6ad46aace7ddfb2455cb597" + +[[region]] +host = "Cargo.toml" +id = "anvil-workspace-lints" +checksum = "sha256:d66a878609d0bf11e3aa52f3d28ef674de9333c97b3f5d5ffdc7c7b487ac5792" + +[[region]] +host = "Justfile" +id = "anvil-imports" +checksum = "sha256:f8affd59b69c7083c2f3b6f593c63672116dda974c1e661dcb66a4412eb3eada" + +[[region]] +host = "clippy.toml" +id = "anvil-clippy" +checksum = "sha256:aba0733632eac4cb54c4768db578fe1f7b7cfe730aa0d7dc13e2828c9062d67d" + +[[region]] +host = "crates/automation/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-anvil/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-coverage-gate/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo-heather/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "crates/cargo_ensure_no_cyclic_deps/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:2dd7c0f21339fd17092b8dedfe924aa86732c3520baab84f914c2d8f4103ac40" + +[[region]] +host = "deny.toml" +id = "anvil-deny" +checksum = "sha256:3d38154ac70567b4b6b39699e244040a1ed869471d3501f7147b5c3c9a748ade" + +[[region]] +host = "rustfmt.toml" +id = "anvil-rustfmt" +checksum = "sha256:c21c68f3f9e46e8a958513e80ebce393d2e2ec8b90e2ded5416bca4215ffa8b7" + +[[region]] +host = "spellcheck.toml" +id = "anvil-spellcheck" +checksum = "sha256:3d85d17a4e9a9a6770738b67d93a801375ffadd159e506eef81e5a9ffe1da0d7" diff --git a/.cargo-heather.toml b/.cargo-heather.toml index 5fd0204c..f7dd27c6 100644 --- a/.cargo-heather.toml +++ b/.cargo-heather.toml @@ -1,4 +1,19 @@ header = """ Copyright (c) Microsoft Corporation. Licensed under the MIT License. -""" \ No newline at end of file +""" + +# Excluded directories. These hold content that legitimately should NOT +# carry the Microsoft copyright header: +# +# - `crates/cargo-anvil/templates/regions/`: managed-region body +# snippets embedded into adopters' config files via include_str!. +# A header here would inject it into every adopter's deny.toml / +# rustfmt.toml / etc. +# - `crates/cargo-anvil/tests/fixtures/`: simulated adopter repos +# used as test inputs. They model arbitrary user content and +# should not inherit our header policy. +exclude = [ + "crates/cargo-anvil/templates/regions", + "crates/cargo-anvil/tests/fixtures", +] \ No newline at end of file diff --git a/.delta.toml b/.delta.toml index d62cb3f2..d4404f18 100644 --- a/.delta.toml +++ b/.delta.toml @@ -81,3 +81,14 @@ assume_patterns = [ # The remote branch to compare against for determining changed files # If not specified, uses the default branch detection remote_branch = "origin/main" + +# >>> anvil-managed: anvil-delta +[delta] +# Include the workspace root files that should invalidate every member's +# impact analysis when changed (lockfile, root manifest, toolchain). +root-files = [ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", +] +# <<< anvil-managed: anvil-delta diff --git a/.github/actions/anvil-impact/action.yml b/.github/actions/anvil-impact/action.yml new file mode 100644 index 00000000..c51769bf --- /dev/null +++ b/.github/actions/anvil-impact/action.yml @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-impact +description: | + Compute the cargo-delta impact set for this PR and emit per-tier + include lists. + + Outputs: + include_modified - "--package X --package Y" string for crates whose + source files changed in the diff, or "--skip" if + the modified set is empty. + include_affected - same shape, for crates in the affected set + (modified ∪ rev-deps). + include_required - same shape, for crates in the required set + (affected ∪ workspace-internal transitive deps). + + Recipes in checks.just interpret each variable per their tier: + modified-tier recipes (fmt, license-headers, spellcheck, ...) short- + circuit on "--skip"; affected-tier recipes (clippy, tests, ...) and + required-tier recipes (doc, cargo-hack, udeps) splice their include + list into the cargo invocation, defaulting to --workspace when unset + (local runs without impact wiring). + + Unscoped recipes (deny, audit, aprz, pr-title) ignore all three + variables and always run unconditionally. +outputs: + include_modified: + description: Pre-formatted --package args for the modified tier. + value: ${{ steps.compute.outputs.include_modified }} + include_affected: + description: Pre-formatted --package args for the affected tier. + value: ${{ steps.compute.outputs.include_affected }} + include_required: + description: Pre-formatted --package args for the required tier. + value: ${{ steps.compute.outputs.include_required }} +runs: + using: composite + steps: + # anvil-setup with group=none bootstraps the rust toolchain + + # just + binstall + cache, but skips the full catalog install. + # We follow it with just the cargo-delta install (the only tool + # this composite needs). This keeps the impact stage lean -- it's + # the critical-path gating dep for every PR-tier group job. + - uses: ./.github/actions/anvil-setup + with: + group: none + - name: Install cargo-delta + shell: bash + run: just anvil-tool-cargo-delta-install binstall + - id: compute + name: Compute impact + shell: bash + run: | + set -euo pipefail + # GITHUB_BASE_REF is the target-branch name on a PR event + # (e.g. "main"); we resolve it to origin/. Adopters can + # override via the BASE_REF env var. + base="${BASE_REF:-origin/${GITHUB_BASE_REF:-main}}" + # cargo delta has no --base flag; the flow is two snapshots + # (baseline at the merge target + current at HEAD) compared by + # `cargo delta impact`. We use a temporary worktree to snapshot + # the baseline without disturbing the checked-out tree. + cargo delta snapshot > "$RUNNER_TEMP/anvil-current.json" + git worktree add --detach "$RUNNER_TEMP/anvil-baseline" "$base" + ( cd "$RUNNER_TEMP/anvil-baseline" && cargo delta snapshot ) \ + > "$RUNNER_TEMP/anvil-baseline.json" + git worktree remove --force "$RUNNER_TEMP/anvil-baseline" + result="$(cargo delta impact \ + --baseline "$RUNNER_TEMP/anvil-baseline.json" \ + --current "$RUNNER_TEMP/anvil-current.json" \ + --format json)" + # cargo-delta emits TitleCase keys (Modified / Affected / + # Required), not lowercase. Format each tier into the + # `--package X --package Y` shape recipes expect, or the + # literal "--skip" sentinel when the tier is empty. + # + # cargo-delta's impact output uses *library names* (snake_case) + # rather than cargo *package names* (which may use hyphens). For + # hyphenated packages — e.g. `cargo-anvil` — that means it + # emits `cargo_anvil`, which cargo rejects as a --package + # specification. We build: + # * `pkg_map`: lib-name -> package-name (and identity for + # package-name -> package-name), for translation; + # * `valid_pkgs`: set of all known package names, for + # validation. Names cargo-delta emits that aren't valid + # packages (e.g. directory-leaf ambiguities like `ffi` / + # `ffi_build` in deeply nested workspaces) are dropped with a + # warning rather than failing the whole build. + declare -A pkg_map + declare -A valid_pkgs + while IFS=$'\t' read -r pkg_name lib_name; do + valid_pkgs["$pkg_name"]=1 + pkg_map["$pkg_name"]="$pkg_name" + [ -n "$lib_name" ] && pkg_map["$lib_name"]="$pkg_name" + done < <(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] as $p | ($p.targets[] | select(.kind | index("lib")) | "\($p.name)\t\(.name)"), "\($p.name)\t"') + format_set() { + local field="$1" + local pkgs + pkgs=$(printf '%s' "$result" | jq -r --arg f "$field" '(.[$f] // []) | .[]' 2>/dev/null || true) + if [ -z "$pkgs" ] ; then + printf '%s' "--skip" + else + local out="" + while IFS= read -r pkg ; do + [ -z "$pkg" ] && continue + local mapped="${pkg_map[$pkg]:-$pkg}" + if [ -z "${valid_pkgs[$mapped]:-}" ] ; then + echo "anvil impact: dropping unknown package '$pkg' (-> '$mapped') from $field set" >&2 + continue + fi + out="$out --package $mapped" + done <> "$GITHUB_OUTPUT" + echo "include_affected=$(format_set Affected)" >> "$GITHUB_OUTPUT" + echo "include_required=$(format_set Required)" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/anvil-pr-fast/action.yml b/.github/actions/anvil-pr-fast/action.yml new file mode 100644 index 00000000..5618ea73 --- /dev/null +++ b/.github/actions/anvil-pr-fast/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-fast is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-fast +description: Run the pr-fast check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-fast + - name: Run just anvil-pr-fast + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-fast diff --git a/.github/actions/anvil-pr-mutants/action.yml b/.github/actions/anvil-pr-mutants/action.yml new file mode 100644 index 00000000..40b300d2 --- /dev/null +++ b/.github/actions/anvil-pr-mutants/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-mutants is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-mutants +description: Run the pr-mutants check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-mutants + - name: Run just anvil-pr-mutants + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-mutants diff --git a/.github/actions/anvil-pr-runtime-analysis/action.yml b/.github/actions/anvil-pr-runtime-analysis/action.yml new file mode 100644 index 00000000..f180c14d --- /dev/null +++ b/.github/actions/anvil-pr-runtime-analysis/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-runtime-analysis is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-runtime-analysis +description: Run the pr-runtime-analysis check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-runtime-analysis + - name: Run just anvil-pr-runtime-analysis + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-runtime-analysis diff --git a/.github/actions/anvil-pr-test/action.yml b/.github/actions/anvil-pr-test/action.yml new file mode 100644 index 00000000..38269f94 --- /dev/null +++ b/.github/actions/anvil-pr-test/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-test is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-test +description: Run the pr-test check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-test + - name: Run just anvil-pr-test + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-test diff --git a/.github/actions/anvil-scheduled-advisories/action.yml b/.github/actions/anvil-scheduled-advisories/action.yml new file mode 100644 index 00000000..63445206 --- /dev/null +++ b/.github/actions/anvil-scheduled-advisories/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-advisories is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-advisories +description: Run the scheduled-advisories check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-advisories + - name: Run just anvil-scheduled-advisories + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-advisories diff --git a/.github/actions/anvil-scheduled-exhaustive/action.yml b/.github/actions/anvil-scheduled-exhaustive/action.yml new file mode 100644 index 00000000..457c4463 --- /dev/null +++ b/.github/actions/anvil-scheduled-exhaustive/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-exhaustive is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-exhaustive +description: Run the scheduled-exhaustive check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-exhaustive + - name: Run just anvil-scheduled-exhaustive + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-exhaustive diff --git a/.github/actions/anvil-scheduled-test/action.yml b/.github/actions/anvil-scheduled-test/action.yml new file mode 100644 index 00000000..2263048c --- /dev/null +++ b/.github/actions/anvil-scheduled-test/action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-test is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-test +description: Run the scheduled-test check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-test + - name: Run just anvil-scheduled-test + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-test diff --git a/.github/actions/anvil-setup/action.yml b/.github/actions/anvil-setup/action.yml new file mode 100644 index 00000000..e9f88a57 --- /dev/null +++ b/.github/actions/anvil-setup/action.yml @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-setup +description: Install `just` and the anvil tools/components needed by a specific group (or everything if omitted). +inputs: + group: + description: | + Which anvil group to install setup for (e.g. "pr-fast", + "pr-test", "scheduled-advisories"). Special values: + - "" (default): install the full catalog via + `just anvil-setup` -- use for local "give me everything" + flows. + - "none": skip tool installation entirely; just bootstrap the + rust toolchain + just + binstall + cache. Used by + anvil-impact, which installs only cargo-delta afterwards. + - anything else: install only that group's prerequisites via + `just anvil--setup`. + default: "" + required: false +runs: + using: composite + steps: + - id: rustc-version + shell: bash + run: echo "version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + - name: Restore cargo cache + id: cargo-cache + uses: actions/cache/restore@v4 + with: + # Key on OS + arch + rustc version + lockfile hashes + catalog + # hash so a Rust toolchain bump, a cross-arch matrix leg, or a + # tool-versions update all invalidate the cache cleanly. + # runner.arch resolves to X64 / ARM64 / X86, which keeps the + # x86_64 and aarch64 legs of the same OS from colliding on + # arch-incompatible target/ contents. + # + # ${{ github.job }} discriminates by workflow-job-id (`pr-fast`, + # `pr-test`, `pr-slow`, etc.) so concurrent matrix legs that + # share OS+arch (e.g. pr-fast linux + pr-test linux) don't race + # on the same cache key. Without this, the first-to-finish job + # reserves the key, the others get "Unable to reserve cache: + # another job may be creating this cache" and silently skip + # the save -- leaving the cache empty forever. The restore-keys + # fall back across jobs so the install work is still shared + # across sibling legs on subsequent runs. + key: anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + restore-keys: | + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}- + # `.crates.toml` and `.crates2.json` track which cargo-installed + # tools and versions live in ~/.cargo/bin/. Without them in the + # cache, `cargo install --list` and (downstream) the + # tool-install recipes' early-skip on "installed >= pin" don't + # see the cached binaries — every run then tries to reinstall + # on top of them and fails with "binary X already exists in + # destination". + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + + # System dependencies for the catalog's source builds. + # + # cargo-spellcheck's build script (clang-sys) needs libclang. The + # Ubuntu runner images don't ship it on PATH by default, so the + # source compile fails with "couldn't execute llvm-config". On + # macOS, libclang is bundled with Xcode CLT (always present on + # GH-hosted macos-*). On Windows, the Visual Studio install on + # the windows-* images includes a usable LLVM, so no install is + # needed. + - name: Install libclang (Linux) + if: runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libclang-dev + + # rustup auto-installs the toolchain from rust-toolchain.toml on + # first cargo invocation, but it doesn't pull profile/component + # extras. The `anvil-setup` recipe in step "Install anvil + # toolchains + tools" below handles that exhaustively (the + # per-component install recipes in tools.just install + # default-toolchain components and pinned-nightly components for + # miri/careful/etc.) -- we don't add components inline here anymore. + # This step exists only to ensure rustup itself has the default + # toolchain set up so subsequent cargo / just calls work. + - name: Ensure default toolchain is installed + shell: bash + run: rustup show active-toolchain || rustup default stable + + # The catalog recipe `anvil-setup` (or `anvil--setup` + # when a group is specified via the `group` input) needs `just` to + # run. We bootstrap just here (chicken-and-egg), then hand off + # everything else to it. The setup recipes are idempotent and + # short-circuit on tools already installed at or above the pinned + # version, so re-running on cache-hit runs is cheap. + # Install a prebuilt cargo-binstall binary in seconds. Without this, + # the first `_install-tool` recipe that needs binstall bootstraps it + # via `cargo install --locked cargo-binstall`, which takes ~4 min of + # source compilation on every cold-cache job. The official action + # downloads the release binary from cargo-bins/cargo-binstall, so + # the bootstrap branch in `tools.just` becomes a no-op on GH. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@main + + - name: Install just + shell: bash + run: | + if ! command -v just >/dev/null 2>&1 ; then + cargo binstall --no-confirm --locked just || cargo install --locked just + fi + + - name: Install anvil toolchains + tools + shell: bash + # When `group` is empty (the default), installs the full catalog + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely (used by anvil-impact, which + # only needs cargo-delta and installs it itself afterwards). When + # `group` is anything else, installs only what that group needs + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. + run: | + case "${{ inputs.group }}" in + none) echo "anvil-setup: group=none, skipping tool install" ;; + "") just anvil-setup binstall ;; + *) just "anvil-${{ inputs.group }}-setup" binstall ;; + esac + + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache@v4` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save@v4` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. + - name: Save cargo cache + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + key: ${{ steps.cargo-cache.outputs.cache-primary-key }} + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + target/ diff --git a/.github/workflows/anvil-pr-impl.yml b/.github/workflows/anvil-pr-impl.yml new file mode 100644 index 00000000..7d10cfce --- /dev/null +++ b/.github/workflows/anvil-pr-impl.yml @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: every multi-OS job below hardcodes its OS axis as +# an inline YAML array. Per-leg runner *labels* are inputs (so adopters +# can swap in self-hosted runners), but the OS axis itself is part of +# the workflow's identity — adopters who need a different shape (add +# macOS, drop ARM, mix in exotic targets) fork this file. Input-driven +# matrices were rejected because they added a silent failure mode +# (mis-formatted inputs produced empty matrices) without meaningfully +# expanding what adopters could customize. + +jobs: + # cargo-delta impact runs per OS so that downstream legs consume an + # impact set computed against THEIR host's cargo-metadata depgraph. + # Without this, an OS-conditional dep change (under + # `[target.'cfg(target_os = ...)'.dependencies]`) computed on Linux + # wouldn't include the cross-OS reverse-deps that only show up in + # the Windows depgraph, so the Windows leg could skip tests that + # ought to run. We split per-OS-family (not per-arch) -- arch-only + # cfg gates are rare enough that paying for 4 impact jobs isn't + # justified; arm legs reuse their OS counterpart's impact set. + impact-linux: + runs-on: ${{ inputs.linux_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + impact-windows: + runs-on: ${{ inputs.windows_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + pr-fast: + # Cross-OS / cross-arch because pr-fast contains compile-sensitive + # checks (clippy, doc-build, udeps, semver-check, external-types) + # whose results can differ across host for crates that use + # #[cfg(target_os = ...)] or #[cfg(target_arch = ...)] gating. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-fast + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + # Advisory PR comments. Recipes that surface non-blocking findings + # (e.g. cargo-semver-checks) write a markdown body to + # `target/anvil/comments/.md` and exit 0. The steps below + # turn presence/absence of those files into upserts/deletions of + # a sticky PR comment. We post from the canonical x86_64 Linux leg + # only so the matrix doesn't race on the same comment, and we use + # `always()` so the comment is updated even when an unrelated + # check in pr-fast failed. The `head.repo.full_name == + # github.repository` guard skips fork PRs (which can't be granted + # write tokens). + - name: Upsert anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') != '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + path: target/anvil/comments/semver.md + - name: Clear anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') == '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + delete: true + + pr-test: + # Tests + coverage: llvm-cov / doc-test / examples. + # 4-leg matrix -- compile and runtime behaviour can differ across + # OS and arch for cfg-gated code, so we exercise tests on every leg. + # Coverage uploads from the canonical x86_64 Linux leg only to + # avoid Codecov double-counting. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-test + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm. OS/arch-gated + # code only gets exercised on its native target, so a single- + # leg upload would systematically under-report coverage on + # cfg(target_os = "windows") / cfg(target_arch = "aarch64") + # branches. windows-11-arm is excluded because LLVM-coverage + # instrumentation on that target produces "malformed + # instrumentation profile data: symbol name is empty" errors. + # Codecov coalesces multiple uploads against the same commit; + # the `flags:` tag distinguishes the per-leg slices in the + # Codecov UI without changing the union total. + # lcov.info is produced by the anvil-llvm-cov recipe inside + # anvil-pr-test; if the affected set was empty the recipe + # no-ops and there is no file to upload, so we gate on the + # impact output. + if: matrix.os != 'windows-arm' && ((startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected != '--skip') || (matrix.os == 'windows' && needs.impact-windows.outputs.include_affected != '--skip')) + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: ${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + pr-runtime-analysis: + # Stricter-runtime correctness: miri + careful. + # 4-leg matrix -- both checks compile per host target and can + # surface OS/arch-specific UB. Both are impact-scoped so the + # wall-clock is proportional to the PR's blast radius. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-runtime-analysis + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + + pr-mutants: + # Mutation testing: cargo mutants (diff-scoped against the PR base). + # 4-leg matrix. cargo-mutants doesn't build on aarch64-pc-windows-msvc + # (upstream winapi crate incompat); the anvil-mutants-diff recipe + # self-skips on that target so this is a no-op (not a failure) on + # the windows-arm leg. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/anvil-pr-mutants + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} diff --git a/.github/workflows/anvil-pr.yml b/.github/workflows/anvil-pr.yml new file mode 100644 index 00000000..297ec04b --- /dev/null +++ b/.github/workflows/anvil-pr.yml @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr + +on: + pull_request: {} + merge_group: {} + +permissions: + contents: read + +concurrency: + group: anvil-pr-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + anvil-pr: + uses: ./.github/workflows/anvil-pr-impl.yml + permissions: + contents: read + # Write needed so the pr-fast job can upsert/clear the sticky PR + # comment carrying the cargo-semver-checks advisory (and any + # future advisory checks that emit target/anvil/comments/*). + pull-requests: write + secrets: inherit diff --git a/.github/workflows/anvil-scheduled-impl.yml b/.github/workflows/anvil-scheduled-impl.yml new file mode 100644 index 00000000..3eae2496 --- /dev/null +++ b/.github/workflows/anvil-scheduled-impl.yml @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: see pr-impl-workflow.yml for the rationale. OS +# matrices are hardcoded; per-leg runner labels are inputs. + +jobs: + scheduled-test: + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-test + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm (see the matching + # comment in pr-impl-workflow.yml for the rationale). + # Multi-flag tag combines the OS with a "scheduled" marker so + # the Codecov UI can distinguish PR-tier uploads from scheduled + # uploads while still tracking each platform separately. + if: matrix.os != 'windows-arm' + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: scheduled,${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + scheduled-advisories: + # Cross-OS / cross-arch because clippy and udeps in this group + # compile per host, so cfg-gated code must be linted/scanned on + # every leg. + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-advisories + + scheduled-exhaustive: + # x86_64-only by design: this group includes mutants-full (which + # doesn't build on aarch64-pc-windows-msvc — winapi crate + # incompatibility) plus cargo-hack feature powerset and bench. + strategy: + fail-fast: false + matrix: + os: [linux, windows] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner || inputs.windows_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-exhaustive diff --git a/.github/workflows/anvil-scheduled.yml b/.github/workflows/anvil-scheduled.yml new file mode 100644 index 00000000..745c087b --- /dev/null +++ b/.github/workflows/anvil-scheduled.yml @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + anvil-scheduled: + uses: ./.github/workflows/anvil-scheduled-impl.yml + permissions: + contents: read + secrets: inherit diff --git a/.github/workflows/regenerate-check.yml b/.github/workflows/regenerate-check.yml new file mode 100644 index 00000000..197f03b5 --- /dev/null +++ b/.github/workflows/regenerate-check.yml @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# +# Verifies that the in-tree state of cargo-anvil's emitted artifacts +# matches what the binary built from this PR would render. The job: +# +# 1. Builds cargo-anvil from the PR branch. +# 2. Runs `cargo anvil --dry-run` against the repo root. +# 3. Fails iff the binary would write or propose anything. +# +# This is the primary dogfooding mechanism described in +# crates/cargo-anvil/docs/verification.md. +# +# Adopters (the maintainers running `cargo anvil` after a +# catalog change) are responsible for landing the regenerated files +# alongside the catalog change in the same PR. +name: regenerate-check + +on: + pull_request: + paths: + - "crates/cargo-anvil/**" + - ".github/workflows/regenerate-check.yml" + merge_group: {} + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: regenerate-check-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + regenerate-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build cargo-anvil + run: cargo build --locked -p cargo-anvil + - name: Verify in-tree state matches templates + run: | + # Add target/debug to PATH so cargo finds the freshly built binary + # as a cargo subcommand. + export PATH="$PWD/target/debug:$PATH" + if ! cargo anvil --dry-run ; then + echo "::error::Repository anvil state is out of date." \ + "Run 'cargo run -p cargo-anvil -- anvil'" \ + "and commit the diff." + exit 1 + fi diff --git a/.gitignore b/.gitignore index 6368ac41..3f41f32e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ ARROW # Agent files .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md.cm.txt +*.snap.new diff --git a/.spelling b/.spelling index 7a2220ab..2f95ab77 100644 --- a/.spelling +++ b/.spelling @@ -1,39 +1,47 @@ -— -§ -→ + +╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£├ª╬ô├╢┬úΓö£├æ +╬ô├▓┬╝Γö£Γöñ╬ô├╢┬úΓö£┬║╬ô├╢┬ú╬ô├▓├│ +╬ô├ç├╢ +╬ô├Ñ├å 0.X.Y 100k 10k 10ms +1ESPT 1h 1ms -307 ACLs acyclic addrs -ADO -agentic +ado Agentic +aggregator allocator Ani api APIs appender AppError +aprz args +argv AspNet async -Async auditable +autocrlf +autodetect +autodetected +autodetection +autodetects +B6 backend backends backoff backtrace -Backtraces backtraces Barbosa Base64-encoded +binstall bitflag bitwise bool @@ -62,24 +70,27 @@ C-SERDE C-SMART-PTR callee cancelled +cargo_toml cargo-llvm-cov Cargo.toml certificate_generator.rs cfg +cfgs chainable Changelog +checksum +checksums chrono -Chrono +cli clippt clippy -Clippy clonable cobertura codebase codebases Codecov -codecov Codecov's +codegen combinators composability composable @@ -89,6 +100,7 @@ contoso CONTRIBUTING.md coverage.json CPUs +crates crates.io CRLF deallocate @@ -96,16 +108,14 @@ Debuggability Deduplicate deduplicating deduplication -deque +dependsOn Deque dereferenced deserialization deserialized deserializer -destructors Destructors destructured -dev Dev DevOps DI @@ -113,30 +123,39 @@ DLLs docs.rs docsrs docstring +dogfood +dogfooded DotNet +dotted dSMS Dyn e.g. emoji enum -Enum enums +env +EOF +extens FFI-compatible fhl-scus4-app-win fhl-scus4-app-win2 filesystem fn foldhash +footgun footguns formatter freeform frontend frontmatter +FS fundle -Fundle Fundle's getters GFM +Git's +github +globbing glommio grey gRPC @@ -148,6 +167,7 @@ hotfixes How-tos HRESULT https +Hunspell i.e. impl impls @@ -157,19 +177,24 @@ inlining insta integrations interop -Interop interoperability interoperate +invariants IOCP IP jitter JSON JSON's +JSON5 +Justfile +Justfiles kebab KiB Kubernetes -LCOV +L lcov +LeaveAlone +LF libc libs libunwind-devel @@ -178,11 +203,16 @@ liveness llvm llvm-cov llvm-tools -Macros +lockfile +lockfiles +lookups +M365PT +macOS macros Makefile Markdown matcher +MD MEMORYSTATUSEX metacharacter metadata @@ -191,8 +221,6 @@ Microservices microsoft.com middleware mimalloc -Mimalloc -Miri miri misconfigured mitigations @@ -212,7 +240,6 @@ NeutralMemoryPool NewRelic newtype newtypes -Newtypes nextest Nomicon non-mockable @@ -222,15 +249,16 @@ NUMA observability ohno ok -Ok onboard onboarding oneshot OpenSource openssl-devel OpenTelemetry +OrphanedKept parameterless passthrough +passthroughs performant PII PowerShell @@ -240,15 +268,23 @@ pre-generate pre-heating prefixed prepend +prepended prepends -proc +preprocess +preprocessed +preprocesses Proc +profdata profiler +profraw PullRequest +pwsh ratchet RDME +README recoverability recoverable +recv Redis reentrancy relocations @@ -258,6 +294,9 @@ renderers repo repos representable +reproposal +reproposed +reproposing Reqwest Reusability RPC @@ -265,16 +304,18 @@ runtime runtimes rustc rustdoc -Rustdoc rustfmt +rustup Sandana sans-io SCCACHE +schemas scopeguard SDKs seeked -SemVer -serde +semver +sentinel +sentinels Serde Serde-based Serde's @@ -286,6 +327,12 @@ SLAs smallvec spawner SPDX +spellcheck +splatting +splice +spliced +splices +splicing startup stderr stdlib @@ -293,12 +340,13 @@ stdout struct struct's structs -Structs subcommand subcommands submodule submodules +SubstratePT subtrait +subtree sudo supertrait supertraits @@ -308,7 +356,10 @@ syscall sysinfo Tarjan's tdnf +templated +templates testability +testable timestamp timestamps Tokio @@ -341,15 +392,16 @@ unregister unregistered unregisters unsized +untestable untrusted UTC UTF-8 v2 v4 v6 +vanishingly vec versioning -Versioning Vijay VMs vs @@ -364,5 +416,14 @@ workflow workflows workspace workspace's +workspaces Xamarin +xargs +xtask xxH3 +YAML +ΓåÆ +ΓÇö +Γò¼├┤Γö£├æΓö£├Ñ +Γò¼├┤Γö£├ºΓö£Γòó +Γò¼├┤Γö£ΓòóΓö¼Γò¥╬ô├╢┬╝╬ô├▓├ª diff --git a/Cargo.lock b/Cargo.lock index 5fdb0c88..b8dbb089 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -45,6 +54,15 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -65,6 +83,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo-anvil" +version = "0.1.0" +dependencies = [ + "clap", + "insta", + "mutants", + "ohno", + "sha2", + "tempfile", + "toml_edit", + "tracing", + "tracing-subscriber", + "walkdir", +] + [[package]] name = "cargo-coverage-gate" version = "0.1.0" @@ -90,7 +124,6 @@ dependencies = [ "clap", "petgraph", "predicates", - "tempfile", ] [[package]] @@ -130,6 +163,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -168,12 +207,41 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "difflib" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "duct" version = "1.1.1" @@ -220,6 +288,29 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -241,6 +332,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.14.0" @@ -249,6 +346,20 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "insta" +version = "1.47.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4a6248eb93a4401ed2f37dfe8ea592d3cf05b7cf4f8efa867b6895af7e094e" +dependencies = [ + "once_cell", + "regex", + "similar", + "tempfile", ] [[package]] @@ -257,6 +368,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "lcov" version = "0.8.2" @@ -266,6 +383,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -278,6 +401,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + [[package]] name = "memchr" version = "2.8.0" @@ -338,6 +467,12 @@ dependencies = [ "indexmap", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "predicates" version = "3.1.4" @@ -365,6 +500,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -383,11 +528,40 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "rustix" @@ -473,6 +647,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_child" version = "1.1.1" @@ -489,6 +683,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52b86057fcb5423f5018e331ac04623e32d6b5ce85e33300f92c79a1973928b0" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "syn" version = "2.0.117" @@ -507,6 +707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", + "getrandom", "once_cell", "rustix", "windows-sys 0.61.2", @@ -538,6 +739,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -546,12 +756,18 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "serde_core", "serde_spanned", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -561,33 +777,99 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.3", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + [[package]] name = "typeid" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -607,6 +889,58 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -705,12 +1039,115 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index e774bed1..673bb909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,8 +21,6 @@ homepage = "https://github.com/microsoft/ox-tools" # is found to allow cycles, we pre-emptively disallow them. [workspace.dependencies] -# local dependencies -cargo-heather = { path = "crates/cargo-heather", default-features = false, version = "0.3.0" } # external dependencies ahash = { version = "0.8", default-features = false } @@ -31,6 +29,8 @@ anyhow = { version = "1.0.100", default-features = false } assert_cmd = { version = "2.2.0", default-features = false } async-once-cell = { version = "0.5", default-features = false } bytes = { version = "1.11.1", default-features = false } +# local dependencies +cargo-heather = { path = "crates/cargo-heather", default-features = false, version = "0.2.1" } cargo_metadata = { version = "0.23.1", default-features = false } chrono = { version = "0.4.40", default-features = false } chrono-tz = { version = "0.10.4", default-features = false } @@ -76,6 +76,7 @@ rustc-hash = { version = "2.1.0", default-features = false } serde = { version = "1.0.228", default-features = false } serde_core = { version = "1.0.228", default-features = false } serde_json = { version = "1.0.145", default-features = false } +sha2 = { version = "0.10.9", default-features = false } smallvec = { version = "1.15.1", default-features = false } static_assertions = { version = "1.1.0", default-features = false } syn = { version = "2.0.111", default-features = false } @@ -84,9 +85,12 @@ thiserror = { version = "2.0.17", default-features = false } time = { version = "0.3.47", default-features = false } tokio = { version = "1.48.0", default-features = false } toml = { version = "1.1.2", default-features = false } +toml_edit = { version = "0.22.22", default-features = false, features = ["parse", "display"] } tower = { version = "0.5.2", default-features = false } tower-layer = { version = "0.3.3", default-features = false } tower-service = { version = "0.3.3", default-features = false } +tracing = { version = "0.1.41", default-features = false } +tracing-subscriber = { version = "0.3.20", default-features = false } trait-variant = { version = "0.1.2", default-features = false } trybuild = { version = "1.0.114", default-features = false } typeid = { version = "1.0.3", default-features = false } @@ -95,80 +99,90 @@ windows-sys = { version = "0.61.2", default-features = false } xutex = { version = "0.2.0", default-features = false } xxhash-rust = { version = "0.8.15", default-features = false } -[workspace.lints.rust] -ambiguous_negative_literals = "warn" -missing_debug_implementations = "warn" -missing_docs = "warn" -redundant_imports = "warn" -redundant_lifetimes = "warn" -trivial_numeric_casts = "warn" -unsafe_op_in_unsafe_fn = "warn" -unused_lifetimes = "warn" - -# Allow our special-purpose nonstandard cfg attributes. -unexpected_cfgs = { level = "warn", check-cfg = [ +# >>> anvil-managed: anvil-workspace-lints +[workspace.lints] +# Catalog of opinionated lints, in dotted-key form so users can extend the +# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. +# The host-specific table header (`[workspace.lints]` or `[lints]`) is +# prepended by cargo-anvil based on whether the manifest is a workspace +# root or a single-crate Cargo.toml. + +# --- rust ------------------------------------------------------------------ +rust.ambiguous_negative_literals = "warn" +rust.missing_debug_implementations = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.trivial_numeric_casts = "warn" +rust.unsafe_op_in_unsafe_fn = "warn" +rust.unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# line (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +rust.unexpected_cfgs = { level = "warn", check-cfg = [ 'cfg(coverage,coverage_nightly)', ] } -[workspace.lints.clippy] -cargo = { level = "warn", priority = -1 } -complexity = { level = "warn", priority = -1 } -correctness = { level = "warn", priority = -1 } -nursery = { level = "warn", priority = -1 } -pedantic = { level = "warn", priority = -1 } -perf = { level = "warn", priority = -1 } -style = { level = "warn", priority = -1 } -suspicious = { level = "warn", priority = -1 } - -# Lints from the `restriction` group that are not enabled by categories above but are still useful. -allow_attributes = "warn" -allow_attributes_without_reason = "warn" -as_pointer_underscore = "warn" -assertions_on_result_states = "warn" -clone_on_ref_ptr = "warn" -deref_by_slicing = "warn" -disallowed_script_idents = "warn" -empty_drop = "warn" -empty_enum_variants_with_brackets = "warn" -empty_structs_with_brackets = "warn" -fn_to_numeric_cast_any = "warn" -if_then_some_else_none = "warn" -map_err_ignore = "warn" -multiple_unsafe_ops_per_block = "warn" -panic = "warn" -redundant_type_annotations = "warn" -renamed_function_params = "warn" -semicolon_outside_block = "warn" -undocumented_unsafe_blocks = "warn" -unnecessary_safety_comment = "warn" -unnecessary_safety_doc = "warn" -unneeded_field_pattern = "warn" -unused_result_ok = "warn" -unwrap_used = "warn" -# -# Below are undesirable lint rules that are enabled by the categories above but are not useful. -# - -# This lint in many cases produces less readable code and is not worth the trouble. -option_if_let_else = "allow" -# Just because a fn can be const does not mean it should be const. Const is a promise, part of the -# API contract, and removing it is a breaking change so we need to be careful where we commit to const. -missing_const_for_fn = "allow" -# This sometimes prevents marking crate-scoped items as pub(crate), so is not welcome. -redundant_pub_crate = "allow" -# Testing panic messages is not universally applicable - panic messages are not an API contract. -should_panic_without_expect = "allow" -# This just misfires a lot and claims we can drop values that actually need to remain borrowed. -# It also often results in much less readable code for no benefit. -significant_drop_tightening = "allow" +# --- rustdoc --------------------------------------------------------------- +rustdoc.broken_intra_doc_links = "warn" +rustdoc.missing_crate_level_docs = "warn" +rustdoc.unescaped_backticks = "warn" + +# --- clippy: category gates (priority -1 so per-lint allows can override) -- +clippy.cargo = { level = "warn", priority = -1 } +clippy.complexity = { level = "warn", priority = -1 } +clippy.correctness = { level = "warn", priority = -1 } +clippy.nursery = { level = "warn", priority = -1 } +clippy.pedantic = { level = "warn", priority = -1 } +clippy.perf = { level = "warn", priority = -1 } +clippy.style = { level = "warn", priority = -1 } +clippy.suspicious = { level = "warn", priority = -1 } + +# --- clippy: opinionated additions ----------------------------------------- +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +clippy.allow_attributes = "warn" +clippy.allow_attributes_without_reason = "warn" +clippy.as_pointer_underscore = "warn" +clippy.assertions_on_result_states = "warn" +clippy.clone_on_ref_ptr = "warn" +clippy.deref_by_slicing = "warn" +clippy.disallowed_script_idents = "warn" +clippy.empty_drop = "warn" +clippy.empty_enum_variants_with_brackets = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.if_then_some_else_none = "warn" +clippy.map_err_ignore = "warn" +clippy.multiple_unsafe_ops_per_block = "warn" +clippy.redundant_type_annotations = "warn" +clippy.renamed_function_params = "warn" +clippy.semicolon_outside_block = "warn" +clippy.undocumented_unsafe_blocks = "warn" +clippy.unnecessary_safety_comment = "warn" +clippy.unnecessary_safety_doc = "warn" +clippy.unneeded_field_pattern = "warn" +clippy.unused_result_ok = "warn" +clippy.unwrap_used = "warn" + +# --- clippy: opinionated suppressions of category-enabled lints ------------ +clippy.missing_const_for_fn = "allow" +clippy.multiple_crate_versions = "allow" +clippy.option_if_let_else = "allow" +clippy.redundant_pub_crate = "allow" +clippy.should_panic_without_expect = "allow" +clippy.significant_drop_tightening = "allow" # Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 -wildcard_imports = "allow" -# In a large workspace, duplicate dependencies are inevitable; this lint causes more maintenance burden than practical benefit. -multiple_crate_versions = "allow" +clippy.wildcard_imports = "allow" -[workspace.lints.rustdoc] -missing_crate_level_docs = "warn" -unescaped_backticks = "warn" +# <<< anvil-managed: anvil-workspace-lints # A bit of debugging support for release builds. [profile.release] diff --git a/justfile b/Justfile similarity index 85% rename from justfile rename to Justfile index b13d329a..a3d1e83b 100644 --- a/justfile +++ b/Justfile @@ -22,3 +22,7 @@ import 'justfiles/coverage.just' import 'justfiles/format.just' import 'justfiles/setup.just' import 'justfiles/spelling.just' + +# >>> anvil-managed: anvil-imports +import 'justfiles/anvil/mod.just' +# <<< anvil-managed: anvil-imports diff --git a/clippy.toml b/clippy.toml index 7c77e477..c3292d95 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,9 +1,36 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# >>> anvil-managed: anvil-clippy +# Fine-tuning settings for clippy lints. These cannot be expressed in +# Cargo.toml's [lints] table (which only carries level: warn/allow/deny); +# they configure lint *behavior* and live in clippy.toml only. + +# Absolute paths up to 3 segments are clarifying — e.g. `std::sync::Mutex` +# vs `tokio::sync::Mutex` disambiguates the source. Beyond 3 segments +# we prefer imports or aliases for readability. absolute-paths-max-segments = 3 -allow-panic-in-tests = true -allow-unwrap-in-tests = true + +# Workspace code is internal. Clippy should suggest the most correct +# fix without worrying about non-breaking-change rules, which only +# matter for published library APIs. avoid-breaking-exported-api = false + +# Required companion for the clippy.semicolon_outside_block lint we +# ship in the catalog. Without this, the lint fires on multiline-block +# forms that are common Rust style. semicolon-outside-block-ignore-multiline = true + +# Required companions for the clippy.unwrap_used lint we ship. +# Test code asserts via unwrap()/panic!() — that's how #[test] reports +# failure. Without these, every test triggers the lint. +allow-panic-in-tests = true +allow-unwrap-in-tests = true + +# Aspirational: when clippy.wildcard_imports is re-enabled (currently +# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# we want the stricter variant that warns on ALL wildcard imports +# including prelude. Setting it now means flipping the lint level to +# warn later is a one-line change with no tuning afterthought. warn-on-all-wildcard-imports = true +# <<< anvil-managed: anvil-clippy diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 3fd1cee8..91f22fda 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -5,19 +5,36 @@ name = "automation" description = "Shared code for writing Rust scripts" version = "0.1.0" -edition.workspace = true -rust-version.workspace = true authors.workspace = true -license.workspace = true +edition.workspace = true homepage.workspace = true +license.workspace = true publish = false +rust-version.workspace = true -[dependencies] -ohno = { workspace = true, features = ["app-err"] } +# Public-API discipline. `automation` is `publish = false` and lives +# solely to be used by `scripts/`, but anvil-external-types still +# runs against it (publish=false isn't a free pass for unbounded API +# surface -- in-workspace callers in `scripts/` rely on type +# stability too). All three allowed types are intentional: +# - `ohno::app::error::AppError`: project-wide app error type, used +# by every fallible function in the workspace. Same allowlist +# entry as cargo-coverage-gate and cargo_anvil. +# - `serde::*` (which surfaces in nightly rustdoc as +# `serde_core::de::Deserialize`): `PackageMetadata` and `Target` +# are derived-Deserialize so `serde_json::from_str` can parse +# `cargo metadata` output. Removing the derive would force every +# caller to write a manual deserializer for trivial shapes. +[package.metadata.cargo_check_external_types] +allowed_external_types = ["ohno::app::error::AppError", "serde::*", "serde_core::*"] +[dependencies] duct = { workspace = true } +ohno = { workspace = true, features = ["app-err"] } serde = { workspace = true, features = ["derive", "alloc"] } serde_json = { workspace = true, features = ["std"] } +# >>> anvil-managed: anvil-lints [lints] workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/automation/README.md b/crates/automation/README.md index 32030813..713a0c5a 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -1,11 +1,3 @@ -# Automation - -[![CI](https://github.com/microsoft/ox-tools/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/ox-tools/actions/workflows/main.yml) -[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +# automation ![License: MIT](https://img.shields.io/badge/license-MIT-blue) [![automation on crates.io](https://img.shields.io/crates/v/automation)](https://crates.io/crates/automation) [![automation on docs.rs](https://docs.rs/automation/badge.svg)](https://docs.rs/automation) [![Rust Version: 1.88.0](https://img.shields.io/badge/rustc-1.88.0-orange.svg)](https://github.com/rust-lang/rust/releases/tag/1.88.0) An unpublished crate for shared code used for writing Rust scripts - -
- -This crate was developed as part of The Oxidizer Project. - diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index fbaaf31f..ddc21bfd 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -6,6 +6,14 @@ #![allow(clippy::missing_errors_doc, reason = "this is an internal crate for scripts")] #![cfg_attr(coverage_nightly, feature(coverage_attribute))] #![cfg_attr(coverage_nightly, coverage(off))] +#![cfg_attr( + test, + allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" + ) +)] use std::path::Path; use std::process::Command; diff --git a/crates/cargo-anvil/Cargo.toml b/crates/cargo-anvil/Cargo.toml new file mode 100644 index 00000000..e0deec8e --- /dev/null +++ b/crates/cargo-anvil/Cargo.toml @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "cargo-anvil" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors = ["The Oxidizer Project Authors"] +description = "Opinionated, unified Rust build and cloud-workflow scaffolding for GitHub Actions and Azure DevOps" +readme = "README.md" +repository = "https://github.com/microsoft/ox-tools/tree/main/crates/cargo-anvil" +homepage = "https://github.com/microsoft/ox-tools/tree/main/crates/cargo-anvil" +keywords = ["cargo", "subcommand", "workflows", "just", "github-actions"] +categories = ["development-tools::cargo-plugins"] + +[package.metadata.cargo_check_external_types] +# - `clap_builder::derive::*`: types reachable through clap's derive +# macros (Args, Subcommand, ValueEnum, ...). The macro-generated impls +# appear in `cargo_anvil::cli::Cli`'s public surface as +# `::augment_args(...)`. Expected; not a real API leak. +# - `clap_builder::Error`: returned by `Cli::parse_from_cargo_args` so +# callers can decide how to render parse failures (clap's pretty +# help text vs. a custom rendering). clap::Error has documented +# stable formatting; re-wrapping it in our own type would lose +# information without adding value. +# - `ohno::app::error::AppError`: project-wide app error type from +# the `ohno` crate. Every fallible anvil entry point +# returns it so that callers can use ohno's `?` and `AppErr` +# conversions uniformly. Intentional; mirrors the same allowlist +# entry in cargo-coverage-gate and automation. +allowed_external_types = [ + "clap_builder::derive::*", + "clap_builder::Error", + "ohno::app::error::AppError", +] + +[[bin]] +name = "cargo-anvil" +path = "src/main.rs" + +[dependencies] +clap = { workspace = true, features = ["derive", "std", "help", "usage", "error-context"] } +mutants = { workspace = true } +ohno = { workspace = true, features = ["app-err"] } +sha2 = { workspace = true, features = ["std"] } +toml_edit = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["fmt"] } + +[dev-dependencies] +insta = { workspace = true, features = ["filters"] } +tempfile = { workspace = true } +walkdir = { workspace = true } + +# >>> anvil-managed: anvil-lints +[lints] +workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/cargo-anvil/README.md b/crates/cargo-anvil/README.md new file mode 100644 index 00000000..9164ab75 --- /dev/null +++ b/crates/cargo-anvil/README.md @@ -0,0 +1,119 @@ +
+ Cargo-Anvil Logo + +# Cargo-Anvil + +[![crates.io](https://img.shields.io/crates/v/cargo-anvil.svg)](https://crates.io/crates/cargo-anvil) +[![docs.rs](https://docs.rs/cargo-anvil/badge.svg)](https://docs.rs/cargo-anvil) +[![MSRV](https://img.shields.io/crates/msrv/cargo-anvil)](https://crates.io/crates/cargo-anvil) +[![CI](https://github.com/microsoft/ox-tools/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/ox-tools/actions/workflows/main.yml) +[![Coverage](https://codecov.io/gh/microsoft/ox-tools/graph/badge.svg?token=FCUG0EL5TI)](https://codecov.io/gh/microsoft/ox-tools) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](../../LICENSE) +This crate was developed as part of the Oxidizer project + +
+ +## cargo-anvil + +Opinionated, unified Rust build and cloud-workflow scaffolding for GitHub Actions and +Azure DevOps Pipelines. One opinionated check catalog, two cloud workflows +backends, generated from the same source of truth. + +### What it does + +`cargo-anvil` writes files. `just` runs them. The repo composes +everything. The tool itself is not on the local-build hot path or in +the cloud-workflow graph at runtime — it is a code generator that you re-run when +you want to upgrade the opinionated baseline. + +Each run of `cargo anvil` writes: + +* The `justfiles/anvil/` recipe tree (`tools.just`, `checks.just`, + `groups.just`, `tiers.just`) — owned files. +* A managed region in your `Justfile` that imports them. +* A managed region in your workspace `Cargo.toml` carrying + `[workspace.lints]` in dotted-key form, plus a `[lints] workspace = true` region in each workspace member. +* Managed regions in `deny.toml`, `rustfmt.toml`, and `.delta.toml`. +* For each selected cloud-workflow backend (`github`, `ado`), the full set of + composite actions / step templates, reusable workflows / stages + templates, and root workflows / pipelines. + +Outside the managed regions, your content is preserved byte-for-byte. + +### Installation + +```bash +cargo install --locked cargo-anvil +``` + +Only the maintainer who runs updates needs the binary. Everyone else +uses `just` (or plain `cargo`). + +### Usage + +```text +cargo anvil [--backend ]... [--no-backends] [--dry-run] +``` + +`update` is the only subcommand. There is no separate `init`, +`migrate`, `check`, `enable`, or `disable`. The algorithm is uniform +— first runs and subsequent runs go through the same decision table. + +Flags: + +* `--backend ` — repeatable. Valid values: `github`, `ado`. If + omitted, the backend is autodetected from the `origin` git remote. +* `--no-backends` — emit only local files; skip every cloud-workflow backend. + Mutually exclusive with `--backend`. +* `--dry-run` — analyze without writing. Exits 1 if anything would be + written or proposed. + +### Daily driver + +After the first run, your daily workflow is plain `just`: + +```text +$ just anvil # alias for `just anvil-pr` +$ just anvil-pr # the PR tier +$ just anvil-scheduled # the scheduled tier +$ just anvil-full # both, sequentially +``` + +cloud workflows invokes the same recipes. Local and cloud-workflow runs are bit-identical because +they share one implementation in the imported `.just` files. + +### Customization + +Four escape valves, in increasing severity: + +1. **Compose around the tool**: add your own `.just` files or + workflows; the tool never touches anything not prefixed + `anvil-`. +1. **Extend managed regions** outside the sentinels — add lints, + deny rules, etc. The tool preserves everything outside. +1. **Opt out by emptying** a managed region or owned file. The tool + will skip the item on every future `update` and only emit a + `.anvil-proposed` sibling when the template actually changes. +1. **Take ownership by editing inside** an owned file or managed + region. The next `update` detects the dirt and writes a + `.anvil-proposed` sibling instead of overwriting. + +### Design docs + +See `docs/design/` for the full architecture: + +* `design.md` — overall principles and CLI shape. +* `checks.md` — the opinionated check catalog. +* `local.md` — the `justfiles/anvil/` tree. +* `updates.md` — the drift-detection algorithm. +* `github.md` — GitHub Actions emission. +* `ado.md` — Azure DevOps Pipelines emission. + +And `docs/verification.md` for the continuous-validation strategy. + + +
+ +This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + diff --git a/crates/cargo-anvil/docs/design/ado.md b/crates/cargo-anvil/docs/design/ado.md new file mode 100644 index 00000000..a2fb3f2a --- /dev/null +++ b/crates/cargo-anvil/docs/design/ado.md @@ -0,0 +1,759 @@ +# Azure DevOps Pipelines Integration + +This document describes what `cargo anvil --backend ado` emits for Azure DevOps +Pipelines, and how a repo wires those files into its own cloud workflows. + +anvil emits three layers, all owned by anvil with the standard owned-file flow (edit → +dirty → `.anvil-proposed` sibling on next update). The split is by what users actually +need to change: + +1. **Root pipelines** (`anvil-pr.yml`, `anvil-scheduled.yml` at `.pipelines/`). Triggers, + runner pool, secret variable groups, and the optional `extends:` to a compliance + template (1ESPT/SubstratePT/CloudBuild) live here. anvil ships an opinionated default; + users who need to customize edit in place and accept the proposal-on-update flow. + anvil's emitted root pipelines contain **no** references to compliance harnesses — + wrapping with 1ESPT is purely a user-side edit. +2. **Stages templates** (`anvil/pr.yml`, `anvil/scheduled.yml`), containing the impact job + and the per-group jobs with all the dependency / output-variable plumbing. These + change when anvil's groups or impact wiring evolve; most users won't ever edit them. +3. **Per-group step templates** (`anvil/steps/*.yml`). Each is a multi-step template that + runs setup + the matching `just anvil--` recipe. + +See also: + +- [design.md §6](./design.md#6-repo-layout) for the file-category model. +- [checks.md](./checks.md) for what each group runs. +- [local.md](./local.md) for the `just` recipes the templates invoke. +- [github.md](./github.md) for the GitHub Actions counterpart. + +## 1. Why three layers + +- **Frequently-changing wiring** (group set, impact computation, fan-out, output-variable + plumbing) lives in the stages template. Updates apply automatically; users don't have + to merge changes. +- **Per-repo customization** (triggers, runner pool, compliance harness, secrets) lives + in the root pipeline. Users who customize it accept the cost of merging the + `.anvil-proposed` sibling when the anvil defaults evolve — which is rare, since the + root pipeline is intentionally minimal. +- **Compliance composition** is purely a user concern. anvil's stages template is plain + ADO YAML; 1ESPT/SubstratePT/CloudBuild composition happens in the user's root pipeline + by way of `extends:` and `parameters.stages`. + +The PR pipeline: + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 35, "padding": 3}, "themeVariables": {"fontSize": "16px"}}}%% +flowchart LR + pr_evt([PR build-validation
branch policy]):::trigger + pr_root[".pipelines/
anvil-pr.yml
(root, ~15 lines)"]:::root + pr_stages[".pipelines/anvil/pr.yml
(stages template)"]:::impl + impact_s["stage: impact_linux + stage: impact_windows
(2 stages;
outputs consumed by every group below)"]:::stage + pr_fast_s["stage: pr_fast
linux + windows jobs"]:::stage + pr_test_s["stage: pr_test
linux + windows jobs"]:::stage + pr_runtime_analysis_s["stage: pr_runtime_analysis
linux + windows jobs"]:::stage + pr_mutants_s["stage: pr_mutants
linux + windows jobs"]:::stage + impact_step[".pipelines/anvil/
steps/impact.yml"]:::step + impact_setup[".pipelines/anvil/
steps/setup.yml"]:::step + fast_setup[".pipelines/anvil/
steps/setup.yml"]:::step + test_setup[".pipelines/anvil/
steps/setup.yml"]:::step + runtime_setup[".pipelines/anvil/
steps/setup.yml"]:::step + mutants_setup[".pipelines/anvil/
steps/setup.yml"]:::step + fast_step[".pipelines/anvil/
steps/pr-fast.yml"]:::step + test_step[".pipelines/anvil/
steps/pr-test.yml"]:::step + runtime_step[".pipelines/anvil/
steps/pr-runtime-analysis.yml"]:::step + mutants_step[".pipelines/anvil/
steps/pr-mutants.yml"]:::step + publish_coverage["PublishCodeCoverageResults@2"]:::external + fast_just["just anvil-pr-fast"]:::recipe + fast_setup_just["just anvil-setup"]:::recipe + impact_just["cargo delta"]:::recipe + impact_setup_just["just anvil-setup"]:::recipe + test_just["just anvil-pr-test"]:::recipe + test_setup_just["just anvil-setup"]:::recipe + runtime_just["just anvil-pr-runtime-analysis"]:::recipe + runtime_setup_just["just anvil-setup"]:::recipe + mutants_just["just anvil-pr-mutants"]:::recipe + mutants_setup_just["just anvil-setup"]:::recipe + + pr_evt --> pr_root + pr_root -. extends/template .-> pr_stages + pr_stages --> impact_s + pr_stages --> pr_fast_s + pr_stages --> pr_test_s + pr_stages --> pr_runtime_analysis_s + pr_stages --> pr_mutants_s + + impact_s ==> impact_step + pr_fast_s ==> fast_step + pr_test_s ==> test_step + pr_test_s ==> publish_coverage + pr_runtime_analysis_s ==> runtime_step + pr_mutants_s ==> mutants_step + + impact_step ==> impact_setup + impact_step ==> impact_just + fast_step ==> fast_setup + fast_step ==> fast_just + test_step ==> test_setup + test_step ==> test_just + runtime_step ==> runtime_setup + runtime_step ==> runtime_just + mutants_step ==> mutants_setup + mutants_step ==> mutants_just + + impact_setup ==> impact_setup_just + fast_setup ==> fast_setup_just + test_setup ==> test_setup_just + runtime_setup ==> runtime_setup_just + mutants_setup ==> mutants_setup_just + + classDef trigger fill:#fff4d6,stroke:#b08800,stroke-width:1px; + classDef root fill:#e6f0ff,stroke:#0366d6,stroke-width:2px; + classDef impl fill:#dff0d8,stroke:#28a745,stroke-width:1px; + classDef stage fill:#f6f8fa,stroke:#586069,stroke-width:1px; + classDef step fill:#fce5e5,stroke:#cb2431,stroke-width:1px; + classDef external fill:#fff7d6,stroke:#a08000,stroke-width:1px; + classDef recipe fill:#f3e8ff,stroke:#6f42c1,stroke-width:1px; +``` + +(Every job in `pr_fast`, `pr_test`, `pr_runtime_analysis`, and `pr_mutants` is rendered through the per-job wrapper at `steps/job.yml`; that uniform indirection is elided from the diagram. See §4.1 for the wrapper's role as a 1ESPT extensibility point.) + +The scheduled pipeline (same colour key): + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 35, "padding": 3}, "themeVariables": {"fontSize": "16px"}}}%% +flowchart LR + sched_evt([schedule]):::trigger + sched_root[".pipelines/
anvil-scheduled.yml
(root)"]:::root + sched_stages[".pipelines/anvil/scheduled.yml
(stages template)"]:::impl + stest_s["stage: scheduled_test
linux + windows jobs"]:::stage + sadv_s["stage: scheduled_advisories
linux + windows jobs"]:::stage + sexh_s["stage: scheduled_exhaustive
linux + windows jobs"]:::stage + stest_setup[".pipelines/anvil/
steps/setup.yml"]:::step + sadv_setup[".pipelines/anvil/
steps/setup.yml"]:::step + sexh_setup[".pipelines/anvil/
steps/setup.yml"]:::step + stest_step[".pipelines/anvil/
steps/scheduled-test.yml"]:::step + sadv_step[".pipelines/anvil/
steps/scheduled-advisories.yml"]:::step + sexh_step[".pipelines/anvil/
steps/scheduled-exhaustive.yml"]:::step + publish_coverage["PublishCodeCoverageResults@2"]:::external + stest_just["just anvil-scheduled-test"]:::recipe + stest_setup_just["just anvil-setup"]:::recipe + sadv_just["just anvil-scheduled-advisories"]:::recipe + sadv_setup_just["just anvil-setup"]:::recipe + sexh_just["just anvil-scheduled-exhaustive"]:::recipe + sexh_setup_just["just anvil-setup"]:::recipe + + sched_evt --> sched_root + sched_root -. extends .-> sched_stages + sched_stages --> stest_s + sched_stages --> sadv_s + sched_stages --> sexh_s + + stest_s ==> stest_step + stest_s ==> publish_coverage + sadv_s ==> sadv_step + sexh_s ==> sexh_step + + stest_step ==> stest_setup + stest_step ==> stest_just + sadv_step ==> sadv_setup + sadv_step ==> sadv_just + sexh_step ==> sexh_setup + sexh_step ==> sexh_just + + stest_setup ==> stest_setup_just + sadv_setup ==> sadv_setup_just + sexh_setup ==> sexh_setup_just + + classDef trigger fill:#fff4d6,stroke:#b08800,stroke-width:1px; + classDef root fill:#e6f0ff,stroke:#0366d6,stroke-width:2px; + classDef impl fill:#dff0d8,stroke:#28a745,stroke-width:1px; + classDef stage fill:#f6f8fa,stroke:#586069,stroke-width:1px; + classDef step fill:#fce5e5,stroke:#cb2431,stroke-width:1px; + classDef external fill:#fff7d6,stroke:#a08000,stroke-width:1px; + classDef recipe fill:#f3e8ff,stroke:#6f42c1,stroke-width:1px; +``` + +Every PR-tier group stage declares `dependsOn: [impact_linux, impact_windows]` so it can read the cargo-delta output variables. That fan-in is elided from the diagram to keep it readable. + +Note the ADO topology differs from GitHub Actions in two places: +1. **No reusable workflow indirection**: ADO `extends:` is one-shot; the root pipeline extends a single template. We compensate by putting all stages in `pr.yml` / `scheduled.yml` as direct templates. +2. **Per-job wrapper**: the `steps/job.yml` template is ADO-specific. GitHub composite actions are uniform; ADO 1ESPT requires per-job extensibility hooks that the wrapper exposes through its `name`/`pool`/`steps`/`artifacts` parameter contract (see §4.1). + +## 2. Emitted artifacts + +```text +.pipelines/ +├── anvil-pr.yml owned (root PR pipeline) +├── anvil-scheduled.yml owned (root scheduled pipeline) +└── anvil/ + ├── pr.yml owned (PR-tier stages template) + ├── scheduled.yml owned (scheduled-tier stages template) + └── steps/ + ├── setup.yml owned (install just + catalog tools) + ├── impact.yml owned (cargo-delta impact step; omitted if .delta.toml disabled) + ├── job.yml owned-but-user-customizable + │ (per-job wrapper; takes `name`, + │ `pool`, `steps`, `artifacts`; + │ users edit to inject 1ESPT + │ `templateContext:` etc.) + ├── pr-fast.yml owned (one step template per group) + ├── pr-test.yml owned + ├── pr-runtime-analysis.yml owned + ├── pr-mutants.yml owned + ├── scheduled-test.yml owned + ├── scheduled-advisories.yml owned + └── scheduled-exhaustive.yml owned +``` + +All files are regular owned files tracked by the sidecar `.anvil.lock` manifest +(no in-file checksum line; see [updates.md §1](./updates.md#1-the-manifest)). +`steps/job.yml` deserves special mention: it is emitted as owned (so first-time +adoption gets a working file with no extra steps), but is *expected* to be +customized by adopters whose ADO instance requires extension templates +(1ES PT, SubstratePT, M365PT). Once a user edits it, the standard dirty-file +flow kicks in — subsequent anvil updates Propose into a `.proposed` sibling +rather than overwriting. The stages templates address the wrapper only via its +parameter contract (`name`, `pool`, `steps`, `artifacts`), so the wrapper can +diverge arbitrarily without blocking stage-shape updates. See §4.1. + +## 3. Root pipelines + +The default `anvil-pr.yml` anvil emits is the minimum needed to run anvil's stages +template. PR validation for the pipeline is configured via Azure DevOps branch policies in +the project UI — the YAML `pr:` trigger is ignored for Azure Repos and only relevant for +GitHub-hosted repos consumed via an Azure Pipelines service connection (in which case the +adopter adds their own `pr:` block). + +```yaml +# .pipelines/anvil-pr.yml +trigger: none + +stages: +- template: anvil/pr.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } +``` + +The scheduled root pipeline adds a schedule: + +```yaml +# .pipelines/anvil-scheduled.yml +trigger: none +pr: none + +schedules: +- cron: "0 6 * * *" + displayName: anvil scheduled + branches: + include: [main, master] + always: true + +stages: +- template: anvil/scheduled.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } +``` + +The schedule lists both `main` and `master` so adopters using either canonical-branch +name get coverage out of the box. ADO matches each entry against existing branches; an +entry that matches nothing contributes nothing, so a repo using only `main` runs the +schedule exactly once per cron tick. + +For an internal/compliance pipeline, the user replaces their root pipeline with one that +extends 1ESPT/SubstratePT and passes anvil's stages template as the stages parameter, +overriding the pools with the team's 1ESPT pools: + +```yaml +# .pipelines/anvil-pr.yml (user-edited for 1ESPT) +trigger: none + +resources: + repositories: + - repository: 1ESPipelineTemplates + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1ESPipelineTemplates + parameters: + pool: { name: } + stages: + - template: /.pipelines/anvil/pr.yml@self + parameters: + linuxPool: { name: } + windowsPool: { name: } +``` + +The `extends:` keyword, the resources block, and the pool definitions are entirely the +user's business. anvil's `pr.yml` is a plain stages template that drops in unchanged. +The default matrix is Linux + Windows in all cases — adopters who want a narrower or +wider matrix edit the emitted stages template directly (taking ownership via the +dirty-file flow). + +## 4. Owned stages templates + +**ARM coverage gap (ADO).** Unlike GitHub, ADO has no Microsoft-hosted ARM agents +(no `vmImage` exists for Linux aarch64 or Windows aarch64). The ADO backend's default +matrix is therefore x86_64 only — `linuxPool` (`ubuntu-latest`) + `windowsPool` +(`windows-latest`). This matches the platforms list `oxidizer`'s root pipelines emit. +Adopters with self-hosted ARM agents extend the stages template in their own root +pipeline (or fork the emitted stages); anvil itself does not ship ARM legs on ADO. +The catalog and recipes are identical across backends — the asymmetry is purely in the +wiring layer's default OS matrix. See +[checks.md §1](./checks.md#1-groups-and-tiers) for the per-group OS scope tables. + +The `pr.yml` stages template is where the wiring lives. Every per-group step template +takes the same three impact-include parameters unconditionally; which ones a group's +checks actually consume is the catalog's concern, not the wiring layer's. This means +moving a check between groups (e.g. `clippy` from `pr-fast` to `scheduled-advisories`) +never changes the stages template. + +### 4.1 Per-job wrapper (`steps/job.yml`) — the 1ESPT extensibility point + +Every job in `pr.yml` and `scheduled.yml` is rendered through a wrapper template at +`.pipelines/anvil/steps/job.yml` rather than declared inline. The wrapper exists +to give adopters whose ADO instance requires extension templates (1ES PT, +SubstratePT, M365PT, custom corporate templates) a single, narrow place to inject +the per-job boilerplate those templates require — `templateContext:` blocks, +build-provenance attributes, SDL hooks, custom checkout depths, etc. — without +forking the much larger owned stages templates. + +The contract is intentionally small and stable: + +| Parameter | Type | Required | Meaning | +|-------------|------------|----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `name` | `string` | yes | Job name; ADO derives the display name from it. | +| `pool` | `object` | yes | Pool block, passed verbatim to ADO's `pool:` key. `linuxPool` and `windowsPool` at the stage level are object parameters, so users can override their shape (e.g. `{ name, os, image }` for 1ESPT). | +| `steps` | `stepList` | yes | Body of the job. Templated step lists are fine — the wrapper splices them in via `${{ each step in parameters.steps }}: - ${{ step }}`. | +| `artifacts` | `object` | no | List of pipeline artifacts to publish. Each item: `{ name: string, path: string }`. Default wrapper appends one `PublishPipelineArtifact@1` per entry; 1ESPT wrappers translate the same list into `templateContext.outputs.pipelineArtifact` blocks. The stages templates don't need to know which backend they're targeting. | + +The default wrapper anvil ships is six lines of logic: + +```yaml +parameters: + - { name: name, type: string } + - { name: pool, type: object } + - { name: steps, type: stepList } + - { name: artifacts, type: object, default: [] } +jobs: + - job: ${{ parameters.name }} + pool: ${{ parameters.pool }} + steps: + - ${{ each step in parameters.steps }}: + - ${{ step }} + - ${{ each artifact in parameters.artifacts }}: + - task: PublishPipelineArtifact@1 + displayName: Publish ${{ artifact.name }} + condition: succeededOrFailed() + inputs: + targetPath: ${{ artifact.path }} + artifact: ${{ artifact.name }} +``` + +A 1ESPT user replaces the wrapper body with something like: + +```yaml +jobs: + - job: ${{ parameters.name }} + pool: ${{ parameters.pool }} + templateContext: + inputs: + - input: checkout + repository: self + fetchDepth: 0 + outputs: + - ${{ each artifact in parameters.artifacts }}: + - output: pipelineArtifact + targetPath: ${{ artifact.path }} + artifactName: ${{ artifact.name }} + condition: succeededOrFailed() + steps: + - ${{ each step in parameters.steps }}: + - ${{ step }} +``` + +`pool` shape is *also* an extensibility point — `linuxPool` / `windowsPool` are +`type: object` at the stage level, so the same root-pipeline that swaps in a +1ESPT-shaped pool (`{ name, os, image }` instead of `{ vmImage }`) doesn't need +any other changes. + +**Why a dedicated wrapper file rather than parameterizing the stages template?** +Because the wrapper is short and stable, but the stages template is long and +changes often (new groups, new dependsOn rules, new impact-output wiring). Putting +the user's customization in a separate file means stages updates flow through +without merging, and the user's wrapper changes survive every anvil upgrade. + +**Why is the wrapper "owned" rather than "proposed-once"?** So that first-time +adoption needs zero extra steps — a fresh `cargo anvil` writes a +working wrapper and the pipeline runs. The dirty-file behavior kicks in only +after the user actually edits the file: from then on, anvil Proposes into +`.proposed` siblings on conflict. This is the same mechanism every other owned +file uses; the wrapper isn't special — it just happens to be the one file most +internal adopters will customize. + +**Template-path note.** Each entry in the `steps:` parameter at the call site +contains a `template:` reference (e.g. `template: steps/pr-fast.yml`). ADO +resolves template paths relative to the file containing the `template:` +keyword, which for parameters defined at the call site is the stages template +itself — so the path is written relative to `pr.yml` / `scheduled.yml`, *not* +relative to `steps/job.yml`. + +### 4.2 Stages template shape + +Approximate shape (anvil writes this verbatim; users normally don't edit it): + +```yaml +# .pipelines/anvil/pr.yml (owned by cargo-anvil) +parameters: + - name: linuxPool + type: object + default: { vmImage: ubuntu-latest } + - name: windowsPool + type: object + default: { vmImage: windows-latest } + +stages: + - stage: impact + displayName: anvil impact + jobs: + - template: steps/job.yml + parameters: + name: compute + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/impact.yml + + - stage: pr_fast + displayName: anvil pr-fast + dependsOn: impact + condition: succeededOrFailed() + variables: + include_modified: $[ stageDependencies.impact.compute.outputs['compute.include_modified'] ] + include_affected: $[ stageDependencies.impact.compute.outputs['compute.include_affected'] ] + include_required: $[ stageDependencies.impact.compute.outputs['compute.include_required'] ] + jobs: + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-fast.yml + parameters: + include_modified: $(include_modified) + include_affected: $(include_affected) + include_required: $(include_required) + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-fast.yml + parameters: { ...same... } + + # pr_test, pr_runtime_analysis, and pr_mutants each follow the same shape and + # run as independent parallel stages. +``` + +The wiring never gates jobs on impact output. Each group always runs; recipes inside +the group decide whether a given check no-ops by testing for the literal sentinel +`--skip` in the relevant include var. This matters because unscoped checks (`fmt`, +`deny`, `audit`, `aprz`, `pr-title`, `mutants-full`) must run on every PR. See +[local.md §4](./local.md#4-impact-scoping-pass-through-env-vars) for the recipe-side +contract. + +ADO's `strategy.matrix` doesn't compose with stage-output expressions cleanly (the +expansion happens at compile time but the values aren't available until impact has +run), so anvil unrolls the OS axis into two explicit jobs (`linux` and `windows`) +at template-compile time. Setting `windowsPool: {}` in the user's root pipeline can +elide the Windows job entirely if their root pipeline is shaped to support that. + +The scheduled stages template is simpler — it omits the `impact` stage and runs each +group full-workspace, with the same `linuxPool` / `windowsPool` parameter shape and +the same `steps/job.yml` delegation. Scheduled step templates don't receive any +`include*` parameters; they default to empty strings and recipes fall through to +`--workspace`. + +If `.delta.toml`'s managed region is disabled +([updates.md §opt-out](./updates.md#6-opting-out-in-file-stubs)), the impact step is +unaffected — `cargo delta impact` uses its own defaults when the config file is missing +or empty. + +## 5. Per-group step templates + +Each per-group step template has the **same** uniform parameter surface — the three +impact-include variables plus a per-template handful of PR-context strings. This means +the stages template doesn't need to know which include vars a group's checks consume; +it just threads all three to every group. Moving a check between groups (or between +buckets) is a pure catalog change. + +```yaml +# .pipelines/anvil/steps/pr-fast.yml (owned by cargo-anvil) +parameters: +- name: prTitle + type: string + default: $(System.PullRequest.Title) +- name: includeModified + type: string + default: "" +- name: includeAffected + type: string + default: "" +- name: includeRequired + type: string + default: "" +steps: +- template: setup.yml +- script: just anvil-pr-fast + displayName: anvil pr-fast + env: + PR_TITLE: ${{ parameters.prTitle }} + ANVIL_INCLUDE_MODIFIED: ${{ parameters.includeModified }} + ANVIL_INCLUDE_AFFECTED: ${{ parameters.includeAffected }} + ANVIL_INCLUDE_REQUIRED: ${{ parameters.includeRequired }} +``` + +Uniform parameter set on every per-group template: + +| Parameter | Default | Notes | +|-------------------|---------|--------------------------------------------------------------------------------------------------------------------| +| `includeModified` | `""` | Forwarded as `ANVIL_INCLUDE_MODIFIED`. `--skip` → recipe exits 0. Empty → recipe defaults to `--workspace`. | +| `includeAffected` | `""` | Forwarded as `ANVIL_INCLUDE_AFFECTED`. Same semantics. | +| `includeRequired` | `""` | Forwarded as `ANVIL_INCLUDE_REQUIRED`. Same semantics. | + +Per-group additions (only where the group consumes PR-context strings the recipe needs): + +| Template | Extra parameters | +|---------------------------|-------------------------------------------------------------------------| +| `pr-fast.yml` | `prTitle` (default `$(System.PullRequest.Title)`) | +| `pr-mutants.yml` | `prBaseRef` (default `$(System.PullRequest.TargetBranch)`) | +| `pr-test.yml`, `pr-runtime-analysis.yml`, `scheduled-*.yml` | — | + +`$(System.PullRequest.*)` are auto-populated by ADO on PR build-validation runs. No +manual web-UI wiring is needed. + +The recipes themselves consume only the env vars they need; the catalog records the +mapping (see [checks.md §5](./checks.md#5-impact-scoping-check--env-var-mapping)). +Threading all three to every template costs a few lines per step template but is the +right separation: wiring is about "which jobs depend on impact and feed it forward", not +about "which check needs which env var." + +These templates are consumed primarily by anvil's own stages template. Users who want to +plug individual groups into an unrelated pipeline can `template:` them directly without +passing any include parameters — they default to empty (recipes fall back to +`--workspace`) — and only override what they want to scope. + +### `setup.yml` and `impact.yml` + +`setup.yml` is a step template that installs `just` +(`cargo install just --locked`) and then invokes the catalog setup recipes. It +takes a single `group` parameter that controls which recipes run: + +- empty (default): runs `just anvil-setup` -- the full catalog. Use for "give + me everything" flows. +- `none`: skips the catalog setup entirely. Used by `impact.yml`, which only + needs `cargo-delta` and installs it itself afterwards. +- any other value (e.g. `pr-fast`, `scheduled-advisories`): runs + `just anvil--setup` -- only the tools, components, and toolchains + that group actually needs. Every per-group step template + (`.pipelines/anvil/steps/.yml`) passes its own group name here, so a + `pr-fast` matrix leg never installs cargo-mutants. + +The template does not install Rust; it expects `cargo` on PATH -- provided by the +user's msrustup step in 1ESPT pipelines or by a previous step in OSS pipelines +(see §6). ADO uses the default `install` backend (source builds) because +`cargo-binstall` has unresolved compliance issues for internal ADO pipelines. + +`impact.yml` invokes `setup.yml` with `group: none`, then installs `cargo-delta` +via `anvil-tool-cargo-delta-install` and runs +`cargo delta impact --format json` against `$(System.PullRequest.TargetBranch)` +(or `$BASE_REF` if set), formatting each tier into a pre-built `--package …` +string or the sentinel `--skip`. The three results are exported as ADO output +variables via `##vso[task.setvariable variable=…;isOutput=true]`: + +- `compute.include_modified` +- `compute.include_affected` +- `compute.include_required` + +Downstream jobs reference them via `dependencies.impact.outputs['compute.']` +inside the runtime macro `$[ … ]` (rather than the compile-time `${{ … }}` macro) +because output variables aren't resolved until the producing job has finished. The +stages template handles all that — users don't write it. + +The check → bucket mapping is in +[checks.md §5](./checks.md#5-impact-scoping-check--env-var-mapping). The recipe-side +mechanics are in [local.md §4](./local.md#4-impact-scoping-pass-through-env-vars). + +## 6. Rust toolchain + +anvil does not install Rust on ADO. The step templates assume `cargo` is on PATH. The +user's root pipeline (or compliance template) installs Rust before the anvil stages run. + +Why anvil doesn't ship a Rust install step: + +- **1ESPT compliance.** Compliance pipelines install Rust via msrustup + (Microsoft-internal). The standard `RustInstaller` ADO task is not used. anvil must + emit nothing that conflicts with that. +- **Toolchain choice is a repo decision.** msrustup channels (`ms-prod-1.93`, etc.) are + repo-policy questions anvil has no business making. + +In the OSS / non-1ESPT case, the user adds a `RustInstaller@1` task (or a rustup +shell script) to their root pipeline before the anvil stages template runs. A typical +placement: a setup stage that `dependsOn`s nothing and runs first, followed by the anvil +stages. + +`anvil-tool-rustc-validate-prereqs` (depended on by every check that needs rustc) +validates the installed `rustc` against the catalog minimum at recipe time; a +below-minimum `rustc` produces a clean failure message. For nightly-requiring +checks (miri, careful, udeps), the matching toolchain-validate-prereqs recipe +fails with a suggestion to ask the team's pipeline owner to add `nightly` to +msrustup. + +## 7. Caching + +`setup.yml` computes a cache key from: OS, rustc version (read from +`rust-toolchain.toml`), `Cargo.lock`, `.cargo/config.toml`, and `versions.just` +(the single source of truth for catalog tool/toolchain pins). Uses the ADO +pipeline workspace cache (`Cache@2` task). `CARGO_HOME` is pinned to a +workspace-scratch location to keep cache scoping predictable. + +The cache covers: + +- The `cargo install`-ed tools installed by the catalog setup recipes. +- The `target/` directory (per anvil recipe; a per-recipe cache scope means a `pr-test` + cache hit doesn't have to wait on a `pr-fast` cache miss). + +Cache scoping inside 1ESPT-compliant pipelines is bounded by the template's allowed cache +namespaces; the emitted cache step uses the project-scoped namespace by default and the +user can override via a parameter on `setup.yml` if their compliance policy requires a +different one. + +## 8. Security + +The step templates do nothing privileged on their own — they just install tools and +invoke `just`. The user's root pipeline controls service-connection scoping, secret +variable groups, and approval gates. + +Recommended user-pipeline shape: + +- PR pipelines and scheduled pipelines are separate root files (so they can have separate + triggers, separate variable groups, and different `extends:` if needed). +- Scheduled-tier variable groups (with any external-service credentials) are referenced only by + the scheduled pipeline. +- All cargo-tool installs done by `setup.yml` use `--locked`. No `cargo-binstall`. + +## 9. Incremental adoption + +For repos with an existing 1ESPT-extending pipeline, adopting anvil is incremental: + +1. Run `cargo anvil --backend ado` to emit owned templates and root pipelines. +2. Either delete the emitted root pipelines (`.pipelines/anvil-{pr,scheduled}.yml`) if they + conflict with the repo's existing ones, or edit the existing pipelines to call out to + `anvil/pr.yml` / `anvil/scheduled.yml`. +3. In the repo's existing pipeline, add a stage that does + `template: /.pipelines/anvil/pr.yml@self` under `parameters.stages` of the 1ESPT + `extends:` block. +4. Verify the stage runs green on a PR. +5. Optionally split into individual group stages by hand if the compliance template + requires it. + +anvil's owned templates compose cleanly with the 1ESPT `enableStages` flag system: each +group is its own job inside the `ANVIL_pr` stage, so 1ESPT can gate or split them as +needed. The pre-existing repo-specific compliance steps (msrustup, NuGet pushes, signing, +…) keep running alongside the anvil stage. anvil does not own the pipeline's shape — +it just contributes a stage. + +## 10. Coverage upload + +After `pr-test` (and `scheduled-test`) runs the `anvil-llvm-cov` recipe, the stages +template adds a `PublishCodeCoverageResults@2` step on **each** OS job that ingests +`target/coverage/cobertura.xml`. The cobertura format is the modern recommendation for +the task (lcov is not accepted) and is produced alongside lcov.info by the same +instrumented test run. + +```yaml +- task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), ne(variables.include_affected_linux, '--skip')) + displayName: Publish coverage (linux) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false +``` + +Both the Linux and Windows jobs publish so that OS-gated code is fully represented in +the resulting coverage report -- a single-leg publish would systematically under-report +the coverage of `cfg(target_os = ...)` branches. ADO's `PublishCodeCoverageResults@2` +coalesces multiple publishes against the same build into one combined report. +The `condition: ne(variables.include_affected_*, '--skip')` skips the upload when impact +scoping decided no tests needed to run; `failIfCoverageEmpty: false` keeps the step from +failing the build +when the upstream cobertura file is missing (e.g. a tooling issue or a skip outcome that +didn't get caught by the condition). + +The data appears in the ADO build page's "Code Coverage" tab natively — totals, file +tree, and a per-file annotation view. ADO does not natively compute diff coverage +between PR and base; that's a known limitation of the platform (see `coverage.md` +for the unified-coverage discussion). + +anvil does not gate the PR on coverage. The cobertura upload is informational; +adopters who want gating add `BuildQualityChecks@9` (Microsoft DevLabs marketplace +task) downstream of the test step and configure it via their branch policy. + +## 11. Advisory PR comments + +Recipes that surface non-blocking findings exit 0 and write a markdown body to +`target/anvil/comments/.md` (see [checks.md §6](./checks.md#6-advisory-pr-comments) +for the cross-backend convention). The ADO backend turns presence/absence of those +files into upserts/deletions of a sticky PR comment via the Azure DevOps REST API +(ADO has no marketplace equivalent of marocchino's sticky-comment action; the REST +path is the supported way). + +The wiring lives in the `pr_fast` stage of `pr-stages.yml`, as a pwsh step that runs +on the canonical Linux leg after the `pr-fast` group's `bash: just anvil-pr-fast` +step: + +```yaml +- task: PowerShell@2 + displayName: anvil advisory PR comments + condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + inputs: + targetType: inline + pwsh: true + script: | + # iterate known files, find existing thread by HTML marker, + # PATCH first comment / POST new thread / set status: closed +``` + +Key ADO-specific details: + +- **Marker-based thread lookup**. ADO comments have no "sticky header" parameter; we + embed an HTML marker (``) as the first line of the comment + body. The script lists PR threads (`GET pullRequests/{id}/threads`), finds the one + whose first comment contains the marker, and PATCHes its first comment with the new + body or sets the thread `status` to `closed` when there's nothing to report. The + marker is invisible to human readers. +- **`$(System.AccessToken)` opt-in**. ADO does not expose `System.*` variables to + scripts by default. The step explicitly maps it via `env:`, and `checkout` must run + with `persistCredentials: true` so the token actually carries write permission. +- **Build identity permission**. The "Project Collection Build Service ()" + identity needs **Contribute to pull requests** on the repo. 1ESPT pipelines usually + have this; vanilla ADO sometimes requires a one-time admin grant. Without it the + REST call returns 403 and the comment is silently skipped (the step is wrapped to + exit 0 on auth failures so it doesn't break PRs in repos that haven't opted in). +- **No fork story**. ADO's PR-from-fork support is limited compared to GitHub; the + REST call works for same-org PRs, which is the only case 1ESPT actually supports + on internal repos. Forks fail closed (no comment posted) rather than fail open + (build red). +- **Canonical leg**. As on the GitHub side, the step runs only on the Linux job so the + Linux/Windows matrix doesn't race on the same thread. + +Adding a new advisory check is a two-step change: the recipe writes +`target/anvil/comments/.md` (and removes it on a clean run); the pwsh step's +`@checks` table gains a `@{ name = ''; file = 'target/anvil/comments/.md' }` +entry. There's deliberately no auto-discovery loop over the convention dir — explicit +per-check entries keep stale comments deterministically clearable when a check is +removed from the catalog. diff --git a/crates/cargo-anvil/docs/design/checks.md b/crates/cargo-anvil/docs/design/checks.md new file mode 100644 index 00000000..c0f3213f --- /dev/null +++ b/crates/cargo-anvil/docs/design/checks.md @@ -0,0 +1,394 @@ +# Check Catalog + +This document defines the opinionated default profile: which checks ship, how they're +grouped, which tier they belong to, and how the tool-version policy works. It is the +canonical source for "what does anvil actually run?" + +See also: + +- [design.md](./design.md) for the overall principles and CLI shape. +- [local.md](./local.md) for how the catalog is exposed as `just` recipes. +- [github.md](./github.md) / [ado.md](./ado.md) for how groups map to cloud-workflow building blocks. + +## 1. Groups and tiers + +The check catalog is hardcoded in the binary. Each check belongs to one or more *groups*, and +each group belongs to exactly one *tier*. Groups are the unit of cloud workflows parallelization (one cloud workflows +job per group) and the unit of local invocation through `just` (one `just` recipe per group). +A user (or cloud workflows) never has to enumerate individual checks — they operate at the group level. + +The **single-tier-per-group** rule is deliberate: if you see `just anvil-pr-fast` in cloud workflows logs, +you know it is a PR-tier check; if you see `just anvil-scheduled-exhaustive`, you know it is +scheduled-only. This makes "what gets executed" trivially answerable from the group name. + +A consequence is that some checks must appear in two groups -- one PR group and one scheduled +group -- when the check should run in both tiers. The two invocations may differ (e.g. +`mutants` runs diff-scoped in PR and full-workspace in scheduled) or be identical (e.g. `tests` +runs the same way in both, but the scheduled run catches flakes/environmental drift on `main`). + +Group recipes follow the pattern `anvil--` (e.g. `anvil-pr-fast`, +`anvil-scheduled-exhaustive`). The tier prefix removes the need to pick distinct names for groups +in different tiers and makes the tier of any failing job obvious from its name alone. + +Visually: + +```mermaid +%%{init: {"flowchart": {"defaultRenderer": "elk", "nodeSpacing": 10, "rankSpacing": 35, "padding": 3}}}%% +flowchart LR + full([anvil-full]):::tier + pr([anvil-pr
alias: anvil]):::tier + sched([anvil-scheduled]):::tier + + full --> pr + full --> sched + + pr --> pr_fast[anvil-pr-fast]:::group + pr --> pr_slow[anvil-pr-slow]:::group + pr_slow --> pr_test[anvil-pr-test]:::group + pr_slow --> pr_runtime_analysis[anvil-pr-runtime-analysis]:::group + pr_slow --> pr_mutants[anvil-pr-mutants]:::group + + sched --> s_test[anvil-scheduled-test]:::group + sched --> s_adv[anvil-scheduled-advisories]:::group + sched --> s_exh[anvil-scheduled-exhaustive]:::group + + pr_fast --> fmt[fmt]:::check + pr_fast --> clippy[clippy]:::check + pr_fast --> cargo_sort[cargo-sort]:::check + pr_fast --> license_headers[license-headers]:::check + pr_fast --> ensure_no_cyclic_deps[ensure-no-cyclic-deps]:::check + pr_fast --> ensure_no_default_features[ensure-no-default-features]:::check + pr_fast --> doc_build[doc-build]:::check + pr_fast --> readme_check[readme-check]:::check + pr_fast --> spellcheck[spellcheck]:::check + pr_fast --> pr_title[pr-title]:::check + pr_fast --> deny[deny]:::check + pr_fast --> audit[audit]:::check + pr_fast --> udeps[udeps]:::check + pr_fast --> semver_check[semver-check]:::check + pr_fast --> external_types[external-types]:::check + pr_fast --> aprz[aprz]:::check + + pr_test --> llvm_cov[llvm-cov]:::check + pr_test --> doc_test[doc-test]:::check + pr_test --> examples[examples]:::check + + pr_runtime_analysis --> miri[miri]:::check + pr_runtime_analysis --> careful[careful]:::check + + pr_mutants --> mutants_diff[mutants-diff]:::check + + s_test --> s_llvm_cov[llvm-cov]:::check + s_test --> s_doc_test[doc-test]:::check + s_test --> s_examples[examples]:::check + + s_adv --> s_deny[deny]:::check + s_adv --> s_audit[audit]:::check + s_adv --> s_aprz[aprz]:::check + s_adv --> s_clippy[clippy]:::check + + s_exh --> mutants_full[mutants-full]:::check + s_exh --> cargo_hack[cargo-hack]:::check + s_exh --> bench[bench]:::check + + classDef tier fill:#e6f0ff,stroke:#0366d6,stroke-width:2px; + classDef group fill:#f6f8fa,stroke:#586069,stroke-width:1px; + classDef check fill:#f3e8ff,stroke:#6f42c1,stroke-width:1px,font-size:10px; +``` + +(Tier nodes are the user-facing entry points; group nodes are the unit of cloud workflows parallelization; check nodes are the individual `anvil-` recipes. `pr-test`, `pr-runtime-analysis`, and `pr-mutants` were split out from a single `pr-slow` group so the three workloads run as parallel cloud-workflow jobs per OS leg rather than sequentially in one job. Locally, `just anvil-pr-slow` is an umbrella recipe that invokes the three sub-recipes in order, and `just anvil` is an alias for `just anvil-pr`.) + +### PR tier (4 groups) + +| Group | OS scope | Purpose | +|--------------------|---------------------------------------|----------------------------------------------------------------------------------------------------------------------| +| `pr-fast` | Linux x86_64 + Windows x86_64 + Linux aarch64 + Windows aarch64 (GH) / Linux x86_64 + Windows x86_64 (ADO) | All static analysis: clippy, `udeps`, `semver-check`, `external-types`, plus the text/metadata checks (fmt, license-headers, ...). Cross-OS because clippy, doc-build, udeps, semver-check, and external-types all compile per host target. Text/metadata checks run on every leg too; the redundancy cost is negligible compared to a separate job's setup overhead. | +| `pr-test` | Same default as `pr-fast` | Tests + coverage: `llvm-cov` (instrumented `nextest`), `doc-test`, `examples`. Coverage is uploaded once from the canonical x86_64 Linux leg. | +| `pr-runtime-analysis` | Same default as `pr-fast` | Stricter-runtime correctness: `miri` and `careful`. Impact-scoped via `ANVIL_INCLUDE_AFFECTED` so wall-clock is proportional to the PR's blast radius. | +| `pr-mutants` | Linux x86_64 + Windows x86_64 + Linux aarch64 (GH) / Linux x86_64 + Windows x86_64 (ADO) | Diff-scoped mutation testing (`mutants --in-diff`). The recipe self-skips on `aarch64-pc-windows-msvc` (cargo-mutants doesn't build there), so the GH windows-arm leg is a no-op rather than a job failure. | + +The three `pr-slow*` groups are independent: failures in `pr-test` don't block `pr-runtime-analysis` or `pr-mutants` from running, and overall PR wall-clock is `max(pr-test, pr-runtime-analysis, pr-mutants)` per leg rather than the sum. Locally, `just anvil-pr-slow` is an umbrella recipe that runs all three sub-recipes sequentially so adopters who want "run everything slow" don't have to type three commands. + +### scheduled tier (3 groups) + +| Group | OS scope | Purpose | +|----------------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------| +| `scheduled-test` | Same default as `pr-test` | Re-runs the test suite on `main` (with coverage instrumentation) to catch flakes/environment-dependent failures and to publish a full coverage snapshot of the current `main`. | +| `scheduled-advisories` | Same default as `pr-fast` | Re-runs every check whose outcome can change without a commit to this repo: `deny`, `audit`, `aprz` (external databases), `clippy` (lint set evolves with toolchain). Cross-OS because clippy compiles per host. | +| `scheduled-exhaustive` | Linux x86_64 + Windows x86_64 | The expensive whole-workspace permutations that don't fit the PR budget: full `cargo mutants`, `cargo-hack --feature-powerset`, and `cargo bench --no-run` plus a single-iteration smoke run per bench target. Cross-OS to match `oxidizer`'s policy and to give cargo-hack / bench compile coverage for cfg-gated code. **x86_64-only**: same `cargo-mutants` / `winapi` constraint as `pr-mutants`. Adopters who can't afford the full matrix (mutants-full can run for hours per leg) override the matrix in their root workflow / pipeline. | + +**Backend asymmetry on ARM coverage.** The GitHub backend ships a four-leg default matrix +(Linux/Windows × x86_64/aarch64) because GH has Microsoft-hosted ARM runners +(`ubuntu-24.04-arm`, `windows-11-arm`). The ADO backend ships a two-leg default +(x86_64 only) because ADO has no hosted ARM agents; adopters with self-hosted ARM pools +extend the stages template themselves. The catalog and recipes are identical across +backends — the asymmetry is purely in the wiring layer's default OS matrix. + +OS-scope is an opinion anvil ships and the user overrides per-repo through the +backend-specific knobs ([github.md §4](./github.md#4-owned-reusable-workflows) for +the per-leg runner-label inputs and forking the workflow when the matrix shape itself +needs to change, [ado.md §4](./ado.md#4-owned-stages-templates) for +`linuxPool`/`windowsPool`). +Locally there is no OS matrix; `just anvil-pr-slow` (the umbrella recipe) runs the three sub-recipes in sequence against whatever OS the +developer is on. See [design.md §8.3](./design.md#83-cross-os-test-matrices) for the +overall rationale. + +The `scheduled-exhaustive` group's checks are independent and could in principle live in +separate parallel jobs; they're folded into one group because each individually is just +one check, and scheduled tolerates the longer wall-clock that serial execution within one +job implies. Repos that want to parallelize them can split the recipe into separate group +recipes locally. + +## 2. Checks by group + +The cell format is `cargo invocation (short rationale)`. "Source" cites the surveyed repo +that provided the strongest version of the check. + +### `pr-fast` + +| Check | Invocation | Source | +|--------------------------------|-----------------------------------------------------------|--------| +| `fmt` | `cargo + fmt --all --check` | all | +| `clippy` | `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` | all | +| `cargo-sort` | `cargo sort --workspace --check` | oxidizer-github | +| `license-headers` | `cargo heather --workspace` | oxidizer (`heather`), oxidizer-github | +| `ensure-no-cyclic-deps` | `cargo ensure-no-cyclic-deps --workspace` | oxidizer-github (sibling crate in `ox-tools-gh`) | +| `ensure-no-default-features` | `cargo ensure-no-default-features --workspace` | oxidizer-github | +| `doc-build` | `RUSTDOCFLAGS='-D warnings' cargo doc --workspace --all-features --no-deps` | oxidizer-github | +| `readme-check` | `cargo doc2readme --check` for each crate that opts in (presence of a `[package.metadata.doc2readme]` table) | oxidizer-github | +| `spellcheck` | `cargo spellcheck check --code 1` | oxidizer-github | +| `pr-title` | Conventional-Commits regex applied to the title in the `PR_TITLE` env var. Skipped silently when the env var is unset (intermediate local runs, scheduled-tier builds). Written as a `[script("pwsh")]` recipe (the one check that needs scripting; see [design.md §8.3](./design.md#83-cross-os-test-matrices)). cloud-workflow wiring: GitHub's per-group composite action reads `${{ inputs.pr_title }}` populated from `${{ github.event.pull_request.title }}` in the reusable PR workflow; ADO's `group.yml` step template injects `PR_TITLE: $(System.PullRequest.Title)` on every group (empty on non-PR builds, where the recipe no-ops). | oxidizer-github | +| `deny` | `cargo deny check` | all | +| `audit` | `cargo audit` | oxidizer | +| `udeps` | `cargo + udeps --workspace --all-features` (deliberately NOT `--all-targets`: with it, a dep listed in both `[dependencies]` and `[dev-dependencies]` and used only by tests is reported as "all used" because the dev-deps target satisfies the lookup, masking the unused entry in main `[dependencies]`. Restricting to the default targets matches main repo cloud workflows' check and surfaces the real bug.) | oxidizer, oxidizer-github | +| `semver-check` | `cargo semver-checks` per library crate (per-package because `--workspace` fails on bin-only members, and we tolerate "not found in registry" / "no library targets found" for unpublished or bin→lib-transition crates). **Advisory only**: findings do not fail the recipe (breaking changes between unreleased commits are normal — the major-version bump happens at release time, not on every PR). Instead the recipe writes a markdown body to `target/anvil/comments/semver.md` when there are findings and removes the file when the tree is clean; cloud-workflow wiring turns presence/absence into a sticky PR comment (see §6 below). | oxidizer-github | +| `external-types` | `cargo + check-external-types --manifest-path` per library crate (per-manifest because the tool has no `--workspace`/`--package`; bin-only crates have no public API surface and are skipped). Nightly is pinned narrowly to the rustdoc JSON schema version the tool expects (`rust_nightly_external_types` in `versions.just`); the pin is bumped alongside any cargo-check-external-types upgrade. With the pin in place, the check is deterministic and PR-suitable. | oxidizer-github | +| `aprz` | `cargo aprz check` — third-party risk analysis published on crates.io | oxidizer | + +### `pr-slow` + +The PR-tier slow checks are split into three independent cloud-workflow-visible groups — +`pr-test`, `pr-runtime-analysis`, `pr-mutants` — that each run as their own job (GitHub) or +stage (ADO) in parallel. An umbrella `anvil-pr-slow` recipe is also provided in +`groups.just` for local use; it invokes the three sub-recipes sequentially so +adopters can type one command to run "everything slow" without needing the cloud workflow +matrix overhead. + +#### `pr-test` (tests + coverage) + +| Check | Invocation | Source | +|--------------|-----------------------------------------------------------------------------|--------| +| `llvm-cov` | Three steps from one instrumented `cargo llvm-cov nextest --no-report` run: `report --lcov` -> `target/coverage/lcov.info`, `report --cobertura` -> `target/coverage/cobertura.xml`, `report --html` -> `target/coverage/html/` (local viewer). The nextest run produces the test pass/fail signal; the three `report` invocations re-render the cached `.profraw` data in each format without re-running tests. lcov feeds Codecov on GitHub; cobertura feeds `PublishCodeCoverageResults@2` on ADO; HTML is purely a local affordance. No threshold enforcement at the check level. | oxidizer, oxidizer-github | +| `doc-test` | `cargo test --doc --workspace --all-features --locked` (nextest does not run doctests, so this is a separate cargo-test invocation) | oxidizer, oxidizer-github | +| `examples` | `cargo build --workspace --examples --all-features --locked` -- verifies that example targets compile. Running each example is intentionally not part of the check (examples are not test scaffolding; their runtime behavior isn't part of what we gate on). | oxidizer, oxidizer-github | + +This is the same set of checks that used to live in the standalone `pr-test` group; merging into `pr-test` removes one cloud-workflow job from the matrix without changing what runs. + +#### `pr-runtime-analysis` (stricter-runtime correctness) + +| Check | Invocation | Source | +|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------| +| `miri` | `cargo + miri nextest run` over the impact-affected packages. Slow tests should opt out per-test with `#[cfg_attr(miri, ignore)]` -- anvil doesn't pass exotic `MIRIFLAGS`; the per-test opt-out is the canonical mechanism. The recipe defaults to `--workspace` locally when no impact env vars are set, so plain `just anvil-pr-runtime-analysis` runs the full miri suite. Recipe passes `--no-tests=pass` so crates with all-FS-tests (skipped under miri) don't fail the run. | oxidizer, oxidizer-github | +| `careful` | `cargo + careful test --all-features --locked` over the impact-affected packages. cargo-careful uses a debug-instrumented std (extra runtime checks, no validation skipped). Typical slowdown is 2-3x over plain `cargo test`; well within PR budget for the affected set. | oxidizer-github | + +#### `pr-mutants` (mutation testing) + +| Check | Invocation | Source | +|-----------|-----------------------------------------------------------------------------|--------| +| `mutants` | `cargo mutants --in-diff ..HEAD --no-shuffle --jobs 0` (diff-scoped). Self-skips on aarch64-pc-windows-msvc where cargo-mutants doesn't build (upstream winapi incompat); other ARM legs run normally. | oxidizer-github | + +The mutants check requires a base ref: locally the recipe resolves `BASE_REF` (if set), then `origin/main`, then `origin/master`, then errors out. In GitHub Actions the workflow passes `${{ github.event.pull_request.base.sha }}`; in ADO the impact step exports `$(System.PullRequest.TargetBranch)` as `BASE_REF`. + +### `scheduled-test` + +Same three checks as `pr-test` -- `llvm-cov`, `doc-test`, `examples` -- and the same +recipe invocations, with the same output paths (`target/coverage/lcov.info` and +`target/coverage/cobertura.xml`). The recipe is shared between tiers; only the cloud workflow +wiring around it changes (PR uploads lcov to Codecov / cobertura to ADO from each +PR run; scheduled does the same against `main` plus flags the upload as `scheduled` in +Codecov so the two streams stay distinguishable in the UI). Two purposes for re-running +on scheduled: catch flakes/environmental sensitivities that didn't trip in PR, and +publish a full-coverage snapshot for the current state of `main`. + +### `scheduled-advisories` + +| Check | Invocation | Source | +|----------|---------------------------------------------------------------------|--------| +| `deny` | `cargo deny check` | all | +| `audit` | `cargo audit` | oxidizer | +| `aprz` | `cargo aprz check` | oxidizer | +| `clippy` | `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` | all | + +These checks share a property: their outcome can change without a commit to this repo. +`deny`/`audit`/`aprz` consult external databases (RustSec advisory DB, license registries, +Azure risk indices). `clippy` reflects whatever lint set ships with the currently-installed +toolchain -- even when `rust-toolchain.toml` is pinned, repos using floating channels +(`stable`, or msrustup channel pointers like `ms-prod-1.93`) can pick up new lints when the +pointer is bumped upstream. Re-running these on the scheduled tier turns "something landed +upstream yesterday" into a tracked failure rather than an invisible regression discovered +next time someone opens an unrelated PR. + +(`udeps` and `external-types` use pinned nightlies and are not re-run here: their outcome is +deterministic given the source + pinned tool versions, so re-running on the same `main` +commit can't surface anything new.) + +### `scheduled-exhaustive` + +| Check | Invocation | Source | +|-----------------------|--------------------------------------------------------------------------------------------------------------|--------| +| `mutants-full` | `cargo mutants --workspace --no-shuffle --jobs 0` | oxidizer-github, oxidizer (sharded cross-OS) | +| `cargo-hack` powerset | `cargo hack --workspace --feature-powerset --depth 2 check` | oxidizer, oxidizer-github | +| `bench` | `cargo bench --workspace --all-features --no-run` + a single-iteration smoke benchmark for each bench target | oxidizer | + +## 3. Per-check vs grouped cloud workflows execution + +Each *group* is one cloud-workflow job. Within a job, the checks belonging to the group run sequentially +as the `just` recipe defines them. A failure in any check fails the group; the per-check log +lines are visible in the job log but the cloud workflow surface (the green/red pill in the PR view) is +per-group. + +This is the deliberate middle ground between "one giant cloud workflows step running `just anvil-pr`" +(loses all per-check structure, one red X for any failure) and "twenty-five individual cloud workflows +steps" (unmaintainable YAML, fragile, and the tool would have to re-emit the workflow file +every time the catalog changes). Groups are stable units of meaning the user can talk about; +checks are implementation details that can churn. + +## 4. What scheduled does and does not re-run + +The rule is simple: **a check belongs in scheduled iff its outcome can change without a +commit to this repo.** Re-running everything else on the scheduled tier would just burn cloud workflows time +duplicating PR signal. + +What that means concretely: + +- **Re-run in scheduled** (in addition to PR): + - `llvm-cov`, `doc-test`, `examples` (in `scheduled-test`) -- non-determinism, environment + sensitivity, runner drift can produce flakes that the PR run missed. + - `deny`, `audit`, `aprz`, `clippy` (in `scheduled-advisories`) -- see §2. +- **Run only in PR** -- checks whose outcome is fully determined by the source tree and + the pinned tool versions, so re-running on the same `main` commit can't surface anything + new: `fmt`, `cargo-sort`, `license-headers`, `ensure-no-cyclic-deps`, + `ensure-no-default-features`, `doc-build`, `readme-check`, `spellcheck`, `pr-title`, + `udeps`, `semver-check`, `external-types`, `miri`, `careful`, diff-scoped `mutants`. +- **Run only in scheduled** -- the expensive whole-workspace work that doesn't fit a PR + budget: full `mutants`, `cargo-hack --feature-powerset`, `bench` (in `scheduled-exhaustive`). + +The single-tier-per-group rule still holds: when a check appears in both tiers it lives in +two different groups (one PR group, one scheduled group). Repos that want a +belt-and-suspenders cron run of `just anvil-pr` on `main` can wire one up in their own +workflow/pipeline file alongside the anvil composite actions / step templates. + +## 5. Impact-scoping check → env-var mapping + +The tool uses [`cargo-delta`](https://crates.io/crates/cargo-delta) to skip checks for +unaffected workspace members on PR runs. cargo-delta computes three concentric impact tiers +(`required ⊇ affected ⊇ modified`) and emits each as a list of crate names. The +`anvil-impact` building block formats each tier into a pre-built `--package X --package Y` +string (or the literal sentinel `--skip` when the tier is empty), publishes the result as +`ANVIL_INCLUDE_MODIFIED`, `ANVIL_INCLUDE_AFFECTED`, and `ANVIL_INCLUDE_REQUIRED` +env vars, and the recipes in `checks.just` consume them. + +Each catalog check is tagged with one of four buckets: + +| Bucket | Env var consumed | Behavior in cloud workflows | Behavior locally (env unset) | +|-----------|-------------------------------|-----------------------------------------------------------------------------|--------------------------------------| +| modified | `ANVIL_INCLUDE_MODIFIED` | If `--skip`: exit 0. Otherwise run unconditionally (tool is workspace-wide). | Run unconditionally. | +| affected | `ANVIL_INCLUDE_AFFECTED` | If `--skip`: exit 0. Otherwise splice the value into the cargo invocation. | Default to `--workspace`. | +| required | `ANVIL_INCLUDE_REQUIRED` | If `--skip`: exit 0. Otherwise splice the value into the cargo invocation. | Default to `--workspace`. | +| unscoped | *(none)* | Always run. | Always run. | + +Bucket assignments per check: + +| Bucket | Checks | +|-----------|-----------------------------------------------------------------------------------------------------------------------| +| modified | `fmt`, `cargo-sort`, `license-headers`, `ensure-no-cyclic-deps`, `ensure-no-default-features`, `readme-check`, `spellcheck` | +| affected | `clippy`*, `llvm-cov`, `doc-test`, `examples`, `mutants` (diff and full), `miri`, `careful`, `semver-check`, `external-types`, `bench` | +| required | `doc-build`, `udeps`, `cargo-hack` (feature powerset) | +| unscoped | `pr-title`, `deny`, `audit`, `aprz`, `mutants-full` | + +\* cargo-delta's README recommends `clippy` with the modified tier. anvil deliberately +runs it on the affected set instead: a change in a crate's API can introduce clippy lints +(trait-bound mismatches, obviously-truthy-condition warnings keying off changed types) in a +dependent crate, so downstream rev-deps need to lint too. The cost is small — clippy is +incremental — and the recall benefit avoids a class of merge surprises. + +`required` is `affected ∪ workspace-internal transitive deps`, not "the whole workspace". +For a small PR it can still be much narrower than `--workspace`. It is used for tools +whose correctness resolves through the dep graph: `cargo doc` (intra-doc links walk into +deps), `cargo udeps` (unused-deps detection needs the resolved graph), `cargo hack +--feature-powerset` (feature combinations cascade through dep features). + +`unscoped` is for checks that have nothing to do with workspace-member identity: +`deny`/`audit` read `Cargo.lock`, `pr-title` reads PR metadata, `aprz` consults an +external risk DB. These ignore the env vars and always run. + +The sentinel `--skip` is a magic string that cannot be a valid cargo argument, so there +is no collision with real package names. Recipes test for it with +`[ "$VAR" = "--skip" ]` and exit 0 to keep the cloud-workflow job green while signalling that +nothing in that tier needed to run. + +The recipe-side mechanics are in +[local.md §4](./local.md#4-impact-scoping-pass-through-env-vars). the cloud workflow-side wiring (the +`anvil-impact` building block, how downstream jobs consume the include vars) is in +[github.md](./github.md#impact-scoping) and [ado.md](./ado.md#impact-scoping). + +Trade-off acknowledged: the risk cargo-delta introduces is that a misconfigured analysis +silently skips checks that should have run, leaving "all green" on a PR that actually broke +something. The design mitigates this with: (1) trip-wire patterns in `.delta.toml` that +bias toward full runs whenever config changes; (2) `unscoped` checks (`deny`, `audit`, +`aprz`, `pr-title`, `mutants-full`) always run regardless of impact analysis; +(3) scheduled always runs full-workspace, catching anything the PR-scoping missed within 24 +hours; + +## 6. Advisory PR comments + +Some checks surface findings that are informative for the reviewer but should not block +the PR. The canonical example is `semver-check`: breaking changes between unreleased +commits are normal, and forcing every breaking-API PR to bump the major version (or wait +on a release) would push enforcement to the wrong moment in the lifecycle. The change is +verifiable at release time, not per PR. + +To carry this signal without making the recipe non-zero, anvil uses a single shared +convention: + +1. **Recipe writes a file**. Advisory recipes write a complete markdown body to a + well-known path, then exit 0. The convention is + `target/anvil/comments/.md`, where `` matches the recipe stem + (`semver` for `anvil-semver-check`). When the recipe has nothing to report it + removes that file. The body's first line is an invisible HTML marker + (``) so a backend without a native "sticky comment header" + concept (ADO) can find an existing thread to update. +2. **cloud-workflow wiring upserts a sticky PR comment**. After each PR job that runs an + advisory-emitting recipe, anvil's cloud workflows templates inspect the convention directory + and: + - if `.md` exists, upsert a sticky PR comment headed `anvil-` with the + file's contents; + - if `.md` does not exist (the recipe removed it because the tree is now + clean), clear any prior sticky comment with that header. +3. **One canonical leg per matrix**. cloud-workflow runs the same recipe on multiple OS legs; the + upsert/clear steps run only on the x86_64 Linux leg so the matrix doesn't race on the + same PR thread. The recipe still writes the file on every leg (local-vs-cloud workflows parity). + +Backend wiring: + +- **GitHub Actions** — [`marocchino/sticky-pull-request-comment@v3`](https://github.com/marocchino/sticky-pull-request-comment) + is invoked twice: with `path:` to upsert when the file exists, and with `delete: true` + to clear when it does not. The workflow's reusable job declares + `permissions: pull-requests: write`. Fork PRs are skipped via a + `github.event.pull_request.head.repo.full_name == github.repository` guard because + forks can't be granted write tokens. +- **Azure DevOps Pipelines** — a pwsh step uses the Azure DevOps REST API + (`$(System.AccessToken)` + the project-collection build identity's "Contribute to + pull requests" permission) to scan PR threads for the HTML marker, then `PATCH`s the + thread's first comment when the file exists or sets the thread `status: closed` when + it does not. + +Local runs (no PR context) just write/remove the file; nothing posts it. This keeps the +file useful as a self-service diagnostic and makes the behaviour bit-identical between +local and cloud workflows. + +Currently `semver-check` is the only advisory-emitting recipe. The convention extends +to any future check that surfaces non-blocking findings (e.g. coverage deltas, security +advisories) by following the same `target/anvil/comments/.md` ↔ +`anvil-` mapping; the catalog's wiring templates list each known file +explicitly so stale comments can be cleared deterministically. diff --git a/crates/cargo-anvil/docs/design/design.md b/crates/cargo-anvil/docs/design/design.md new file mode 100644 index 00000000..99eb4833 --- /dev/null +++ b/crates/cargo-anvil/docs/design/design.md @@ -0,0 +1,402 @@ +# cargo-anvil — Design + +> **Why "anvil"?** Borrowing the smithing metaphor: an anvil is a stable, opinionated +> surface against which build tooling is forged into the shape each repo needs. +> The metaphor stays out of the user-facing docs; this is the only place it's named. + +This is the top-level design document. It captures the why, the principles, and the +user-visible shape of the tool. Detail lives in companion documents: + +- [checks.md](./checks.md) — the opinionated check catalog, the group/tier structure +- [local.md](./local.md) — the `justfiles/anvil/` layout, recipe surface, and customization. +- [updates.md](./updates.md) — the drift-detection and update algorithm; opt-out semantics. +- [github.md](./github.md) — GitHub Actions emission, example workflows, impact wiring. +- [ado.md](./ado.md) — Azure DevOps Pipelines emission, 1ESPT/msrustup composition. +- [../verification.md](../verification.md) — continuous-validation strategy: dogfooding, + fixture tests, schema validation. + +## 1. Problem + +Across the surveyed Rust repos (`oxidizer`, `oxidizer-github`, `ox-tools`, `ox-tools-gh`, +`assistants-oxide`, `ox-docs`) the build/test/cloud workflows infrastructure is conceptually similar but +implemented six different ways: + +| Repo | cloud workflows | Justfile shape | Toolchain | Notable specifics | +|------|----|----------------|-----------|-------------------| +| `oxidizer` | ADO 1ESPT (`SubstratePT`) | 500-line monolith + `just_mutants.just` | `ms-prod-1.93` | Stage flags (`enableStages`), `cargo-aprz`, stable-API checks | +| `oxidizer-github` | GitHub Actions | Modular `justfiles/{basic,coverage,format,setup,spelling}.just` + `constants.env` | `1.93` | `cargo-delta` impact-scoped builds, sticky semver comments, composite `setup` action | +| `ox-tools` | ADO (CloudBuild + classic) | none in worktree | `ms-prod-1.92` | NuGet/MSBuild scaffolding, internal templates | +| `ox-tools-gh` | GitHub Actions | Same modular shape as `oxidizer-github` | `1.93` | Mirror surface to OSS oxidizer | +| `assistants-oxide` | ADO 1ESPT (custom `rust/`) | Monolith + `.just/tds.just` | `ms-prod-1.93` | Symcrypt setup steps, NuGet publish stage | +| `ox-docs` | ADO classic | Monolith | `ms-prod-1.88` | Mixed C#/.NET + Rust, mdbook/docfx | + +The same logical checks (clippy, fmt, deny, miri, mutants, coverage, hack feature-powerset, udeps, +semver, spellcheck, license headers, doc/doctest, careful, audit, ensure-no-cyclic-deps, +ensure-no-default-features, doc2readme, …) are spelled in subtly different ways in each repo, with +different argument sets, different tool versions, and different opinions about which tier (PR vs. +scheduled) a check belongs to. + +Maintaining six artisanal copies is expensive: improvements made in one repo (e.g. `cargo-delta` +impact scoping in `oxidizer-github`) take months to propagate, security/policy upgrades are +missed, and onboarding new Rust repos requires copying-and-praying. + +## 2. Goals + +1. **One opinionated build profile** for Rust repos, with sane defaults distilled from the + strongest patterns observed across the existing repos. +2. **Two tiers**: `pr` (blocking on every pull request) and `scheduled` (slow, runs on a schedule). +3. **Both cloud-workflow backends** — GitHub Actions and Azure DevOps Pipelines — generated from the same + source of truth. The user picks one or both per repo via a CLI flag. +4. **Compliance preservation**: ADO pipelines that must `extends:` 1ESPT/SubstratePT continue to + do so. The tool's emitted templates contain no references to those harnesses; the user's + root pipeline does the wrapping. See [ado.md](./ado.md). +5. **Local/cloud workflows parity at every level**: every individual check, every group of checks, and the + full tier are all reproducible locally with a single `just` invocation, using the exact same + arguments cloud workflows uses. The three commands `just anvil-pr`, `just anvil-scheduled`, and + `just anvil-full` (= pr + scheduled) are first-class local entry points. +6. **Plain-cargo fallback**: a developer with only `cargo` installed (no `just`, no + `cargo-anvil`) can still build and run tests. +7. **Friendly updates**: the tool detects, per file and per managed region, whether the user has + modified it, and updates only the unmodified bits. +8. **Open source**: the crate ships from `github.com/microsoft/ox-tools` and publishes to + crates.io. The binary contains no Microsoft-internal dependencies; everything it can install + on the user's behalf comes from crates.io. + +## 3. Non-Goals + +- Replacing 1ESPT, SubstratePT, CloudBuild, or any other compliance/release pipeline. anvil's + emitted templates contain no references to those harnesses; users wrap anvil's stages + template in their compliance-extending pipeline themselves. See [ado.md](./ado.md). +- Building a general-purpose cloud workflows compiler/IR. We share **check semantics**, not cloud workflows features. +- Owning `.cargo/config.toml`, `rust-toolchain.toml`, or workspace layout in `Cargo.toml`. +- Installing the Rust toolchain. msrustup owns it on 1ESPT; the runner image owns it on + GitHub-hosted runners; the user owns it locally. The tool validates `rustc` version at + recipe time and produces a clean failure when it doesn't meet the catalog minimum. + Future work: warn (not fail) when the locally-installed toolchain drifts materially + from the version the catalog targets, so local results stay predictive of cloud workflows. +- Managing exact tool versions on the user's behalf — we enforce minimums only. See + [local.md §3](./local.md#3-tool-versions-and-installation). +- Hosting a service. The tool is a CLI binary; updates ship via crates.io. +- Acting as a runtime: the tool emits `just` recipes and cloud-workflow building blocks, then exits. + It is **not** invoked at build/test/cloud workflows time. `just` is the runtime. (A narrow exception + may be made in the future for runtime subcommands tightly coupled to cloud workflows execution — + e.g. coverage gating — but the generator stance remains the default.) +- Destructive operations: `cargo anvil` never deletes files. Removing a previously + configured cloud-workflow backend is a manual `rm -rf` by the user. + +## 4. Guiding Principle + +> **`cargo-anvil` writes files. `just` runs them. The repo composes everything.** + +Corollaries that drive every section below: + +- The tool's only job is to author and update files. It is not on the local-build hot path or in + the cloud-workflow graph at runtime. +- The local daily-driver is `just anvil` (and friends). Those recipes call `cargo …` directly. cloud workflows + jobs invoke the same `just` recipes. Local and cloud-workflow runs are bit-identical because they share one + implementation in the imported `.just` files. +- Drift detection lives inside the files themselves (per-file checksums and per-managed-region + checksums). There is no parallel metadata file. See [updates.md](./updates.md). +- The tool inserts managed sections into the user's `Justfile` and into a small set of shared + config files (`deny.toml`, `[workspace.lints]` in the workspace `Cargo.toml`, and `[lints]` + in each crate's `Cargo.toml`, plus `.delta.toml` and `rustfmt.toml`). Outside those sections, + the user's content is preserved verbatim. Everything else is in tool-owned files under + `justfiles/anvil/` and the backend-specific cloud workflows directories. + +## 5. User Experience + +### 5.1 Installation (maintainer) + +```sh +cargo install --locked cargo-anvil +``` + +Only the repo maintainer who runs updates needs the binary installed. Everyone else uses +`just` (or plain `cargo`). + +### 5.2 The single command + +```text +cargo anvil [--backend ]... [--no-backends] [--dry-run] +``` + +That is the entire CLI surface. There is intentionally no `init`, `migrate`, `check`, `run`, +`doctor`, `diff`, `explain`, `disable`, `enable`, or `versions` subcommand. + +The algorithm is uniform — there is no distinction between "first run" and "subsequent run." +The full per-item decision table lives in [updates.md](./updates.md). + +`--dry-run` performs the same analysis but writes nothing. Exit code 0 means "everything is in +sync with the binary's current templates and all managed content matched, ignoring disabled +items"; exit code 1 means "something is out of date or user-modified." + +`--backend ` is a repeatable flag controlling which cloud-workflow backend(s) get emitted. Valid +backend names today are `github` and `ado`; the flag is repeatable (`--backend github +--backend ado`) so that adding a third backend in the future doesn't require new CLI +syntax. If `--backend` is omitted, the tool autodetects from the `origin` git remote URL +(`github.com` → `github`; `dev.azure.com` / `*.visualstudio.com` → `ado`). `--no-backends` +is valid and useful for repos that want only the local `just` setup with no cloud workflows files. +`update` never deletes files; to stop using a backend the user removes its directory by +hand and reruns without that backend. + +### 5.3 Daily driver + +The local UX is plain `just`: + +```text +$ just anvil +[just] running anvil-validate-prereqs +[just] running anvil-pr-fast +[just] running anvil-pr-slow +anvil OK +``` + +`anvil` is an alias for `anvil-pr`. Both are plain `just` recipes (not wrappers around +`cargo anvil`). The PR tier is made up of a small set of *check groups* — each group is a +`just` recipe that runs the individual checks belonging to it. Groups are the level at which +cloud workflows parallelizes. See [checks.md](./checks.md) for the group → check mapping and +[local.md](./local.md) for the recipe tree. + +Other tier entry points: + +- `just anvil-pr` — fast checks suitable for every PR. +- `just anvil-scheduled` — slow checks: miri, full mutants, feature-powerset, bench, etc. +- `just anvil-full` — both tiers, run sequentially. + +A user with only `just` installed (no `cargo-anvil`) can run any check, any group, or any tier +without ever invoking the tool. `cargo-anvil` is only required by the maintainer who wants to +update the recipes or cloud-workflow building blocks. + +### 5.4 No-tooling fallback + +A user with only `cargo` (no `just`, no `cargo-anvil`) can still run the basics: + +```sh +cargo test --workspace --all-targets --all-features --locked +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo fmt --check +``` + +The same commands appear as the body of the corresponding `just` recipes in +`justfiles/anvil/checks.just`, so they are discoverable by reading that file. The fallback +covers core hygiene only — coverage, miri, mutants, etc. still require their respective tools. + +## 6. Repo Layout + +The tool produces a small set of files. They fall into three categories: + +- **owned** — the tool fully writes the file. There is no in-file checksum line; anvil + tracks ownership and last-rendered content in a sidecar manifest at the repo root + (`.anvil.lock`). An advisory one-line `# Managed by cargo-anvil` comment may appear + at the top of each owned file, but it carries no metadata. Updates apply + automatically when the user hasn't touched the file. If the user edits the file, the + next `update` writes a `.anvil-proposed` sibling **only if the template has changed + since the last render** — claiming a file with no upstream churn produces zero + noise. +- **managed-region** — a user-composed file with one or more tool-managed sections + bracketed by sentinel comments. The sentinel pair (`# >>> anvil-managed: ` … + `# <<< anvil-managed: `) delimits the region body and identifies it by stable ID; + the manifest tracks the last-rendered checksum per `(host, id)`. Outside the + sentinels, the user's content is preserved byte-for-byte. +- **user-authored** — files the user owns; the tool only reads them. + `rust-toolchain.toml` and `.cargo/config.toml` fall in this category. + +Opt-out is expressed inline by **emptiness**: an empty managed-region body (just the +sentinels, no content between them) disables a region; an empty owned file disables +that owned item. See [updates.md §6](./updates.md#6-opting-out-in-file-stubs). + +```text +repo/ +├── .anvil.lock sidecar manifest tracking last-rendered checksums (see updates.md) +├── Justfile managed-region: anvil-imports +├── justfiles/anvil/ owned (see local.md) +├── Cargo.toml managed-region: anvil-workspace-lints (or anvil-lints in single-crate) +├── crates//Cargo.toml managed-region: anvil-lints (one per workspace member) +├── deny.toml managed-region: anvil-deny +├── rustfmt.toml managed-region: anvil-rustfmt (opt out with empty stub) +├── .delta.toml managed-region: anvil-delta (opt out disables impact scoping) +├── rust-toolchain.toml user-authored (read only) +├── .cargo/config.toml user-authored (read only) +│ +├── .github/ only if --backend github (or autodetected) — see github.md +│ ├── actions/anvil-*/ owned (per-group composite actions) +│ ├── workflows/anvil-pr-impl.yml owned (reusable workflow doing the wiring) +│ ├── workflows/anvil-scheduled-impl.yml owned +│ ├── workflows/anvil-pr.yml owned (root workflow: triggers/permissions/runner) +│ └── workflows/anvil-scheduled.yml owned +│ +└── .pipelines/ only if --backend ado (or autodetected) — see ado.md + ├── anvil/pr.yml owned (stages template doing the wiring) + ├── anvil/scheduled.yml owned + ├── anvil/steps/*.yml owned (per-group step templates) + ├── anvil-pr.yml owned (root pipeline: triggers/pool/optional extends:) + └── anvil-scheduled.yml owned +``` + +Detail on each host: + +- **`Justfile` and `justfiles/anvil/*.just`** — see [local.md](./local.md). +- **`Cargo.toml` lints regions** — workspace `Cargo.toml` carries the + `anvil-workspace-lints` region containing a single `[workspace.lints]` table whose + rust/clippy/rustdoc entries are written in dotted-key form + (`rust.unsafe_op_in_unsafe_fn = "warn"`, `clippy.unwrap_used = "warn"`, etc.). This + form is chosen because TOML forbids re-declaring a table header — if anvil wrote + `[workspace.lints.clippy]` inside the region, users couldn't add another + `[workspace.lints.clippy]` block elsewhere in the file. With dotted keys, users + append new lints in the same scope right after the closing sentinel; see §7. Each + member `Cargo.toml` carries an `anvil-lints` region with exactly + `[lints]\nworkspace = true`. The emitter uses `toml-edit` for round-trip-safe + manipulation. In a single-crate repo (no `[workspace]` table), the workspace region + becomes `anvil-lints` and contains a single `[lints]` table with the same + dotted-key layout. +- **`deny.toml`** — managed region at the end of the file with the tool's baseline + license/advisory rules. Users add their own keys outside the region. Created if absent. + Content detailed in [checks.md](./checks.md). +- **`rustfmt.toml`** — created with the opinionated baseline if absent; managed region at the + end of the file. The most contested opinion in the catalog; users who want to keep their own + formatting opt the file out via the empty-stub mechanism in [updates.md](./updates.md). +- **`.delta.toml`** — cargo-delta configuration that drives impact-scoped cloud-workflow runs. Created if + absent. Region at the end of the file. Disabling the region opts the repo out of impact + scoping entirely. See [checks.md](./checks.md#impact-scoping) and the per-backend wiring in + [github.md](./github.md) / [ado.md](./ado.md). +- **`rust-toolchain.toml`** and **`.cargo/config.toml`** — never touched. Read-only inputs + used by `anvil-tool-rustc-validate-prereqs` to validate the user's `rustc` version + against the catalog minimum. the cloud workflow building blocks do not install Rust; that is the + user's pipeline's job + (msrustup in 1ESPT, rustup on GH runners). + +The tool's persistent state lives in `.anvil.lock` at the repo root — the sidecar +manifest tracking last-rendered checksums per owned file and per managed region. See +[updates.md §1](./updates.md#1-the-manifest). All other state — including opt-outs — +lives in the affected file itself; see [updates.md](./updates.md). + +## 7. Customization + +Four escape valves, in increasing severity: + +1. **Compose around the tool**: add your own `.just` files and import them from your `Justfile` + alongside the `anvil/*` imports. Add your own `.github/workflows/*.yml` files (anything not + prefixed `anvil-` is left alone). Add your own `.pipelines/` templates and root pipelines. + The path of least resistance and the recommended approach for project-specific checks. +2. **Edit a managed-region host file outside the sentinels**: extra recipes in your + `Justfile`, extra rules in `deny.toml` outside the managed region, extra clippy + lints written in dotted-key form after the closing sentinel (e.g. `clippy.pedantic = "warn"` + in the `[workspace.lints]` scope). The tool preserves everything outside the + sentinels verbatim. Note that TOML forbids redeclaring a table header (`[workspace.lints.clippy]` + etc.), so user extensions must use dotted-key form or sit in a different parent + table; overriding an individual key already set inside the region requires editing + inside it, which triggers the dirty-file flow (see [updates.md §5](./updates.md#5-the-decision-algorithm)). +3. **Opt out by emptying.** Empty a managed region (leave only the sentinels) or empty + an owned file. The tool will skip the item on every future `update` and only emit a + `.anvil-proposed` sibling when the template actually changes. See + [updates.md §6](./updates.md#6-opting-out-in-file-stubs). +4. **Take ownership of an owned file or managed region by editing it.** The next + `update` detects the dirt (via checksum comparison against the manifest), leaves your + file alone, and writes a `.anvil-proposed` sibling only if the template changed since + the last render. Re-bless by deleting your file (or region) and rerunning `update`. + Suitable for one-off divergence; for permanent divergence prefer the opt-out stub. + +What the tool deliberately does **not** do: + +- Modify `Cargo.toml` outside the `anvil-workspace-lints` / `anvil-lints` managed regions. +- Modify `.cargo/config.toml` or `rust-toolchain.toml`. +- Replace existing workflows, root pipelines, or any file it didn't create. + +The intentional consequence: there is exactly one place to look for "what does this repo do +differently from the default?" — the working tree itself, plus the `--dry-run` summary listing +outstanding proposed updates. + +## 8. Cross-Cutting Concerns + +### 8.1 Security + +- Generated GH composite actions and ADO step templates do nothing privileged on their own; + they just invoke `just` recipes. The user's workflow / pipeline file controls permissions + and secrets. +- All cargo-tool installs done by the setup building blocks use `--locked`. No + `cargo-binstall`. +- The tool never sources or executes content from any user-edited file at runtime; + everything executable in the repo is plain `just` recipes the user can read. +- Recommended user-workflow shape: `permissions: contents: read` on PR workflows; grant + `pull-requests: read` only on the pr-fast job (the static-analysis group that runs the + PR-title check; see [checks.md](./checks.md) for the full group definition). + Scheduled-tier secrets, if any, live on the scheduled workflow only — never on the + PR workflow. See the snippets in [github.md](./github.md) and [ado.md](./ado.md). + +### 8.2 Monorepo / multi-workspace + +Out of scope for v1. `anvil-*` recipes always operate on `--workspace` from the repo root. +Repos with multiple workspaces (uncommon in the surveyed set) compose by having a separate +anvil tree per workspace root, each with its own `cargo anvil`. Revisit after first +adopters report friction. + +### 8.3 Cross-OS test matrices + +cloud workflows fans out the catalog across operating systems and architectures. The default matrix +differs by backend: + +**GitHub backend (default: four legs).** GH ships Microsoft-hosted ARM runners +(`ubuntu-24.04-arm`, `windows-11-arm`), so the default matrix covers Linux/Windows × +x86_64/aarch64 for every group except groups with no cfg-sensitivity (none currently). +Matches `oxidizer-github`'s `extended-analysis` matrix. + +**ADO backend (default: two legs).** ADO has no Microsoft-hosted ARM agents. The default +matrix is x86_64 Linux + x86_64 Windows. Matches the platforms list in `oxidizer`'s root +pipelines. Adopters with self-hosted ARM pools extend the stages template in their root +pipeline. + +| Group | OS / arch scope (default) | Rationale | +|-------------------------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------| +| `pr-fast`, `scheduled-advisories` | All legs above | Contain compile-sensitive checks (clippy, doc-build, udeps, semver-check, external-types) that only see the host's compiled crate graph -- cfg-gated code is invisible to a single-leg run. Text/metadata checks running redundantly is cheaper than splitting jobs. | +| `pr-test`, `pr-runtime-analysis`, `scheduled-test` | All legs above | Where compile-time and runtime OS / arch bugs actually surface. The three `pr-slow*` groups run as parallel cloud-workflow jobs (split out from a former single `pr-slow`) for shorter wall-clock per leg. | +| `pr-mutants` | GH: Linux x86_64 + Windows x86_64 + Linux aarch64 (windows-arm self-skips). ADO: Linux x86_64 + Windows x86_64 | Diff-scoped mutation testing. cargo-mutants doesn't build on `aarch64-pc-windows-msvc`; the recipe self-skips so the windows-arm leg is a no-op. | +| `scheduled-exhaustive` | Linux x86_64 + Windows x86_64 | Full `cargo-mutants` / `cargo-hack` / `bench`. cargo-mutants doesn't build on `aarch64-pc-windows-msvc`; rather than splitting the matrix to add an ARM-Linux leg for cargo-hack and bench, the whole group is x86-only. Adopters with ARM-specific concerns extend the matrix in their root workflow. | + +macOS is not in the default matrix — adopters who need it fork the owned reusable +workflow (GH) or override `testPools` (ADO). The GH-side knob set is intentionally +limited to per-leg runner labels; the OS axis shape itself is part of the workflow's +identity. See [github.md §4](./github.md#4-owned-reusable-workflows) and +[ado.md §4](./ado.md#4-owned-stages-templates) for full details. + +Locally there is no matrix — `just anvil-pr-slow` runs against whatever OS the developer +is on. cloud workflows fan-out lives entirely in the owned wiring layer (the reusable workflow / stages +template), so users don't write per-OS jobs. + +cargo-delta impact runs **per OS family** (one stage on Linux, one on Windows) and +each downstream matrix leg consumes the impact set from its own OS. This way, an +OS-conditional dep change (under `[target.'cfg(target_os = ...)'.dependencies]`) is +correctly reflected in the per-OS depgraph cargo-delta walks. Arm legs reuse their OS +counterpart's impact set — cfg(target_arch) gates are rare enough that paying for four +impact jobs isn't justified; if a repo finds the gap matters, splitting further is a +local catalog override. Caching keys already include OS (see +[github.md §8](./github.md#8-caching) and [ado.md §7](./ado.md#7-caching)). + +**Helper scripts use PowerShell Core (`pwsh`) on every platform.** Almost every check +recipe is a single-line `cargo …` invocation that works unmodified on Windows — +including `license-headers` (which calls `cargo heather`), `ensure-no-cyclic-deps` +(`cargo ensure-no-cyclic-deps`), and `ensure-no-default-features` +(`cargo ensure-no-default-features`), all of which are plain cargo subcommands from the +ox-tools family. The one current exception is `pr-title`, which does a regex match +against `$PR_TITLE` (no equivalent cargo subcommand and `just` itself has no +boolean-regex primitive). That check is written as a `[script("pwsh")]` block. `pwsh` +is preinstalled on GH-hosted runners (`ubuntu-latest` included) and Microsoft-hosted +ADO Linux agents; Linux/macOS developers install it from + as a one-time prerequisite. The +`anvil-tool-pwsh-validate-prereqs` recipe enforces this with a clean failure message and a per-OS +install hint. The dependency is kept (rather than dropped to remove the one script) +so future additions that don't fit cleanly as cargo subcommands have an established +escape hatch. + +### 8.4 Internal vs OSS + +The crate ships from `github.com/microsoft/ox-tools` (alongside the existing tools published +from that repo) and from crates.io. The binary contains: + +- The full check catalog (see [checks.md](./checks.md)), including `cargo aprz`, which is + itself published to crates.io. +- All emitters (GH, ADO). + +There is no overlay system, no internal-only check, and no proprietary content. ADO +templates are plain ADO templates — they happen to be shaped to compose cleanly with +SubstratePT/1ESPT, but they are freely usable in any ADO environment. + diff --git a/crates/cargo-anvil/docs/design/github.md b/crates/cargo-anvil/docs/design/github.md new file mode 100644 index 00000000..4f5329e9 --- /dev/null +++ b/crates/cargo-anvil/docs/design/github.md @@ -0,0 +1,720 @@ +# GitHub Actions Integration + +This document describes what `cargo anvil --backend github` emits for GitHub +Actions, and how a repo wires those files into its own cloud workflows. + +anvil emits three layers, all owned by anvil with the standard owned-file flow (edit → +dirty → `.anvil-proposed` sibling on next update). The split is by what users actually +need to change: + +1. **Root workflows** (`anvil-pr.yml`, `anvil-scheduled.yml` at `.github/workflows/`). + Triggers, `permissions`, runner choice, any secret pass-through. anvil ships an + opinionated default; users who need to customize edit in place and accept the + proposal-on-update flow. +2. **Reusable workflows** (`anvil-pr-impl.yml`, `anvil-scheduled-impl.yml`), containing the + impact job and the per-group jobs with all the `needs.impact.outputs.*` plumbing. + These change when anvil's groups or impact wiring evolve; most users won't ever edit + them. +3. **Per-group composite actions** (`.github/actions/anvil-*/`). Each is a multi-step + composite that runs setup + the matching `just anvil--` recipe. + +See also: + +- [design.md §6](./design.md#6-repo-layout) for the file-category model. +- [checks.md](./checks.md) for what each group runs. +- [local.md](./local.md) for the `just` recipes the composite actions invoke. +- [ado.md](./ado.md) for the ADO counterpart. + +## 1. Why three layers + +- **Frequently-changing wiring** (group set, impact computation, fan-out, `needs:` graph) + lives in the reusable workflows. Updates apply automatically; users don't have to merge + changes. +- **Per-repo customization** (triggers, permissions, runner pool, secret scoping) lives + in the root workflows. Users who customize them accept the cost of merging the + `.anvil-proposed` sibling when the anvil defaults evolve — which is rare, since the + root workflow is intentionally minimal. +- The reusable-workflow seam ([`workflow_call`][1]) is GitHub's first-class mechanism for + exactly this: a workflow can call another workflow in the same repo, passing inputs and + secrets. We use it so the root workflow stays ~10 lines. + +[1]: https://docs.github.com/en/actions/sharing-automations/reusing-workflows + +The PR pipeline: + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 35, "padding": 3}, "themeVariables": {"fontSize": "16px"}}}%% +flowchart LR + pr_evt([pull_request
merge_group]):::trigger + pr_root[".github/workflows/
anvil-pr.yml
(root, ~10 lines)"]:::root + pr_impl[".github/workflows/
anvil-pr-impl.yml
(reusable workflow_call)"]:::impl + impact["impact-linux + impact-windows
(2 jobs;
outputs consumed by every group below)"]:::job + pr_fast_job["pr-fast
matrix: linux, windows,
linux-arm, windows-arm"]:::job + pr_test_job["pr-test
matrix: linux, windows,
linux-arm, windows-arm"]:::job + pr_runtime_analysis_job["pr-runtime-analysis
matrix: linux, windows,
linux-arm, windows-arm"]:::job + pr_mutants_job["pr-mutants
matrix: linux, windows,
linux-arm, windows-arm"]:::job + impact_act[".github/actions/
anvil-impact"]:::action + impact_setup[".github/actions/
anvil-setup"]:::action + fast_setup[".github/actions/
anvil-setup"]:::action + test_setup[".github/actions/
anvil-setup"]:::action + runtime_setup[".github/actions/
anvil-setup"]:::action + mutants_setup[".github/actions/
anvil-setup"]:::action + fast_act[".github/actions/
anvil-pr-fast"]:::action + test_act[".github/actions/
anvil-pr-test"]:::action + runtime_act[".github/actions/
anvil-pr-runtime-analysis"]:::action + mutants_act[".github/actions/
anvil-pr-mutants"]:::action + codecov_act["codecov/codecov-action@v5"]:::external + impact_just["cargo delta"]:::recipe + fast_just["just anvil-pr-fast"]:::recipe + fast_setup_just["just anvil-setup"]:::recipe + impact_setup_just["just anvil-setup"]:::recipe + test_just["just anvil-pr-test"]:::recipe + test_setup_just["just anvil-setup"]:::recipe + runtime_just["just anvil-pr-runtime-analysis"]:::recipe + runtime_setup_just["just anvil-setup"]:::recipe + mutants_just["just anvil-pr-mutants"]:::recipe + mutants_setup_just["just anvil-setup"]:::recipe + + pr_evt --> pr_root + pr_root -. uses .-> pr_impl + pr_impl --> impact + pr_impl --> pr_fast_job + pr_impl --> pr_test_job + pr_impl --> pr_runtime_analysis_job + pr_impl --> pr_mutants_job + + impact ==> impact_act + pr_fast_job ==> fast_act + pr_test_job ==> test_act + pr_test_job ==> codecov_act + pr_runtime_analysis_job ==> runtime_act + pr_mutants_job ==> mutants_act + + impact_act ==> impact_setup + impact_act ==> impact_just + fast_act ==> fast_setup + fast_act ==> fast_just + test_act ==> test_setup + test_act ==> test_just + runtime_act ==> runtime_setup + runtime_act ==> runtime_just + mutants_act ==> mutants_setup + mutants_act ==> mutants_just + + impact_setup ==> impact_setup_just + fast_setup ==> fast_setup_just + test_setup ==> test_setup_just + runtime_setup ==> runtime_setup_just + mutants_setup ==> mutants_setup_just + + classDef trigger fill:#fff4d6,stroke:#b08800,stroke-width:1px; + classDef root fill:#e6f0ff,stroke:#0366d6,stroke-width:2px; + classDef impl fill:#dff0d8,stroke:#28a745,stroke-width:1px; + classDef job fill:#f6f8fa,stroke:#586069,stroke-width:1px; + classDef action fill:#fce5e5,stroke:#cb2431,stroke-width:1px; + classDef external fill:#fff0db,stroke:#d97706,stroke-width:1px; + classDef recipe fill:#f3e8ff,stroke:#6f42c1,stroke-width:1px; +``` + +The scheduled pipeline (same colour key): + +```mermaid +%%{init: {"flowchart": {"nodeSpacing": 10, "rankSpacing": 35, "padding": 3}, "themeVariables": {"fontSize": "16px"}}}%% +flowchart LR + sched_evt([schedule
workflow_dispatch]):::trigger + sched_root[".github/workflows/
anvil-scheduled.yml
(root, ~10 lines)"]:::root + sched_impl[".github/workflows/
anvil-scheduled-impl.yml
(reusable workflow_call)"]:::impl + stest_job["scheduled-test
matrix: linux, windows,
linux-arm, windows-arm"]:::job + sadv_job["scheduled-advisories
matrix: linux, windows,
linux-arm, windows-arm"]:::job + sexh_job["scheduled-exhaustive
matrix: linux, windows"]:::job + stest_setup[".github/actions/
anvil-setup"]:::action + sadv_setup[".github/actions/
anvil-setup"]:::action + sexh_setup[".github/actions/
anvil-setup"]:::action + stest_act[".github/actions/
anvil-scheduled-test"]:::action + sadv_act[".github/actions/
anvil-scheduled-advisories"]:::action + sexh_act[".github/actions/
anvil-scheduled-exhaustive"]:::action + codecov_act["codecov/codecov-action@v5"]:::external + stest_just["just anvil-scheduled-test"]:::recipe + stest_setup_just["just anvil-setup"]:::recipe + sadv_just["just anvil-scheduled-advisories"]:::recipe + sadv_setup_just["just anvil-setup"]:::recipe + sexh_just["just anvil-scheduled-exhaustive"]:::recipe + sexh_setup_just["just anvil-setup"]:::recipe + + sched_evt --> sched_root + sched_root -. uses .-> sched_impl + sched_impl --> stest_job + sched_impl --> sadv_job + sched_impl --> sexh_job + + stest_job ==> stest_act + stest_job ==> codecov_act + sadv_job ==> sadv_act + sexh_job ==> sexh_act + + stest_act ==> stest_setup + stest_act ==> stest_just + sadv_act ==> sadv_setup + sadv_act ==> sadv_just + sexh_act ==> sexh_setup + sexh_act ==> sexh_just + + stest_setup ==> stest_setup_just + sadv_setup ==> sadv_setup_just + sexh_setup ==> sexh_setup_just + + classDef trigger fill:#fff4d6,stroke:#b08800,stroke-width:1px; + classDef root fill:#e6f0ff,stroke:#0366d6,stroke-width:2px; + classDef impl fill:#dff0d8,stroke:#28a745,stroke-width:1px; + classDef job fill:#f6f8fa,stroke:#586069,stroke-width:1px; + classDef action fill:#fce5e5,stroke:#cb2431,stroke-width:1px; + classDef external fill:#fff0db,stroke:#d97706,stroke-width:1px; + classDef recipe fill:#f3e8ff,stroke:#6f42c1,stroke-width:1px; +``` + +Every PR-tier group job declares `needs: [impact-linux, impact-windows]` so it can read the cargo-delta output variables. That fan-in is elided from the diagram to keep it readable; the scheduled tier has no such dependency because scheduled runs always operate on the full workspace. + +## 2. Emitted artifacts + +```text +.github/ +├── actions/ +│ ├── anvil-setup/action.yml owned (install just + group-scoped catalog tools) +│ ├── anvil-impact/action.yml owned (cargo-delta; omitted if .delta.toml disabled) +│ ├── anvil-pr-fast/action.yml owned (one composite action per group) +│ ├── anvil-pr-test/action.yml owned +│ ├── anvil-pr-runtime-analysis/action.yml owned +│ ├── anvil-pr-mutants/action.yml owned +│ ├── anvil-scheduled-test/action.yml owned +│ ├── anvil-scheduled-advisories/action.yml owned +│ └── anvil-scheduled-exhaustive/action.yml owned +└── workflows/ + ├── anvil-pr-impl.yml owned (reusable workflow doing the wiring) + ├── anvil-scheduled-impl.yml owned (reusable workflow for the scheduled tier) + ├── anvil-pr.yml owned (root workflow; triggers/permissions/runner) + └── anvil-scheduled.yml owned +``` + +All files are regular owned files tracked by the sidecar `.anvil.lock` manifest +(no in-file checksum line; see [updates.md §1](./updates.md#1-the-manifest)). Users +who customize the root workflow take ownership through the standard dirty-file +flow. + +## 3. Root workflows + +The default `anvil-pr.yml` anvil emits is the minimum needed to call the reusable +workflow: + +```yaml +# .github/workflows/anvil-pr.yml +name: anvil-pr +on: + pull_request: {} + merge_group: {} +permissions: + contents: read +jobs: + anvil: + uses: ./.github/workflows/anvil-pr-impl.yml +``` + +The scheduled root workflow adds a schedule and `workflow_dispatch`: + +```yaml +# .github/workflows/anvil-scheduled.yml +name: anvil-scheduled +on: + schedule: [{ cron: '0 6 * * *' }] + workflow_dispatch: {} +permissions: + contents: read +jobs: + anvil: + uses: ./.github/workflows/anvil-scheduled-impl.yml +``` + +Common edits users make to the root workflow (these flip the file to "dirty" and produce +a `.anvil-proposed` sibling on the next `update` — see +[updates.md §5](./updates.md#5-the-decision-algorithm)): + +- **Self-hosted runners**: pass `with: { linux_runner: 'self-hosted-rust', windows_runner: 'self-hosted-rust-win', linux_arm_runner: 'self-hosted-rust-arm', windows_arm_runner: 'self-hosted-rust-win-arm' }` +- **Different OS matrix scope**: not a workflow input. The matrices are part of the + workflow's identity — adopters who want to add macOS, drop ARM, or otherwise change + the OS axis fork the emitted `anvil-pr-impl.yml` / `anvil-scheduled-impl.yml` + in their own repo and dirty-file-flow takes over from there. Surveyed-repo precedent + (`oxidizer-github`, `oxidizer`) does the same. + to the reusable workflow. The runner inputs are CSV-keyed by OS (see §4 for the + exact contract). +- **Different OS matrix scope**: not a workflow input. The matrices are part of the + workflow's identity — adopters who want to add macOS, drop ARM, or otherwise change + the OS axis fork the emitted `anvil-pr-impl.yml` / `anvil-scheduled-impl.yml` + in their own repo and dirty-file-flow takes over from there. Surveyed-repo precedent + (`oxidizer-github`, `oxidizer`) does the same. + (`linux`/`windows`/`macos`), not runner labels — runner labels come from the separate + `*_runner` inputs. +- **Different schedule** for the scheduled tier. +- **Path filters** to skip the workflow on docs-only PRs (though anvil's + `cargo delta impact` step already produces a `--skip` sentinel for the include lists + when nothing relevant changed). + +anvil ships two defaults in the root workflow that adopters typically keep but can +remove if they have specific reasons: + +- `concurrency: { group: anvil-pr-${{ github.head_ref || github.ref }}, cancel-in-progress: true }` + on `anvil-pr.yml`. Prevents two anvil runs from racing on the same PR + branch — the newer push cancels the older. Removing it costs cloud workflows minutes but + is otherwise harmless. +- `secrets: inherit` on the `anvil:` job. Forwards the calling repo's + secrets (notably `CODECOV_TOKEN`) into the reusable workflow without each + adopter having to enumerate them. Removing it disables Codecov uploads + for private repos but doesn't affect anything else. + +## 4. Owned reusable workflows + +`anvil-pr-impl.yml` is where the wiring lives. Every per-group composite action takes +the same three impact-exclude inputs unconditionally; which ones a group's checks +actually consume is the catalog's concern, not the wiring layer's. Moving a check +between groups never changes the reusable workflow. + +Approximate shape (anvil writes this verbatim; users never edit it): + +```yaml +# .github/workflows/anvil-pr-impl.yml (owned by cargo-anvil) +on: + workflow_call: + inputs: + linux_runner: { type: string, default: ubuntu-latest } + windows_runner: { type: string, default: windows-latest } + linux_arm_runner: { type: string, default: ubuntu-24.04-arm } + windows_arm_runner: { type: string, default: windows-11-arm } + +jobs: + impact: + runs-on: ${{ inputs.linux_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - id: delta + uses: ./.github/actions/anvil-impact + + pr-fast: + needs: impact + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-fast + with: + include_modified: ${{ needs.impact.outputs.include_modified }} + include_affected: ${{ needs.impact.outputs.include_affected }} + include_required: ${{ needs.impact.outputs.include_required }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + + pr-test: + # Tests + coverage: llvm-cov, doc-test, examples. Coverage upload + # is gated to the canonical x86_64 Linux leg (omitted here for brevity). + needs: impact + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-test + with: + include_modified: ${{ needs.impact.outputs.include_modified }} + include_affected: ${{ needs.impact.outputs.include_affected }} + include_required: ${{ needs.impact.outputs.include_required }} + + # pr-runtime-analysis (miri + careful) and pr-mutants (mutants) follow the same + # shape; pr-mutants additionally sets `env: BASE_REF` for diff-scoped + # cargo-mutants, and the anvil-mutants-diff recipe self-skips on + # aarch64-pc-windows-msvc (where cargo-mutants doesn't build). +``` + +Every multi-OS job hardcodes its OS axis as an inline YAML array. Per-leg runner +*labels* are inputs (so adopters can swap in self-hosted runners), but the OS axis +itself is part of the workflow's identity. Adopters who need a different shape (add +macOS, drop ARM, mix in exotic targets) fork the reusable workflow and let +dirty-file-flow take over. The previously-considered `fromJSON(inputs.X)` pattern +was rejected because it added a silent failure mode (mis-formatted inputs produced +empty matrices that GitHub Actions silently treats as "no legs to run") without +meaningfully expanding what adopters could customize — anyone who wants to change +the OS axis is almost certainly making other changes too. + +The wiring never gates whole jobs on impact output. Each group always runs; recipes +inside the group decide whether a given check no-ops, by testing for the literal sentinel +`--skip` in the relevant include var. This matters because unscoped checks (`fmt`, `deny`, +`audit`, `aprz`, `pr-title`, `mutants-full`) must run on every PR, including docs-only +PRs where every tier comes back `--skip`. See +[local.md §4](./local.md#4-impact-scoping-pass-through-env-vars) for the recipe-side +contract. + +The scheduled reusable workflow is simpler — it omits the `impact` job and runs each group +full-workspace. The include inputs default to empty strings, so recipes fall through to +their local-default behavior (`--workspace`): + +```yaml +# .github/workflows/anvil-scheduled-impl.yml (owned) +on: + workflow_call: + inputs: + linux_runner: { type: string, default: ubuntu-latest } + windows_runner: { type: string, default: windows-latest } + linux_arm_runner: { type: string, default: ubuntu-24.04-arm } + windows_arm_runner: { type: string, default: windows-11-arm } +jobs: + scheduled-test: + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: [ { uses: actions/checkout@v4 }, { uses: ./.github/actions/anvil-scheduled-test } ] + scheduled-advisories: + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: [ { uses: actions/checkout@v4 }, { uses: ./.github/actions/anvil-scheduled-advisories } ] + scheduled-exhaustive: + # x86_64 only -- cargo-mutants constraint. + strategy: + fail-fast: false + matrix: + os: [linux, windows] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner || inputs.windows_runner }} + steps: [ { uses: actions/checkout@v4 }, { uses: ./.github/actions/anvil-scheduled-exhaustive } ] +``` + +Scheduled composite actions don't receive any `include_*` inputs at all — their inputs +default to empty strings (recipes default to `--workspace`) and the reusable workflow +omits the passthrough. Threading them through is purely a PR-tier optimization; +the scheduled tier never benefits. + +If `.delta.toml`'s managed region is emptied +([updates.md §opt-out](./updates.md#6-opting-out-in-file-stubs)), +`cargo delta impact` runs with its own defaults — the file is optional configuration, not +a feature gate — and the `impact` job still emits include lists that recipes interpret +normally. The user has opted out of *anvil's curated cargo-delta config*, not out of +impact scoping itself. + +The reusable workflow declares a small input set so the root workflow can pass overrides: + +| Input | Type | Default | Meaning | +|----------------------|--------|----------------------|--------------------------------------------------------| +| `linux_runner` | string | `ubuntu-latest` | Runner label for x86_64 Linux jobs and the single-leg `impact` job. | +| `windows_runner` | string | `windows-latest` | Runner label for x86_64 Windows jobs. | +| `linux_arm_runner` | string | `ubuntu-24.04-arm` | Runner label for aarch64 Linux jobs. | +| `windows_arm_runner` | string | `windows-11-arm` | Runner label for aarch64 Windows jobs. | + +The input surface is intentionally narrow: only per-leg *runner labels* are exposed, +because swapping in self-hosted runners is the one common need that doesn't require +otherwise touching the workflow. The OS matrix shape (which legs run) is fixed in the +workflow source — see the discussion under the PR snippet above. + +The reusable workflows also declare an optional `workflow_call` secret +`CODECOV_TOKEN`. See §10 (Coverage upload) for how it's used. + +We deliberately keep this input surface minimal. Anything more elaborate (e.g. +per-job runner overrides) lives in the user's own workflow, which can compose its own +`uses:`-of-reusable-workflow shape. + +## 5. Per-group composite actions + +Each per-group composite action has the **same** uniform input surface — the three +impact-include variables plus a per-action handful of PR-context strings. This means +the reusable workflow doesn't need to know which include vars a group's checks consume; +it threads all three to every action. Moving a check between groups (or between +buckets) is a pure catalog change. + +```yaml +# .github/actions/anvil-pr-fast/action.yml (owned) +name: anvil-pr-fast +description: anvil PR fast group +inputs: + pr_title: + description: PR title for the pr-title check. + required: false + default: "" + include_modified: + description: | + Pre-formatted --package args from anvil-impact for the modified + tier, or "--skip" when the modified set is empty. Empty string = + local invocation; recipes default to --workspace. + required: false + default: "" + include_affected: + description: Same shape as include_modified, for the affected tier. + required: false + default: "" + include_required: + description: Same shape as include_modified, for the required tier. + required: false + default: "" +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + - shell: bash + env: + PR_TITLE: ${{ inputs.pr_title }} + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + run: just anvil-pr-fast +``` + +Uniform input set on every per-group composite action: + +| Input | Default | Notes | +|--------------------|---------|----------------------------------------------------------------------------------------------------------------------------------------| +| `include_modified` | `""` | Forwarded as `ANVIL_INCLUDE_MODIFIED`. `--skip` → recipe exits 0. Empty → recipe defaults to `--workspace`. | +| `include_affected` | `""` | Forwarded as `ANVIL_INCLUDE_AFFECTED`. Same semantics. | +| `include_required` | `""` | Forwarded as `ANVIL_INCLUDE_REQUIRED`. Same semantics. | + +Per-action additions (only where the action consumes PR-context strings the recipe needs): + +| Action | Extra inputs | +|------------------------------|-------------------------------------------------------------------------| +| `anvil-pr-fast` | `pr_title` | +| `anvil-pr-mutants` | `base_ref` | +| `anvil-pr-test`, `anvil-pr-runtime-analysis`, `anvil-scheduled-*` | — | + +The recipes themselves consume only the env vars they need; the catalog records the +mapping (see [checks.md §5](./checks.md#5-impact-scoping-check--env-var-mapping)). +Threading all three to every action costs a few lines per composite but is the right +separation: wiring is about "which jobs depend on impact and feed it forward", not about +"which check needs which env var." + +These actions are consumed primarily by anvil's own reusable workflow. Users who want to +plug individual groups into an unrelated workflow can `uses:` them directly. + +### `anvil-setup` + +`anvil-setup` is a composite action that installs `just` +(`cargo install just --locked`) and then invokes the catalog setup recipes. It +takes a single `group` input that controls which recipes run: + +- empty (default): runs `just anvil-setup binstall` -- the full catalog. Use + for local "give me everything" flows. +- `none`: skips the catalog setup entirely. Used by `anvil-impact`, which only + needs `cargo-delta` and installs it itself afterwards. +- any other value (e.g. `pr-fast`, `scheduled-advisories`): runs + `just anvil--setup binstall` -- only the tools, components, and + toolchains that group actually needs. Every per-group composite action + (`.github/actions/anvil-`) passes its own group name here, so a + `pr-fast` matrix leg never installs cargo-mutants. + +The action does not install Rust; it expects `cargo` on PATH (see §7). +`anvil-impact` is described in §6 below. + +## 6. Impact scoping + +`.github/actions/anvil-impact/action.yml` is a composite action with input `base_ref`. It +runs: + +1. `./.github/actions/anvil-setup` with `group: none` (bootstrap rust + just + + cache; no catalog tools). +2. `just anvil-tool-cargo-delta-install binstall` -- only tool this composite + needs. +3. `cargo delta impact --base $base_ref --format json` once, capturing the JSON tier + sets in a single invocation. +4. For each of the three tiers (`modified`, `affected`, `required`), format the crate + list into a pre-built `--package X --package Y …` string, or emit the sentinel + `--skip` when the tier is empty. + +Outputs: + +| Output | Meaning | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `include_modified` | `--package X --package Y …` for cargo-delta's `modified` tier, or `--skip` when empty. | +| `include_affected` | Same shape, for the `affected` tier (modified ∪ workspace rev-deps). | +| `include_required` | Same shape, for the `required` tier (affected ∪ workspace-internal transitive deps). | + +The wiring never gates jobs on these outputs — every job runs regardless of `--skip` +status. Per-recipe interpretation lives in the recipes themselves (see [local.md §4](./local.md#4-impact-scoping-pass-through-env-vars)). +This is intentional: unscoped checks (`deny`, `audit`, `aprz`, `pr-title`, +`mutants-full`) must run on every PR even when every tier reports `--skip`. + +The check → bucket mapping is in +[checks.md §5](./checks.md#5-impact-scoping-check--env-var-mapping). + +## 7. Rust toolchain + +anvil does not install Rust on GitHub. The composite actions assume `cargo` is on PATH. +GH-hosted runners ship with a recent stable Rust and `rustup` pre-installed; if your +`rust-toolchain.toml` pins a different channel, the first `cargo` invocation in a job +triggers `rustup` to download the pinned toolchain. For a published stable channel this +typically takes 10–30 seconds on Linux (somewhat longer on Windows and longer still for +nightly with components). The auto-install runs once per job and is not cached across +jobs by anvil — `~/.rustup` has high invalidation churn and the install cost is small +relative to the cached cargo registry / `target/` paths (§8). Repos that want to skip +even this per-job overhead can add their own toolchain-install step (e.g. +`dtolnay/rust-toolchain@stable`) before the anvil composite action runs. + +On self-hosted runners or pre-baked images without rustup, the user adds a Rust install +step to their root workflow before the `uses:` of the reusable workflow: + +```yaml +jobs: + anvil: + uses: ./.github/workflows/anvil-pr-impl.yml + # Self-hosted? Add a setup workflow that runs first and uploads + # toolchain to a shared cache, then reference it here. +``` + +Since reusable workflows can't accept "previous step" handoff, self-hosted users usually +forgo the reusable-workflow shape and write a single workflow that calls the composite +actions directly. anvil's composite actions are exposed for that use case. + +`anvil-tool-rustc-validate-prereqs` (depended on by every check that needs rustc) +validates the installed `rustc` against the catalog minimum at recipe time; a +below-minimum `rustc` produces a clean failure message. + +## 8. Caching + +The `anvil-setup` composite action computes a cache key from: OS, rustc version (read +from `rust-toolchain.toml`), `Cargo.lock`, `.cargo/config.toml`, and `versions.just` +(the single source of truth for catalog tool/toolchain pins). Uses `actions/cache` +natively. `CARGO_HOME` is pinned to a workspace-scratch location to keep cache +scoping predictable. + +The cache covers: + +- The `cargo install`-ed tools installed by the catalog setup recipes. +- The `target/` directory (per anvil recipe; a per-recipe cache scope means a `pr-test` + cache hit doesn't have to wait on a `pr-fast` cache miss). + +## 9. Security + +The composite actions do nothing privileged on their own — they just install tools and +invoke `just`. The reusable workflow propagates only what the root workflow passes (and +only the inputs explicitly declared). + +Recommended root workflow shape: + +- `permissions: contents: read` at the workflow level. anvil's default ships with + this. +- No `pull-requests: write` (the PR-title check only needs the title from the event + payload, which is already in `${{ github.event.pull_request.title }}`). +- Scheduled-tier secrets, if any, live on `anvil-scheduled.yml` only — never on `anvil-pr.yml`. +- All cargo-tool installs done by the catalog setup recipes use `--locked` (with + `cargo install` or `cargo binstall` depending on `installer`). + +## 10. Coverage upload + +After `pr-test` (and `scheduled-test`) runs the `anvil-llvm-cov` recipe, the reusable +workflow uploads the resulting `target/coverage/lcov.info` to Codecov from every leg of +the matrix except `windows-11-arm`. The windows-arm leg is excluded because its +LLVM-coverage instrumentation produces `malformed instrumentation profile data: symbol +name is empty` errors that make the profile unusable. Coverage from every other leg is +necessary because OS/arch-gated code (`cfg(target_os = ...)`, `cfg(target_arch = ...)`) +is only exercised on its native target, so a single-leg upload would systematically +under-report the coverage of those branches. Codecov coalesces multiple uploads against +the same commit; we pass `flags: ${{ matrix.os }}` so each per-leg slice is also +queryable individually in the Codecov UI. + +The upload step: + +```yaml +- name: Upload coverage to Codecov + if: matrix.os != 'windows-arm' && needs.impact.outputs.skip != 'true' + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: ${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false +``` + +The reusable workflow declares `CODECOV_TOKEN` as an optional `workflow_call` secret; +the root workflow's default `secrets: inherit` (see §3) forwards it without each adopter +having to enumerate. Public repos with Codecov OIDC trust configured need no token at +all; private repos set `CODECOV_TOKEN` at the repo level. `fail_ci_if_error: false` +keeps the build green when Codecov is unreachable (typical for internal repos that +can't reach `codecov.io`). + +On the scheduled upload the step additionally combines the OS flag with a `scheduled` +marker (`flags: scheduled,${{ matrix.os }}`) so PR vs scheduled streams stay +distinguishable in the Codecov UI while still being queryable per-OS. + +anvil does not gate the PR on coverage. The lcov upload is informational; Codecov's +own status check is the gating layer when the adopter wants one (configured in Codecov, +visible as a separate required check in branch protection). + +## 11. Advisory PR comments + +Recipes that surface non-blocking findings exit 0 and write a markdown body to +`target/anvil/comments/.md` (see [checks.md §6](./checks.md#6-advisory-pr-comments) +for the cross-backend convention). The GitHub backend turns presence/absence of those +files into upserts/deletions of a sticky PR comment via +[`marocchino/sticky-pull-request-comment@v3`](https://github.com/marocchino/sticky-pull-request-comment). + +The wiring lives in the `pr-fast` job of `anvil-pr-impl.yml` (the only group whose +recipes emit comments today). Two steps run after the composite that executes the +`pr-fast` group: + +```yaml +- name: Upsert anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' + && github.event.pull_request.head.repo.full_name == github.repository + && hashFiles('target/anvil/comments/semver.md') != '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + path: target/anvil/comments/semver.md +- name: Clear anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' + && github.event.pull_request.head.repo.full_name == github.repository + && hashFiles('target/anvil/comments/semver.md') == '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + delete: true +``` + +Conditions explained: + +- `always()` keeps the comment in sync even if an unrelated `pr-fast` check failed; the + advisory state is independent of the rest of the job's pass/fail. +- `github.event_name == 'pull_request'` skips the steps on `merge_group` and other + triggers where there's no PR thread to post to. +- `matrix.os == 'linux'` picks the canonical x86_64 Linux leg so the four-OS matrix + doesn't race on the same comment. +- `head.repo.full_name == github.repository` skips fork PRs. GitHub doesn't grant + `pull-requests: write` to fork-PR workflow runs by default, so the action would 403. + +Permissions: the reusable workflow's caller (`anvil-pr.yml`) declares +`pull-requests: write` on the `anvil-pr` job that calls `anvil-pr-impl.yml`. The +top-level `permissions:` block stays at `contents: read` so unrelated reads in the same +workflow are still least-privilege. + +Adding a new advisory check is a two-step change: the recipe writes +`target/anvil/comments/.md` (and removes it on a clean run); the workflow gains +a matching `Upsert anvil-` / `Clear anvil-` pair with +`header: anvil-`. There's deliberately no auto-discovery loop over the +convention dir — explicit per-check steps keep stale comments deterministically +clearable when a check is removed from the catalog. diff --git a/crates/cargo-anvil/docs/design/local.md b/crates/cargo-anvil/docs/design/local.md new file mode 100644 index 00000000..ba91f5db --- /dev/null +++ b/crates/cargo-anvil/docs/design/local.md @@ -0,0 +1,508 @@ +# Local Recipe Surface + +This document describes the `justfiles/anvil/` tree that anvil writes into a repo, how the +recipes are organized, and how local invocations differ from cloud workflows invocations (spoiler: they +don't — that's the design). + +See also: + +- [design.md](./design.md) for the overall principles. +- [checks.md](./checks.md) for the catalog the recipes implement. +- [updates.md](./updates.md) for how these files are tracked / regenerated. + +## 1. File layout + +```text +repo/ +├── Justfile managed-region: anvil-imports +│ # >>> anvil-managed: anvil-imports +│ import 'justfiles/anvil/mod.just' +│ # <<< anvil-managed: anvil-imports +│ …user content… +│ +└── justfiles/anvil/ owned (one checksum per file) + ├── mod.just entry point: imports the sibling files and defines + │ `alias anvil := anvil-pr`. The user's Justfile + │ region pulls in this single file; everything else is + │ reached transitively. + ├── checks.just per-check recipes (anvil-fmt, anvil-clippy, anvil-llvm-cov, …). + │ Starts with `set unstable` (needed for the `[script("pwsh")]` + │ attribute on `anvil-pr-title`). + ├── groups.just group recipes (anvil-pr-fast, anvil-pr-test, + │ anvil-pr-runtime-analysis, anvil-pr-mutants, anvil-scheduled-test, …) + │ plus a convenience `anvil-pr-slow` umbrella that + │ invokes the three pr-slow* sub-recipes sequentially. + ├── tiers.just tier aggregators (anvil-pr, anvil-scheduled, anvil-full). + ├── tools.just tool/component/toolchain install + validate-prereqs recipes, + │ plus anvil-system-deps-check and anvil-validate-prereqs. + └── versions.just pinned nightly toolchains and pinned cargo-subcommand versions + as plain just variables (rust_nightly, cargo_nextest_version, …). + Read by recipes via `{{ var }}` interpolation. + Single source of truth for all version pins. See §3. +``` + +The Justfile region is the only file anvil adds to that the user co-owns, and it's +a single `import` line — everything anvil-specific lives inside `justfiles/anvil/`. +All files under that directory are tool-owned (tracked by full-file checksum in +the sidecar manifest). If the user wants to add project-specific recipes, they add them +to the top-level `Justfile` outside the managed region, or to their own additional +imported `.just` files. The alias `anvil := anvil-pr` lives in `mod.just`, not in +the user's `Justfile`, so renaming or retargeting the alias is a template update with +no managed-region churn. + +Recipes in `groups.just`, `tiers.just`, and `checks.just` that actually *run* checks +are annotated with `[group("anvil")]`. The install/validate-prereqs/setup recipes +in `tools.just` (and the per-check/group/tier setup recipes appended to the other +files) are annotated with `[group("anvil-setup")]`. `just --groups` therefore shows +two clean clusters: one for "run checks", one for "install prereqs". + +## 2. Recipe layers + +`justfiles/anvil/` is structured to make all three levels (check, group, tier) addressable +from the command line. + +### checks.just + +One recipe per individual check, each named `anvil-`. Recipes are usually a single +`cargo …` line; a handful (license-headers, ensure-no-cyclic-deps, +ensure-no-default-features, pr-title, the bench smoke loop) are short `[script]` blocks. +Every check recipe depends on its `*-validate-prereqs` recipe: + +```just +anvil-clippy: anvil-clippy-validate-prereqs + cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +The per-check `*-validate-prereqs` recipe (in the `anvil-setup` group) chains the +relevant atomic validators -- e.g. `anvil-component-default-clippy-validate-prereqs` +for clippy, plus `anvil-tool-rustc-validate-prereqs` for the toolchain pin -- each of +which calls `cargo install --list` / `rustup component list` / `rustc --version` to +confirm the tool meets the catalog's pin. Missing or below-pin tools fail with a +one-line install hint pointing at the matching `anvil-tool--install` recipe. +The cost is a handful of cheap lookups per check, well under a second on a warm cache. + +### groups.just + +One recipe per cloud-workflow-visible group, named `anvil--`. The check-recipe and group-recipe +namespaces are kept disjoint by naming choice: no check is named `-` for +any tier × group combination (e.g. the coverage-instrumented test check is named +`llvm-cov`, not `test`, so that group names like `anvil-pr-test` unambiguously refer to a group recipe). + +The `pr-slow` work is split into three independent cloud-workflow-visible sub-groups +(`pr-test`, `pr-runtime-analysis`, `pr-mutants`) so they run as parallel cloud-workflow jobs/stages. +A convenience umbrella `anvil-pr-slow` recipe is also provided for local +use; it invokes the three sub-recipes sequentially. `pr-mutants` (mutants) is +diff-scoped against the PR base; `scheduled-exhaustive` runs the +full-workspace mutants recipe: + +```just +anvil-pr-fast: anvil-fmt anvil-clippy anvil-cargo-sort anvil-license-headers \ + anvil-ensure-no-cyclic-deps anvil-ensure-no-default-features \ + anvil-doc-build anvil-readme-check anvil-spellcheck anvil-pr-title \ + anvil-deny anvil-audit anvil-udeps anvil-semver-check \ + anvil-external-types anvil-aprz + +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants +anvil-pr-test: anvil-llvm-cov anvil-doc-test anvil-examples +anvil-pr-runtime-analysis: anvil-miri anvil-careful +anvil-pr-mutants: anvil-mutants-diff + +anvil-scheduled-test: anvil-llvm-cov anvil-doc-test anvil-examples +anvil-scheduled-advisories: anvil-deny anvil-audit anvil-aprz anvil-clippy +anvil-scheduled-exhaustive: anvil-mutants-full anvil-cargo-hack anvil-bench +``` + +### tiers.just + +Three tier aggregators. Each tier is a recipe that depends on the appropriate set of groups +in a deterministic order: + +```just +anvil-pr: anvil-pr-validate-prereqs anvil-pr-fast anvil-pr-slow +anvil-scheduled: anvil-scheduled-validate-prereqs anvil-scheduled-test anvil-scheduled-advisories \ + anvil-scheduled-exhaustive +anvil-full: anvil-pr anvil-scheduled +``` + +### tools.just + +`tools.just` houses six layers of recipes: + +1. **`anvil-system-deps-check`** — probe for system-level libs that catalog tools need to + build from source (currently: `libclang` for `cargo-spellcheck`). Best-effort presence + check; on missing deps emits per-OS install hints and exits non-zero. No auto-install. + See §3.3.1. +2. **Private helpers** (`_install-tool`, `_check-tool`, `_install-toolchain`, + `_check-toolchain`, `_install-component`, `_check-component`) — the single + implementation point for "install this thing at the pinned version" and + "verify this thing is installed at >= the pinned version". +3. **Per-toolchain recipes** — `anvil-toolchain--install` and + `anvil-toolchain--validate-prereqs`. Symbolic names are `nightly` and + `nightly-external-types`, mapped to the pinned version strings in `versions.just`. +4. **Per-component recipes** — `anvil-component---install` + and `-validate-prereqs` (e.g. `anvil-component-nightly-miri-install`). + Component installs depend on the matching toolchain install. +5. **Per-tool recipes** — `anvil-tool--install installer="install"` and + `anvil-tool--validate-prereqs` for every cargo subcommand the catalog needs + (`cargo-nextest`, `cargo-llvm-cov`, `cargo-mutants`, …) plus `rustc` and `pwsh`. + `installer` selects `cargo install` vs `cargo binstall`. +6. **Per-check / per-group / per-tier / global setup** — composition layer; see §3.3. + +All atomic install recipes are idempotent: they early-skip when the tool is already +present at or above the pinned version (`_install-tool` uses `cargo install --list` +plus a `[version]` comparison in pwsh). So calling any composition layer on every cloud workflows +run costs nothing on a cache hit. + +The full tool-version policy these recipes implement is detailed in §3 below. + +## 3. Tool versions, toolchains, and installation + +### 3.1 Policy + +The catalog records, for each cargo subcommand, a **pinned version** (e.g. +`cargo_nextest_version := "0.9.122"`). The pin is used two different ways: + +- **On install** (`anvil-tool--install` writing into `~/.cargo/bin`): the recipe + installs *exactly* that version (`--version '={{ pin }}'`), never `>=`. Pulling + latest-matching at install time is a cloud-workflow reproducibility risk -- an upstream release + between yesterday's green build and today's PR can break things, even though the + catalog hasn't moved. `cargo-spellcheck 0.15.7`'s em-dash word-boundary regression is + the canonical example: with `>=0.15.1` the catalog would have silently picked it up, + breaking every PR until the catalog was edited. With `=0.15.1` the catalog locks in + the version it was validated against. +- **On runtime check** (`anvil-tool--validate-prereqs`): the recipe enforces + `installed >= pin`. A local developer who has manually upgraded a tool for their own + reasons (e.g. needing a bugfix the catalog hasn't pinned yet) is not downgraded by + setup. Their newer version still satisfies the gate; recipes run against it. + +This asymmetry -- "install exact, accept newer if already present" -- gives cloud workflows +reproducibility *and* leaves the user in control. Bumping a pin is a deliberate +catalog edit (changing a variable in `versions.just`), not an upstream-release-triggered +surprise. + +### 3.2 Detecting installed versions + +The atomic `_check-tool` helper (a private recipe in `tools.just`) uses +`cargo install --list` to enumerate currently-installed cargo subcommands and their +versions, then checks `installed >= pin` via pwsh's `[version]` cast. This avoids the +problem of tools without a stable `--version` flag, is fast, and works uniformly for +everything the catalog cares about. For non-cargo dependencies (`just` itself, `rustc`, +`pwsh`), there are dedicated `anvil-tool--validate-prereqs` recipes that fall +back to `tool --version` and a known parser. + +### 3.3 Installing tools (and toolchains, and components) + +Installation is layered. The bottom layer is a per-tool / per-component / per-toolchain +install recipe (one per atomic resource); composition layers chain those. + +**Atomic layer** (in `tools.just`): + +- `anvil-tool--install installer="install"` — install one cargo subcommand + (e.g. `cargo-nextest`) at its pinned version using either `cargo install --locked` + (the default, `installer="install"`) or `cargo binstall --locked` + (`installer="binstall"`). +- `anvil-toolchain--install` — `rustup toolchain install` for a pinned + nightly (e.g. `nightly-2026-02-10`). +- `anvil-component---install` — `rustup component add` + on a specific toolchain. Depends on the matching toolchain-install recipe. + +Each has a matching `*-validate-prereqs` recipe that exits 0 when the resource is +already present at or above its pin and fails with a one-line install hint otherwise. + +**Composition layer** (per check, per group, per tier, global): + +- `anvil--setup installer="install"` — depends on every atomic-layer + install recipe that this check needs. So `anvil-clippy-setup` brings up + `cargo-clippy` (a default-toolchain component) and `rustc`, and nothing else. +- `anvil--setup installer="install"` — depends on every per-check setup + in the group. cloud workflows matrix jobs call this so a `pr-fast` leg never installs + cargo-mutants. +- `anvil--setup installer="install"` — depends on every per-group setup + in the tier. Local "I want to run the whole PR tier" convenience. +- `anvil-setup installer="install"` — depends on every per-tier setup. The + catch-all that brings an empty environment up to where any catalog recipe runs. + This is what `cargo anvil` adopters get when they run "the global one". + +Every composition recipe takes the same `installer` parameter and threads it +through to the atomic-layer installs. + +Mirror `*-validate-prereqs` recipes exist at every composition layer +(`anvil--validate-prereqs`), so it's possible to verify a group's +prerequisites without installing them. + +The atomic installs are fully idempotent (early-skip on installed >= pin), so calling +any composition layer on every cloud-workflow run is cheap on a cache hit. There is intentionally +no separate "install-missing" variant: every install recipe IS the install-missing +recipe. + +The `installer` argument: + +- `install` (default) -- `cargo install --locked --version '='`. Pure + source builds; works in any cargo environment with no extra runtime dependency. + Slow on a cold runner (~30 min for the full catalog) because every tool + re-compiles common deps (`clap`, `syn`, `quote`, ...) from scratch independently. +- `binstall` -- `cargo binstall --no-confirm --locked --version '='`. + Downloads a prebuilt binary from each tool's GitHub Releases when available. + Cuts the cold-runner install phase from ~30 min to ~1 min. `cargo-binstall` + itself needs to be on PATH; the GH setup composite arranges this. + +The GitHub composite setup action calls `just anvil--setup binstall` +(or just `anvil-setup binstall` when no group is scoped). The ADO setup step +template uses the default `install` backend because cargo-binstall has unresolved +compliance issues for internal ADO pipelines (the binary registry it pulls from +isn't on the standard allow-list), so the slower pure-cargo path is the +conservative choice there. Locally, users pick whichever matches their environment. + +#### Version source of truth + +All pins live in `justfiles/anvil/versions.just` as plain just variables: +`rust_nightly`, `rust_nightly_external_types`, `cargo_nextest_version`, +`cargo_spellcheck_version`, … There is intentionally **no** sidecar data file -- +edits to versions are normal catalog edits, picked up by `cargo anvil` +like any other tool-owned change. + +Two prerequisites are not cargo-installable and must be present before any +install recipe can run: + +- **`just`** itself -- bootstrap with `cargo install just --locked` once, or use a + system package. Every backend's setup composite/template installs it via cargo as + a one-shot before calling any catalog recipe. +- **`pwsh`** (PowerShell Core) -- used by every `[script("pwsh")]` recipe in the + catalog. Preinstalled on every relevant cloud-workflow runner (GH-hosted + Linux/Windows/macOS, Microsoft-hosted ADO agents). On a developer machine + without pwsh, `anvil-tool-pwsh-validate-prereqs` fails with a per-OS install + hint pointing at . + +Trade-off acknowledged: `cargo install --locked` is slow on a cold cache (several +minutes for the full catalog). It is also the most reliable mechanism in restricted +networks. Caching (via the GH cache action and the ADO pipeline workspace cache) is +configured by the setup action/template to key on `Cargo.lock`, the toolchain +channel, and `versions.just`. See +[github.md](./github.md#caching) and [ado.md](./ado.md#caching). + +#### 3.3.1 System-level prerequisites + +A small set of catalog tools have non-Rust build dependencies that `cargo install` +can't satisfy on its own. Today the only entry is `libclang`, needed by +`cargo-spellcheck` (via `clang-sys` / `hunspell-rs`) at build time. The `binstall` +install path sidesteps these entirely by downloading prebuilt binaries. + +Scope policy: only check for system libs that an anvil catalog tool **directly** +requires. anvil is not a general-purpose dev-env doctor. Repository-specific +system deps (e.g. `openssl-devel`, `symcrypt` for the adopter's own crates) belong +in the adopter's `setup.yml` customization, not in the anvil catalog. + +Detection (`anvil-system-deps-check`) uses presence-only probes -- file existence +in standard install dirs plus the `LIBCLANG_PATH` env var override. No version +checks: system libs upgrade independently of the catalog and any reasonably modern +libclang satisfies clang-sys. + +On a missing dep the recipe prints per-OS install hints (apt-get / tdnf / brew / +scoop / winget) and exits non-zero. **No auto-install** -- admin/sudo decisions and +package-manager choice stay with the user. Tool-install recipes that need a system +lib depend on `anvil-system-deps-check` (only on the source-build `install` +backend), so missing system libs surface as a clear hint instead of a cryptic +clang-sys build error 10 minutes into the install. + +Adding a new system dep is a one-block catalog change in `tools.just`; it +propagates to adopters via `cargo anvil` like any other catalog edit. + +### 3.4 Per-check warnings + +Every check recipe depends on `anvil--validate-prereqs` so even ad-hoc +invocations like `just anvil-miri` fail loudly if a required tool is missing or +predates the catalog minimum, with a one-line hint pointing at the matching +`anvil-tool--install` recipe. + +### 3.5 The Rust toolchain + +`rust-toolchain.toml` is read but never written, and anvil never installs the *project's* +Rust toolchain itself. Per-backend rationale lives in [github.md](./github.md#rust-toolchain) +and [ado.md](./ado.md#rust-toolchain); short version: msrustup owns it on ADO/1ESPT, the +runner image owns it on GH, the user owns it locally. + +`anvil-tool-rustc-validate-prereqs` validates the installed `rustc` against the +catalog's minimum at recipe time; a below-minimum `rustc` produces a clean failure +message naming the version mismatch. Per-check toolchain requirements (e.g. miri, +careful, udeps need nightly) are enforced by the matching +`anvil-toolchain--validate-prereqs` recipe, which suggests the +user-environment-appropriate install command in the failure message +(`rustup install nightly-YYYY-MM-DD` or "ask your team's pipeline owner to add +nightly to msrustup"). + +### 3.6 Nightly pinning + +A handful of catalog checks need nightly Rust: `fmt`, `udeps`, `miri`, `careful`, and +`check-external-types`. We **pin** the nightly snapshots used by these checks rather than +floating bare `+nightly`. Pinning eliminates "rustup update on Tuesday broke main on +Wednesday" — every cloud-workflow run uses the same nightly until we deliberately bump the pin. + +`fmt` is on nightly because the catalog's `rustfmt.toml` opts into `unstable_features` +to get import grouping (`imports_granularity = "Module"`, `group_imports = +"StdExternalCrate"`) and `format_code_in_doc_comments`. Those are the high-value +opinions every surveyed Microsoft Rust repo reaches for; the stable rustfmt option set +doesn't include them. Pinning is what makes nightly fmt sustainable — formatting +churn happens on a pin bump, not on every `rustup update`. + +The pins live in `justfiles/anvil/versions.just` as plain just variables: + +```just +rust_nightly := "nightly-YYYY-MM-DD" +rust_nightly_external_types := "nightly-YYYY-MM-DD" +``` + +**One source of truth, two consumers.** Recipes read the pins by `{{ }}` interpolation +(`cargo +{{ rust_nightly }} udeps ...`). The `anvil-toolchain--install` +recipes read the same variables and pass them to `rustup toolchain install`. The +setup composites/templates call those install recipes (directly or transitively via +a group's `*-setup` recipe). There is no env-file duplicate. + +**Two pins, not one.** `rust_nightly` is the general-purpose nightly used by udeps, miri, +careful. `rust_nightly_external_types` is intentionally narrower: it's tied to the rustdoc +JSON schema version that the currently-selected `cargo-check-external-types` release +accepts. Bump it alongside `cargo-check-external-types` upgrades, not on the general +cadence. When the two pins resolve to the same date the setup composite installs only one +toolchain. + +**Bump policy.** The general `rust_nightly` is intended to move on a regular cadence +(monthly is a reasonable default) so adopters absorb nightly drift in predictable chunks. +`rust_nightly_external_types` moves only when `cargo-check-external-types` releases a new +version that targets a newer rustdoc JSON schema. Both bumps are normal `cargo anvil +update` operations: edit `versions.just`, regenerate, validate, commit. Adopters are free +to override either pin in their `versions.just` (it's an owned file) — the next run sees +the dirt and emits a `.anvil-proposed` sibling instead of overwriting. + +**Why pin, not float?** We tried floating nightly once and immediately needed +regex-based tolerance code in the `check-external-types` recipe to absorb rustdoc JSON +schema bumps. That was a tell: any tool that depends on nightly internals will routinely +break on schema/lint/intrinsic drift, and the alternative to pinning is per-tool +tolerance shims accumulating in the recipes. Pinning is one mechanism that handles all +present and future cases; tolerance shims are bespoke and silently degrade what the +check actually validates. + +## 4. Impact-scoping pass-through env vars + +Every check recipe whose work is per-crate accepts an optional pass-through env var +that the cloud-workflow wiring populates from the `anvil-impact` building block. There are three +such env vars, one per cargo-delta tier: + +| Env var | Bucket | What recipes do with it | +|------------------------------|-----------|------------------------------------------------------------------------------------------------| +| `ANVIL_INCLUDE_MODIFIED` | modified | `--skip` → recipe exits 0. Otherwise: run unconditionally (modified-tier tools are workspace-wide). | +| `ANVIL_INCLUDE_AFFECTED` | affected | `--skip` → recipe exits 0. Otherwise: splice the value into the cargo invocation, defaulting to `--workspace` when unset. | +| `ANVIL_INCLUDE_REQUIRED` | required | Same semantics as `ANVIL_INCLUDE_AFFECTED`, but consumed by recipes that need transitive dep graph in scope (doc-build, cargo-hack, udeps). | + +Each var holds either the literal sentinel `--skip` (the tier is empty for this PR), or +a pre-built argument string like `--package alpha --package beta`. The cloud-workflow wiring sets +exactly one form; local invocations leave the vars unset, and recipes fall back to +`--workspace`. + +A typical affected-tier recipe: + +```just +anvil-clippy: + @if [ "$ANVIL_INCLUDE_AFFECTED" = "--skip" ]; then \ + echo "anvil-clippy: no affected packages; skipping"; exit 0; \ + fi; \ + cargo clippy ${ANVIL_INCLUDE_AFFECTED:---workspace} --all-targets --all-features --locked -- -D warnings +``` + +A typical modified-tier recipe (the tool is workspace-wide, so there's nothing to +splice — only the skip guard matters): + +```just +anvil-fmt: + @if [ "$ANVIL_INCLUDE_MODIFIED" = "--skip" ]; then \ + echo "anvil-fmt: no modified packages; skipping"; exit 0; \ + fi; \ + cargo fmt --all --check +``` + +The mapping from check to bucket is fixed in the catalog (see +[checks.md §5](./checks.md#5-impact-scoping-check--env-var-mapping)). Unscoped checks +(`pr-title`, `deny`, `audit`, `aprz`, `mutants-full`) ignore the vars entirely — they +always run. Group recipes do not interpolate the vars themselves; each underlying check +recipe reads what it needs, so a group recipe is just a dependency list and nothing +changes when scoping is disabled. + +### 4.1 The `--skip` sentinel + +`--skip` is a magic string the impact step emits when a tier is empty for the PR +(typically a docs-only PR or a PR touching only files cargo-delta's +`file_exclude_patterns` ignore). It is not a valid cargo argument, so there is no risk +of collision with a real package name. Recipes test for it with `[ "$VAR" = "--skip" ]` +and exit 0 cleanly, keeping the cloud-workflow job green while signalling that nothing in that tier +needed to run. + +This separation is what makes the wiring layer durably structural: "which checks can +no-op when nothing in the relevant tier is affected" is a per-check property living in +the catalog/recipe, not in the wiring layer. Moving a check between buckets is a pure +catalog change; the cloud workflow templates always thread all three vars and never gate jobs on +their values. + +### 4.2 Local impact-scoped runs + +Not the default. To preview what cloud workflows would skip, run cargo-delta manually and export the +env vars: + +```sh +# Compute the affected-tier include list (--package … form) against origin/main. +export ANVIL_INCLUDE_AFFECTED="$(cargo delta impact --base origin/main --format cargo-args --affected)" +just anvil-pr-test +``` + +A wrapper recipe to compute and export all three vars in one shot is left to v2: it has +subtle git-state interactions and the manual flow is good enough for the rare case a +developer actually wants to reproduce cloud workflows scoping locally. + +## 5. Daily driver + +```text +$ just anvil +[just] running anvil-pr-validate-prereqs +[just] running anvil-pr-fast +[just] running anvil-pr-slow +anvil OK +``` + +`anvil` is an alias for `anvil-pr` (set in the managed `Justfile` region). All three tiers +(`anvil-pr`, `anvil-scheduled`, `anvil-full`) are first-class -- locally reproducible with +exactly the same arguments cloud workflows uses, because cloud workflows invokes the same `just` recipes. + +## 6. No-tooling fallback + +A user with only `cargo` (no `just`, no `cargo-anvil`) can still run the basics: + +```sh +cargo test --workspace --all-targets --all-features --locked +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +cargo fmt --check +``` + +The same commands appear as the body of the corresponding `just` recipes in +`justfiles/anvil/checks.just`, so they are discoverable by reading that file. The fallback +covers core hygiene only — coverage, miri, mutants, etc. still require their respective +tools. + +## 7. Customization at the recipe level + +Per the four customization tiers in [design.md §7](./design.md#7-customization): + +- **Add your own recipes** to the top-level `Justfile` outside the managed region. The + Justfile's managed region only contains `import` lines and an alias — your recipes never + collide with it. +- **Add your own `.just` files** and `import` them after the managed region's closing + sentinel. +- **Override a single anvil recipe**: the `just` import-and-override rules make this awkward + (just doesn't have a "the most specific definition wins" rule). The recommended way is to + copy the recipe you want to change into your top-level Justfile with a different name + (e.g. `my-clippy`) and reference *that* from your own group/tier recipes. Don't fight the + anvil-* names; just compose around them. +- **Disable a recipe wholesale**: opt out of the managed `Justfile` region per + [updates.md §opt-out](./updates.md#6-opting-out-in-file-stubs). This stops the imports from + happening at all, so all `anvil-*` recipes vanish. Use this only when anvil is no longer + the right tool for your repo. + +Customizing the *contents* of `justfiles/anvil/*.just` is supported — they're owned files, +so editing them flips them to "dirty" and the next `update` writes a `.anvil-proposed` +sibling instead of overwriting. See [updates.md](./updates.md) for the lifecycle. diff --git a/crates/cargo-anvil/docs/design/updates.md b/crates/cargo-anvil/docs/design/updates.md new file mode 100644 index 00000000..7a37a83e --- /dev/null +++ b/crates/cargo-anvil/docs/design/updates.md @@ -0,0 +1,436 @@ +# Updates, Drift Detection, and Opt-Out + +This document defines how `cargo anvil` decides what to write, what to leave alone, +and what to flag. The mechanism is built around a single sidecar manifest file that +captures what anvil last wrote, plus per-region sentinel comments that let the tool find +its content again on subsequent runs. + +See also: + +- [design.md](./design.md) for the overall principles and the CLI shape. +- [local.md](./local.md) for the just-recipe files this algorithm manages. +- [github.md](./github.md) / [ado.md](./ado.md) for the cloud workflow building-block files. + +## 1. The manifest + +`.anvil.lock` at the repo root. TOML, committed to the repo. Tracks, for every owned +file and every managed region, the checksum of what anvil most recently rendered there. + +```toml +# .anvil.lock — generated by cargo-anvil. Do not edit by hand. +version = 1 +rendered_by = "cargo-anvil 0.4.1" + +[[file]] +path = "justfiles/anvil/checks.just" +checksum = "sha256:8f3a…" + +[[file]] +path = ".github/actions/anvil-pr-fast/action.yml" +checksum = "sha256:b91c…" + +[[file]] +path = ".github/workflows/anvil-pr.yml" +checksum = "sha256:c4d2…" + +[[region]] +host = "Justfile" +id = "anvil-imports" +checksum = "sha256:1e5b…" + +[[region]] +host = "Cargo.toml" +id = "anvil-workspace-lints" +checksum = "sha256:7d22…" + +[[region]] +host = "crates/foo/Cargo.toml" +id = "anvil-lints" +checksum = "sha256:0a13…" +``` + +The manifest is the **single source of truth** for "what did anvil last write." Every +decision the tool makes about whether to overwrite, propose, or leave alone is driven by +comparing three things: + +- `last_rendered_checksum` — what the manifest says anvil wrote last time. +- `current_disk_checksum` — what is on disk right now. +- `current_template_checksum` — what anvil's current catalog would write. + +There is no per-template version tracking and no in-file checksum line. The manifest is +small, deterministic (sorted, fixed-format), and human-readable; diffs are minimal across +anvil version bumps. + +### When the manifest is read and written + +- `cargo anvil` reads the manifest at startup. Missing or unreadable manifest is + treated as a first run (every item is "never seen"). +- `cargo anvil` rewrites the manifest at the end of a successful run, reflecting + what was actually written (or what proposal contents were recorded). +- `cargo anvil --dry-run` reads the manifest but never writes. + +### Format and stability + +- TOML schema version is `version = 1`. The tool refuses to run against a newer schema + and migrates automatically from an older one. +- File entries are keyed by `path` (slash-separated, relative to repo root). +- Region entries are keyed by `(host, id)`. The `id` is the same identifier carried by + the in-file sentinel comments (`# >>> anvil-managed: `); it's globally unique + within the anvil catalog. See §3. +- Entries are written in a deterministic order: files alphabetically by path; regions + alphabetically by `(host, id)`. This makes `.anvil.lock` diff-friendly across anvil + version bumps that don't change content. + +## 2. Owned files + +Identified by **path**. The catalog (compiled into the binary) specifies the full set of +paths the tool owns. Examples: `justfiles/anvil/checks.just`, +`.github/actions/anvil-pr-fast/action.yml`, `.pipelines/anvil/pr.yml`. + +There is no in-file checksum line. Owned files carry at most a single advisory comment +on line 1 (where the file's syntax allows), naming the file as anvil-managed and +pointing readers at the manifest: + +```just +# Managed by cargo-anvil. See .anvil.lock at repo root. Do not edit by hand. +``` + +This warning is informational only. The manifest is the actual source of truth — a +reader can run `cargo anvil --dry-run` to see exactly what would happen if they +edited the file. + +## 3. Managed regions + +Co-owned files with one or more tool-managed sections delimited by sentinel comments. + +```just +# >>> anvil-managed: anvil-imports +import 'justfiles/anvil/checks.just' +import 'justfiles/anvil/groups.just' +import 'justfiles/anvil/tiers.just' +import 'justfiles/anvil/tools.just' +alias anvil := anvil-pr +# <<< anvil-managed: anvil-imports +``` + +The sentinel pair serves two purposes: + +1. **Stable identification.** The `` is the same string anvil's catalog uses and + matches the manifest's `[[region]].id` field. The tool finds the region by scanning + for the opening sentinel — line-number-independent, robust against user edits to + surrounding content. +2. **Body delimitation.** Everything between the sentinels (exclusive) is "the region's + body." Lines outside the sentinels are user-owned and preserved verbatim. + +For TOML hosts the sentinels are TOML line comments around the affected content. To +work with TOML's no-duplicate-table rule, anvil writes a single parent-table header +inside each region and expresses all nested values via dotted keys; this lets users +append further entries outside the region in the same parent scope without redeclaring +any sub-table header: + +```toml +# >>> anvil-managed: anvil-workspace-lints +[workspace.lints] +rust.unsafe_op_in_unsafe_fn = "warn" +clippy.unwrap_used = "warn" +clippy.expect_used = "warn" +rustdoc.broken_intra_doc_links = "warn" +# <<< anvil-managed: anvil-workspace-lints +# User-added lints continue [workspace.lints] via dotted keys — valid TOML, anvil +# preserves them verbatim: +clippy.pedantic = "warn" +rust.missing_docs = "warn" +``` + +Whitespace and comments inside the region are preserved verbatim by the rewrite. +Checksums (in `.anvil.lock`) are computed over the body bytes with line endings +normalized to LF so a Git checkout setting `core.autocrlf=true` doesn't trigger +spurious "user edited" detection. Trailing whitespace on individual lines and a +trailing-newline-or-not difference are preserved (they're part of the body), but tools +that strip them on save (most modern editors with "trim trailing whitespace" on) will +register as a user edit on the next `cargo anvil`. + +anvil's baseline severity is `"warn"`, not `"deny"`. Promotion to deny happens at +the lint-run boundary via `cargo clippy -- -D warnings`, which is what the `anvil-clippy` +recipe invokes. This keeps the baseline friendly to incremental adoption (a new repo +running anvil for the first time sees warnings rather than a wall of build failures) +while still failing cloud workflows on anything the catalog covers. Users who want stricter local +behavior set per-lint `"deny"` values inside the region — the dirty-file flow then +preserves their edit. + +### User-extension limits for TOML regions + +Three constraints follow from TOML's no-duplicate-tables rule and anvil's chosen +dotted-key layout: + +- **Adding new keys** to the parent table from below the region works and is the + recommended extension pattern (the example above). +- **Overriding a key set inside the region** is *not* possible from outside — + repeating `clippy.unwrap_used = "deny"` below the region while anvil wrote + `clippy.unwrap_used = "warn"` inside it is a duplicate-key TOML parse error. To + override, edit the value inside the region; the dirty-file flow (§5) takes over from + there, and anvil will leave the user's edit alone going forward. +- **Extending a TOML array set inside the region** (e.g. `licenses.allow = [...]` in + `deny.toml`) is not possible from outside — TOML has no array-merge syntax. The + user edits inside the region (dirty flow) or empties the region and writes their own + config alongside. + +For non-TOML hosts (`Justfile`), no such constraints apply: the user can write any +additional recipes above or below the imports region. + +## 4. Per-host insertion anchors + +If a host file exists but does not contain the expected region's sentinels (and the +region is not disabled per §6), the tool inserts the region at a deterministic per-host +anchor: + +| Host | Region | Anchor for insertion | +|---------------------------------------|-----------------------------------------|---------------------------------------------------------------| +| `Justfile` | `anvil-imports` | After leading `set …` / shebang lines, before the first user recipe. | +| Workspace `Cargo.toml` (workspace) | `anvil-workspace-lints` | End of file (after the last existing table). | +| Workspace `Cargo.toml` (single-crate) | `anvil-lints` | End of file. | +| Per-crate `Cargo.toml` | `anvil-lints` | End of file. | +| `deny.toml` | `anvil-deny` | End of file. | +| `rustfmt.toml` | `anvil-rustfmt` | End of file. | +| `.delta.toml` | `anvil-delta` | End of file. | + +If the host file is missing entirely (and the region is not disabled by a pre-created +stub), the tool creates it containing only the managed region. + +## 5. The decision algorithm + +For every item the catalog manages (each owned file path; each `(host, region-id)` +pair), the tool runs the same three-checksum comparison. There is no distinction between +"first run" and "subsequent run" — the absence of a manifest entry is just one possible +state — and there is no separate disable-detection pass: emptying a file or region +just produces the standard dirty-file behavior (see §6). + +Let: + +- `D` = current disk content's checksum (or *absent* if the file/region isn't there). +- `L` = `last_rendered_checksum` from `.anvil.lock` (or *absent* if no entry). +- `T` = checksum of what the current catalog would render. + +| `D` | `L` | `T` vs `L` | Action | +|-----|-----|------------|-------------------------------------------------------------------------------------------------------------------| +| absent | absent | n/a | Render, write, record. Set `L = T`. | +| absent | present | n/a | "User deleted." Re-render and write — deletion is the re-bless gesture. Set `L = T`. | +| present | absent | n/a | "User got there first." Leave the on-disk content alone; write `.proposed` with the rendered template. Set `L = T`. The on-disk file is dirty from inception; future runs will see `D != L`. | +| present | present | `T == L` and `D == L` | Clean and up to date. No-op. | +| present | present | `T != L` and `D == L` | Clean; template changed. Overwrite the file/region with the freshly rendered content. Set `L = T`. | +| present | present | `T == L` and `D != L` | Dirty; template unchanged. Leave the user's edit alone. **No proposal** — this is the case that the old design got wrong. | +| present | present | `T != L` and `D != L` | Dirty and template changed. Leave the user's edit alone; write `.proposed`. Set `L = T`. | + +The fourth-last row is the heart of the new model: a user-claimed file with no upstream +churn produces zero noise. They claimed it; anvil stays out of the way until anvil's +template actually changes. + +### `.anvil-proposed` side files + +Every proposal — for an owned file or for a managed region — is written as +`.anvil-proposed` next to the host file. A proposal is always a **full, +ready-to-use file**, not a fragment: + +- **Owned file proposal**: the freshly rendered file content. +- **Managed-region proposal**: the host file with the affected region's body replaced + by the freshly rendered body. All content outside the sentinels is preserved + byte-for-byte from the host's current on-disk state. Other managed regions in the + same host that are not pending an update keep their current on-disk content. +- **Multiple pending regions in one host**: bundled into a single proposal file + showing what the host would look like if every pending region update were accepted. + (Common case is one region per host; bundling is the conservative fallback.) + +Concretely the user can: + +- `diff .anvil-proposed` to see exactly what would change. +- `mv .anvil-proposed ` to accept all pending changes wholesale. +- Open the proposal in their editor and merge selectively into `` by hand. +- Delete the proposal to reject. + +There is no separate naming scheme for region proposals — `.anvil-proposed` is +the only file the user has to know about. The previous `..proposed` +naming was dropped because (a) it produced unhelpful fragmentary content that didn't +diff cleanly against the host, and (b) the same flat proposal-per-host convention +already worked for owned files. + +After a proposal is written, the manifest's `L` is bumped to the new `T`. This means: + +- Deleting the proposal without doing anything else dismisses the nag for the current + template revision. The next anvil version that changes this template will write a + fresh proposal. +- Leaving the proposal in place also dismisses the nag. The proposal file just sits in + `git status` as a visible reminder; anvil won't write another one until `T` changes + again. +- The user's copy on disk is never touched. + +**Rejection flow.** A user who wants to keep their custom version and ignore an +upstream change does nothing — or deletes the proposal — and the tool stops surfacing +this revision. The next time anvil's template changes, a fresh proposal will appear so +the user can re-evaluate. The user never has to learn a "reject" gesture; rejection is +the default behavior of doing nothing. + +**Acceptance flow.** A user who wants to take the upstream change either: + +- Merges the proposal content into their file by hand (then deletes the proposal). + After merging, `D == T` so the next run sees clean state. +- Or deletes their copy (`rm path/to/file` or empty the region between sentinels and + then delete the sentinels). On the next run, the file/region is absent → tool + re-renders with the current template → `L = T`, `D = T`, clean. + +`.anvil-proposed` files are **not** added to `.gitignore`. Showing up in `git status` +and diffs is the point — a proposal you can't see is a proposal you'll forget about. + +### 5.1 Orphan handling: catalog removals and backend changes + +When an entry exists in the manifest (`L` present) but the current catalog *does not* +produce a corresponding plan item — because the file was dropped from the catalog by +an anvil version bump, or the user deselected a backend, or a workspace member +moved — the tool runs a parallel decision against the previously-tracked item: + +| `D` | Action | +|------------|-------------------------------------------------------------------------------------------------------------------------| +| absent | Already gone. Just drop the manifest entry; no disk change. | +| `D == L` | **`Remove`** — the user hasn't touched it. Delete the file from disk (or splice the markers + body out of the host file for a managed region). Drop the manifest entry. | +| `D != L` | **`OrphanedKept`** — the user has customized it. Leave the file/region on disk untouched; drop the manifest entry. Ownership transfers to the user. | + +The `OrphanedKept` path is deliberately silent past the dry-run summary: the user +hasn't asked anvil to track this item anymore, and they invested effort customizing +it. Nagging them would be antagonistic. They can delete the orphan themselves at any +time, or rename it, or keep editing it — anvil no longer has an opinion about it. + +The `Remove` path is what makes catalog churn safe to ship. When anvil 0.5 drops +`nightly-builds.yml` (collapsed into `scheduled-exhaustive`), every adopter on 0.4 who +runs `cargo anvil` gets the file cleanly deleted, without manual janitorial +work — provided they hadn't customized it. + +### Exit codes + +- `--dry-run` exit code 0: this run would write no proposal (clean, or dirty but + template unchanged since last render). +- `--dry-run` exit code 1: at least one item is dirty *and* the template has changed + since last render — a `.anvil-proposed` sibling would be written. + +The same partitioning is printed at the end of every non-`--dry-run` `update`. + +## 6. Opting out: empty the file or region + +There is no separate "disable" mechanism. The dirty-file path from §5 already produces +the right behavior, so the way to opt out is to make anvil see the file or region as +"dirty with no further content to add": empty it. + +- **Owned file**: make the file empty (zero bytes, whitespace-only, whatever — anvil + computes a checksum either way and it won't match `L`). +- **Managed region**: leave the sentinel pair in place but empty the body: + + ```just + # >>> anvil-managed: anvil-imports + # <<< anvil-managed: anvil-imports + ``` + +The state machine handles the rest: + +- After anvil first renders the item, `L` is set to the template's checksum. +- The user empties it. Now `D` (empty checksum) ≠ `L`. The item is "dirty." +- On the next `update`, anvil sees `D != L`: + - `T == L` (template unchanged): no-op, no proposal. Quiet. + - `T != L` (template changed since last render): write `.proposed`, set `L = T`. + Quiet again on subsequent runs until template changes again. + +### Disabling an item the tool hasn't yet rendered + +Pre-create the empty file (zero bytes) or empty stub (region sentinels with no body) +before the first `update`. anvil sees `D` present, `L` absent → "user got there first": +the on-disk emptiness is preserved and a one-shot `.anvil-proposed` is written so the +user can see what they're opting out of. Delete the proposal to dismiss; thereafter +silent unless the template changes. + +### Re-enabling + +- A region: delete both sentinel lines; the next `update` re-inserts the region at the + anchor (§4) with fresh content. +- An owned file: delete the file; the next `update` recreates it. + +### No special cases + +Opting out of `anvil-imports` will break the `just anvil-*` recipes; that is the user's +choice. The tool does not refuse to honor an empty file or region for any ID or path. + +### Bulk handling + +There is no bulk-disable command. Empty-ing a file is a one-keystroke operation per +item, and disabling more than two or three regions is a strong signal that anvil is +not the right fit for the repo (in which case the user should remove anvil entirely). + +## 7. Why a manifest file + +Two alternatives were considered and rejected: + +- **Per-file checksums in line-1 comments.** Earlier design. Two problems: (a) every + managed file gets a noisy comment line; (b) claiming a file by removing the checksum + loses information about which template version was last rendered, so the tool can't + distinguish "user claimed and there's no upstream change" from "user claimed and + there is an upstream change." Both cases looked identical and forced a permanent + `.anvil-proposed` nag. + +- **Template-version markers without checksums.** Putting only the anvil version (e.g. + `# anvil-version: 0.4.1`) in-file. Compact, but the tool would either need to ship + every historical template version (heavy) or only compare against the current one + (losing claiming information again). + +The sidecar manifest: + +- Captures both the **content** the tool last wrote (via checksum) and, implicitly, the + anvil version that wrote it. +- Eliminates the claiming ambiguity: the tool always knows what it last produced, + independent of what the user has done since. +- Removes all in-file checksum noise; only a single advisory comment (and the + region-sentinel pair) remain. +- Is small, deterministic, and diff-friendly — meaningful changes in the manifest + reflect meaningful changes in what anvil manages. + +The trade-off is one additional file in the repo. The user's experience is otherwise +unchanged: they still don't have to know about the manifest most of the time, and the +opt-out mechanism remains entirely in-file. + +## 8. Backend selection during update + +`--backend ` is a repeatable flag; valid names today are `github` and `ado`. If +omitted, the tool autodetects from the `origin` git remote URL: + +- `github.com` → `github` +- `dev.azure.com` or `*.visualstudio.com` → `ado` +- anything else, or if `origin` is missing → the tool errors out asking for an explicit + `--backend` flag. + +`--no-backends` is valid and useful for repos that want only the local `just` setup +with no cloud workflows files; it is mutually exclusive with `--backend`. Autodetection runs every +time `--backend` and `--no-backends` are both absent; there is no "first run" special +case. When the backend set changes (or an item is dropped from the catalog), the tool +detects orphaned files / regions per §5.1 and removes them on the next non-dry-run +unless the user has customized them (in which case ownership transfers to the user +silently). + +## 9. Dry-run UX + +`cargo anvil --dry-run` performs the full analysis but writes nothing (neither +the manifest nor any file). Output is grouped by category: + +- **Will create**: items that don't exist in the manifest or on disk. +- **Will update**: clean items whose template content has changed. +- **Will leave alone (silent)**: dirty items whose template hasn't changed since last + render. No proposal will be written. +- **Will propose**: dirty items whose template *has* changed; a `.anvil-proposed` + sibling will be written. Includes both user-edited and emptied/opted-out items — + emptiness has no special status in the algorithm. +- **Will remove**: items that were tracked by the previous manifest but are no longer + in the catalog *and* the user has not customized them. Owned files get deleted; + managed regions get spliced out of their host file. +- **Orphaned (customized; transferring ownership)**: items that were tracked but are + no longer in the catalog *and* the user has customized them. Files / regions are + left on disk unchanged; the manifest entry is dropped so anvil stops tracking + them. The user can keep, edit, or delete them at their own discretion. +- **Unchanged**: clean items whose template is identical to last render. + +Exit code 0 if everything is clean (only `InSync` and `LeaveAlone`); exit code 1 if +the tool would change anything on disk. The same output format is printed at the end +of a non-dry-run `update`, summarizing what actually happened. diff --git a/crates/cargo-anvil/docs/implementation-plans/0000.md b/crates/cargo-anvil/docs/implementation-plans/0000.md new file mode 100644 index 00000000..7bf30b1a --- /dev/null +++ b/crates/cargo-anvil/docs/implementation-plans/0000.md @@ -0,0 +1,265 @@ +# Initial Implementation Plan + +This document breaks the [design](./design/design.md) into review-sized commits. Each commit +should be independently reviewable, build cleanly, and (where reasonable) ship with unit +tests. Integration tests against full repo fixtures arrive late in the plan; until then we +lean on unit tests over individual modules. + +The plan is sequenced so that **every commit leaves the crate compiling**, and so that +the dogfooding wiring (final commit) can light up incrementally. + +Companion docs: +- [design.md](./design/design.md) — overall design. +- [checks.md](./design/checks.md), [local.md](./design/local.md), + [updates.md](./design/updates.md), [github.md](./design/github.md), + [ado.md](./design/ado.md) — detail. +- [verification.md](./verification.md) — continuous-validation strategy. + +--- + +## Sequencing principles + +1. **Core scaffolding first, emitters last.** Manifest, drift detection, and region + parsing are reused by every emitter, so they land before anything writes real content. +2. **Local before cloud workflows.** The justfile/cargo-config/lints emitters validate the manifest + and region machinery on a small surface before the heavier cloud-workflow emitters land. +3. **One backend per commit.** GitHub Actions and ADO are independent surfaces; landing + them separately keeps diffs reviewable. +4. **Dogfood last, but dogfood early enough to learn.** A minimal dogfooding wiring + commit lands as soon as the `pr-fast` group is end-to-end functional, then expands + group-by-group with each new emitter. +5. **No premature abstractions.** No trait per backend until the second backend lands. + +--- + +## Phase 0 — Foundations + +### Commit 1: crate skeleton, CLI surface, no-op `update` + +- Wire `clap` (derive). One subcommand: `update`. Flags: `--backend ` (repeatable + `Vec`), `--no-backends`, `--dry-run`. +- Validate flag combinations (`--backend` and `--no-backends` mutually exclusive). +- Replace the placeholder `main.rs` so `cargo anvil --dry-run` prints a banner + and exits 0. No file I/O yet. +- Add `ohno` (with the `app-err` feature), `thiserror`, `tracing`, `tracing-subscriber` deps. +- Unit test the arg-parser permutations. + +### Commit 2: backend autodetection from `origin` + +- New `backend` module. Parse the git `origin` URL via `git2` (or `gix` — pick one and + pin in workspace deps). Return `Vec` from the detected host. +- Resolution order in CLI: explicit `--backend` > `--no-backends` > autodetection. +- Unit tests cover `git@github.com:…`, `https://github.com/…`, + `https://dev.azure.com/…`, `*.visualstudio.com`, missing remote, unknown host. + +### Commit 3: repo-root discovery + workspace member enumeration + +- Walk up from CWD looking for the workspace `Cargo.toml`. Refuse to run outside a + Cargo workspace (single-crate repos are still a Cargo workspace of one member). +- Parse `Cargo.toml` via `toml_edit::Document` (keep it round-trippable). Enumerate + workspace members (resolve globs) for use by the per-member lints emitter. +- Unit tests: workspace, single-crate, glob members. + +--- + +## Phase 1 — Manifest & drift core + +### Commit 4: `.anvil.lock` manifest read/write + +- Define the TOML schema from [updates.md §1](./design/updates.md#1-the-manifest). +- `Manifest::load(repo_root) -> Result` (missing = empty). +- `Manifest::save(repo_root) -> Result<()>` writes deterministically (sorted entries, + fixed key ordering, trailing newline). +- Schema-version handling: refuse newer, migrate older (only `v1` for now, so trivial). +- Property-test round-trip equality. + +### Commit 5: SHA-256 helper + three-checksum decision table + +- Tiny `checksum` module wrapping `sha2`. Format: `sha256:`. +- `decision` module implementing the table from + [updates.md §5](./design/updates.md#5-the-decision-algorithm): inputs are the + three checksums (L = last-rendered, D = disk, T = current-template); output is + one of `InSync`, `Write`, `Propose`, `LeaveAlone`. Opt-out (emptied file or + region body) needs no extra flag — empty content has a stable checksum that + is never equal to the template, so the standard `D ≠ L` branches reach + `LeaveAlone` (when the template is unchanged) or `Propose` (when it has + moved), both preserving the user's stub. +- Exhaustive unit tests over the cells of the table (including the + empty-stub branches and the missing-on-disk branches). + +### Commit 6: managed-region parser/writer + +- `region` module. Locate `# >>> anvil-managed: ` … `# <<< anvil-managed: ` + in arbitrary text, extract the body, splice in a new body while preserving the + bytes outside the sentinels. Sentinel-comment syntax is configurable per host + (Justfile/TOML use `#`, YAML uses `#`, future hosts may need `//`). +- Handle: no region present (insert at end), multiple regions in one file, malformed + region (unterminated start / dangling end → hard error with file/line). +- Unit tests across each host syntax and each malformed input. + +### Commit 7: proposed-file sidecar + dry-run summary + +- Implement the `.anvil-proposed` writer (full-file output even for regions, per + [updates.md §7](./design/updates.md)). +- `Plan` and `PlanItem` structs accumulate decisions during a run. `--dry-run` renders + a stable summary to stdout (`N files in sync, M would be written, K would propose + changes`) and exits 1 iff anything is non-`Skip`/`LeaveAlone`. +- Persist nothing in dry-run mode (no manifest write, no `.anvil-proposed` write). +- Unit tests against a `Plan` builder. + +--- + +## Phase 2 — Local emission + +At this point no real templates exist. The next commits introduce templates one host +at a time. Each emitter takes a `RenderContext` and returns rendered bytes, which the +top-level driver feeds through the decision module and the manifest. + +### Commit 8: `justfiles/anvil/tools.just` + `tools-check` + +- Embed `tools.just` via `include_str!`. It contains `_anvil-require`, + `anvil-tools-check`, `anvil-tools-install`, and the minimum-version map. +- First template integration: drive it through Phase 1 (decision → manifest update). +- Smoke test: render into a temp dir, parse with `just --unstable --dump --justfile + …` (skip if `just` isn't on PATH) to confirm syntactic validity. + +### Commit 9: `justfiles/anvil/checks.just` + per-check recipes + +- Embed all individual check recipes from + [checks.md §2](./design/checks.md#2-checks-by-group). One recipe per check, named + `anvil-`. +- Includes the `pr-title` `[script("pwsh")]` block. + +### Commit 10: `justfiles/anvil/groups.just` + `tiers.just` + +- Group recipes (`anvil-pr-fast`, `anvil-pr-test`, `anvil-pr-mutants`, + `anvil-scheduled-*`). +- Tier aggregators (`anvil-pr`, `anvil-scheduled`, `anvil-full`) and the + `anvil := anvil-pr` alias. + +### Commit 11: `Justfile` `anvil-imports` managed region + +- First *managed region* emitter. Inserts the four `import` lines and the alias into + the user's `Justfile` (creating it if absent — owned-file path for the empty case, + region path for the non-empty case). +- Tests: empty `Justfile`, `Justfile` with prior user content, opt-out (empty region). + +### Commit 12: `Cargo.toml` workspace/member lints regions + +- Use `toml_edit` to manipulate the `[workspace.lints]` table (or `[lints]` in + single-crate mode) without disturbing surrounding content. +- Emit dotted-key form (`rust.unsafe_op_in_unsafe_fn = "deny"`, …) per + [design.md §6](./design/design.md#6-repo-layout) so users can add their own lints + in the same scope outside the sentinels. +- For each member crate, add the `[lints] workspace = true` region. + +### Commit 13: `deny.toml`, `rustfmt.toml`, `.delta.toml` regions + +- Three small region emitters with baselines per [checks.md](./design/checks.md). +- Each can be opted out by emptying its region (no special-casing). + +### Commit 14: end-to-end local-only `update` + +- Wire commits 1–13 together. `cargo anvil --no-backends` against an empty + workspace produces a working local setup. +- Add the first **fixture-based integration test** under `tests/fixtures/local/`: + bare workspace → expected file tree. Snapshot the output with `insta`. + +--- + +## Phase 3 — GitHub Actions backend + +### Commit 15: composite actions (`.github/actions/anvil-*/action.yml`) + +- One composite action per group. Each runs `cargo-anvil`'s tools-check, then + `just anvil--`. See [github.md §5](./design/github.md). +- Owned files; no managed regions on GH side. + +### Commit 16: reusable workflows `anvil-pr-impl.yml` and `anvil-scheduled-impl.yml` + +- The "wiring layer" from [github.md §4](./design/github.md#4-owned-reusable-workflows): + cargo-delta impact job feeding excludes/skip env vars to each group job, matrix + fan-out for the OSes per [checks.md §1](./design/checks.md#1-groups-and-tiers). +- Inputs: `test_os` (default `linux,windows`), runner labels. + +### Commit 17: root workflows `anvil-pr.yml` + `anvil-scheduled.yml` + +- Triggers (`pull_request`, `schedule`), permissions, concurrency. Calls the reusable + workflow with the project's chosen inputs. +- After this commit `--backend github` is fully functional. Add a fixture test + covering the GH tree. + +--- + +## Phase 4 — Azure DevOps backend + +### Commit 18: step templates `.pipelines/anvil/steps/*.yml` + +- One step template per group, invoking the same `just` recipes as the GH composite + actions. See [ado.md §5](./design/ado.md). + +### Commit 19: stages templates `pr.yml` + `scheduled.yml` + +- The wiring layer: impact-scoping stage + per-group stages with the right + dependencies and `condition: succeededOrFailed()` semantics per + [ado.md §4](./design/ado.md#4-owned-stages-templates). Parameters for + `linuxPool`/`windowsPool`. + +### Commit 20: root pipelines `anvil-pr.yml` + `anvil-scheduled.yml` + +- Minimal root pipelines (no `extends:` to 1ESPT — the user adds that). After this + commit `--backend ado` is fully functional. Add a fixture test covering the ADO + tree. + +--- + +## Phase 5 — Verification & dogfooding + +### Commit 21: schema validation in the test suite + +- `tests/schemas.rs` runs `actionlint` over emitted `.github/workflows/*.yml`, `taplo + check` over emitted `*.toml`, and `just --fmt --check` over emitted `*.just`. Per + [verification.md §3](./verification.md). Skip gracefully when binaries aren't + installed; require them in cloud workflows. + +### Commit 22: dogfood anvil in this repo (`ox-tools`) + +- Run `cargo anvil --backend github` from the workspace root. Commit the + generated files. +- Hand-write `.github/workflows/regenerate-check.yml` per + [verification.md §3](./verification.md): build the binary, re-run `update + --dry-run`, fail if anything is non-zero. Then call `anvil-pr-impl.yml` to run + the actual checks. +- Document the bootstrap sequence in `verification.md` if any gaps are discovered + in practice. + +### Commit 23: README + crates.io polish + +- README is auto-generated from doc-comments per the repo's AGENTS.md convention; add + the doc-comments to `src/lib.rs` (move CLI parsing into a small library so the + README has something to document). +- `cargo publish --dry-run` runs clean. + +--- + +## Out of scope for this plan + +- Adopters beyond `ox-tools` itself. Rolling out to `oxidizer`, `oxidizer-github`, + `assistants-oxide`, `ox-docs` happens after the dogfood commit lands cleanly. +- Migration tooling. The first adoption in each repo is hand-driven: run + `cargo anvil`, review the diff, fix conflicts, land. +- A second binary or library surface. Stays a single CLI crate. + +--- + +## Risk and rework hot-spots + +- **Region parsing in TOML.** `toml_edit` and free-form sentinel comments must + coexist. Worst case we lose the "dotted-key form" affordance and require users to + put their extensions in a separate parent table. Validate early (commit 12). +- **cargo-delta impact wiring.** The env-var contract between the impact job and + each group is the most intricate cross-cutting design element. Validate end-to-end + in commit 17 with a fixture that includes a non-trivial workspace. +- **ADO `extends:` composition.** The shape that lets users wrap our stages + template inside a 1ESPT extension may need iteration after the first internal + adopter tries it. Capture friction in `verification.md` §4. diff --git a/crates/cargo-anvil/docs/implementation-plans/0001.md b/crates/cargo-anvil/docs/implementation-plans/0001.md new file mode 100644 index 00000000..5eaa413e --- /dev/null +++ b/crates/cargo-anvil/docs/implementation-plans/0001.md @@ -0,0 +1,429 @@ +# Implementation Plan 0001 — Closing the design/implementation gap + +This plan addresses every gap identified in the design-vs-implementation audit +of `cargo-anvil` (see the audit findings inline in commit message bodies +and in this plan). It is sequenced into seven phases, each landing as a small +set of independently-reviewable commits. + +Companion docs (same locations as 0000.md): +- [design/](./design/) — top-level design and per-host detail +- [verification.md](./verification.md) — continuous-validation strategy + +The plan does **not** add new user-facing features beyond what the existing +design already promises. Its job is to make the implementation match the +design, fix the few places where the implementation has drifted ahead +unannounced, and reconcile internal contradictions in the docs themselves. + +--- + +## Sequencing principles + +1. **Doc sweep first** for items where the implementation deliberately + diverged from the design (impl is correct; docs need to follow). This + commit is pure doc churn so it can be reviewed independently of any + behavioral change. +2. **Easy correctness fixes** next: a one-line template fix that unblocks + the setup step, the Propose-bumps-manifest algorithm change, stale-entry + purging, and the categorized dry-run summary. Each is small and visible. +3. **Tool-version policy** as a coherent group of three commits — this is + the most-promised feature in `local.md` and currently doesn't work at + all. +4. **Impact-scoping rework** next, also as a coherent group. Three-tier + includes (modified / affected / required) with include lists (not + excludes) per the discussion that flipped the audit's initial + recommendation. Recipes interpret the env vars. No special-case + handling for an opted-out `.delta.toml` — `cargo delta impact` + handles a missing/empty file gracefully via its own defaults. +5. **Smaller improvements** (HTML coverage report, missing lints, + `BASE_REF` master fallback, per-recipe tool-requires fan-out) in + self-contained commits. +6. **Caching** after the tool-version work, since cache keys reference + the catalog. +7. **Verification harness** last — write the fixture scenarios the design + describes and rewrite `verification.md` to describe the actual test + layers (snapshots + fixtures + schema). + +Each behavioral commit updates the doc sections directly affected by its +behavior change; the Phase 0 sweep covers only the docs that need no +accompanying code change. + +--- + +## Phase 0 — Documentation reconciliation + +### Commit 1: bring docs in sync with shipped implementation + +Single doc-only commit. No behavior change. + +- **`github.md` §2, `ado.md` §2**: remove the "carry an `anvil-checksum` + first line" claim (audit D1). Owned files are tracked solely via the + sidecar manifest. +- **`ado.md` §3**: drop the `pr: branches: include: [main]` snippet from + the root pipeline example; explain that ADO PR validation uses branch + policies, not the YAML `pr:` trigger (audit B4). +- **`github.md` (new §): Coverage upload**: document the + `codecov/codecov-action@v5` upload on PR and scheduled, the optional + `CODECOV_TOKEN` `workflow_call` secret, `fail_ci_if_error: false` + behavior, and the `flags: scheduled` separation in the Codecov UI + (audit C1). +- **`ado.md` (new §): Coverage upload**: document the + `PublishCodeCoverageResults@2` step on PR and scheduled, the cobertura + input, and `failIfCoverageEmpty: false` for skip-safe behavior + (audit C2). +- **`checks.md` §2 (llvm-cov row)**: rewrite to reflect the three-step + recipe (`clean --profraw-only` → `nextest --no-report` → two `report` + invocations), the two output files (lcov + cobertura), and drop the + threshold/HTML claims that aren't (yet) shipped (audit B9, A12). + Update the scheduled-test row to point at the same recipe (drop the + `target/llvm-cov/scheduled.lcov` path). +- **`github.md` §3, §4**: replace the JSON-array `test_os` documentation + with the actual CSV shape; document `linux_runner`/`windows_runner`/ + `macos_runner` inputs; document `concurrency:` + `secrets: inherit` + as defaults (audit B2, C3, D7). +- **`ado.md` §3, §4**: rewrite the stages-shape section to describe the + multi-stage layout (separate `impact` / `pr_fast` / `pr_test` / + `pr_mutants` stages), the `stageDependencies.impact.compute.outputs` + reference path, and the impact-step's inline `$BASE_REF` / + `$(System.PullRequest.TargetBranch)` resolution (audit B3, B8). +- **`ado.md` §3**: update the scheduled-tier schedule example to + `branches.include: [main, master]` and explain the dual default + (audit C4). +- **`local.md` §1, §2, §5**: replace the four-import region body with + the single `import 'justfiles/anvil/mod.just'` form; describe + `mod.just` as the entry point that owns the four imports plus + `alias anvil := anvil-pr`; update §2 to list five files + (audit B1, C8, D3). +- **`local.md` §2**: align recipe names with the actual catalog — + `anvil-mutants` (not `mutants-diff`), `anvil-bench` (not + `bench-only`) (audit D2, C5-name). +- **`local.md` §2**: mention `[group("anvil")]` decorators and + `set unstable` in `checks.just` (audit C7). +- **`updates.md` §3, `design.md` §6**: lints baseline severity in the + examples is `warn`, not `deny` (matching `cargo-lints-body.toml`). + Add `clippy.expect_used` and `rustdoc.broken_intra_doc_links` to + the example list (audit B10 — the missing lints land in Phase 4). +- **`verification.md` §2.2, §6**: rewrite to describe the actual test + layers (`tests/snapshots.rs` + `tests/schemas.rs`); note that + fixture scenarios are introduced in Phase 6 (audit A8, D5). +- **`github.md` §4, `ado.md` §4**: rewrite the `.delta.toml`-opt-out + description to drop the "impact job is omitted" promise that was + never implemented and isn't needed. Emptying the region just means + "use cargo-delta's defaults" — the impact step still runs and still + produces meaningful include lists, because cargo-delta itself + handles a missing or empty config file gracefully (audit C6, + resolved doc-side). +- **`design.md` §3 (Non-Goals)**: add a note that `cargo-anvil` may + in future introduce small runtime subcommands for tasks tightly + coupled to the cloud workflow execution (coverage gating, etc.); the + "tool only writes files" stance remains the default but isn't + absolute. + +--- + +## Phase 1 — Easy correctness fixes + +### Commit 2: fix the broken `anvil-setup` step + +- **`templates/justfiles/anvil/tools.just`**: rename the no-op stub + `anvil-tools-install` to do its real job (loop the catalog and + `cargo install --locked` each item). For this commit, the "catalog" + is empty — Phase 2 populates it. So the recipe stays a no-op + *body* but its existence is canonical. +- **`templates/github/setup-action.yml`**, **`templates/ado/steps/setup.yml`**: + keep calling `just anvil-tools-install`. (No template change + needed; this commit's job is just renaming the audit's "missing + recipe" gap into "stubbed recipe waiting for catalog.") +- **`local.md` §2**: drop `anvil-tools-install-missing` from the + recipe list; document that `anvil-tools-install` is idempotent + by way of `cargo install`'s no-op-on-match behavior, so a separate + install-missing recipe is unnecessary. +- Audit refs: A4. + +### Commit 3: `Propose` decision bumps the manifest + +- **`src/plan.rs`**: in `Plan::apply`, the `Propose` arm now updates + `next.files` / `next.regions` with the new template's checksum + (matching the `Write` arm) in addition to writing the + `.anvil-proposed` sibling. Doc-comment that previously said + "the manifest is NOT updated for proposals" gets reversed. +- **`src/decision.rs`**: tests `user_diverged_template_changed_proposes` + and friends already pass; add explicit test of the + "second run after Propose → LeaveAlone, no proposal re-emitted" + behavior in `src/run.rs`. +- **`updates.md` §5 (decision table row 3)** and **§6**: align the two + sections to the new behavior: a `Propose` "burns through" — the + user's choice to delete or accept the proposal is permanent until + the next template change (audit B6, D4). +- Snapshot impact: none — the emitted file tree doesn't change on a + first run, only the manifest behavior on subsequent runs. + +### Commit 4: stale-manifest-entry purging + +- **`src/plan.rs`**: in `Plan::apply`, collect the set of `(path, id)` + keys the run actually computed for; in the returned manifest, retain + only entries whose keys are in that set. Existing entries for items + no longer in the plan (e.g. a removed workspace member, a backend + the user disabled) are dropped. +- **`src/run.rs`**: integration test where the workspace loses a + member between runs; assert the manifest's `region` table shrinks + to match. +- **`updates.md` §8, §9**: doc text matches the new behavior; add a + one-line note that purging happens on non-dry-run only (audit A9). + +### Commit 5: categorized `--dry-run` summary + +- **`src/plan.rs`**: rewrite `Plan::summary()` to group items under + `Will create / Will update / Will leave alone (silent) / + Will propose / Unchanged / Stale manifest entries` headers, matching + `updates.md` §9. +- **Snapshot impact**: none — `summary()` output isn't snapshotted. +- Audit refs: A10. + +--- + +## Phase 2 — Tool-version policy + +### Commit 6: tool minimums catalog + version-aware `_anvil-require` + +- **New file** `templates/justfiles/anvil/tools.just` overhaul: + `_anvil-require ` parses `cargo install --list` output to + find the installed version of ``, compares against a minimum + embedded in the same file (a tiny key-value table at the top of + `tools.just`), and fails with a `cargo install --locked + --version '>='` hint when missing or below-minimum. Falls back + to `command -v ` for non-cargo binaries (`just`, `pwsh`). +- **New file** `templates/regions/tool-minimums.json` (or embedded + in `tools.just` directly): the catalog. One entry per check-catalog + tool with the minimum version observed in the surveyed repos. +- **`local.md` §3.1–3.4**: doc the now-real behavior; remove the + "future commits parse `cargo install --list`" note. +- Audit refs: A5. + +### Commit 7: `anvil-tools-install` loops the catalog + +- **`tools.just`**: `anvil-tools-install` iterates the same catalog + and runs `cargo install --locked --version '>='` for + each. Idempotent (cargo is a no-op when version already satisfies). +- **Tests**: snapshot regeneration only; functional verification is + manual (we don't actually run `cargo install` in the test suite). +- Audit refs: A5. + +### Commit 8: `rustc` and `pwsh` gates + +- **`tools.just`**: `_anvil-require rustc` reads + `rustc --version`, parses the semver, compares against the catalog + minimum, fails with a `rustup` install hint pointing at the + workspace's `rust-toolchain.toml`. +- **`tools.just`**: `_anvil-require pwsh` checks `command -v pwsh` + with a per-OS install hint (linux: package manager; macos: brew; + windows: built-in) — matches `design.md` §8.3. +- **`checks.just`**: `anvil-pr-title` (the lone `[script("pwsh")]` + check) gains `(_anvil-require "pwsh")` as a dependency. +- Audit refs: A6. + +--- + +## Phase 3 — Impact scoping (three-tier includes) + +### Commit 9: impact computation emits three sets + two include lists + +- **`templates/github/impact-action.yml`** and + **`templates/ado/steps/impact.yml`**: invoke `cargo delta impact` + with the right flags to produce three sets (modified, affected, + required). Format two outputs per backend: + - `include_modified` — `--package alpha --package beta`-formatted + string for the modified set, or the sentinel `--skip` if the + set is empty. + - `include_affected` — same shape, for the affected set. + - The `required` tier is implicit: required-tier recipes ignore + both env vars and always run with `--workspace`. +- Audit refs: A1 (now impl-side), A2 (skip semantics roll into the + include sentinel). + +### Commit 10: composite/step group templates wire the inputs through + +- **`templates/github/group-action.yml`**: declare two inputs + `include_modified` and `include_affected`; export them as env vars + `ANVIL_INCLUDE_MODIFIED` / `ANVIL_INCLUDE_AFFECTED` before + invoking the recipe. +- **`templates/ado/steps/group.yml`**: matching change. Drop the old + `excludes` / `skip` parameters. +- **`templates/github/pr-impl-workflow.yml`** and + **`templates/ado/pr-stages.yml`**: route the two outputs from the + impact job to every per-group call. +- Audit refs: A1, A2, B7 partial. + +### Commit 11: per-check recipes interpret the include lists + +- **`templates/justfiles/anvil/checks.just`**: each check recipe + uses the right env var per its tier: + - **modified tier** (`fmt`, `license-headers`, `spellcheck`, + `cargo-sort`, `ensure-no-cyclic-deps`, + `ensure-no-default-features`): guard + `if [ "$ANVIL_INCLUDE_MODIFIED" = "--skip" ]; then exit 0; fi` + at the top; otherwise run as usual (these tools don't take + `--package` selectively — they're "all or nothing"). + - **affected tier** (`clippy`, `llvm-cov`, `doc-test`, + `doc-build`, `examples`, `mutants`): same skip guard, then + splice `${ANVIL_INCLUDE_AFFECTED:---workspace}` into the + cargo invocation. + - **required tier** (`deny`, `audit`, `udeps`, `aprz`, + `semver-check`, `external-types`, `pr-title`): no env var + reference, always run. +- **`checks.md` §5**: write the per-tier mapping table (each check + → its tier) as the canonical source of truth. `local.md` §4 cites + the table. +- Snapshot impact: significant — `checks.just` body changes in every + backend snapshot. +- Audit refs: A1, A2, D6. + +> **Note on `.delta.toml` opt-out**: there is no special-case handling +> for an emptied `.delta.toml` region. `cargo delta impact` runs with +> sensible defaults when the file is missing or empty (modified = +> crates touched in the diff; affected = modified ∪ rev-deps; +> required = empty). The include lists the impact step emits are +> meaningful in all three cases. The general region-empty mechanism +> applies as it does for every other managed region — the user opts +> out of *anvil's custom cargo-delta configuration*, not out of +> impact scoping itself. The Phase 0 doc sweep updates `github.md` +> §4 and `ado.md` §4 to reflect this. + +--- + +## Phase 4 — Smaller improvements + +### Commit 12: llvm-cov HTML report + missing lints + +- **`templates/justfiles/anvil/checks.just`**: add a third + `cargo llvm-cov report --html` step after the cobertura emit. + HTML lands at `target/coverage/html/index.html` for local browsing. + HTML report has no cloud-workflow consumer; it's purely a local affordance. +- **`templates/regions/cargo-lints-body.toml`**: add + `clippy.expect_used = "warn"` and + `rustdoc.broken_intra_doc_links = "warn"` to the catalog. +- Snapshot impact: yes for both files. +- Audit refs: B9, B10. + +### Commit 13: `BASE_REF` master fallback in `anvil-mutants` + +- **`templates/justfiles/anvil/checks.just`**: the `anvil-mutants` + recipe's `BASE_REF` resolution now tries `origin/main`, then + `origin/master`, then errors out. +- Audit refs: C5 (fallback half). + +### Commit 14: per-recipe `_anvil-require` fan-out + +- **`templates/justfiles/anvil/checks.just`**: every check recipe + gains a `_anvil-require ` dependency, replacing the + inconsistent `anvil-tools-check` reuse. Each recipe declares + exactly the tools it actually needs. +- `anvil-tools-check` reduces to a top-level "everything in the + catalog passes its version gate" smoke test, which is what its + name says. +- Audit refs: A11, B7 (full). + +--- + +## Phase 5 — Caching + +### Commit 15: GitHub `actions/cache` in the setup composite + +- **`templates/github/setup-action.yml`**: add an `actions/cache` + step keyed on + `${{ runner.os }}-${{ steps.rust-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml') }}-anvil-v1`. + Cache paths: `~/.cargo/registry/cache/`, `~/.cargo/registry/index/`, + `~/.cargo/bin/`, `target/`. Restore on miss with looser keys. +- **`github.md` §8**: doc the actual keys and paths (the existing + doc text already describes this; bring template up to match). +- Audit refs: A7 (GH side). + +### Commit 16: ADO `Cache@2` in the setup step + +- **`templates/ado/steps/setup.yml`**: add a `Cache@2` task with + parallel structure to the GH side. Cache paths and key inputs as + per `ado.md` §7. +- Audit refs: A7 (ADO side). + +--- + +## Phase 6 — Verification harness + +### Commit 17: fixture scenarios under `tests/fixtures/` + +- **New** `tests/fixtures/single-crate/` — a workspace with a single + `[package]` (no `[workspace]`); assert single-crate lints region + emission and Justfile region. +- **New** `tests/fixtures/opt-outs/` — a workspace whose `rustfmt.toml` + has an emptied managed region; assert the rustfmt region stays + empty and the proposal lifecycle is correct. +- **New** `tests/fixtures/customized/` — a workspace where the user + has edited inside a managed region; assert `LeaveAlone` decision + on the first run and `Propose` on the second (after a synthetic + template change in the test). +- **New** `tests/fixtures/migration/` — a workspace that already has + a hand-written `Justfile`, `Cargo.toml` lints block, and + `deny.toml`; assert anvil splices its regions without losing + user content. +- **New** `tests/update.rs` — runs the four scenarios as integration + tests, using `tempdir` plus `fs_extra::copy` (or a custom helper) + to seed each fixture. +- Audit refs: A8. + +### Commit 18: rewrite `verification.md` to describe the actual test layers + +- **`verification.md`**: three layers documented in §2: + - Unit tests (under `#[cfg(test)]` in each module). + - Snapshot tests (`tests/snapshots.rs`, three backend combos). + - Fixture tests (`tests/update.rs`, four scenarios). + Plus the schema-validation suite (`tests/schemas.rs`) and the + dogfooding mechanism (`.github/workflows/regenerate-check.yml`). +- Drop references to the never-built `tests/update.rs` placeholder + and the singular `tests/schema.rs` typo. +- Audit refs: D5. + +--- + +## Out of scope for this plan + +- **Separate `cargo-coverage-gate` tool** (per the parallel design at + `D:\repos\unified-builds\ox-tools-gh-coverage-gate\`). Tracked + separately and intentionally not folded into anvil. +- **Diff coverage UI / Codecov ADO integration**. Coverage upload + is done; richer integration is left to adopter taste. +- **macOS in default matrices**. macOS is not part of the default + test matrix. Adopters who want it edit the emitted templates + directly (taking ownership via the dirty-file flow). +- **Per-OS opt-out** (e.g. Linux-only adopters). The default + matrix is Linux + Windows in all cases. Adopters who want a + narrower matrix edit the emitted templates directly. +- **1ESPT auto-wiring**. The design is explicit that 1ESPT + composition is the user's job; anvil provides composable + stages, not a 1ESPT extender. + +--- + +## Risk and rework hot-spots + +- **Phase 3 is the largest single change set.** The three commits + (9 / 10 / 11) hang together — landing 9 without 10 or 11 leaves + cloud workflows broken because the wiring layer references inputs the impact + step doesn't produce. Review each commit independently but land + them together or revert together. +- **Phase 2 tool-version policy validates its own minimums against + the surveyed-repo state.** If we pin minimums too high, adopters + fail at `_anvil-require` time; too low and we silently accept + outdated tools. The catalog needs a real survey pass, not an + educated guess. +- **Caching keys (Phase 5) must include the tool-minimums catalog + hash**, so a Phase 2 catalog update invalidates the cache as + intended. Easy to forget; called out explicitly in Commit 15. +- **`Plan::apply` changes in Commits 3 (Propose bumps manifest) and 4 + (purge stale entries) interact**: the order in which we update + `next.files` / `next.regions` matters. Commit 4 must purge + *after* Commit 3's bumps have been applied, otherwise the + newly-bumped entry would be considered stale and dropped on the + same run. +- **Phase 6 fixture tests will surface bugs that the in-memory tests + miss.** Plan for follow-up patch commits if scenarios reveal + issues with `find_workspace_root`, region splicing into + already-populated TOML, etc. diff --git a/crates/cargo-anvil/docs/verification.md b/crates/cargo-anvil/docs/verification.md new file mode 100644 index 00000000..e3ab28c0 --- /dev/null +++ b/crates/cargo-anvil/docs/verification.md @@ -0,0 +1,271 @@ +# Continuous Validation Strategy + +This document defines how `cargo-anvil` is kept correct over time. The headline mechanism +is dogfooding — the `microsoft/ox-tools` repo, where cargo-anvil itself lives, uses +`cargo anvil` to manage its own cloud workflows. Every PR that touches the catalog or the +emitters produces a visible diff in `.github/` and `justfiles/anvil/`, then runs through +the regenerated cloud workflows on the same commit. A broken emitter or catalog fails the PR's own +checks immediately. + +See also: + +- [design/](./design/) — the tool's design. +- [design/updates.md](./design/updates.md) — the state machine validated by fixture tests. +- [design/checks.md](./design/checks.md) — the catalog dogfooded by ox-tools. + +## 1. Goals + +- **Detect regressions on the PR that introduces them.** No "this broke a downstream repo" + surprises after a release. +- **Validate the whole pipeline**, not just unit-level behavior: catalog → templates → + manifest → emitted cloud workflows → cloud workflows actually running. +- **Cover the state machine in [updates.md §5](./design/updates.md#5-the-decision-algorithm) + exhaustively** — every row of the decision table is exercised by some test. +- **Keep validation cheap** — most of it runs in the PR pipeline; nothing requires a + bespoke test environment. + +## 2. Layers + +### 2.1 Self-hosting (primary) + +`microsoft/ox-tools` is the canonical adopter of `cargo-anvil`. Its `.github/workflows/`, +`.github/actions/`, `justfiles/anvil/`, `[workspace.lints]` region in `Cargo.toml`, etc. +are all emitted by `cargo anvil` against the in-repo version of the binary. There +is no manual maintenance of these files after the initial migration. + +Every PR runs (via a small bootstrap workflow described in §3): + +1. `cargo build --locked -p cargo-anvil` — build the binary from source. +2. `target/debug/cargo-anvil anvil` — regenerate every owned file and managed region. +3. `git diff --exit-code` — fail with a clear message if regeneration produced changes the + PR didn't commit. +4. Continue into the normal `anvil-pr` workflow, which is itself the freshly regenerated + workflow file. + +What this validates end-to-end: + +- The catalog renders to valid YAML / TOML / `just`. +- The manifest's three-checksum state machine produces idempotent output (rerunning + `update` with no changes is a no-op). +- Every emitted cloud-workflow building block actually runs — broken composite actions, broken + reusable workflows, broken step templates surface immediately. +- The full default check catalog is exercised on every PR. ox-tools deliberately enables + every catalog check (no opt-out stubs) and the default cross-OS matrix (Linux + + Windows for test groups). + +What this doesn't catch — see §2.4. + +### 2.2 Snapshot tests (shipping today) + +Under `crates/cargo-anvil/tests/snapshots.rs`, three integration tests drive the full +emitter against a bare-workspace tempdir for representative input combinations +(`--no-backends`, `--backend github`, `--backend ado`) and snapshot the full collection of +emitted files via [`insta`][insta]. Template edits then surface as reviewable diffs in +PRs — `cargo insta review` accepts them. + +Snapshot files live committed under `tests/snapshots/`, one per backend combination. +The `.anvil.lock` manifest is filtered out of the snapshot input to keep the snapshots +stable across version bumps (the manifest carries `rendered_by = "cargo-anvil "` +which would otherwise churn on every release). + +### 2.3 Fixture-based integration tests + +Alongside the snapshot tests, a `tests/fixtures/` corpus covers +directory-tree scenarios that benefit from being reviewable as real +files on disk: + +| Fixture | What it pins | +|--------------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `single-crate/` | Non-workspace repo. Validates the `[lints]` (vs `[workspace.lints]`) branch and that the full `justfiles/anvil/` tree is written. | +| `opt-outs/` | A user-emptied managed region stays empty across re-runs (steady-state opt-out, `LeaveAlone` decision). | +| `customized/` | A user edit inside a managed region is preserved verbatim across re-runs when the template is unchanged (`LeaveAlone` decision). | +| `migration/` | A repo with pre-existing hand-written `Justfile`, `deny.toml`, and `[profile.release]` in `Cargo.toml`. Ox-check splices its regions without losing the user content. | + +`tests/update.rs` stages each fixture into a tempdir (via `walkdir` + +`std::fs::copy`), runs `run_update`, and asserts the scenario-specific +invariants above. The single-crate and migration scenarios additionally +assert idempotence — a second run produces an empty plan. + +The fixtures are complementary to the imperative scenarios in +`src/run.rs`, which seed equivalent setups inline. The on-disk fixtures +are easier to review and to copy when designing new migration paths. + +[insta]: https://crates.io/crates/insta + +### 2.4 Schema validation + +Run as part of `anvil-pr-fast` against ox-tools's emitted output: + +- **`actionlint`** on every emitted `.github/workflows/*.yml` and + `.github/actions/*/action.yml`. Catches GitHub-Actions-specific errors that plain + YAML validation misses. +- **`just --summary --unstable`** on every `justfiles/anvil/*.just`. Verifies recipes + parse and dependency graph is well-formed. +- **`taplo check`** on every TOML file anvil writes to. Verifies the post-edit file is + still parsable TOML and conforms to the cargo schema where applicable. +- **ADO YAML**: no widely-available local validator. The snapshot tests + are the contract; the manual release checklist (§2.5) covers + semantic verification against real ADO. We accept this gap because + ox-tools cannot dogfood ADO emission anyway. + +A small in-process schema-validation suite at +`crates/cargo-anvil/tests/schemas.rs` covers the subset that can be +checked without external tooling (TOML parseability of every emitted +TOML region/file, the `.anvil.lock` schema, etc.). + +### 2.5 Manual release verification + +Three things ox-tools dogfooding doesn't catch, addressed by a pre-release checklist +maintained in `docs/release-checklist.md`: + +- **Compliance-extending ADO pipelines** (1ESPT, SubstratePT, CloudBuild). Validated + manually by running `cargo anvil --dry-run` against `oxidizer`, + `assistants-oxide`, and `ox-docs` (internal mirrors) and inspecting the diff. If the + diff looks right, queue a buddy build to confirm the regenerated pipeline still + passes. +- **Cross-repo migrations**. Each release that bumps the manifest schema or renames a + catalog item runs `update --dry-run` against every surveyed repo and confirms the + proposed migration is correct. +- **Self-hosted runners and non-default matrices**. Spot-checked against `oxidizer`'s + Microsoft-pool builds before each release. + +The release checklist is a literal markdown file in the repo; checking it off is part +of the publish PR. + +## 3. The PR-time workflow + +ox-tools's `.github/workflows/anvil-pr.yml` (post-migration) wraps the regenerated +`anvil-pr-impl.yml` with a small self-validation gate. Sketch: + +```yaml +name: anvil-pr +on: + pull_request: {} + merge_group: {} +permissions: { contents: read } +jobs: + regenerate-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --locked -p cargo-anvil + - name: Regenerate emitted files + run: ./target/debug/cargo-anvil anvil + - name: Assert no drift + run: | + if ! git diff --exit-code; then + echo "::error::cargo-anvil changed files. Run 'cargo anvil' locally and commit the diff." + exit 1 + fi + + anvil: + needs: regenerate-check + uses: ./.github/workflows/anvil-pr-impl.yml +``` + +The `regenerate-check` job runs first. If a PR changes the catalog or emitter without +also committing the regenerated output, this fails with an actionable message. After +that, the standard `anvil-pr-impl.yml` reusable workflow runs every group, exactly as +in any consumer repo. + +The wrapper workflow above is the **one** hand-written workflow in ox-tools — it +bootstraps the dogfood loop. Every other cloud workflows artifact is regenerated. + +## 4. Bootstrap and breaking changes + +### Initial bootstrap + +The very first cargo-anvil PR cannot dogfood itself — the binary doesn't exist yet. +Bootstrap plan: + +1. PR #1: lands the binary's skeleton (current state, no `update` logic yet) plus + hand-written workflows for the unit and integration tests. ox-tools's cloud workflows is still + hand-written. +2. PR #N (first usable `update`): lands the emitter implementation. Run + `cargo anvil` locally, commit the diff, push. From this point forward + ox-tools is self-hosted. +3. PR #N+1 onward: every PR runs the regenerate-check gate. + +### Breaking changes inside cargo-anvil + +Two flavors require care: + +- **Manifest-schema bumps.** Migration logic must be in place before any release that + needs it. The `migration/` fixture exercises old-schema-to-new-schema upgrades. + Release notes call out the bump. +- **Renames in the emitted cloud workflows surface** (e.g., `anvil-pr-fast` → `anvil-pr-static`). + Treated as major-version bumps. The PR introducing the rename is split into two + commits: (a) implement, (b) `cargo anvil` to regenerate. Downstream repos do + the same two-step on adoption. + +Both flavors are caught by ox-tools's own regenerate-check: a missing migration or a +rename that doesn't round-trip will produce drift on the second run, failing the gate. + +### Recovering from a self-inflicted breakage + +If a PR lands that breaks the regenerate-check (because reviewers missed it), the +breakage is **not stuck** — every PR builds cargo-anvil from source on its own branch, +so the fix PR is free to either revert the offending change or land the missing +regenerated output, and its own cloud workflows will pass cleanly. + +What is affected: unrelated PRs that branched off the broken commit will fail their +regenerate-check, because they inherit the drift through the merge base. They recover +by rebasing past the fix. + +Procedure: + +1. Open a fix PR. Either: (a) revert the offending commit, or (b) commit the missing + `cargo anvil` output. Either flavor builds the binary from the fix branch + and produces a clean `git diff` against its own tree, so cloud workflows passes. +2. Merge. +3. In-flight PRs rebase to pick up the fix; their regenerate-check passes once their + merge base is past the fix commit. + +ox-tools never depends on the published crates.io version of cargo-anvil for its own +checks — it always builds from source. So a broken release on crates.io doesn't +cascade into ox-tools's cloud workflows; only a broken `main` does, and only for unrelated +in-flight PRs. + +## 5. Coverage gaps + +Acknowledged limits of this strategy: + +- **1ESPT/SubstratePT/CloudBuild composition** — ox-tools is OSS; it cannot dogfood + internal compliance harnesses. Manual release checklist covers this (§2.5). +- **Self-hosted runner pools** — ox-tools uses GH-hosted; the `runs_on` input is set + to defaults. Self-hosted shapes are documented but exercised only by the manual + release checklist. +- **macOS** — not in the default matrix (see [design.md §8.3](./design/design.md#83-cross-os-test-matrices)); + not dogfooded. +- **Very deep workspaces or unusual layouts** — covered only by fixtures, not by + real-world traffic. New layouts that adopters surface become new fixtures. +- **Long-lived divergence** — a repo that's been on an old cargo-anvil version for + many releases is only validated by the cross-repo migration step in §2.5. + +## 6. Files and locations + +| Path | Purpose | +|-----------------------------------------------------|-------------------------------------------------------------------------| +| `crates/cargo-anvil/tests/snapshots.rs` | Snapshot tests over the three backend combinations (insta). | +| `crates/cargo-anvil/tests/snapshots/` | Committed snapshot files (one per backend combination). | +| `crates/cargo-anvil/tests/fixtures/` | Integration test fixtures (one directory per shape). | +| `crates/cargo-anvil/tests/update.rs` | Per-fixture assertions for opt-outs, customizations, migrations, single-crate. | +| `crates/cargo-anvil/tests/schemas.rs` | actionlint / taplo / just-parse wrappers run against the emitted output. | +| `.github/workflows/anvil-pr.yml` | Hand-written self-validation wrapper (the one bootstrap file). | +| `.github/workflows/anvil-pr-impl.yml` (and friends) | Regenerated by `cargo anvil`. Subject to the regenerate-check. | +| `justfiles/anvil/*.just` | Regenerated. Subject to the regenerate-check. | +| `Cargo.toml` (anvil-workspace-lints region) | Regenerated. Subject to the regenerate-check. | +| `.anvil.lock` | The manifest itself. Diffed on every PR. | +| `docs/release-checklist.md` | Pre-publish checks for things dogfooding misses. | + +## 7. Future work + +- A dedicated `cargo anvil verify` subcommand that runs `update --dry-run` + all + schema validators + the manifest-consistency check in one step, for local + pre-commit use. +- A small set of "downstream canary" repos in `microsoft/` org that pin cargo-anvil + to `main` (not to a release) and report failures via issues. Catches regressions + earlier than the manual release checklist. +- Pre-release smoke runs of every adopter's cloud workflows against a release candidate, gated by + a draft pre-release tag. diff --git a/crates/cargo-anvil/src/backend.rs b/crates/cargo-anvil/src/backend.rs new file mode 100644 index 00000000..dff0cd84 --- /dev/null +++ b/crates/cargo-anvil/src/backend.rs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! cloud-workflow backend identification and autodetection. +//! +//! `cargo-anvil` emits files for one or more cloud-workflow backends (`github`, `ado`). +//! The set of backends is chosen by, in order: +//! +//! 1. Explicit `--backend ` flag(s). +//! 2. Explicit `--no-backends` switch (yields the empty set). +//! 3. Autodetection from the `origin` git remote URL. +//! +//! See [`design.md §5.2`](../../docs/design/design.md) for the resolution order. + +use std::path::Path; +use std::process::Command; + +use ohno::{AppError, IntoAppError as _, app_err}; + +/// Supported cloud-workflow backends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Backend { + /// GitHub Actions. + GitHub, + /// Azure DevOps Pipelines. + Ado, +} + +impl Backend { + /// Canonical lowercase name as used on the command line. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::GitHub => "github", + Self::Ado => "ado", + } + } + + /// Parse a backend name as accepted by `--backend`. + /// + /// # Errors + /// + /// Returns an error for any name other than `github` or `ado`. + pub fn parse(name: &str) -> Result { + match name { + "github" => Ok(Self::GitHub), + "ado" => Ok(Self::Ado), + other => Err(app_err!("unknown backend '{other}' (valid values: github, ado)")), + } + } +} + +/// Autodetect backends from a git remote URL. +/// +/// Returns an empty `Vec` if the host is unrecognized. The caller decides +/// whether an empty result is an error. +#[must_use] +pub fn detect_from_url(url: &str) -> Vec { + if let Some(host) = extract_host(url) { + if host == "github.com" || host.ends_with(".github.com") { + return vec![Backend::GitHub]; + } + if host == "dev.azure.com" || host == "ssh.dev.azure.com" || host.ends_with(".visualstudio.com") { + return vec![Backend::Ado]; + } + } + Vec::new() +} + +/// Extract the host portion of a git URL. +/// +/// Handles the three common forms: +/// - `https://host/owner/repo[.git]` +/// - `ssh://user@host/path` +/// - `user@host:owner/repo[.git]` (the scp-style shorthand) +fn extract_host(url: &str) -> Option<&str> { + let url = url.trim(); + if url.is_empty() { + return None; + } + + // scheme://[user@]host[:port]/path + if let Some(scheme_end) = url.find("://") + && scheme_end > 0 + { + let after_scheme = url.get(scheme_end + 3..).unwrap_or_default(); + let authority_end = after_scheme.find('/').unwrap_or(after_scheme.len()); + let authority = &after_scheme[..authority_end]; + let host_start = authority.rfind('@').map_or(0, |i| i + 1); + let host_part = &authority[host_start..]; + let host_end = host_part.find(':').unwrap_or(host_part.len()); + let host = &host_part[..host_end]; + return (!host.is_empty()).then_some(host); + } + + // scp-style: user@host:path + if let Some(at_idx) = url.find('@') + && let Some(colon_idx) = url[at_idx + 1..].find(':') + { + let host = &url[at_idx + 1..at_idx + 1 + colon_idx]; + return (!host.is_empty()).then_some(host); + } + + None +} + +/// Read the `origin` remote URL via `git config`. +/// +/// # Errors +/// +/// Returns an error if `git` is not on PATH, the command exits non-zero, or +/// no `origin` remote is configured. +#[mutants::skip] // Shells out to `git config`; behavior tested via backend autodetect integration paths, not unit-mutatable. +pub fn read_origin_url(repo_root: &Path) -> Result { + let output = Command::new("git") + .args(["config", "--get", "remote.origin.url"]) + .current_dir(repo_root) + .output() + .into_app_err("failed to invoke `git config` — is git installed and on PATH?")?; + + if !output.status.success() { + return Err(app_err!( + "`git config --get remote.origin.url` exited with {} in {}", + output.status, + repo_root.display() + )); + } + + let url = String::from_utf8(output.stdout) + .into_app_err("git config output was not valid UTF-8")? + .trim() + .to_owned(); + + if url.is_empty() { + return Err(app_err!("no `origin` remote configured in {}", repo_root.display())); + } + + Ok(url) +} + +/// Resolve the effective backend set from CLI flags plus autodetection. +/// +/// Resolution order: +/// 1. `--no-backends` → empty set. +/// 2. Explicit `--backend ` flags. +/// 3. Autodetect from `origin`. +/// +/// # Errors +/// +/// - Returns an error if a `--backend` name is invalid. +/// - Returns an error if no backends are specified, `--no-backends` is not +/// set, and autodetection fails (unrecognized host or no remote). +pub fn resolve(flag_backends: &[String], no_backends: bool, repo_root: &Path) -> Result, AppError> { + if no_backends { + return Ok(Vec::new()); + } + + if !flag_backends.is_empty() { + let mut parsed = Vec::with_capacity(flag_backends.len()); + for name in flag_backends { + parsed.push(Backend::parse(name)?); + } + parsed.sort_unstable(); + parsed.dedup(); + return Ok(parsed); + } + + let url = read_origin_url(repo_root)?; + let detected = detect_from_url(&url); + if detected.is_empty() { + return Err(app_err!( + "could not autodetect a cloud-workflow backend from origin URL '{url}'. \ + Pass --backend github|ado explicitly, or --no-backends." + )); + } + Ok(detected) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_backend_names() { + assert_eq!(Backend::parse("github").unwrap(), Backend::GitHub); + assert_eq!(Backend::parse("ado").unwrap(), Backend::Ado); + Backend::parse("gitlab").unwrap_err(); + Backend::parse("").unwrap_err(); + } + + #[test] + fn backend_names_roundtrip() { + assert_eq!(Backend::GitHub.name(), "github"); + assert_eq!(Backend::Ado.name(), "ado"); + } + + #[test] + fn extract_host_https() { + assert_eq!(extract_host("https://github.com/foo/bar.git"), Some("github.com")); + assert_eq!(extract_host("https://dev.azure.com/org/proj/_git/repo"), Some("dev.azure.com")); + assert_eq!( + extract_host("https://acme.visualstudio.com/proj/_git/repo"), + Some("acme.visualstudio.com") + ); + } + + #[test] + fn extract_host_ssh_url() { + assert_eq!(extract_host("ssh://git@github.com:22/foo/bar.git"), Some("github.com")); + assert_eq!( + extract_host("ssh://git@ssh.dev.azure.com/v3/org/proj/repo"), + Some("ssh.dev.azure.com") + ); + } + + #[test] + fn extract_host_scp_style() { + assert_eq!(extract_host("git@github.com:foo/bar.git"), Some("github.com")); + assert_eq!(extract_host("git@ssh.dev.azure.com:v3/org/proj/repo"), Some("ssh.dev.azure.com")); + } + + #[test] + fn extract_host_handles_garbage() { + assert_eq!(extract_host(""), None); + assert_eq!(extract_host(" "), None); + assert_eq!(extract_host("not-a-url"), None); + assert_eq!(extract_host("://nohost"), None); + } + + #[test] + fn detect_github() { + assert_eq!(detect_from_url("https://github.com/foo/bar.git"), vec![Backend::GitHub]); + assert_eq!(detect_from_url("git@github.com:foo/bar.git"), vec![Backend::GitHub]); + } + + #[test] + fn detect_ado() { + assert_eq!(detect_from_url("https://dev.azure.com/org/proj/_git/repo"), vec![Backend::Ado]); + assert_eq!(detect_from_url("https://acme.visualstudio.com/proj/_git/repo"), vec![Backend::Ado]); + assert_eq!(detect_from_url("ssh://git@ssh.dev.azure.com/v3/org/proj/repo"), vec![Backend::Ado]); + } + + #[test] + fn detect_unknown_host() { + assert!(detect_from_url("https://gitlab.com/foo/bar.git").is_empty()); + assert!(detect_from_url("").is_empty()); + } + + #[test] + fn resolve_no_backends_wins() { + let result = resolve(&[], true, Path::new(".")).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn resolve_explicit_backends_skip_autodetect() { + let result = resolve(&["github".to_owned(), "ado".to_owned()], false, Path::new("/nonexistent")).unwrap(); + assert_eq!(result, vec![Backend::GitHub, Backend::Ado]); + } + + #[test] + fn resolve_explicit_backends_deduplicate() { + let result = resolve(&["github".to_owned(), "github".to_owned()], false, Path::new("/nonexistent")).unwrap(); + assert_eq!(result, vec![Backend::GitHub]); + } + + #[test] + fn resolve_invalid_backend_name() { + let result = resolve(&["gitlab".to_owned()], false, Path::new("/nonexistent")); + let err = result.unwrap_err().to_string(); + assert!(err.contains("unknown backend 'gitlab'")); + } +} diff --git a/crates/cargo-anvil/src/checksum.rs b/crates/cargo-anvil/src/checksum.rs new file mode 100644 index 00000000..6c4da3b4 --- /dev/null +++ b/crates/cargo-anvil/src/checksum.rs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! SHA-256 helpers. +//! +//! All checksums in `cargo-anvil` are stored as the string +//! `sha256:`. This module centralizes hashing so the prefix +//! and encoding are guaranteed consistent across the codebase. +//! +//! **Line endings are normalized to LF before hashing** so that a file +//! committed once produces the same checksum regardless of host OS or +//! Git's `core.autocrlf` setting. Without normalization, a region +//! authored on Windows (CRLF source under `autocrlf=true`) and validated +//! on Linux (LF after git normalizes the commit) would always diverge +//! and confuse the three-checksum decision algorithm. + +use sha2::{Digest as _, Sha256}; + +/// Compute the canonical checksum string for a byte slice. +/// +/// CRLF byte pairs are replaced with LF before hashing so the result +/// is invariant under line-ending conversion (see module docs). +#[must_use] +pub fn checksum_bytes(data: &[u8]) -> String { + let normalized = normalize_line_endings(data); + let digest = Sha256::digest(&normalized); + let mut s = String::with_capacity(7 + digest.len() * 2); + s.push_str("sha256:"); + for byte in digest { + // 0..=15 always fits in the lookup; no panic possible. + s.push(HEX[(byte >> 4) as usize] as char); + s.push(HEX[(byte & 0x0f) as usize] as char); + } + s +} + +/// Compute the canonical checksum string for a UTF-8 string. +/// +/// CRLF sequences are replaced with LF before hashing so the result +/// is invariant under line-ending conversion (see module docs). +#[must_use] +pub fn checksum_str(data: &str) -> String { + checksum_bytes(data.as_bytes()) +} + +const HEX: &[u8; 16] = b"0123456789abcdef"; + +/// Replace every CRLF (`\r\n`) byte pair with a single LF (`\n`). +/// +/// Bare CR bytes are left alone — they're vanishingly rare in +/// modern source trees, and treating them specially would risk +/// false-equating distinct content. +fn normalize_line_endings(data: &[u8]) -> Vec { + // Pre-allocate optimistically; the output is the same length as + // the input minus one byte per CRLF. + let mut out = Vec::with_capacity(data.len()); + let mut i = 0; + while i < data.len() { + // Progress guard: each iteration must advance `i`. Catches + // infinite-loop regressions (and infinite-loop mutants generated + // by `cargo mutants` against the `+=` operators below) in debug + // builds. + let prev = i; + if data[i] == b'\r' && data.get(i + 1) == Some(&b'\n') { + out.push(b'\n'); + i += 2; + } else { + out.push(data[i]); + i += 1; + } + debug_assert!(i > prev, "normalize_line_endings must make progress (prev={prev}, i={i})"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_input_has_known_digest() { + // SHA-256 of empty input. + assert_eq!( + checksum_bytes(b""), + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn abc_has_known_digest() { + assert_eq!( + checksum_str("abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn checksum_is_deterministic() { + let a = checksum_str("hello world"); + let b = checksum_str("hello world"); + assert_eq!(a, b); + } + + #[test] + fn different_input_yields_different_checksum() { + assert_ne!(checksum_str("a"), checksum_str("b")); + } + + #[test] + fn prefix_is_always_sha256() { + assert!(checksum_str("anything").starts_with("sha256:")); + } + + #[test] + fn crlf_and_lf_yield_same_checksum() { + // The core portability invariant: the same logical content + // hashes identically regardless of line-ending convention. + let lf = "line one\nline two\nline three\n"; + let crlf = "line one\r\nline two\r\nline three\r\n"; + assert_eq!(checksum_str(lf), checksum_str(crlf)); + } + + #[test] + fn mixed_line_endings_normalize_consistently() { + let mixed = "line one\r\nline two\nline three\r\n"; + let lf_only = "line one\nline two\nline three\n"; + assert_eq!(checksum_str(mixed), checksum_str(lf_only)); + } + + #[test] + fn bare_cr_is_preserved() { + // A lone CR (not followed by LF) is real content, not a line + // ending convention. Leave it alone so distinct strings stay + // distinct. + let with_cr = "old\rmac\rstyle"; + let with_n = "old\nmac\nstyle"; + assert_ne!(checksum_str(with_cr), checksum_str(with_n)); + } + + #[test] + fn normalize_keeps_lf_unchanged() { + assert_eq!(normalize_line_endings(b"a\nb\nc"), b"a\nb\nc"); + } + + #[test] + fn normalize_collapses_crlf() { + assert_eq!(normalize_line_endings(b"a\r\nb\r\nc"), b"a\nb\nc"); + } + + #[test] + fn normalize_preserves_bare_cr() { + assert_eq!(normalize_line_endings(b"a\rb\rc"), b"a\rb\rc"); + } + + #[test] + fn normalize_handles_trailing_cr() { + // A CR at the very end has no LF to pair with, so it stays. + assert_eq!(normalize_line_endings(b"abc\r"), b"abc\r"); + } +} diff --git a/crates/cargo-anvil/src/cli.rs b/crates/cargo-anvil/src/cli.rs new file mode 100644 index 00000000..3b74c51c --- /dev/null +++ b/crates/cargo-anvil/src/cli.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Command-line interface for `cargo-anvil`. +//! +//! The entry binary is invoked as `cargo anvil …`. Cargo passes +//! `anvil` as the first argument; we strip it and parse the +//! remainder. + +use clap::Parser; + +/// Parsed top-level CLI. +/// +/// Constructed via [`Cli::parse_from_cargo_args`], which strips the leading +/// `anvil` token that Cargo injects when the binary is invoked as a +/// subcommand. +/// +/// The tool intentionally has a single action (update local recipes, +/// cloud-workflow building blocks, and managed regions), so the flags live at the top +/// level rather than under a subcommand. +#[derive(Debug, Parser, Clone, Default)] +#[command( + name = "cargo-anvil", + bin_name = "cargo anvil", + about = "Update local recipes, cloud-workflow building blocks, and managed regions for the anvil unified build setup", + version, + disable_help_subcommand = true +)] +pub struct Cli { + /// cloud-workflow backend(s) to emit. Repeatable. Valid values: `github`, `ado`. + /// + /// If omitted and `--no-backends` is not set, the backend is autodetected + /// from the `origin` git remote. + #[arg(long = "backend", value_name = "NAME")] + pub backends: Vec, + + /// Emit only local files; skip every cloud-workflow backend. + /// + /// Mutually exclusive with `--backend`. + #[arg(long, conflicts_with = "backends")] + pub no_backends: bool, + + /// Analyze and report without writing any files. + /// + /// Exits with code 1 if anything would be written or proposed. + #[arg(long)] + pub dry_run: bool, +} + +impl Cli { + /// Parse the CLI from the raw `std::env::args_os` iterator that cargo + /// passes to its subcommand binaries. + /// + /// Cargo invokes `cargo-anvil anvil ` when the + /// user types `cargo anvil `. We drop the + /// `anvil` token if present so that clap sees a normal argv. + /// + /// # Errors + /// + /// Returns clap's parse error (typically with an exit code already + /// encoded) on invalid input. + pub fn parse_from_cargo_args(args: I) -> Result + where + I: IntoIterator, + T: Into + Clone, + { + let mut iter = args.into_iter().map(Into::::into); + let exe = iter.next(); + let mut rest: Vec = iter.collect(); + if rest.first().is_some_and(|a| a == "anvil") { + rest.remove(0); + } + let argv_iter = exe.into_iter().chain(rest); + Self::try_parse_from(argv_iter) + } +} + +#[cfg(test)] +mod tests { + use clap::Parser as _; + + use super::*; + + #[test] + fn parse_no_args() { + let cli = Cli::parse_from(["cargo-anvil"]); + assert!(cli.backends.is_empty()); + assert!(!cli.no_backends); + assert!(!cli.dry_run); + } + + #[test] + fn parse_dry_run() { + let cli = Cli::parse_from(["cargo-anvil", "--dry-run"]); + assert!(cli.dry_run); + } + + #[test] + fn parse_single_backend() { + let cli = Cli::parse_from(["cargo-anvil", "--backend", "github"]); + assert_eq!(cli.backends, vec!["github"]); + } + + #[test] + fn parse_multiple_backends() { + let cli = Cli::parse_from(["cargo-anvil", "--backend", "github", "--backend", "ado"]); + assert_eq!(cli.backends, vec!["github", "ado"]); + } + + #[test] + fn parse_no_backends() { + let cli = Cli::parse_from(["cargo-anvil", "--no-backends"]); + assert!(cli.no_backends); + } + + #[test] + fn backend_and_no_backends_conflict() { + let err = Cli::try_parse_from(["cargo-anvil", "--backend", "github", "--no-backends"]).unwrap_err(); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + #[test] + fn parse_from_cargo_args_strips_subcommand_token() { + let cli = Cli::parse_from_cargo_args(["cargo-anvil", "anvil", "--dry-run"]).unwrap(); + assert!(cli.dry_run); + } + + #[test] + fn parse_from_cargo_args_works_without_subcommand_token() { + let cli = Cli::parse_from_cargo_args(["cargo-anvil", "--dry-run"]).unwrap(); + assert!(cli.dry_run); + } + + #[test] + fn unknown_backend_value_accepted_at_parse_time() { + // Validation of backend names is the resolver's job, not clap's. + let cli = Cli::parse_from(["cargo-anvil", "--backend", "weird"]); + assert_eq!(cli.backends, vec!["weird"]); + } +} diff --git a/crates/cargo-anvil/src/decision.rs b/crates/cargo-anvil/src/decision.rs new file mode 100644 index 00000000..4b98ac4d --- /dev/null +++ b/crates/cargo-anvil/src/decision.rs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The three-checksum decision algorithm. +//! +//! For every owned file and every managed region, `cargo-anvil` makes a +//! single decision per run by comparing three inputs: +//! +//! - `last_rendered` (`L`) — what the manifest says anvil wrote last +//! time, or `None` if never seen. +//! - `disk` (`D`) — what is on disk right now. `None` if the file is +//! missing or the region's host file is missing. +//! - `template` (`T`) — what anvil's current catalog would render right +//! now. +//! +//! Opt-out via emptying needs no separate flag: an empty file or +//! whitespace-only region body has a stable checksum that no template +//! ever produces, so `D ≠ L` lands the item in `LeaveAlone` (when the +//! template is unchanged) or `Propose` (when it has moved). Both +//! outcomes preserve the user's empty stub. +//! +//! See [`updates.md §5`](../../docs/design/updates.md) for the decision table. + +/// Inputs to one decision. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecisionInputs<'a> { + /// Checksum of the last-rendered content per the manifest. `None` if + /// the item is new (never tracked). + pub last_rendered: Option<&'a str>, + /// Checksum of the current on-disk content. `None` if the host file + /// is missing entirely. + pub disk: Option<&'a str>, + /// Checksum of what the current template would render. + pub template: &'a str, +} + +/// Decision the driver should carry out for one item. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// Item is in sync with the current template. Do nothing; manifest + /// entry stays. + InSync, + /// Render the current template to disk and refresh the manifest. + Write, + /// User has diverged AND the template changed since last render — + /// write a `.anvil-proposed` sibling and leave the user's content + /// alone. Manifest stays unchanged. + Propose, + /// User has diverged but the template hasn't changed; leave the + /// user's content alone with no proposed file. Manifest stays + /// unchanged. Also the steady-state outcome for opt-out (empty file + /// or empty region body) when the template hasn't moved. + LeaveAlone, + /// Item was in the previous manifest but is no longer in the + /// catalog. The on-disk content still matches `last_rendered`, so + /// the user has not customized it — safe to delete (file) or + /// splice out (region). Manifest entry is dropped. + Remove, + /// Item was in the previous manifest, is no longer in the catalog, + /// and the user has customized it since the last render. Leave the + /// file/region in place but drop the manifest entry so ownership + /// transfers to the user (no more anvil tracking). + OrphanedKept, +} + +impl Decision { + /// Whether this decision means "everything is in sync" for `--dry-run` + /// exit-code purposes. + #[must_use] + pub const fn is_in_sync(self) -> bool { + matches!(self, Self::InSync | Self::LeaveAlone) + } + + /// Whether this decision causes writes (the file or the manifest). + #[must_use] + pub const fn writes(self) -> bool { + matches!(self, Self::Write | Self::Propose | Self::Remove | Self::OrphanedKept) + } +} + +/// Compute the decision for one item. +#[must_use] +pub fn decide(inputs: &DecisionInputs<'_>) -> Decision { + match (inputs.disk, inputs.last_rendered) { + // The on-disk file (or host file) is missing entirely. Either the + // user deleted it to re-bless, or it has never been written. + // Either way, we render. + (None, _) => Decision::Write, + + // Item exists on disk. Compare against template & manifest. + (Some(d), _) if d == inputs.template => Decision::InSync, + (Some(d), Some(l)) if d == l => { + // User hasn't touched it since last render, and it doesn't + // match the current template => template moved on, so write. + Decision::Write + } + (Some(_), Some(l)) if l == inputs.template => { + // User has diverged but the template hasn't changed since + // last render. Don't pester them. (This is also the + // steady-state outcome for opt-out: empty content stays + // empty, template hasn't moved.) + Decision::LeaveAlone + } + (Some(_), Some(_)) => { + // User diverged AND template moved. Propose. + Decision::Propose + } + (Some(_), None) => { + // First time we're tracking this item, but the user already + // has content there that doesn't match the template. Treat as + // adoption-with-divergence: propose. + Decision::Propose + } + } +} + +/// Compute the decision for one previously-tracked item that is no +/// longer in the catalog. +/// +/// - If the on-disk content is missing, the item is already gone; +/// treat the residual manifest entry as in-sync (no plan item needed, +/// just purge the manifest). +/// - If the on-disk content matches `last_rendered`, the user hasn't +/// touched it since anvil wrote it. Safe to remove. +/// - Otherwise the user has customized; transfer ownership and leave +/// the content in place. +#[must_use] +pub fn decide_removal(last_rendered: &str, disk: Option<&str>) -> Decision { + match disk { + None => Decision::InSync, + Some(d) if d == last_rendered => Decision::Remove, + Some(_) => Decision::OrphanedKept, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn inputs<'a>(last: Option<&'a str>, disk: Option<&'a str>, template: &'a str) -> DecisionInputs<'a> { + DecisionInputs { + last_rendered: last, + disk, + template, + } + } + + #[test] + fn missing_disk_writes() { + assert_eq!(decide(&inputs(None, None, "T")), Decision::Write); + assert_eq!(decide(&inputs(Some("L"), None, "T")), Decision::Write); + } + + #[test] + fn disk_matches_template_in_sync() { + assert_eq!(decide(&inputs(Some("L"), Some("T"), "T")), Decision::InSync); + assert_eq!(decide(&inputs(None, Some("T"), "T")), Decision::InSync); + } + + #[test] + fn disk_matches_last_template_changed_writes() { + assert_eq!(decide(&inputs(Some("L"), Some("L"), "T")), Decision::Write); + } + + #[test] + fn user_diverged_template_unchanged_leaves_alone() { + assert_eq!(decide(&inputs(Some("L"), Some("D"), "L")), Decision::LeaveAlone); + } + + #[test] + fn user_diverged_template_changed_proposes() { + assert_eq!(decide(&inputs(Some("L"), Some("D"), "T")), Decision::Propose); + } + + #[test] + fn first_time_with_existing_user_content_proposes() { + assert_eq!(decide(&inputs(None, Some("D"), "T")), Decision::Propose); + } + + #[test] + fn opt_out_via_empty_steady_state_leaves_alone() { + // After a successful render the manifest has L = template. The + // user empties the file (D = empty-checksum, never equal to L). + // Template hasn't moved (T == L). Result: LeaveAlone, silent. + let empty = "sha256:empty"; + let tmpl = "sha256:template"; + assert_eq!(decide(&inputs(Some(tmpl), Some(empty), tmpl)), Decision::LeaveAlone); + } + + #[test] + fn opt_out_via_empty_with_template_change_proposes() { + // Same as above but the template has since moved — the user gets + // a proposed sibling so they can see what's new. + let empty = "sha256:empty"; + assert_eq!(decide(&inputs(Some("sha256:old"), Some(empty), "sha256:new")), Decision::Propose); + } + + #[test] + fn decision_in_sync_predicate() { + assert!(Decision::InSync.is_in_sync()); + assert!(Decision::LeaveAlone.is_in_sync()); + assert!(!Decision::Write.is_in_sync()); + assert!(!Decision::Propose.is_in_sync()); + assert!(!Decision::Remove.is_in_sync()); + assert!(!Decision::OrphanedKept.is_in_sync()); + } + + #[test] + fn decision_writes_predicate() { + assert!(!Decision::InSync.writes()); + assert!(!Decision::LeaveAlone.writes()); + assert!(Decision::Write.writes()); + assert!(Decision::Propose.writes()); + assert!(Decision::Remove.writes()); + assert!(Decision::OrphanedKept.writes()); + } + + #[test] + fn removal_missing_disk_is_in_sync() { + // Already gone — no action needed beyond manifest purge. + assert_eq!(decide_removal("sha256:abc", None), Decision::InSync); + } + + #[test] + fn removal_untouched_disk_removes() { + // Disk matches what we wrote last; safe to delete. + assert_eq!(decide_removal("sha256:abc", Some("sha256:abc")), Decision::Remove); + } + + #[test] + fn removal_customized_disk_orphans() { + // User edited since last render — keep, transfer ownership. + assert_eq!(decide_removal("sha256:abc", Some("sha256:xyz")), Decision::OrphanedKept); + } +} diff --git a/crates/cargo-anvil/src/emit/ado.rs b/crates/cargo-anvil/src/emit/ado.rs new file mode 100644 index 00000000..a9ad73e0 --- /dev/null +++ b/crates/cargo-anvil/src/emit/ado.rs @@ -0,0 +1,328 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Azure DevOps Pipelines backend emitter. +//! +//! Emits three layers per [`ado.md`](../../../docs/design/ado.md): +//! +//! 1. Step templates under `.pipelines/anvil/steps/*.yml`. +//! 2. Stages templates (`pr.yml`, `nightly.yml`). +//! 3. Root pipelines (`anvil-pr.yml`, `anvil-scheduled.yml`). + +use std::path::Path; + +use ohno::AppError; + +use super::owned_file::plan_owned_file; +use crate::manifest::Manifest; +use crate::plan::PlanItem; + +/// Embedded body of the shared setup step template. +pub const SETUP_STEP: &str = include_str!("../../templates/ado/steps/setup.yml"); + +/// Embedded body of the cargo-delta impact step template. +pub const IMPACT_STEP: &str = include_str!("../../templates/ado/steps/impact.yml"); + +/// Embedded body of the advisory-comments step template. +/// +/// Posts/closes sticky PR comments for advisory checks (see +/// [`checks.md §6`](../../../docs/design/checks.md#6-advisory-pr-comments) +/// and [`ado.md §11`](../../../docs/design/ado.md#11-advisory-pr-comments)). +/// Referenced from the `pr_fast` Linux job in `pr-stages.yml` via +/// `- template: steps/advisory-comments.yml`. +pub const ADVISORY_COMMENTS_STEP: &str = include_str!("../../templates/ado/steps/advisory-comments.yml"); + +/// Embedded body of the dirty-file job wrapper. +/// +/// Every job in `pr.yml` / `nightly.yml` is rendered through this +/// wrapper; 1ESPT (and similar extension-template) users take ownership +/// of it to inject `templateContext:` blocks without forking the owned +/// stages templates. See [`ado.md §4`](../../../docs/design/ado.md#4-owned-stages-templates). +pub const JOB_WRAPPER: &str = include_str!("../../templates/ado/steps/job.yml"); + +/// Embedded body of the PR-tier stages template. +pub const PR_STAGES: &str = include_str!("../../templates/ado/pr-stages.yml"); + +/// Embedded body of the scheduled-tier stages template. +pub const SCHEDULED_STAGES: &str = include_str!("../../templates/ado/scheduled-stages.yml"); + +/// Embedded body of the PR root pipeline. +pub const PR_ROOT_PIPELINE: &str = include_str!("../../templates/ado/pr-root-pipeline.yml"); + +/// Embedded body of the scheduled root pipeline. +pub const SCHEDULED_ROOT_PIPELINE: &str = include_str!("../../templates/ado/scheduled-root-pipeline.yml"); + +/// All check groups that get a per-group step template. +/// +/// See [`emit::github::GROUPS`](super::github::GROUPS) for the +/// rationale around splitting `pr-slow` into three cloud-workflow-visible +/// sub-stages (`pr-test`, `pr-runtime-analysis`, `pr-mutants`) that run in +/// parallel. The `anvil-pr-slow` umbrella recipe is preserved in +/// `groups.just` for local convenience but does not appear as a +/// discrete cloud-workflow stage here. +pub const GROUPS: &[&str] = &[ + "pr-fast", + "pr-test", + "pr-runtime-analysis", + "pr-mutants", + "scheduled-test", + "scheduled-advisories", + "scheduled-exhaustive", +]; + +/// Embedded template for one per-group step. `__GROUP__` is substituted +/// with the group name at emit time. +pub const GROUP_STEP_TEMPLATE: &str = include_str!("../../templates/ado/steps/group.yml"); + +/// Placeholder token the per-group template uses for the group name. +const GROUP_PLACEHOLDER: &str = "__GROUP__"; + +/// Render the step template for one group. +/// +/// Substitutes the group name into [`GROUP_STEP_TEMPLATE`]. The +/// resulting template: +/// +/// - Skips itself if the `skip` parameter is `'true'` (set from the +/// impact stage's `skip` output in the stages template). +/// - Sets `ANVIL_EXCLUDES` from the `excludes` parameter. +/// - Invokes `just anvil-` via bash. +#[must_use] +pub fn render_group_step(group: &str) -> String { + GROUP_STEP_TEMPLATE.replace(GROUP_PLACEHOLDER, group) +} + +/// Repo-root-relative path for one group's step template. +#[must_use] +pub fn group_step_path(group: &str) -> String { + format!(".pipelines/anvil/steps/{group}.yml") +} + +/// Plan the two stages templates. +/// +/// # Errors +/// +/// Propagates I/O errors from the owned-file driver. +pub fn plan_stages_templates(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_owned_file(repo_root, manifest, ".pipelines/anvil/pr.yml", PR_STAGES)?, + plan_owned_file(repo_root, manifest, ".pipelines/anvil/scheduled.yml", SCHEDULED_STAGES)?, + ]) +} + +/// Plan the two root pipelines. +/// +/// # Errors +/// +/// Propagates I/O errors from the owned-file driver. +pub fn plan_root_pipelines(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_owned_file(repo_root, manifest, ".pipelines/anvil-pr.yml", PR_ROOT_PIPELINE)?, + plan_owned_file(repo_root, manifest, ".pipelines/anvil-scheduled.yml", SCHEDULED_ROOT_PIPELINE)?, + ]) +} + +/// Plan every file the ADO backend emits. +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +pub fn plan_ado_backend(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + let mut items = Vec::new(); + items.extend(plan_step_templates(repo_root, manifest)?); + items.extend(plan_stages_templates(repo_root, manifest)?); + items.extend(plan_root_pipelines(repo_root, manifest)?); + Ok(items) +} + +/// Plan every step template: setup, impact, and the seven per-group steps. +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +pub fn plan_step_templates(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + let mut items = Vec::with_capacity(GROUPS.len() + 4); + items.push(plan_owned_file( + repo_root, + manifest, + ".pipelines/anvil/steps/setup.yml", + SETUP_STEP, + )?); + items.push(plan_owned_file( + repo_root, + manifest, + ".pipelines/anvil/steps/impact.yml", + IMPACT_STEP, + )?); + items.push(plan_owned_file( + repo_root, + manifest, + ".pipelines/anvil/steps/advisory-comments.yml", + ADVISORY_COMMENTS_STEP, + )?); + items.push(plan_owned_file(repo_root, manifest, ".pipelines/anvil/steps/job.yml", JOB_WRAPPER)?); + for group in GROUPS { + let body = render_group_step(group); + items.push(plan_owned_file(repo_root, manifest, &group_step_path(group), &body)?); + } + Ok(items) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::decision::Decision; + + #[test] + fn setup_and_impact_step_templates_are_non_empty() { + assert!(SETUP_STEP.contains("just anvil-setup")); + assert!(IMPACT_STEP.contains("cargo-delta")); + assert!(IMPACT_STEP.contains("##vso[task.setvariable")); + } + + #[test] + fn setup_step_takes_group_parameter_and_dispatches() { + // group="" -> full catalog; group="none" -> skip; else -> per-group. + assert!(SETUP_STEP.contains("name: group")); + assert!(SETUP_STEP.contains("just anvil-setup")); + assert!(SETUP_STEP.contains("just anvil-${{ parameters.group }}-setup")); + assert!(SETUP_STEP.contains("eq(parameters.group, 'none')")); + } + + #[test] + fn group_step_passes_group_to_setup() { + let body = render_group_step("pr-fast"); + assert!(body.contains("template: setup.yml")); + assert!(body.contains("group: pr-fast")); + } + + #[test] + fn impact_step_uses_group_none_and_installs_only_cargo_delta() { + assert!(IMPACT_STEP.contains("group: none")); + assert!(IMPACT_STEP.contains("anvil-tool-cargo-delta-install")); + // The old inline install line is gone. + assert!(!IMPACT_STEP.contains("cargo install --locked cargo-delta")); + } + + #[test] + fn job_wrapper_declares_expected_contract() { + // Contract is intentionally small and stable: name, pool, steps, + // artifacts. Anything more elaborate is the user's responsibility + // once they take ownership of the wrapper. + for needle in [ + "name: name", + "name: pool", + "name: steps", + "type: stepList", + "name: artifacts", + "PublishPipelineArtifact@1", + ] { + assert!(JOB_WRAPPER.contains(needle), "wrapper missing '{needle}'"); + } + } + + #[test] + fn render_group_step_has_include_inputs_and_env() { + let body = render_group_step("pr-fast"); + assert!(body.contains("parameters:")); + assert!(body.contains("name: include_modified")); + assert!(body.contains("name: include_affected")); + assert!(body.contains("name: include_required")); + assert!(body.contains("just anvil-pr-fast")); + assert!(body.contains("ANVIL_INCLUDE_MODIFIED")); + assert!(body.contains("ANVIL_INCLUDE_AFFECTED")); + assert!(body.contains("ANVIL_INCLUDE_REQUIRED")); + // anvil-pr-title (in pr-fast) reads PR_TITLE; group.yml + // injects it uniformly so the per-group template stays simple. + assert!(body.contains("PR_TITLE: $(System.PullRequest.Title)")); + } + + #[test] + fn group_step_path_is_under_pipelines() { + assert_eq!(group_step_path("scheduled-test"), ".pipelines/anvil/steps/scheduled-test.yml"); + } + + #[test] + fn pr_stages_has_impact_and_group_stages() { + // pr_test / pr_runtime_analysis / pr_mutants run as independent + // stages in parallel. The umbrella `pr_slow` stage no longer + // exists. + for needle in [ + "stage: impact_linux", + "stage: impact_windows", + "stage: pr_fast", + "stage: pr_test", + "stage: pr_runtime_analysis", + "stage: pr_mutants", + ] { + assert!(PR_STAGES.contains(needle), "PR stages missing '{needle}'"); + } + // Stale historical names must not reappear. + for needle in ["stage: pr_slow\n", "stage: pr_slow1\n", "stage: pr_slow2\n", "stage: pr_slow3\n"] { + assert!( + !PR_STAGES.contains(needle), + "Stale stage '{needle}' should be gone after the pr-slow rename" + ); + } + assert!(PR_STAGES.contains("stageDependencies.impact_linux.compute.outputs")); + assert!(PR_STAGES.contains("stageDependencies.impact_windows.compute.outputs")); + // Every job is rendered through the dirty-file wrapper. + assert!(PR_STAGES.contains("- template: steps/job.yml")); + // No bare `- job:` keys -- they must all go through the wrapper. + assert!( + !PR_STAGES.contains("\n - job: "), + "PR stages defines a bare `- job:` instead of going through steps/job.yml" + ); + // PublishCodeCoverageResults@2 is emitted as a per-job step + // and appears once per job that runs anvil-llvm-cov. For + // pr_test that's the linux and windows jobs (no hosted ARM on + // ADO), so 2 publish-task instances total. + assert_eq!( + PR_STAGES.matches("- task: PublishCodeCoverageResults@2").count(), + 2, + "cobertura publish should appear once per pr_test job (linux + windows)" + ); + } + + #[test] + fn scheduled_stages_has_three_groups() { + for needle in [ + "stage: scheduled_test", + "stage: scheduled_advisories", + "stage: scheduled_exhaustive", + ] { + assert!(SCHEDULED_STAGES.contains(needle), "scheduled stages missing '{needle}'"); + } + // scheduled-runtime was deleted; miri + careful moved to pr-slow. + assert!(!SCHEDULED_STAGES.contains("scheduled_runtime")); + // Scheduled tier publishes coverage via PublishCodeCoverageResults@2. + assert!(SCHEDULED_STAGES.contains("PublishCodeCoverageResults@2")); + // Every job is rendered through the dirty-file wrapper. + assert!(SCHEDULED_STAGES.contains("- template: steps/job.yml")); + assert!( + !SCHEDULED_STAGES.contains("\n - job: "), + "Scheduled stages defines a bare `- job:` instead of going through steps/job.yml" + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_stages_templates_emits_two() { + let tmp = TempDir::new().unwrap(); + let items = plan_stages_templates(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(items.len(), 2); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_step_templates_emits_setup_impact_advisory_job_wrapper_plus_groups() { + let tmp = TempDir::new().unwrap(); + let items = plan_step_templates(tmp.path(), &Manifest::default()).unwrap(); + // 4 fixed step templates (setup, impact, advisory-comments, job) + one per group. + assert_eq!(items.len(), GROUPS.len() + 4); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } +} diff --git a/crates/cargo-anvil/src/emit/cargo_toml.rs b/crates/cargo-anvil/src/emit/cargo_toml.rs new file mode 100644 index 00000000..c65d9850 --- /dev/null +++ b/crates/cargo-anvil/src/emit/cargo_toml.rs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `Cargo.toml` lint-region emitters. +//! +//! The workspace `Cargo.toml` (or a single-crate `Cargo.toml`) carries the +//! catalog of `rust`/`clippy`/`rustdoc` lints inside a managed region. +//! Each workspace member also carries a tiny region asserting +//! `workspace = true` so the member inherits the catalog. +//! +//! All lints are emitted in *dotted-key form* (`clippy.unwrap_used = +//! "deny"`) — see [`design.md §6`](../../../docs/design/design.md) for the +//! rationale (TOML forbids re-declaring a table header, so dotted keys +//! let users extend the scope outside the sentinels). + +use std::path::Path; + +use ohno::AppError; + +use super::managed_region::plan_managed_region; +use crate::manifest::Manifest; +use crate::plan::PlanItem; +use crate::region::CommentSyntax; +use crate::workspace::Workspace; + +/// Region id for the workspace-scope lints (multi-crate workspaces). +pub const WORKSPACE_LINTS_REGION_ID: &str = "anvil-workspace-lints"; + +/// Region id for crate-scope lints — used both for single-crate repos +/// (full catalog) and for each member of a multi-crate workspace +/// (just `workspace = true`). +pub const CRATE_LINTS_REGION_ID: &str = "anvil-lints"; + +/// Embedded body of the lint catalog, in dotted-key form (no table header). +/// The header (`[workspace.lints]` or `[lints]`) is prepended per host. +pub const LINTS_BODY: &str = include_str!("../../templates/regions/cargo-lints-body.toml"); + +/// Embedded body of a workspace-member lints region. +pub const MEMBER_LINTS_BODY: &str = include_str!("../../templates/regions/cargo-member-lints.toml"); + +/// Render the body of the workspace-scope lints region: `[workspace.lints]` +/// header followed by the embedded catalog. +#[must_use] +pub fn render_workspace_lints_body() -> String { + let mut out = String::with_capacity(LINTS_BODY.len() + 32); + out.push_str("[workspace.lints]\n"); + out.push_str(LINTS_BODY); + out +} + +/// Render the body of the single-crate lints region: `[lints]` header +/// followed by the embedded catalog. +#[must_use] +pub fn render_single_crate_lints_body() -> String { + let mut out = String::with_capacity(LINTS_BODY.len() + 16); + out.push_str("[lints]\n"); + out.push_str(LINTS_BODY); + out +} + +/// Emit lint-region plan items for every appropriate `Cargo.toml` in the +/// workspace. +/// +/// - Multi-crate workspace: one workspace-scope region in the root +/// `Cargo.toml` + one member region per workspace member. +/// - Single-crate repo: one crate-scope region in the root `Cargo.toml`. +/// +/// # Errors +/// +/// Propagates I/O and region-parsing errors. +pub fn plan_cargo_lints(repo_root: &Path, workspace: &Workspace, manifest: &Manifest) -> Result, AppError> { + let mut items = Vec::new(); + if workspace.has_workspace_table { + let body = render_workspace_lints_body(); + items.push(plan_managed_region( + repo_root, + manifest, + "Cargo.toml", + WORKSPACE_LINTS_REGION_ID, + &body, + CommentSyntax::Hash, + )?); + for member in &workspace.members { + items.push(plan_managed_region( + repo_root, + manifest, + &member.manifest_relpath, + CRATE_LINTS_REGION_ID, + MEMBER_LINTS_BODY, + CommentSyntax::Hash, + )?); + } + } else { + let body = render_single_crate_lints_body(); + items.push(plan_managed_region( + repo_root, + manifest, + "Cargo.toml", + CRATE_LINTS_REGION_ID, + &body, + CommentSyntax::Hash, + )?); + } + Ok(items) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::decision::Decision; + use crate::workspace::WorkspaceMember; + + fn write(path: &std::path::Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); + } + + #[test] + fn embedded_catalog_uses_dotted_keys() { + // The catalog body has no TOML table headers — only dotted keys. + // (A reference to "[workspace.lints]" can appear in a comment.) + for line in LINTS_BODY.lines() { + let trimmed = line.trim_start(); + assert!( + !trimmed.starts_with('['), + "unexpected table header in cargo-lints-body.toml: {line}" + ); + } + assert!(LINTS_BODY.contains("rust.unsafe_op_in_unsafe_fn = \"warn\"")); + assert!(LINTS_BODY.contains("clippy.unwrap_used = \"warn\"")); + } + + /// Locks in the deliberate decisions to omit these from the catalog: + /// `missing_docs` (large-workspace noise), `expect_used` and + /// `panic` (over-strict for tools/libraries with legitimate panic + /// paths). Adopters who want any of them add them outside the + /// managed region with no conflict. If we change our mind, this + /// test goes; until then, accidentally re-adding any of them + /// fires here instead of in the next adopter's cloud workflows. + #[test] + fn catalog_intentionally_omits_contested_lints() { + for needle in ["rust.missing_docs", "clippy.expect_used", "clippy.panic "] { + assert!( + !LINTS_BODY.contains(needle), + "catalog now contains '{needle}'; if intentional, update the catalog-omission test" + ); + } + } + + /// Pins the Bucket A folding (restriction-group consensus from + /// oxidizer + oxidizer-github). If any of these get dropped from + /// the catalog the test fires; if a maintainer intends to drop + /// them they update this list too. + #[test] + fn catalog_includes_consensus_restriction_lints() { + for needle in [ + "clippy.as_pointer_underscore = \"warn\"", + "clippy.assertions_on_result_states = \"warn\"", + "clippy.deref_by_slicing = \"warn\"", + "clippy.empty_drop = \"warn\"", + "clippy.empty_enum_variants_with_brackets = \"warn\"", + "clippy.fn_to_numeric_cast_any = \"warn\"", + "clippy.if_then_some_else_none = \"warn\"", + "clippy.multiple_unsafe_ops_per_block = \"warn\"", + "clippy.redundant_type_annotations = \"warn\"", + "clippy.renamed_function_params = \"warn\"", + "clippy.semicolon_outside_block = \"warn\"", + "clippy.unnecessary_safety_doc = \"warn\"", + "clippy.unneeded_field_pattern = \"warn\"", + "clippy.unused_result_ok = \"warn\"", + "clippy.redundant_pub_crate = \"allow\"", + "clippy.should_panic_without_expect = \"allow\"", + ] { + assert!(LINTS_BODY.contains(needle), "catalog missing consensus lint '{needle}'"); + } + } + + /// `unexpected_cfgs` is on-by-default at warn since Rust 1.80; + /// combined with the catalog's `-D warnings` cloud-workflow policy, an + /// undeclared cfg is a hard build failure. The catalog pre-declares + /// the `coverage`/`coverage_nightly` cfgs that anvil's own + /// `llvm-cov` recipe sets so the recommended + /// `#[cfg_attr(coverage_nightly, coverage(off))]` pattern works + /// out of the box. If this line moves out of the catalog, + /// adopters using that pattern silently break. + #[test] + fn catalog_declares_llvm_cov_cfgs_for_unexpected_cfgs_lint() { + assert!( + LINTS_BODY.contains("rust.unexpected_cfgs"), + "catalog must declare rust.unexpected_cfgs to pre-allow llvm-cov's coverage cfgs" + ); + assert!( + LINTS_BODY.contains("'cfg(coverage,coverage_nightly)'"), + "catalog's unexpected_cfgs check-cfg list must include coverage,coverage_nightly" + ); + } + + #[test] + fn workspace_body_prepends_workspace_lints_header() { + let body = render_workspace_lints_body(); + assert!(body.starts_with("[workspace.lints]\n")); + assert!(body.contains("clippy.pedantic = { level = \"warn\", priority = -1 }")); + } + + #[test] + fn single_crate_body_prepends_lints_header() { + let body = render_single_crate_lints_body(); + assert!(body.starts_with("[lints]\n")); + assert!(body.contains("clippy.unwrap_used = \"warn\"")); + // No second header would appear (no `[workspace.lints]` line). + for line in body.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('[') { + assert_eq!(trimmed, "[lints]", "unexpected table header in single-crate body: {line}"); + } + } + } + + #[test] + fn member_body_is_workspace_inheritance_stub() { + assert!(MEMBER_LINTS_BODY.contains("[lints]")); + assert!(MEMBER_LINTS_BODY.contains("workspace = true")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_multi_crate_workspace_emits_root_plus_members() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write(&root.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\n"); + write(&root.join("crates/a/Cargo.toml"), "[package]\nname='a'\nversion='0.1.0'\n"); + write(&root.join("crates/b/Cargo.toml"), "[package]\nname='b'\nversion='0.1.0'\n"); + + let ws = crate::workspace::load_workspace(root).unwrap(); + let items = plan_cargo_lints(root, &ws, &Manifest::default()).unwrap(); + assert_eq!(items.len(), 3); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_single_crate_emits_one_region() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write(&root.join("Cargo.toml"), "[package]\nname='solo'\nversion='0.1.0'\n"); + + let ws = crate::workspace::load_workspace(root).unwrap(); + let items = plan_cargo_lints(root, &ws, &Manifest::default()).unwrap(); + assert_eq!(items.len(), 1); + } + + #[test] + fn dotted_key_body_parses_as_valid_toml_when_appended_to_workspace() { + let host = "[workspace]\nmembers = [\"crates/a\"]\n"; + let region_body = render_workspace_lints_body(); + let spliced = crate::region::upsert_region(host, WORKSPACE_LINTS_REGION_ID, ®ion_body, CommentSyntax::Hash).unwrap(); + let _: toml_edit::DocumentMut = spliced.parse().expect("spliced TOML must be valid"); + } + + #[test] + fn user_extension_after_region_parses() { + let host = "[workspace]\nmembers = [\"x\"]\n"; + let region_body = render_workspace_lints_body(); + let mut spliced = crate::region::upsert_region(host, WORKSPACE_LINTS_REGION_ID, ®ion_body, CommentSyntax::Hash).unwrap(); + spliced.push_str("clippy.todo = \"warn\"\n"); + let _: toml_edit::DocumentMut = spliced.parse().expect("user extension keeps document valid"); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn member_relpaths_use_forward_slashes() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write(&root.join("Cargo.toml"), "[workspace]\nmembers = [\"crates/a\"]\n"); + write(&root.join("crates/a/Cargo.toml"), "[package]\nname='a'\nversion='0.1.0'\n"); + + let ws = crate::workspace::load_workspace(root).unwrap(); + let items = plan_cargo_lints(root, &ws, &Manifest::default()).unwrap(); + let member_targets: Vec<_> = items + .iter() + .filter_map(|i| match &i.target { + crate::plan::Target::Region { host, .. } if host != "Cargo.toml" => Some(host.as_str()), + _ => None, + }) + .collect(); + assert_eq!(member_targets, vec!["crates/a/Cargo.toml"]); + let _ = std::any::type_name::(); + } +} diff --git a/crates/cargo-anvil/src/emit/github.rs b/crates/cargo-anvil/src/emit/github.rs new file mode 100644 index 00000000..f4ae0685 --- /dev/null +++ b/crates/cargo-anvil/src/emit/github.rs @@ -0,0 +1,341 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! GitHub Actions backend emitter. +//! +//! Emits three layers per [`github.md`](../../../docs/design/github.md): +//! +//! 1. Composite actions under `.github/actions/anvil-*/action.yml`. +//! 2. Reusable workflows (`anvil-pr-impl.yml`, `anvil-scheduled-impl.yml`). +//! 3. Root workflows (`anvil-pr.yml`, `anvil-scheduled.yml`). +//! +//! All emitted files are owned files (no managed regions). Users who +//! customize take ownership via the standard dirty-file flow. + +use std::path::Path; + +use ohno::AppError; + +use super::owned_file::plan_owned_file; +use crate::manifest::Manifest; +use crate::plan::PlanItem; + +/// Embedded body of the shared setup composite action. +pub const SETUP_ACTION: &str = include_str!("../../templates/github/setup-action.yml"); + +/// Embedded body of the cargo-delta impact composite action. +pub const IMPACT_ACTION: &str = include_str!("../../templates/github/impact-action.yml"); + +/// Embedded body of the PR reusable workflow. +pub const PR_IMPL_WORKFLOW: &str = include_str!("../../templates/github/pr-impl-workflow.yml"); + +/// Embedded body of the scheduled reusable workflow. +pub const SCHEDULED_IMPL_WORKFLOW: &str = include_str!("../../templates/github/scheduled-impl-workflow.yml"); + +/// Embedded body of the PR root workflow. +pub const PR_ROOT_WORKFLOW: &str = include_str!("../../templates/github/pr-root-workflow.yml"); + +/// Embedded body of the scheduled root workflow. +pub const SCHEDULED_ROOT_WORKFLOW: &str = include_str!("../../templates/github/scheduled-root-workflow.yml"); + +/// All check groups for which the GitHub backend emits a composite action. +/// +/// All anvil groups that get a per-group composite action. +/// +/// Mirrors [`checks.md`](../../../docs/design/checks.md) `§1`. +/// +/// The PR-tier "pr-slow" umbrella is split into three cloud-workflow-visible +/// sub-groups (`pr-test`, `pr-runtime-analysis`, `pr-mutants`) so each runs as +/// its own job and they execute in parallel across the matrix. The +/// umbrella `anvil-pr-slow` recipe is preserved in `groups.just` +/// for local convenience but does not appear in cloud workflows as a discrete +/// job. `pr-mutants` (mutants) self-skips on aarch64-pc-windows-msvc +/// where cargo-mutants doesn't build. +pub const GROUPS: &[&str] = &[ + "pr-fast", + "pr-test", + "pr-runtime-analysis", + "pr-mutants", + "scheduled-test", + "scheduled-advisories", + "scheduled-exhaustive", +]; + +/// Embedded template for one per-group composite action. `__GROUP__` is +/// substituted with the group name at emit time. +pub const GROUP_ACTION_TEMPLATE: &str = include_str!("../../templates/github/group-action.yml"); + +/// Placeholder token the per-group template uses for the group name. +const GROUP_PLACEHOLDER: &str = "__GROUP__"; + +/// Render the `action.yml` for one check group's composite action. +/// +/// Substitutes the group name into [`GROUP_ACTION_TEMPLATE`]. The action +/// takes two inputs (`excludes`, `skip`) supplied by the reusable +/// workflow from the impact job's outputs, sets them as environment +/// variables, and invokes `just anvil-`. +#[must_use] +pub fn render_group_action(group: &str) -> String { + GROUP_ACTION_TEMPLATE.replace(GROUP_PLACEHOLDER, group) +} + +/// Repo-root-relative path for a per-group composite action. +#[must_use] +pub fn group_action_path(group: &str) -> String { + format!(".github/actions/anvil-{group}/action.yml") +} + +/// Plan every composite-action file: setup, impact, and the seven per-group actions. +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +pub fn plan_composite_actions(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + let mut items = Vec::with_capacity(GROUPS.len() + 2); + items.push(plan_owned_file( + repo_root, + manifest, + ".github/actions/anvil-setup/action.yml", + SETUP_ACTION, + )?); + items.push(plan_owned_file( + repo_root, + manifest, + ".github/actions/anvil-impact/action.yml", + IMPACT_ACTION, + )?); + for group in GROUPS { + let body = render_group_action(group); + items.push(plan_owned_file(repo_root, manifest, &group_action_path(group), &body)?); + } + Ok(items) +} + +/// Plan the two reusable workflows. +/// +/// # Errors +/// +/// Propagates I/O errors from the owned-file driver. +pub fn plan_reusable_workflows(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_owned_file(repo_root, manifest, ".github/workflows/anvil-pr-impl.yml", PR_IMPL_WORKFLOW)?, + plan_owned_file( + repo_root, + manifest, + ".github/workflows/anvil-scheduled-impl.yml", + SCHEDULED_IMPL_WORKFLOW, + )?, + ]) +} + +/// Plan the two root workflows. +/// +/// # Errors +/// +/// Propagates I/O errors from the owned-file driver. +pub fn plan_root_workflows(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_owned_file(repo_root, manifest, ".github/workflows/anvil-pr.yml", PR_ROOT_WORKFLOW)?, + plan_owned_file( + repo_root, + manifest, + ".github/workflows/anvil-scheduled.yml", + SCHEDULED_ROOT_WORKFLOW, + )?, + ]) +} + +/// Plan every file the GitHub backend emits. +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +pub fn plan_github_backend(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + let mut items = Vec::new(); + items.extend(plan_composite_actions(repo_root, manifest)?); + items.extend(plan_reusable_workflows(repo_root, manifest)?); + items.extend(plan_root_workflows(repo_root, manifest)?); + Ok(items) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::decision::Decision; + + #[test] + fn setup_and_impact_templates_are_non_empty() { + assert!(SETUP_ACTION.contains("name: anvil-setup")); + assert!(IMPACT_ACTION.contains("name: anvil-impact")); + assert!(IMPACT_ACTION.contains("cargo-delta")); + } + + #[test] + fn setup_action_takes_group_input_and_dispatches() { + // The group input drives whether we install the full catalog, + // skip tool install entirely (group=none, used by impact), or + // scope install to one group. + assert!(SETUP_ACTION.contains("group:")); + assert!(SETUP_ACTION.contains("just anvil-setup binstall")); + assert!(SETUP_ACTION.contains("just \"anvil-${{ inputs.group }}-setup\" binstall")); + assert!(SETUP_ACTION.contains("none)")); + } + + #[test] + fn group_action_passes_group_to_setup() { + let body = render_group_action("pr-fast"); + // The per-group composite invokes anvil-setup with its own + // group name so only that group's prerequisites get installed. + assert!(body.contains("uses: ./.github/actions/anvil-setup")); + assert!(body.contains("group: pr-fast")); + } + + #[test] + fn impact_action_uses_group_none_and_installs_only_cargo_delta() { + assert!(IMPACT_ACTION.contains("group: none")); + assert!(IMPACT_ACTION.contains("anvil-tool-cargo-delta-install")); + } + + #[test] + fn render_group_action_uses_correct_name() { + let body = render_group_action("pr-fast"); + assert!(body.contains("name: anvil-pr-fast")); + assert!(body.contains("just anvil-pr-fast")); + assert!(body.contains("ANVIL_INCLUDE_MODIFIED")); + assert!(body.contains("ANVIL_INCLUDE_AFFECTED")); + assert!(body.contains("ANVIL_INCLUDE_REQUIRED")); + } + + #[test] + fn group_actions_declare_include_inputs() { + let body = render_group_action("scheduled-test"); + assert!(body.contains("include_modified:")); + assert!(body.contains("include_affected:")); + assert!(body.contains("include_required:")); + } + + #[test] + fn rendered_action_is_valid_yaml() { + // Use serde_yaml? Not in workspace. Use a string-based sanity check: + // every line is either empty, a comment, or 2-space indented. + let body = render_group_action("pr-test"); + for line in body.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let trimmed_indent = line.trim_start_matches(' ').len(); + let indent = line.len() - trimmed_indent; + assert_eq!(indent % 2, 0, "non-aligned indent in:\n{body}\n>>> at line: {line}"); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_composite_actions_emits_setup_impact_and_all_groups() { + let tmp = TempDir::new().unwrap(); + let items = plan_composite_actions(tmp.path(), &Manifest::default()).unwrap(); + // GROUPS.len() per-group composites + 2 shared composites (setup, impact) + assert_eq!(items.len(), GROUPS.len() + 2); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } + + #[test] + fn pr_impl_workflow_has_expected_jobs() { + assert!(PR_IMPL_WORKFLOW.contains("workflow_call:")); + // pr-slow is split into three cloud-workflow-visible jobs (pr-test, + // pr-runtime-analysis, pr-mutants) that run in parallel. The + // umbrella `anvil-pr-slow` recipe exists in groups.just for + // local convenience but does NOT appear as a cloud-workflow job here. + for needle in [ + "impact-linux:", + "impact-windows:", + "pr-fast:", + "pr-test:", + "pr-runtime-analysis:", + "pr-mutants:", + ] { + assert!(PR_IMPL_WORKFLOW.contains(needle), "PR impl workflow missing job '{needle}'"); + } + // Stale historical names must not reappear. + for needle in ["\n pr-slow:\n", "\n pr-slow1:\n", "\n pr-slow2:\n", "\n pr-slow3:\n"] { + assert!( + !PR_IMPL_WORKFLOW.contains(needle), + "Stale job '{needle}' should be gone after the pr-slow rename" + ); + } + // Downstream groups must fan in BOTH per-OS impact jobs. + assert!(PR_IMPL_WORKFLOW.contains("needs: [impact-linux, impact-windows]")); + // All three pr-slow* jobs run on the 4-leg matrix. + assert!(PR_IMPL_WORKFLOW.contains("os: [linux, windows, linux-arm, windows-arm]")); + assert!(!PR_IMPL_WORKFLOW.contains("fromJSON")); + // pr-fast carries the PR title for the anvil-pr-title check. + assert!(PR_IMPL_WORKFLOW.contains("PR_TITLE")); + // pr-mutants needs the base SHA for diff-scoped mutants. + assert!(PR_IMPL_WORKFLOW.contains("BASE_REF")); + // Coverage upload lives in pr-test (after llvm-cov runs). It's + // a single YAML step that runs on every leg except windows-arm + // (skipped because of LLVM-coverage instrumentation bugs). + // Therefore the codecov-action reference appears once in the + // workflow YAML; the per-leg behaviour is the `if:` condition. + assert_eq!( + PR_IMPL_WORKFLOW.matches("codecov/codecov-action").count(), + 1, + "Codecov upload step should be declared exactly once (gated per-leg via `if:`)" + ); + // The gating condition must exclude windows-arm and reference + // both per-OS impact outputs. + assert!(PR_IMPL_WORKFLOW.contains("matrix.os != 'windows-arm'")); + assert!(PR_IMPL_WORKFLOW.contains("flags: ${{ matrix.os }}")); + } + + #[test] + fn scheduled_impl_workflow_has_expected_jobs() { + for needle in ["scheduled-test:", "scheduled-advisories:", "scheduled-exhaustive:"] { + assert!( + SCHEDULED_IMPL_WORKFLOW.contains(needle), + "scheduled impl workflow missing job '{needle}'" + ); + } + // Nightly uploads the lcov artifact. + // Nightly uploads the lcov to Codecov. + assert!(SCHEDULED_IMPL_WORKFLOW.contains("codecov/codecov-action")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_reusable_workflows_emits_two() { + let tmp = TempDir::new().unwrap(); + let items = plan_reusable_workflows(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(items.len(), 2); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } + + #[test] + fn root_workflows_call_reusable_workflows() { + assert!(PR_ROOT_WORKFLOW.contains("uses: ./.github/workflows/anvil-pr-impl.yml")); + assert!(PR_ROOT_WORKFLOW.contains("pull_request:")); + assert!(PR_ROOT_WORKFLOW.contains("merge_group:")); + assert!(SCHEDULED_ROOT_WORKFLOW.contains("uses: ./.github/workflows/anvil-scheduled-impl.yml")); + assert!(SCHEDULED_ROOT_WORKFLOW.contains("schedule:")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_github_backend_emits_full_file_set() { + let tmp = TempDir::new().unwrap(); + let items = plan_github_backend(tmp.path(), &Manifest::default()).unwrap(); + // 2 shared actions + 6 group actions + 2 reusable workflows + 2 root workflows + assert_eq!(items.len(), 2 + GROUPS.len() + 2 + 2); + } + + #[test] + fn group_action_path_is_under_dot_github() { + assert_eq!(group_action_path("pr-fast"), ".github/actions/anvil-pr-fast/action.yml"); + } +} diff --git a/crates/cargo-anvil/src/emit/local.rs b/crates/cargo-anvil/src/emit/local.rs new file mode 100644 index 00000000..37ee0a77 --- /dev/null +++ b/crates/cargo-anvil/src/emit/local.rs @@ -0,0 +1,501 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Local recipe emission (the `justfiles/anvil/` tree). +//! +//! Each owned file under `justfiles/anvil/` is embedded at compile time +//! via [`include_str!`] from the `templates/justfiles/anvil/` directory. +//! The emitter just forwards the template through the owned-file driver. +//! +//! See [`local.md`](../../docs/design/local.md) for the recipe surface. + +use std::path::Path; + +use ohno::AppError; + +use super::owned_file::plan_owned_file; +use crate::manifest::Manifest; +use crate::plan::PlanItem; + +/// Contents of `justfiles/anvil/mod.just` baked into the binary. +/// +/// This is the single-import entry point: it pulls in the four sibling +/// recipe files and defines `alias anvil := anvil-pr`. +pub const MOD_JUST: &str = include_str!("../../templates/justfiles/anvil/mod.just"); + +/// Repo-root-relative path of the entry-point recipe file. +pub const MOD_JUST_PATH: &str = "justfiles/anvil/mod.just"; + +/// Contents of `justfiles/anvil/versions.just` baked into the binary. +/// +/// Pinned toolchain versions consumed by recipes (via `{{ var }}` +/// interpolation) and by setup composites (via `just --evaluate`). +/// See [`local.md`](../../docs/design/local.md#nightly-pinning) for +/// the bump policy. +pub const VERSIONS_JUST: &str = include_str!("../../templates/justfiles/anvil/versions.just"); + +/// Repo-root-relative path of the pinned-versions recipe file. +pub const VERSIONS_JUST_PATH: &str = "justfiles/anvil/versions.just"; + +/// Contents of `justfiles/anvil/tools.just` baked into the binary. +pub const TOOLS_JUST: &str = include_str!("../../templates/justfiles/anvil/tools.just"); + +/// Repo-root-relative path of the tools recipe file. +pub const TOOLS_JUST_PATH: &str = "justfiles/anvil/tools.just"; + +/// Contents of `justfiles/anvil/checks.just` baked into the binary. +pub const CHECKS_JUST: &str = include_str!("../../templates/justfiles/anvil/checks.just"); + +/// Repo-root-relative path of the per-check recipe file. +pub const CHECKS_JUST_PATH: &str = "justfiles/anvil/checks.just"; + +/// Contents of `justfiles/anvil/groups.just` baked into the binary. +pub const GROUPS_JUST: &str = include_str!("../../templates/justfiles/anvil/groups.just"); + +/// Repo-root-relative path of the group recipe file. +pub const GROUPS_JUST_PATH: &str = "justfiles/anvil/groups.just"; + +/// Contents of `justfiles/anvil/tiers.just` baked into the binary. +pub const TIERS_JUST: &str = include_str!("../../templates/justfiles/anvil/tiers.just"); + +/// Repo-root-relative path of the tier aggregator file. +pub const TIERS_JUST_PATH: &str = "justfiles/anvil/tiers.just"; + +/// Embedded body of the `anvil-imports` region in the user's Justfile. +pub const JUSTFILE_IMPORTS_BODY: &str = include_str!("../../templates/regions/justfile-imports.just"); + +/// Emit a [`PlanItem`] for `justfiles/anvil/mod.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_mod_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, MOD_JUST_PATH, MOD_JUST) +} + +/// Emit a [`PlanItem`] for `justfiles/anvil/tools.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_tools_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, TOOLS_JUST_PATH, TOOLS_JUST) +} + +/// Emit a [`PlanItem`] for `justfiles/anvil/versions.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_versions_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, VERSIONS_JUST_PATH, VERSIONS_JUST) +} + +/// Emit a [`PlanItem`] for `justfiles/anvil/checks.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_checks_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, CHECKS_JUST_PATH, CHECKS_JUST) +} + +/// Emit a [`PlanItem`] for `justfiles/anvil/groups.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_groups_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, GROUPS_JUST_PATH, GROUPS_JUST) +} + +/// Emit a [`PlanItem`] for `justfiles/anvil/tiers.just`. +/// +/// # Errors +/// +/// Propagates I/O errors from [`plan_owned_file`]. +pub fn plan_tiers_just(repo_root: &Path, manifest: &Manifest) -> Result { + plan_owned_file(repo_root, manifest, TIERS_JUST_PATH, TIERS_JUST) +} + +/// Region id for the imports block in the user's `Justfile`. +pub const JUSTFILE_REGION_ID: &str = "anvil-imports"; + +/// Canonical repo-root-relative path of the user's `Justfile`. +/// +/// Capitalized to match the dominant Unix convention for repo-root +/// build-config files (`Makefile`, `Dockerfile`, `Rakefile`, `Gemfile`, +/// `Procfile`, `Brewfile`, ...) and the surveyed Microsoft Rust repos +/// (`oxidizer`, `ox-tools`). `just` itself accepts either case. +/// +/// For repos that already committed a lowercase `justfile`, the +/// [`plan_justfile_imports`] function prefers the existing file rather +/// than creating a sibling — see that function for details. +pub const JUSTFILE_PATH: &str = "Justfile"; + +/// Alternative lowercase form. Recognized when looking for an existing +/// file on disk, but never written by anvil; new files always use +/// the canonical [`JUSTFILE_PATH`] capitalization. +const JUSTFILE_PATH_LOWERCASE: &str = "justfile"; + +/// Resolve the on-disk Justfile path for `repo_root`. +/// +/// Prefers an existing lowercase `justfile` if (and only if) the +/// canonical `Justfile` doesn't already exist. This means: +/// +/// - Fresh repos: anvil writes `Justfile` (canonical). +/// - Repos with `Justfile` (the common case): we splice into it. +/// - Repos with only `justfile`: we splice into it without renaming. +/// - Repos with both (case-sensitive FS oddity): we honor the canonical +/// `Justfile` and leave the lowercase file alone. +/// +/// This guards against the case-sensitivity footgun on Linux: without +/// it, an adopter with a lowercase `justfile` would silently get a +/// sibling `Justfile` containing the imports region, and `just` would +/// load whichever it finds first (lowercase wins by default) — so the +/// imports never take effect. +fn resolve_justfile_path(repo_root: &Path) -> &'static str { + if repo_root.join(JUSTFILE_PATH).exists() { + JUSTFILE_PATH + } else if repo_root.join(JUSTFILE_PATH_LOWERCASE).exists() { + JUSTFILE_PATH_LOWERCASE + } else { + JUSTFILE_PATH + } +} + +/// Emit a [`PlanItem`] for the `Justfile` imports region. +/// +/// # Errors +/// +/// Propagates I/O and region-parsing errors. +pub fn plan_justfile_imports(repo_root: &Path, manifest: &Manifest) -> Result { + super::managed_region::plan_managed_region( + repo_root, + manifest, + resolve_justfile_path(repo_root), + JUSTFILE_REGION_ID, + JUSTFILE_IMPORTS_BODY, + crate::region::CommentSyntax::Hash, + ) +} + +/// Move a legacy lowercase `justfile` manifest entry to the canonical +/// `Justfile` capital key. No-op when no migration is needed. +/// +/// Earlier versions of cargo-anvil used `JUSTFILE_PATH = "justfile"` +/// (lowercase), so manifests written by those versions track the +/// imports region under the lowercase host. The canonical +/// capitalization is now `Justfile`. Without this migration, the +/// orphan-detection pass would see the lowercase entry as an orphan +/// (no live plan item for it), notice the file content matches its +/// last-rendered hash, and splice the region out — destroying anvil +/// integration on every re-render after the upgrade. The bug +/// manifested as silent loss of region on case-sensitive Linux and +/// physical-same-file region removal on case-insensitive Windows. +/// +/// The migration only fires when the lowercase entry is present AND +/// the canonical entry is absent, so it never overwrites a legitimate +/// case-sensitive setup where both files were intentionally tracked. +pub fn migrate_legacy_justfile_case(manifest: &mut Manifest) { + use crate::manifest::RegionKey; + + let legacy = RegionKey { + host: "justfile".to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + }; + let canonical = RegionKey { + host: JUSTFILE_PATH.to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + }; + if manifest.regions.contains_key(&canonical) { + // Already on the canonical key; leave the lowercase entry (if + // any) alone — could be a deliberate dual-file setup. + return; + } + if let Some(hash) = manifest.regions.remove(&legacy) { + manifest.regions.insert(canonical, hash); + } +} + +/// Plan all five files of the `justfiles/anvil/` tree. +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +/// Plan all files of the `justfiles/anvil/` tree (recipes + data). +/// +/// # Errors +/// +/// Propagates I/O errors from any per-file emitter. +pub fn plan_local_just_tree(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_mod_just(repo_root, manifest)?, + plan_tools_just(repo_root, manifest)?, + plan_versions_just(repo_root, manifest)?, + plan_checks_just(repo_root, manifest)?, + plan_groups_just(repo_root, manifest)?, + plan_tiers_just(repo_root, manifest)?, + ]) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::decision::Decision; + + #[test] + fn tools_just_template_is_not_empty() { + // Sample a handful of the per-tool/component/toolchain recipes that + // tools.just must define. + assert!(TOOLS_JUST.contains("anvil-system-deps-check")); + assert!(TOOLS_JUST.contains("anvil-tool-cargo-deny-install")); + assert!(TOOLS_JUST.contains("anvil-tool-cargo-deny-validate-prereqs")); + assert!(TOOLS_JUST.contains("anvil-component-default-clippy-install")); + assert!(TOOLS_JUST.contains("anvil-toolchain-nightly-install")); + } + + #[test] + fn checks_just_template_includes_all_catalog_checks() { + // Sample a handful from each group to guard against accidental deletions. + for needle in [ + "anvil-fmt:", + "anvil-clippy:", + "anvil-license-headers:", + "anvil-pr-title:", + "anvil-llvm-cov:", + "anvil-doc-test:", + "anvil-mutants-diff:", + "anvil-miri:", + "anvil-mutants-full:", + "anvil-bench:", + ] { + assert!(CHECKS_JUST.contains(needle), "checks.just missing recipe '{needle}'"); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn checks_just_emitter_writes_on_first_render() { + let tmp = TempDir::new().unwrap(); + let item = plan_checks_just(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(item.decision, Decision::Write); + } + + #[test] + fn groups_just_template_includes_all_groups_and_pr_slow_sub_recipes() { + // pr-slow has three cloud-workflow-visible sub-groups (pr-test, pr-runtime-analysis, + // pr-mutants) that each get their own composite action / step + // template / cloud-workflow job. The umbrella `anvil-pr-slow` recipe is + // preserved for local convenience (it runs the three sub- + // recipes sequentially) but is not in GROUPS. + for needle in [ + "anvil-pr-fast:", + "anvil-pr-slow:", + "anvil-pr-test:", + "anvil-pr-runtime-analysis:", + "anvil-pr-mutants:", + "anvil-scheduled-test:", + "anvil-scheduled-advisories:", + "anvil-scheduled-exhaustive:", + ] { + assert!(GROUPS_JUST.contains(needle), "groups.just missing '{needle}'"); + } + // The old pr-slow1/2/3 names should no longer appear. + for needle in ["anvil-pr-slow1:", "anvil-pr-slow2:", "anvil-pr-slow3:"] { + assert!(!GROUPS_JUST.contains(needle), "groups.just still contains stale '{needle}'"); + } + // pr-slow umbrella recipe should depend on its three sub-recipes. + assert!(GROUPS_JUST.contains("anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants")); + } + + #[test] + fn tiers_just_template_has_three_tiers() { + for needle in ["anvil-pr:", "anvil-scheduled:", "anvil-full:"] { + assert!(TIERS_JUST.contains(needle), "tiers.just missing '{needle}'"); + } + } + + #[test] + fn versions_just_has_known_tools() { + for needle in [ + "cargo_nextest_version", + "cargo_llvm_cov_version", + "cargo_deny_version", + "cargo_mutants_version", + ] { + assert!(VERSIONS_JUST.contains(needle), "versions.just missing variable '{needle}'"); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_local_just_tree_emits_six_items() { + let tmp = TempDir::new().unwrap(); + let items = plan_local_just_tree(tmp.path(), &Manifest::default()).unwrap(); + // mod, tools, versions, checks, groups, tiers + assert_eq!(items.len(), 6); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } + + #[test] + fn mod_just_imports_siblings_and_defines_alias() { + for needle in [ + "import 'checks.just'", + "import 'groups.just'", + "import 'tiers.just'", + "import 'tools.just'", + "import 'versions.just'", + "alias anvil := anvil-pr", + ] { + assert!(MOD_JUST.contains(needle), "mod.just missing '{needle}'"); + } + } + + #[test] + fn versions_just_defines_both_nightly_pins() { + // Required source of truth for the setup composites' `just --evaluate` + // step and recipe `{{ var }}` interpolation. If either name changes, + // the templates / docs must change in lockstep. + assert!(VERSIONS_JUST.contains("rust_nightly :="), "versions.just missing rust_nightly"); + assert!( + VERSIONS_JUST.contains("rust_nightly_external_types :="), + "versions.just missing rust_nightly_external_types" + ); + } + + #[test] + fn checks_just_has_no_floating_nightly_invocations() { + // Catch a regression where a recipe falls back to bare `+nightly` + // instead of using the pinned `{{ rust_nightly }}` / + // `{{ rust_nightly_external_types }}` interpolations. + for line in CHECKS_JUST.lines() { + let stripped = line.split('#').next().unwrap_or(""); + assert!( + !stripped.contains("+nightly "), + "checks.just has a floating '+nightly' invocation: {line}" + ); + assert!( + !stripped.contains("'+nightly'"), + "checks.just has a floating '+nightly' invocation: {line}" + ); + } + } + + #[test] + fn justfile_imports_body_is_a_single_import_line() { + let body = JUSTFILE_IMPORTS_BODY.trim(); + assert_eq!(body, "import 'justfiles/anvil/mod.just'"); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn justfile_imports_writes_into_empty_repo() { + let tmp = TempDir::new().unwrap(); + let item = plan_justfile_imports(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(item.decision, Decision::Write); + let spliced = item.spliced_host.as_deref().unwrap(); + assert!(spliced.contains("# >>> anvil-managed: anvil-imports")); + assert!(spliced.contains("import 'justfiles/anvil/mod.just'")); + // The alias lives in mod.just, not in the user's Justfile. + assert!(!spliced.contains("alias anvil")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn first_render_writes_tools_just() { + let tmp = TempDir::new().unwrap(); + let item = plan_tools_just(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(item.decision, Decision::Write); + assert_eq!(item.rendered.as_deref(), Some(TOOLS_JUST)); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn matching_file_is_in_sync() { + let tmp = TempDir::new().unwrap(); + std::fs::create_dir_all(tmp.path().join("justfiles/anvil")).unwrap(); + std::fs::write(tmp.path().join(TOOLS_JUST_PATH), TOOLS_JUST).unwrap(); + let item = plan_tools_just(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(item.decision, Decision::InSync); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn resolve_justfile_path_returns_canonical_when_empty() { + let tmp = TempDir::new().unwrap(); + assert_eq!(resolve_justfile_path(tmp.path()), "Justfile"); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn resolve_justfile_path_prefers_existing_capital() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Justfile"), "# existing\n").unwrap(); + assert_eq!(resolve_justfile_path(tmp.path()), "Justfile"); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn resolve_justfile_path_honors_existing_lowercase() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("justfile"), "# existing\n").unwrap(); + // The canonical path returned here is whatever the FS reports — + // on case-insensitive Windows that will match `Justfile.exists()` + // (and the test takes the first branch, returning "Justfile"); on + // case-sensitive Linux it falls through and returns "justfile". + // Either outcome is correct because the actual on-disk file + // resolves the same way at write time. + let resolved = resolve_justfile_path(tmp.path()); + assert!( + resolved == "Justfile" || resolved == "justfile", + "unexpected resolution: {resolved}" + ); + } + + #[test] + fn migrate_legacy_justfile_case_moves_lowercase_entry() { + use crate::checksum::checksum_str; + let mut m = Manifest::default(); + m.set_region("justfile", JUSTFILE_REGION_ID, checksum_str("body\n")); + migrate_legacy_justfile_case(&mut m); + assert!(m.regions.contains_key(&crate::manifest::RegionKey { + host: "Justfile".to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + })); + assert!(!m.regions.contains_key(&crate::manifest::RegionKey { + host: "justfile".to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + })); + } + + #[test] + fn migrate_legacy_justfile_case_noop_when_canonical_present() { + use crate::checksum::checksum_str; + let mut m = Manifest::default(); + m.set_region("Justfile", JUSTFILE_REGION_ID, checksum_str("canonical\n")); + m.set_region("justfile", JUSTFILE_REGION_ID, checksum_str("legacy\n")); + migrate_legacy_justfile_case(&mut m); + // Both entries preserved — canonical was already there, so we + // don't touch the lowercase entry (could be intentional). + assert!(m.regions.contains_key(&crate::manifest::RegionKey { + host: "Justfile".to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + })); + assert!(m.regions.contains_key(&crate::manifest::RegionKey { + host: "justfile".to_owned(), + id: JUSTFILE_REGION_ID.to_owned(), + })); + } + + #[test] + fn migrate_legacy_justfile_case_noop_when_neither_present() { + let mut m = Manifest::default(); + migrate_legacy_justfile_case(&mut m); + assert!(m.regions.is_empty()); + } +} diff --git a/crates/cargo-anvil/src/emit/managed_region.rs b/crates/cargo-anvil/src/emit/managed_region.rs new file mode 100644 index 00000000..ef696454 --- /dev/null +++ b/crates/cargo-anvil/src/emit/managed_region.rs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Driver for a single managed region. +//! +//! Given a host file path, region id, and the rendered region body, this +//! module reads the host file (if any), locates the region (if present), +//! consults the manifest, computes the decision, and returns a +//! [`PlanItem`] ready to be applied. + +use std::path::Path; + +use ohno::AppError; + +use crate::checksum::checksum_str; +use crate::decision::{Decision, DecisionInputs, decide}; +use crate::io::read_file_if_present; +use crate::manifest::{Manifest, RegionKey}; +use crate::plan::{PlanItem, Target}; +use crate::region::{CommentSyntax, find_region, upsert_region}; + +/// Compute the [`PlanItem`] for a managed region. +/// +/// `host_relpath` is the repo-root-relative forward-slash path of the +/// host file. `region_id` is the stable region id. `rendered_body` is +/// the byte-exact content the template would render between the +/// sentinels. `syntax` is the host's comment flavor. +/// +/// If the host file is missing, the region is treated as a `Write` and +/// the spliced output will be just the rendered region (sentinels + body). +/// +/// # Errors +/// +/// Returns an error if the host file exists but can't be read, or if the +/// region in the host is malformed. +pub fn plan_managed_region( + repo_root: &Path, + manifest: &Manifest, + host_relpath: &str, + region_id: &str, + rendered_body: &str, + syntax: CommentSyntax, +) -> Result { + let abs = repo_root.join(host_relpath); + let host_text = read_file_if_present(&abs)?; + + let template_checksum = checksum_str(rendered_body); + let key = RegionKey { + host: host_relpath.to_owned(), + id: region_id.to_owned(), + }; + let last_rendered = manifest.regions.get(&key).map(String::as_str); + + let disk_checksum = match host_text.as_deref() { + None => None, + Some(text) => find_region(text, region_id, syntax)?.map(|region| checksum_str(region.body_str())), + }; + + let inputs = DecisionInputs { + last_rendered, + disk: disk_checksum.as_deref(), + template: &template_checksum, + }; + let decision = decide(&inputs); + + let target = Target::Region { + host: host_relpath.to_owned(), + id: region_id.to_owned(), + }; + let item = match decision { + Decision::InSync => PlanItem::insync(target, template_checksum), + Decision::LeaveAlone => PlanItem::noop(target, decision), + Decision::Write => { + let spliced = splice(host_text.as_deref(), region_id, rendered_body, syntax)?; + PlanItem::write_region(host_relpath, region_id, rendered_body.to_owned(), spliced, template_checksum) + } + Decision::Propose => { + let spliced = splice(host_text.as_deref(), region_id, rendered_body, syntax)?; + PlanItem::propose_region(host_relpath, region_id, spliced, template_checksum) + } + Decision::Remove | Decision::OrphanedKept => { + unreachable!("decide() never returns removal decisions; those come from plan_removals") + } + }; + + Ok(item) +} + +fn splice(host_text: Option<&str>, region_id: &str, rendered_body: &str, syntax: CommentSyntax) -> Result { + let base = host_text.unwrap_or(""); + upsert_region(base, region_id, rendered_body, syntax) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + const SYN: CommentSyntax = CommentSyntax::Hash; + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn missing_host_writes_new_file() { + let tmp = TempDir::new().unwrap(); + let item = plan_managed_region(tmp.path(), &Manifest::default(), "Justfile", "r", "body line\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::Write); + let spliced = item.spliced_host.as_deref().unwrap(); + assert!(spliced.contains("# >>> anvil-managed: r")); + assert!(spliced.contains("body line")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn existing_host_without_region_appends_region() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("Justfile"), "user content\n").unwrap(); + let item = plan_managed_region(tmp.path(), &Manifest::default(), "Justfile", "r", "body\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::Write); + let spliced = item.spliced_host.as_deref().unwrap(); + assert!(spliced.starts_with("user content\n")); + assert!(spliced.contains("# >>> anvil-managed: r")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn matching_region_is_in_sync() { + let tmp = TempDir::new().unwrap(); + let host = "before\n\ + # >>> anvil-managed: r\n\ + body\n\ + # <<< anvil-managed: r\n\ + after\n"; + std::fs::write(tmp.path().join("Justfile"), host).unwrap(); + let item = plan_managed_region(tmp.path(), &Manifest::default(), "Justfile", "r", "body\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::InSync); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn user_modified_proposes_when_template_changed() { + let tmp = TempDir::new().unwrap(); + let host = "# >>> anvil-managed: r\nuser body\n# <<< anvil-managed: r\n"; + std::fs::write(tmp.path().join("Justfile"), host).unwrap(); + let mut manifest = Manifest::default(); + manifest.set_region("Justfile", "r", checksum_str("old body\n")); + let item = plan_managed_region(tmp.path(), &manifest, "Justfile", "r", "new body\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::Propose); + assert!(item.spliced_host.is_some()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn user_modified_template_unchanged_leaves_alone() { + let tmp = TempDir::new().unwrap(); + let host = "# >>> anvil-managed: r\nuser body\n# <<< anvil-managed: r\n"; + std::fs::write(tmp.path().join("Justfile"), host).unwrap(); + let mut manifest = Manifest::default(); + manifest.set_region("Justfile", "r", checksum_str("body\n")); + let item = plan_managed_region(tmp.path(), &manifest, "Justfile", "r", "body\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::LeaveAlone); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn empty_region_opts_out_when_template_unchanged() { + // Steady-state opt-out: user emptied the region, template hasn't moved. + let tmp = TempDir::new().unwrap(); + let host = "# >>> anvil-managed: r\n# <<< anvil-managed: r\n"; + std::fs::write(tmp.path().join("Justfile"), host).unwrap(); + let mut manifest = Manifest::default(); + manifest.set_region("Justfile", "r", checksum_str("body\n")); + let item = plan_managed_region(tmp.path(), &manifest, "Justfile", "r", "body\n", SYN).unwrap(); + assert_eq!(item.decision, Decision::LeaveAlone); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn empty_region_with_new_template_proposes() { + let tmp = TempDir::new().unwrap(); + let host = "# >>> anvil-managed: r\n# <<< anvil-managed: r\n"; + std::fs::write(tmp.path().join("Justfile"), host).unwrap(); + let mut manifest = Manifest::default(); + manifest.set_region("Justfile", "r", checksum_str("old\n")); + let item = plan_managed_region(tmp.path(), &manifest, "Justfile", "r", "new\n", SYN).unwrap(); + // Opt-out remains in place but the user gets a proposed host file. + assert_eq!(item.decision, Decision::Propose); + } +} diff --git a/crates/cargo-anvil/src/emit/mod.rs b/crates/cargo-anvil/src/emit/mod.rs new file mode 100644 index 00000000..9c8ba54f --- /dev/null +++ b/crates/cargo-anvil/src/emit/mod.rs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Owned-file and managed-region emitters. +//! +//! Each emitter produces a [`crate::plan::PlanItem`] given the workspace +//! and the previous manifest. The driver in [`mod@crate::run`] collects +//! them into a [`crate::plan::Plan`] and either applies or summarizes it. + +pub mod ado; +pub mod cargo_toml; +pub mod github; +pub mod local; +pub mod managed_region; +pub mod owned_file; +pub mod shared_configs; + +pub use managed_region::plan_managed_region; +pub use owned_file::plan_owned_file; diff --git a/crates/cargo-anvil/src/emit/owned_file.rs b/crates/cargo-anvil/src/emit/owned_file.rs new file mode 100644 index 00000000..1da4f60a --- /dev/null +++ b/crates/cargo-anvil/src/emit/owned_file.rs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Driver for a single owned file. +//! +//! Given a repo-root-relative path and the rendered template, this module +//! reads the disk file (if any), consults the manifest, computes the +//! decision, and returns a [`PlanItem`] ready to be applied. + +use std::path::Path; + +use ohno::AppError; + +use crate::checksum::checksum_str; +use crate::decision::{Decision, DecisionInputs, decide}; +use crate::io::read_file_if_present; +use crate::manifest::Manifest; +use crate::plan::{PlanItem, Target}; + +/// Compute the [`PlanItem`] for an owned file. +/// +/// `relpath` is the repo-root-relative forward-slash path. +/// `rendered` is the byte-exact content the template would produce. +/// +/// # Errors +/// +/// Returns an error if the file exists but can't be read. +pub fn plan_owned_file(repo_root: &Path, manifest: &Manifest, relpath: &str, rendered: &str) -> Result { + let abs = repo_root.join(relpath); + let on_disk = read_file_if_present(&abs)?; + let disk_checksum = on_disk.as_deref().map(checksum_str); + let template_checksum = checksum_str(rendered); + let last_rendered = manifest.files.get(relpath).map(String::as_str); + + let inputs = DecisionInputs { + last_rendered, + disk: disk_checksum.as_deref(), + template: &template_checksum, + }; + let decision = decide(&inputs); + + let target = Target::File { path: relpath.to_owned() }; + let item = match decision { + Decision::InSync => PlanItem::insync(target, template_checksum), + Decision::LeaveAlone => PlanItem::noop(target, decision), + Decision::Write => PlanItem::write_file(relpath, rendered.to_owned(), template_checksum), + Decision::Propose => PlanItem::propose_file(relpath, rendered.to_owned(), template_checksum), + Decision::Remove | Decision::OrphanedKept => { + unreachable!("decide() never returns removal decisions; those come from plan_removals") + } + }; + + Ok(item) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn missing_file_writes() { + let tmp = TempDir::new().unwrap(); + let item = plan_owned_file(tmp.path(), &Manifest::default(), "a.txt", "content\n").unwrap(); + assert_eq!(item.decision, Decision::Write); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn matching_file_in_sync() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "content\n").unwrap(); + let item = plan_owned_file(tmp.path(), &Manifest::default(), "a.txt", "content\n").unwrap(); + assert_eq!(item.decision, Decision::InSync); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn user_modified_after_render_proposes_when_template_changed() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "user-edited\n").unwrap(); + let mut manifest = Manifest::default(); + let old_template_checksum = checksum_str("old template\n"); + manifest.set_file("a.txt", &old_template_checksum); + let item = plan_owned_file(tmp.path(), &manifest, "a.txt", "new template\n").unwrap(); + assert_eq!(item.decision, Decision::Propose); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn user_modified_template_unchanged_leaves_alone() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "user-edited\n").unwrap(); + let mut manifest = Manifest::default(); + let same_checksum = checksum_str("template\n"); + manifest.set_file("a.txt", &same_checksum); + let item = plan_owned_file(tmp.path(), &manifest, "a.txt", "template\n").unwrap(); + assert_eq!(item.decision, Decision::LeaveAlone); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn empty_file_opts_out_when_template_unchanged() { + // After a previous render, user empties the file. Template hasn't + // moved → LeaveAlone (silent, opt-out preserved). + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "").unwrap(); + let mut manifest = Manifest::default(); + manifest.set_file("a.txt", checksum_str("template\n")); + let item = plan_owned_file(tmp.path(), &manifest, "a.txt", "template\n").unwrap(); + assert_eq!(item.decision, Decision::LeaveAlone); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn empty_file_with_changed_template_proposes() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "").unwrap(); + let mut manifest = Manifest::default(); + manifest.set_file("a.txt", checksum_str("old\n")); + let item = plan_owned_file(tmp.path(), &manifest, "a.txt", "new\n").unwrap(); + assert_eq!(item.decision, Decision::Propose); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn whitespace_only_file_is_treated_as_user_divergence() { + // No special-casing for whitespace any more — it's just user + // content that diverges from the template. Steady-state with an + // unchanged template is still LeaveAlone, so opt-out works. + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), " \n\t\n").unwrap(); + let mut manifest = Manifest::default(); + manifest.set_file("a.txt", checksum_str("template\n")); + let item = plan_owned_file(tmp.path(), &manifest, "a.txt", "template\n").unwrap(); + assert_eq!(item.decision, Decision::LeaveAlone); + } +} diff --git a/crates/cargo-anvil/src/emit/shared_configs.rs b/crates/cargo-anvil/src/emit/shared_configs.rs new file mode 100644 index 00000000..06f37835 --- /dev/null +++ b/crates/cargo-anvil/src/emit/shared_configs.rs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Regions for the small shared-config files: `deny.toml`, `rustfmt.toml`, +//! `.delta.toml`, `spellcheck.toml`, `clippy.toml`. +//! +//! Each is a TOML file with a single `anvil-*` region near the end. +//! The body content is small and opinionated; emptying the region is the +//! opt-out path. + +use std::path::Path; + +use ohno::AppError; + +use super::managed_region::plan_managed_region; +use crate::manifest::Manifest; +use crate::plan::PlanItem; +use crate::region::CommentSyntax; + +/// Repo-root-relative path of the `cargo-deny` config. +pub const DENY_PATH: &str = "deny.toml"; +/// Region id for the managed section of `deny.toml`. +pub const DENY_REGION_ID: &str = "anvil-deny"; + +/// Repo-root-relative path of the `rustfmt` config. +pub const RUSTFMT_PATH: &str = "rustfmt.toml"; +/// Region id for the managed section of `rustfmt.toml`. +pub const RUSTFMT_REGION_ID: &str = "anvil-rustfmt"; + +/// Repo-root-relative path of the `cargo-delta` config. +pub const DELTA_PATH: &str = ".delta.toml"; +/// Region id for the managed section of `.delta.toml`. +pub const DELTA_REGION_ID: &str = "anvil-delta"; + +/// Repo-root-relative path of the `cargo-spellcheck` config. +pub const SPELLCHECK_PATH: &str = "spellcheck.toml"; +/// Region id for the managed section of `spellcheck.toml`. +pub const SPELLCHECK_REGION_ID: &str = "anvil-spellcheck"; + +/// Repo-root-relative path of the `clippy` lint-tuning config. +pub const CLIPPY_PATH: &str = "clippy.toml"; +/// Region id for the managed section of `clippy.toml`. +pub const CLIPPY_REGION_ID: &str = "anvil-clippy"; + +/// Embedded body of the deny.toml managed region — a permissive license +/// allow-list, deny-yanked advisories, and a sources-allowlist baseline. +pub const DENY_BODY: &str = include_str!("../../templates/regions/deny.toml"); + +/// Embedded body of the rustfmt.toml managed region. +/// +/// A minimal opinion set. Contested choices stay at rustfmt defaults to +/// keep adoption friction low; users who want different formatting +/// empty the region. +pub const RUSTFMT_BODY: &str = include_str!("../../templates/regions/rustfmt.toml"); + +/// Embedded body of the .delta.toml managed region. +/// +/// Minimum cargo-delta config covering the impact-scoping inputs used +/// by the cloud-workflow emitter. +pub const DELTA_BODY: &str = include_str!("../../templates/regions/delta.toml"); + +/// Embedded body of the spellcheck.toml managed region. +/// +/// Defaults aligned with the `anvil-spellcheck` recipe's behavior: +/// `hunspell` with `en_US`, project-local `target/spelling.dic` derived +/// from `.spelling`, no OS dictionary lookups (cross-platform +/// determinism), `CamelCase` concatenation enabled. +pub const SPELLCHECK_BODY: &str = include_str!("../../templates/regions/spellcheck.toml"); + +/// Embedded body of the clippy.toml managed region. +/// +/// Fine-tuning settings (not lint levels — those live in Cargo.toml +/// `[workspace.lints]`) that pair with the catalog's enabled lints. +/// `allow-panic-in-tests` / `allow-unwrap-in-tests` are required +/// companions to `clippy.unwrap_used`; `semicolon-outside-block- +/// ignore-multiline` tames `clippy.semicolon_outside_block` for +/// common multiline-block style. +pub const CLIPPY_BODY: &str = include_str!("../../templates/regions/clippy.toml"); + +/// Plan all five shared-config regions. +/// +/// # Errors +/// +/// Propagates I/O and region-parsing errors. +pub fn plan_shared_configs(repo_root: &Path, manifest: &Manifest) -> Result, AppError> { + Ok(vec![ + plan_managed_region(repo_root, manifest, DENY_PATH, DENY_REGION_ID, DENY_BODY, CommentSyntax::Hash)?, + plan_managed_region( + repo_root, + manifest, + RUSTFMT_PATH, + RUSTFMT_REGION_ID, + RUSTFMT_BODY, + CommentSyntax::Hash, + )?, + plan_managed_region(repo_root, manifest, DELTA_PATH, DELTA_REGION_ID, DELTA_BODY, CommentSyntax::Hash)?, + plan_managed_region( + repo_root, + manifest, + SPELLCHECK_PATH, + SPELLCHECK_REGION_ID, + SPELLCHECK_BODY, + CommentSyntax::Hash, + )?, + plan_managed_region(repo_root, manifest, CLIPPY_PATH, CLIPPY_REGION_ID, CLIPPY_BODY, CommentSyntax::Hash)?, + ]) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::decision::Decision; + use crate::region::upsert_region; + + #[test] + fn deny_body_includes_allowlist_and_advisories() { + assert!(DENY_BODY.contains("[licenses]")); + assert!(DENY_BODY.contains("\"MIT\"")); + assert!(DENY_BODY.contains("\"Apache-2.0\"")); + assert!(DENY_BODY.contains("[advisories]")); + assert!(DENY_BODY.contains("yanked = \"deny\"")); + } + + #[test] + fn rustfmt_body_sets_edition_and_width() { + assert!(RUSTFMT_BODY.contains("edition = \"2024\"")); + // Catalog default is 140, matching the explicit choice in + // oxidizer-github and ox-tools (the two surveyed repos that + // opted to set a width). oxidizer/assistants-oxide/ox-docs + // didn't set one, defaulting to rustfmt's 100; they can + // override outside the managed region if they prefer that. + assert!(RUSTFMT_BODY.contains("max_width = 140")); + // Nightly-only opinions: import grouping + granularity, doc + // comment code formatting. anvil-fmt invokes nightly + // rustfmt via the pinned `rust_nightly` so these never go + // stale on a `rustup update`. + assert!(RUSTFMT_BODY.contains("unstable_features = true")); + assert!(RUSTFMT_BODY.contains("imports_granularity = \"Module\"")); + assert!(RUSTFMT_BODY.contains("group_imports = \"StdExternalCrate\"")); + assert!(RUSTFMT_BODY.contains("format_code_in_doc_comments = true")); + } + + #[test] + fn delta_body_has_root_files() { + assert!(DELTA_BODY.contains("root-files")); + assert!(DELTA_BODY.contains("Cargo.lock")); + } + + #[test] + fn spellcheck_body_configures_hunspell_with_extra_dictionary() { + assert!(SPELLCHECK_BODY.contains("[Hunspell]")); + assert!(SPELLCHECK_BODY.contains("lang = \"en_US\"")); + // Generated by anvil-spellcheck from .spelling — the recipe + // and the config must agree on this path. + assert!(SPELLCHECK_BODY.contains("\"target/spelling.dic\"")); + // Required for cross-platform reproducibility (no system dict). + assert!(SPELLCHECK_BODY.contains("skip_os_lookups = true")); + assert!(SPELLCHECK_BODY.contains("use_builtin = true")); + // CamelCase concatenation handling — major false-positive reducer + // on Rust codebases. + assert!(SPELLCHECK_BODY.contains("[Hunspell.quirks]")); + assert!(SPELLCHECK_BODY.contains("allow_concatenation = true")); + } + + #[test] + fn clippy_body_carries_companion_tunings_for_catalog_lints() { + // Required companions for clippy.unwrap_used — tests should be + // free to unwrap/panic without the catalog lint firing. + assert!(CLIPPY_BODY.contains("allow-panic-in-tests = true")); + assert!(CLIPPY_BODY.contains("allow-unwrap-in-tests = true")); + // Required tuning for clippy.semicolon_outside_block. + assert!(CLIPPY_BODY.contains("semicolon-outside-block-ignore-multiline = true")); + // Workspace-internal code: prefer the correct fix over + // exported-API stability. + assert!(CLIPPY_BODY.contains("avoid-breaking-exported-api = false")); + // Path-length tuning shared across all four surveyed repos. + assert!(CLIPPY_BODY.contains("absolute-paths-max-segments = 3")); + // Aspirational: when wildcard_imports is flipped back to warn + // (clippy bug #15036 fix), this tuning is already in place. + assert!(CLIPPY_BODY.contains("warn-on-all-wildcard-imports = true")); + } + + #[test] + fn bodies_round_trip_through_toml_parser() { + for (id, body) in [ + (DENY_REGION_ID, DENY_BODY), + (RUSTFMT_REGION_ID, RUSTFMT_BODY), + (DELTA_REGION_ID, DELTA_BODY), + (SPELLCHECK_REGION_ID, SPELLCHECK_BODY), + (CLIPPY_REGION_ID, CLIPPY_BODY), + ] { + let spliced = upsert_region("", id, body, CommentSyntax::Hash).unwrap(); + let _: toml_edit::DocumentMut = spliced + .parse() + .unwrap_or_else(|e| panic!("body for region '{id}' did not parse: {e}")); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn plan_shared_configs_emits_five_items() { + let tmp = TempDir::new().unwrap(); + let items = plan_shared_configs(tmp.path(), &Manifest::default()).unwrap(); + assert_eq!(items.len(), 5); + for item in &items { + assert_eq!(item.decision, Decision::Write); + } + } +} diff --git a/crates/cargo-anvil/src/io.rs b/crates/cargo-anvil/src/io.rs new file mode 100644 index 00000000..d90cd988 --- /dev/null +++ b/crates/cargo-anvil/src/io.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Trivial filesystem helpers shared across emit/plan paths. + +use std::path::Path; + +use ohno::{AppError, IntoAppError as _}; + +/// Read a file's contents to a string, returning `Ok(None)` if the file +/// does not exist. Any other I/O error is propagated as an `AppError`. +/// +/// # Errors +/// +/// Returns an error if reading the file fails for a reason other than +/// `NotFound` (e.g., permissions, invalid UTF-8). +#[mutants::skip] // Trivial `fs::read_to_string` + `NotFound` passthrough; mutations on its match guard / Ok arms are not behavior-meaningful and exhaustively exercised via every plan/emit path. +pub fn read_file_if_present(path: &Path) -> Result, AppError> { + match std::fs::read_to_string(path) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err::, _>(e).into_app_err_with(|| format!("failed to read {}", path.display())), + } +} diff --git a/crates/cargo-anvil/src/lib.rs b/crates/cargo-anvil/src/lib.rs new file mode 100644 index 00000000..6d408ef8 --- /dev/null +++ b/crates/cargo-anvil/src/lib.rs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg_attr( + test, + allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" + ) +)] + +//! # cargo-anvil +//! +//! Opinionated, unified Rust build and cloud-workflow scaffolding for GitHub Actions and +//! Azure DevOps Pipelines. One opinionated check catalog, two cloud workflows +//! backends, generated from the same source of truth. +//! +//! ## What it does +//! +//! `cargo-anvil` writes files. `just` runs them. The repo composes +//! everything. The tool itself is not on the local-build hot path or in +//! the cloud-workflow graph at runtime — it is a code generator that you re-run when +//! you want to upgrade the opinionated baseline. +//! +//! Each run of `cargo anvil` writes: +//! +//! - The `justfiles/anvil/` recipe tree (`tools.just`, `checks.just`, +//! `groups.just`, `tiers.just`) — owned files. +//! - A managed region in your `Justfile` that imports them. +//! - A managed region in your workspace `Cargo.toml` carrying +//! `[workspace.lints]` in dotted-key form, plus a `[lints] workspace = +//! true` region in each workspace member. +//! - Managed regions in `deny.toml`, `rustfmt.toml`, and `.delta.toml`. +//! - For each selected cloud-workflow backend (`github`, `ado`), the full set of +//! composite actions / step templates, reusable workflows / stages +//! templates, and root workflows / pipelines. +//! +//! Outside the managed regions, your content is preserved byte-for-byte. +//! +//! ## Installation +//! +//! ```bash +//! cargo install --locked cargo-anvil +//! ``` +//! +//! Only the maintainer who runs updates needs the binary. Everyone else +//! uses `just` (or plain `cargo`). +//! +//! ## Usage +//! +//! ```text +//! cargo anvil [--backend ]... [--no-backends] [--dry-run] +//! ``` +//! +//! `update` is the only subcommand. There is no separate `init`, +//! `migrate`, `check`, `enable`, or `disable`. The algorithm is uniform +//! — first runs and subsequent runs go through the same decision table. +//! +//! Flags: +//! +//! - `--backend ` — repeatable. Valid values: `github`, `ado`. If +//! omitted, the backend is autodetected from the `origin` git remote. +//! - `--no-backends` — emit only local files; skip every cloud-workflow backend. +//! Mutually exclusive with `--backend`. +//! - `--dry-run` — analyze without writing. Exits 1 if anything would be +//! written or proposed. +//! +//! ## Daily driver +//! +//! After the first run, your daily workflow is plain `just`: +//! +//! ```text +//! $ just anvil # alias for `just anvil-pr` +//! $ just anvil-pr # the PR tier +//! $ just anvil-scheduled # the scheduled tier +//! $ just anvil-full # both, sequentially +//! ``` +//! +//! cloud workflows invokes the same recipes. Local and cloud-workflow runs are bit-identical because +//! they share one implementation in the imported `.just` files. +//! +//! ## Customization +//! +//! Four escape valves, in increasing severity: +//! +//! 1. **Compose around the tool**: add your own `.just` files or +//! workflows; the tool never touches anything not prefixed +//! `anvil-`. +//! 2. **Extend managed regions** outside the sentinels — add lints, +//! deny rules, etc. The tool preserves everything outside. +//! 3. **Opt out by emptying** a managed region or owned file. The tool +//! will skip the item on every future `update` and only emit a +//! `.anvil-proposed` sibling when the template actually changes. +//! 4. **Take ownership by editing inside** an owned file or managed +//! region. The next `update` detects the dirt and writes a +//! `.anvil-proposed` sibling instead of overwriting. +//! +//! ## Design docs +//! +//! See `docs/design/` for the full architecture: +//! +//! - `design.md` — overall principles and CLI shape. +//! - `checks.md` — the opinionated check catalog. +//! - `local.md` — the `justfiles/anvil/` tree. +//! - `updates.md` — the drift-detection algorithm. +//! - `github.md` — GitHub Actions emission. +//! - `ado.md` — Azure DevOps Pipelines emission. +//! +//! And `docs/verification.md` for the continuous-validation strategy. + +#![deny(unsafe_code)] + +pub mod backend; +pub mod checksum; +pub mod cli; +pub mod decision; +pub mod emit; +pub mod io; +pub mod manifest; +pub mod plan; +pub mod region; +pub mod run; +pub mod workspace; + +pub use backend::Backend; +pub use checksum::{checksum_bytes, checksum_str}; +pub use cli::Cli; +pub use decision::{Decision, DecisionInputs, decide}; +pub use manifest::{Manifest, RegionKey}; +pub use plan::{Plan, PlanItem, Target}; +pub use region::{CommentSyntax, Region, find_region, render_region, upsert_region}; +pub use run::run; +pub use workspace::{Workspace, WorkspaceMember}; diff --git a/crates/cargo-anvil/src/main.rs b/crates/cargo-anvil/src/main.rs new file mode 100644 index 00000000..c953a669 --- /dev/null +++ b/crates/cargo-anvil/src/main.rs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `cargo-anvil`: opinionated, unified Rust build and cloud-workflow scaffolding. + +use std::process::ExitCode; + +use cargo_anvil::cli::Cli; +use tracing_subscriber::fmt::format::FmtSpan; + +#[mutants::skip] // Entry point: tracing/clap setup + dispatch to lib::run; behavior is integration-tested. +fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_target(false) + .with_level(false) + .with_span_events(FmtSpan::NONE) + .without_time() + .init(); + + let cli = match Cli::parse_from_cargo_args(std::env::args_os()) { + Ok(cli) => cli, + Err(err) => { + // clap formats and prints the help/error itself. + err.exit(); + } + }; + + match cargo_anvil::run(&cli) { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("error: {err:#}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/cargo-anvil/src/manifest.rs b/crates/cargo-anvil/src/manifest.rs new file mode 100644 index 00000000..75259955 --- /dev/null +++ b/crates/cargo-anvil/src/manifest.rs @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! `.anvil.lock` — the sidecar manifest. +//! +//! Tracks, for every owned file and every managed region, the checksum of +//! what `cargo-anvil` most recently rendered there. This is the single +//! source of truth for "what did the tool last write" — drift detection +//! compares this against the current on-disk content and the current +//! template content. +//! +//! Schema is documented in [`updates.md §1`](../../docs/design/updates.md). +//! The schema version is `1`. Newer schemas cause the tool to refuse +//! running; older schemas are migrated automatically (no older schemas +//! exist today). + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use ohno::{AppError, IntoAppError as _, app_err, bail}; +use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, value}; + +/// File name of the manifest at the repo root. +pub const MANIFEST_FILE_NAME: &str = ".anvil.lock"; + +/// Current schema version we read/write. +pub const SCHEMA_VERSION: i64 = 1; + +/// The full parsed manifest. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Manifest { + /// `rendered_by` string (binary name + version) of the last writer. + /// `None` for empty/never-written manifests. + pub rendered_by: Option, + + /// Last-rendered checksum per owned file, keyed by repo-root-relative + /// forward-slash path. + pub files: BTreeMap, + + /// Last-rendered checksum per managed region, keyed by `(host_path, + /// region_id)`. + pub regions: BTreeMap, +} + +/// Composite key identifying one managed region. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RegionKey { + /// The host file's repo-root-relative forward-slash path. + pub host: String, + /// The region's stable `id` from the sentinel comments. + pub id: String, +} + +impl Manifest { + /// Path the manifest should be saved at, given a workspace root. + #[must_use] + pub fn path_for(repo_root: &Path) -> PathBuf { + repo_root.join(MANIFEST_FILE_NAME) + } + + /// Load the manifest from `repo_root`, returning an empty manifest if no + /// file exists. + /// + /// # Errors + /// + /// Returns an error if the file exists but can't be read, can't be + /// parsed, or declares an unsupported schema version. + pub fn load(repo_root: &Path) -> Result { + let path = Self::path_for(repo_root); + if !path.exists() { + return Ok(Self::default()); + } + let text = std::fs::read_to_string(&path).into_app_err_with(|| format!("failed to read {}", path.display()))?; + Self::parse(&text).into_app_err_with(|| format!("failed to parse manifest at {}", path.display())) + } + + /// Parse a manifest from a TOML string. + /// + /// # Errors + /// + /// Returns an error on malformed TOML or unsupported schema version. + pub fn parse(text: &str) -> Result { + let doc: DocumentMut = text.parse::().into_app_err("manifest is not valid TOML")?; + + let version = doc + .get("version") + .and_then(Item::as_integer) + .ok_or_else(|| app_err!("manifest is missing a top-level `version` integer"))?; + if version > SCHEMA_VERSION { + bail!("manifest schema version {version} is newer than supported ({SCHEMA_VERSION}); upgrade cargo-anvil"); + } + + let rendered_by = doc.get("rendered_by").and_then(Item::as_str).map(str::to_owned); + + let mut files = BTreeMap::new(); + if let Some(arr) = doc.get("file").and_then(Item::as_array_of_tables) { + for table in arr { + let path = table + .get("path") + .and_then(Item::as_str) + .ok_or_else(|| app_err!("[[file]] entry is missing `path`"))? + .to_owned(); + let checksum = table + .get("checksum") + .and_then(Item::as_str) + .ok_or_else(|| app_err!("[[file]] entry '{path}' is missing `checksum`"))? + .to_owned(); + if files.insert(path.clone(), checksum).is_some() { + bail!("duplicate [[file]] entry for '{path}'"); + } + } + } + + let mut regions = BTreeMap::new(); + if let Some(arr) = doc.get("region").and_then(Item::as_array_of_tables) { + for table in arr { + let host = table + .get("host") + .and_then(Item::as_str) + .ok_or_else(|| app_err!("[[region]] entry is missing `host`"))? + .to_owned(); + let id = table + .get("id") + .and_then(Item::as_str) + .ok_or_else(|| app_err!("[[region]] entry '{host}' is missing `id`"))? + .to_owned(); + let checksum = table + .get("checksum") + .and_then(Item::as_str) + .ok_or_else(|| app_err!("[[region]] entry '{host}'/'{id}' is missing `checksum`"))? + .to_owned(); + let key = RegionKey { host, id }; + if regions.insert(key.clone(), checksum).is_some() { + bail!("duplicate [[region]] entry for host '{}' id '{}'", key.host, key.id); + } + } + } + + Ok(Self { + rendered_by, + files, + regions, + }) + } + + /// Render the manifest as a deterministic TOML string. + /// + /// Entries are sorted (files alphabetically by path; regions by + /// `(host, id)`). The output ends with a trailing newline. + #[must_use] + pub fn to_toml(&self) -> String { + let mut doc = DocumentMut::new(); + + doc.insert("version", value(SCHEMA_VERSION)); + if let Some(rb) = &self.rendered_by { + doc.insert("rendered_by", value(rb.as_str())); + } + + if !self.files.is_empty() { + let mut tables = ArrayOfTables::new(); + for (path, checksum) in &self.files { + let mut t = Table::new(); + t.insert("path", value(path.as_str())); + t.insert("checksum", value(checksum.as_str())); + tables.push(t); + } + doc.insert("file", Item::ArrayOfTables(tables)); + } + + if !self.regions.is_empty() { + let mut tables = ArrayOfTables::new(); + for (key, checksum) in &self.regions { + let mut t = Table::new(); + t.insert("host", value(key.host.as_str())); + t.insert("id", value(key.id.as_str())); + t.insert("checksum", value(checksum.as_str())); + tables.push(t); + } + doc.insert("region", Item::ArrayOfTables(tables)); + } + + let mut out = doc.to_string(); + if !out.ends_with('\n') { + out.push('\n'); + } + out + } + + /// Save the manifest to `/.anvil.lock` atomically (write + /// to a temp file, then rename). + /// + /// # Errors + /// + /// Returns an error if the write fails. + pub fn save(&self, repo_root: &Path) -> Result<(), AppError> { + let path = Self::path_for(repo_root); + let text = self.to_toml(); + let tmp = path.with_extension("lock.tmp"); + std::fs::write(&tmp, text.as_bytes()).into_app_err_with(|| format!("failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, &path).into_app_err_with(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; + Ok(()) + } + + /// Insert or update one file entry. + pub fn set_file(&mut self, path: impl Into, checksum: impl Into) { + self.files.insert(path.into(), checksum.into()); + } + + /// Insert or update one region entry. + pub fn set_region(&mut self, host: impl Into, id: impl Into, checksum: impl Into) { + self.regions.insert( + RegionKey { + host: host.into(), + id: id.into(), + }, + checksum.into(), + ); + } +} + +// Suppress an unused-import lint when no callers reference `Array`/`Value` +// yet (they will once writers gain inline-table support in later commits). + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn sample_manifest() -> Manifest { + let mut m = Manifest { + rendered_by: Some("cargo-anvil 0.1.0".into()), + ..Manifest::default() + }; + m.set_file("Justfile", "sha256:aaaa"); + m.set_file("justfiles/anvil/checks.just", "sha256:bbbb"); + m.set_region("Cargo.toml", "anvil-workspace-lints", "sha256:cccc"); + m.set_region("Justfile", "anvil-imports", "sha256:dddd"); + m + } + + #[test] + fn round_trip_preserves_content() { + let m1 = sample_manifest(); + let text = m1.to_toml(); + let m2 = Manifest::parse(&text).unwrap(); + assert_eq!(m1, m2); + } + + #[test] + fn empty_manifest_round_trip() { + let m1 = Manifest::default(); + let text = m1.to_toml(); + let m2 = Manifest::parse(&text).unwrap(); + assert_eq!(m1, m2); + } + + #[test] + fn to_toml_always_ends_with_exactly_one_newline() { + // Catches mutation of the `if !out.ends_with('\n')` guard in to_toml. + // Asserting `ends_with('\n')` alone is insufficient: when toml_edit's + // serialization naturally ends with '\n' (the common case), the + // mutated guard would double the newline -- still ending with '\n', + // so a weaker assertion would pass. + for m in [Manifest::default(), sample_manifest()] { + let text = m.to_toml(); + let stripped = text.trim_end_matches('\n'); + assert_eq!( + format!("{stripped}\n"), + text, + "to_toml output must end with exactly one newline, got: {text:?}" + ); + } + } + + #[test] + fn toml_output_is_deterministic() { + // Same content via two different insertion orders should serialize identically. + let mut a = Manifest::default(); + a.set_file("z", "sha256:1"); + a.set_file("a", "sha256:2"); + let mut b = Manifest::default(); + b.set_file("a", "sha256:2"); + b.set_file("z", "sha256:1"); + assert_eq!(a.to_toml(), b.to_toml()); + } + + #[test] + fn rejects_newer_schema() { + let text = "version = 999\n"; + let err = Manifest::parse(text).unwrap_err(); + assert!(err.to_string().contains("newer than supported")); + } + + #[test] + fn rejects_missing_version() { + let text = "rendered_by = \"x\"\n"; + let err = Manifest::parse(text).unwrap_err(); + assert!(err.to_string().contains("`version`")); + } + + #[test] + fn rejects_malformed_file_entry() { + let text = "version = 1\n[[file]]\npath = \"foo\"\n"; + let err = Manifest::parse(text).unwrap_err(); + assert!(err.to_string().contains("`checksum`")); + } + + #[test] + fn rejects_duplicate_file_entry() { + let text = "version = 1\n[[file]]\npath=\"x\"\nchecksum=\"sha256:1\"\n[[file]]\npath=\"x\"\nchecksum=\"sha256:2\"\n"; + let err = Manifest::parse(text).unwrap_err(); + assert!(err.to_string().contains("duplicate")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn load_missing_file_yields_empty_manifest() { + let tmp = TempDir::new().unwrap(); + let m = Manifest::load(tmp.path()).unwrap(); + assert_eq!(m, Manifest::default()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn save_then_load_round_trip() { + let tmp = TempDir::new().unwrap(); + let m1 = sample_manifest(); + m1.save(tmp.path()).unwrap(); + assert!(Manifest::path_for(tmp.path()).is_file()); + + let m2 = Manifest::load(tmp.path()).unwrap(); + assert_eq!(m1, m2); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn save_overwrites_existing() { + let tmp = TempDir::new().unwrap(); + let m1 = sample_manifest(); + m1.save(tmp.path()).unwrap(); + + let mut m2 = Manifest::default(); + m2.set_file("only", "sha256:e"); + m2.save(tmp.path()).unwrap(); + + let loaded = Manifest::load(tmp.path()).unwrap(); + assert_eq!(loaded, m2); + assert_eq!(loaded.files.len(), 1); + assert!(loaded.regions.is_empty()); + } + + #[test] + fn toml_ends_with_newline() { + let text = sample_manifest().to_toml(); + assert!(text.ends_with('\n')); + } +} diff --git a/crates/cargo-anvil/src/plan.rs b/crates/cargo-anvil/src/plan.rs new file mode 100644 index 00000000..7d5e7c3e --- /dev/null +++ b/crates/cargo-anvil/src/plan.rs @@ -0,0 +1,817 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Plan accumulation, proposed-file emission, and dry-run summary. +//! +//! The `update` driver builds a [`Plan`] by appending one [`PlanItem`] +//! per file or region it processes. After all decisions are made, the +//! plan either: +//! +//! - **Applies** to disk: writes owned files, splices in region updates, +//! writes `.anvil-proposed` siblings for divergent items, and +//! refreshes the manifest. +//! - **Summarizes** for `--dry-run`: prints counts and outstanding items, +//! without touching disk. Returns a non-zero exit code if anything is +//! out of date. +//! +//! See [`updates.md §7`](../../docs/design/updates.md) for the proposed-file +//! protocol. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use ohno::{AppError, IntoAppError as _}; + +use crate::decision::Decision; +use crate::manifest::{Manifest, RegionKey}; + +/// What is being changed by a single plan item. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Target { + /// An owned file at a repo-root-relative forward-slash path. + File { + /// Repo-root-relative forward-slash path. + path: String, + }, + /// A managed region inside a host file. + Region { + /// Repo-root-relative forward-slash path to the host file. + host: String, + /// Stable region id. + id: String, + }, +} + +impl Target { + /// A short human-readable label for summary output. + #[must_use] + pub fn label(&self) -> String { + match self { + Self::File { path } => path.clone(), + Self::Region { host, id } => format!("{host} [{id}]"), + } + } +} + +/// One unit of work the driver decided on. +#[derive(Debug, Clone)] +pub struct PlanItem { + /// What is being changed. + pub target: Target, + /// The driver's decision. + pub decision: Decision, + /// What the driver wants to write — either to disk (for `Write`) or + /// to a `.anvil-proposed` sibling (for `Propose`). `None` for + /// decisions that don't write. + pub rendered: Option, + /// The full host-file body that contains the rendered region after + /// splice — used for `Region` targets in either `Write` or `Propose` + /// modes. Per [`updates.md §7`](../../docs/design/updates.md), proposed + /// outputs show the *full file* even for regions, not just the + /// region body. `None` for `File` targets. + pub spliced_host: Option, + /// Checksum of [`Self::rendered`], populated when `rendered` is + /// `Some`. The manifest stores this for `Write` decisions. + pub rendered_checksum: Option, +} + +impl PlanItem { + /// Construct a plan item for an in-sync, skipped, or leave-alone + /// decision (no payload). + #[must_use] + pub fn noop(target: Target, decision: Decision) -> Self { + debug_assert!(!decision.writes()); + Self { + target, + decision, + rendered: None, + spliced_host: None, + rendered_checksum: None, + } + } + + /// Construct a plan item for an `InSync` decision that *also* + /// carries the current template checksum. The apply step uses it + /// to opportunistically refresh the manifest's `L` value — so any + /// stale `L` from older binary versions (e.g. before line-ending + /// normalization landed) gets self-healed once the file is observed + /// in sync with the current template. Without this, a subsequent + /// template change would mis-classify as `Propose` instead of + /// `Write` because the algorithm would see `F ≠ L` and assume user + /// customization. + #[must_use] + pub fn insync(target: Target, template_checksum: String) -> Self { + Self { + target, + decision: Decision::InSync, + rendered: None, + spliced_host: None, + rendered_checksum: Some(template_checksum), + } + } + + /// Construct a plan item for a `Write` decision on an owned file. + #[must_use] + pub fn write_file(path: impl Into, rendered: String, checksum: String) -> Self { + Self { + target: Target::File { path: path.into() }, + decision: Decision::Write, + rendered: Some(rendered), + spliced_host: None, + rendered_checksum: Some(checksum), + } + } + + /// Construct a plan item for a `Propose` decision on an owned file. + /// + /// `template_checksum` is the checksum of `rendered`; the apply step + /// records it in the manifest so the next run sees the user's + /// divergence as `LeaveAlone` (`D ≠ L, L = T`) rather than reproposing + /// the same content. The .anvil-proposed sibling is the user's + /// review artifact; the proposal "disappears" from the dry-run + /// summary on subsequent runs unless the template moves again. + #[must_use] + pub fn propose_file(path: impl Into, rendered: String, template_checksum: String) -> Self { + Self { + target: Target::File { path: path.into() }, + decision: Decision::Propose, + rendered: Some(rendered), + spliced_host: None, + rendered_checksum: Some(template_checksum), + } + } + + /// Construct a plan item for a `Write` decision on a region. + #[must_use] + pub fn write_region(host: impl Into, id: impl Into, body: String, spliced_host: String, body_checksum: String) -> Self { + Self { + target: Target::Region { + host: host.into(), + id: id.into(), + }, + decision: Decision::Write, + rendered: Some(body), + spliced_host: Some(spliced_host), + rendered_checksum: Some(body_checksum), + } + } + + /// Construct a plan item for a `Propose` decision on a region. The + /// proposed payload is the *full host file* that would result from + /// the splice (so the user can review by diffing the proposed file + /// against the live host). `body_checksum` is the checksum of the + /// rendered region body — the apply step records it in the + /// manifest so subsequent runs see the proposal as resolved (see + /// [`propose_file`](Self::propose_file)). + #[must_use] + pub fn propose_region(host: impl Into, id: impl Into, spliced_host: String, body_checksum: String) -> Self { + Self { + target: Target::Region { + host: host.into(), + id: id.into(), + }, + decision: Decision::Propose, + rendered: None, + spliced_host: Some(spliced_host), + rendered_checksum: Some(body_checksum), + } + } + + /// Construct a plan item for a `Remove` decision on an owned file — + /// the file is no longer in the catalog and the user hasn't + /// customized it. + #[must_use] + pub fn remove_file(path: impl Into) -> Self { + Self { + target: Target::File { path: path.into() }, + decision: Decision::Remove, + rendered: None, + spliced_host: None, + rendered_checksum: None, + } + } + + /// Construct a plan item for a `Remove` decision on a managed region. + /// `spliced_host` is the host-file content with the region (markers + /// + body) excised. + #[must_use] + pub fn remove_region(host: impl Into, id: impl Into, spliced_host: String) -> Self { + Self { + target: Target::Region { + host: host.into(), + id: id.into(), + }, + decision: Decision::Remove, + rendered: None, + spliced_host: Some(spliced_host), + rendered_checksum: None, + } + } + + /// Construct a plan item for an `OrphanedKept` decision. The + /// file/region is no longer in the catalog, but the user has + /// customized it — leave the disk state alone and drop the + /// manifest entry to transfer ownership. + #[must_use] + pub fn orphaned_kept(target: Target) -> Self { + Self { + target, + decision: Decision::OrphanedKept, + rendered: None, + spliced_host: None, + rendered_checksum: None, + } + } +} + +/// A collection of plan items ready to be applied or summarized. +#[derive(Debug, Default)] +pub struct Plan { + items: Vec, +} + +impl Plan { + /// Append a new item. + pub fn push(&mut self, item: PlanItem) { + self.items.push(item); + } + + /// All plan items in insertion order. + #[must_use] + pub fn items(&self) -> &[PlanItem] { + &self.items + } + + /// Whether the plan would change anything on disk if applied. + #[must_use] + pub fn has_changes(&self) -> bool { + self.items.iter().any(|i| i.decision.writes()) + } + + /// Exit code for `--dry-run`: 0 if everything is in sync, 1 otherwise. + #[must_use] + pub fn dry_run_exit_code(&self) -> i32 { + i32::from(self.has_changes()) + } + + /// Render a stable, line-oriented summary suitable for stdout. + /// + /// When `previous_manifest` is provided, `Write` items are split + /// into "Will create" (no prior manifest entry) and "Will update" + /// (existing entry getting refreshed) per + /// [`updates.md §9`](../../docs/design/updates.md). The stale-entries + /// section enumerates manifest entries that were present before + /// this run but are no longer in the plan; these are purged on + /// non-dry-run application (see [`Plan::apply`]). + #[must_use] + pub fn summary(&self, previous_manifest: Option<&Manifest>) -> String { + let mut creates: Vec<&PlanItem> = Vec::new(); + let mut updates: Vec<&PlanItem> = Vec::new(); + let mut leave_alones: Vec<&PlanItem> = Vec::new(); + let mut proposes: Vec<&PlanItem> = Vec::new(); + let mut in_syncs: Vec<&PlanItem> = Vec::new(); + let mut removes: Vec<&PlanItem> = Vec::new(); + let mut orphans_kept: Vec<&PlanItem> = Vec::new(); + + for item in &self.items { + match item.decision { + Decision::Write => { + let existed = previous_manifest.is_some_and(|m| match &item.target { + Target::File { path } => m.files.contains_key(path), + Target::Region { host, id } => m.regions.contains_key(&RegionKey { + host: host.clone(), + id: id.clone(), + }), + }); + if existed { + updates.push(item); + } else { + creates.push(item); + } + } + Decision::LeaveAlone => leave_alones.push(item), + Decision::Propose => proposes.push(item), + Decision::InSync => in_syncs.push(item), + Decision::Remove => removes.push(item), + Decision::OrphanedKept => orphans_kept.push(item), + } + } + + let mut out = String::new(); + let _ = writeln!(out, "cargo-anvil plan: {} item(s)", self.items.len()); + + write_section(&mut out, "Will create", &creates); + write_section(&mut out, "Will update", &updates); + write_section(&mut out, "Will propose", &proposes); + write_section(&mut out, "Will remove", &removes); + write_section(&mut out, "Orphaned (customized; transferring ownership)", &orphans_kept); + write_section(&mut out, "Will leave alone (silent)", &leave_alones); + + if !in_syncs.is_empty() { + let _ = writeln!(out, "Unchanged: {} item(s)", in_syncs.len()); + } + + out + } + + /// Apply the plan to disk and return an updated manifest. + /// + /// - `Write` items write their owned-file content or splice region + /// bodies into host files and record their checksums in the new + /// manifest. + /// - `Propose` items write a `.anvil-proposed` sibling and + /// bump the manifest entry to the new template checksum so + /// subsequent runs see the divergence as resolved + /// (`LeaveAlone`) until the template moves again — see + /// [`updates.md §5`](../../docs/design/updates.md). + /// - `InSync`, `LeaveAlone` items preserve their existing + /// manifest entries from `previous_manifest`. + /// - Stale entries — items present in `previous_manifest` but not + /// in this plan (e.g. a removed workspace member, a backend the + /// user disabled) — are purged from the returned manifest. + /// + /// # Errors + /// + /// Returns an error if any filesystem operation fails. + /// + /// # Panics + /// + /// Panics if a plan item's invariants are violated — `Write` and + /// `Propose` items for files must carry rendered content; region + /// items must carry a spliced host. These invariants are enforced + /// by the `PlanItem::*` constructors, so violations only happen if + /// callers build `PlanItem` directly with inconsistent fields. + #[expect( + clippy::too_many_lines, + clippy::expect_used, + reason = "single dispatch site covering every (target × decision) pair; the expects encode constructor-enforced invariants" + )] + pub fn apply(&self, repo_root: &Path, previous_manifest: &Manifest) -> Result { + let mut next = Manifest { + rendered_by: Some(format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))), + files: previous_manifest.files.clone(), + regions: previous_manifest.regions.clone(), + }; + + for item in &self.items { + match (&item.target, item.decision) { + (Target::File { path }, Decision::Write) => { + let content = item.rendered.as_ref().expect("Write decision must carry rendered content"); + let abs = repo_root.join(path); + write_file(&abs, content)?; + if let Some(checksum) = &item.rendered_checksum { + next.files.insert(path.clone(), checksum.clone()); + } + } + (Target::File { path }, Decision::Propose) => { + let content = item.rendered.as_ref().expect("Propose decision must carry rendered content"); + let abs = repo_root.join(format!("{path}.anvil-proposed")); + write_file(&abs, content)?; + if let Some(checksum) = &item.rendered_checksum { + // Bump L to the new T so subsequent runs see the + // divergence as resolved (LeaveAlone). The user's + // .anvil-proposed sibling stays on disk for + // review; deleting or accepting it is the user's + // job. + next.files.insert(path.clone(), checksum.clone()); + } + } + (Target::Region { host, id }, Decision::Write) => { + let spliced = item.spliced_host.as_ref().expect("region Write must carry spliced host"); + let abs = repo_root.join(host); + write_file(&abs, spliced)?; + if let Some(checksum) = &item.rendered_checksum { + next.regions.insert( + RegionKey { + host: host.clone(), + id: id.clone(), + }, + checksum.clone(), + ); + } + } + (Target::Region { host, id }, Decision::Propose) => { + let spliced = item.spliced_host.as_ref().expect("region Propose must carry spliced host"); + let abs = repo_root.join(format!("{host}.anvil-proposed")); + write_file(&abs, spliced)?; + if let Some(checksum) = &item.rendered_checksum { + // Same rationale as the File/Propose branch: bump + // L = T so subsequent runs see LeaveAlone until + // the template moves again. + next.regions.insert( + RegionKey { + host: host.clone(), + id: id.clone(), + }, + checksum.clone(), + ); + } + } + (Target::File { path }, Decision::Remove) => { + // Untouched orphan file: delete and drop the + // manifest entry. If the file is already missing + // (race / external delete), absorb the error so + // the result is idempotent. + let abs = repo_root.join(path); + if let Err(e) = std::fs::remove_file(&abs) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err::(e).into_app_err_with(|| format!("failed to remove {}", abs.display())); + } + next.files.remove(path); + } + (Target::Region { host, id }, Decision::Remove) => { + // Untouched orphan region: splice the markers + body + // out of the host file and drop the manifest entry. + let spliced = item.spliced_host.as_ref().expect("region Remove must carry spliced host"); + let abs = repo_root.join(host); + write_file(&abs, spliced)?; + next.regions.remove(&RegionKey { + host: host.clone(), + id: id.clone(), + }); + } + (Target::File { path }, Decision::OrphanedKept) => { + // Customized orphan: leave the file in place, + // transfer ownership by dropping the manifest entry. + next.files.remove(path); + } + (Target::Region { host, id }, Decision::OrphanedKept) => { + // Customized orphan region: leave the host file + // and the in-region content in place, transfer + // ownership by dropping the manifest entry. + next.regions.remove(&RegionKey { + host: host.clone(), + id: id.clone(), + }); + } + (_, Decision::InSync) => { + // Disk content matches the current template. No + // file write needed. We DO refresh the manifest L + // to the current template checksum if the plan + // item carries one — this self-heals stale-L + // values left over from older binary versions + // whose hash function differed (e.g. before + // line-ending normalization). + if let Some(checksum) = &item.rendered_checksum { + match &item.target { + Target::File { path } => { + next.files.insert(path.clone(), checksum.clone()); + } + Target::Region { host, id } => { + next.regions.insert( + RegionKey { + host: host.clone(), + id: id.clone(), + }, + checksum.clone(), + ); + } + } + } + } + (_, Decision::LeaveAlone) => { + // No-op; manifest entry already preserved. + } + } + } + + Ok(next) + } +} + +fn write_section(out: &mut String, header: &str, items: &[&PlanItem]) { + if items.is_empty() { + return; + } + let _ = writeln!(out, "{header}: {} item(s)", items.len()); + for item in items { + let _ = writeln!(out, " - {}", item.target.label()); + } +} + +fn write_file(path: &Path, content: &str) -> Result<(), AppError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).into_app_err_with(|| format!("failed to create parent directory {}", parent.display()))?; + } + let tmp = make_temp_path(path); + std::fs::write(&tmp, content).into_app_err_with(|| format!("failed to write {}", tmp.display()))?; + std::fs::rename(&tmp, path).into_app_err_with(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?; + Ok(()) +} + +fn make_temp_path(path: &Path) -> PathBuf { + let mut name = path.file_name().map(std::ffi::OsString::from).unwrap_or_default(); + name.push(".anvil-tmp"); + path.with_file_name(name) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn empty_plan_is_in_sync() { + let plan = Plan::default(); + assert!(!plan.has_changes()); + assert_eq!(plan.dry_run_exit_code(), 0); + } + + #[test] + fn plan_with_write_is_out_of_sync() { + let mut plan = Plan::default(); + plan.push(PlanItem::write_file("a.txt", "x".into(), "sha256:1".into())); + assert!(plan.has_changes()); + assert_eq!(plan.dry_run_exit_code(), 1); + } + + #[test] + fn plan_with_only_in_sync_items_is_in_sync() { + let mut plan = Plan::default(); + plan.push(PlanItem::noop(Target::File { path: "a.txt".into() }, Decision::InSync)); + plan.push(PlanItem::noop( + Target::Region { + host: "Justfile".into(), + id: "x".into(), + }, + Decision::LeaveAlone, + )); + assert!(!plan.has_changes()); + } + + #[test] + fn summary_categorizes_items() { + let mut plan = Plan::default(); + // A fresh write (no prior manifest entry). + plan.push(PlanItem::write_file("a.txt", "x".into(), "sha256:1".into())); + // An item that was previously rendered and is now in sync. + plan.push(PlanItem::noop(Target::File { path: "b.txt".into() }, Decision::InSync)); + // No previous manifest → no "Will update" distinction, but the + // categories still render. + let s = plan.summary(Some(&Manifest::default())); + assert!(s.contains("Will create: 1 item(s)"), "summary:\n{s}"); + assert!(s.contains("- a.txt")); + assert!(s.contains("Unchanged: 1 item(s)")); + // b.txt is in-sync — listed in the unchanged count, not enumerated by path. + assert!(!s.contains("- b.txt")); + } + + #[test] + fn summary_distinguishes_create_from_update() { + let mut plan = Plan::default(); + plan.push(PlanItem::write_file("new.txt", "x".into(), "sha256:1".into())); + plan.push(PlanItem::write_file("existing.txt", "y".into(), "sha256:2".into())); + let mut prev = Manifest::default(); + prev.set_file("existing.txt", "sha256:old"); + let s = plan.summary(Some(&prev)); + assert!(s.contains("Will create: 1 item(s)"), "summary:\n{s}"); + assert!(s.contains("- new.txt")); + assert!(s.contains("Will update: 1 item(s)")); + assert!(s.contains("- existing.txt")); + } + + #[test] + fn summary_lists_removal_and_orphan_sections() { + // Plan items with the new Remove / OrphanedKept decisions surface + // in dedicated summary sections — they replaced the older + // implicit "Stale manifest entries" footer because removal is + // now an explicit plan action rather than a side effect of + // apply(). + let mut plan = Plan::default(); + plan.push(PlanItem::remove_file("dropped.txt")); + plan.push(PlanItem::orphaned_kept(Target::Region { + host: "Justfile".into(), + id: "anvil-old".into(), + })); + let s = plan.summary(None); + assert!(s.contains("Will remove: 1 item(s)")); + assert!(s.contains("- dropped.txt")); + assert!(s.contains("Orphaned (customized; transferring ownership): 1 item(s)")); + assert!(s.contains("- Justfile [anvil-old]")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_writes_owned_file() { + let tmp = TempDir::new().unwrap(); + let mut plan = Plan::default(); + plan.push(PlanItem::write_file("subdir/a.txt", "hello\n".into(), "sha256:abcd".into())); + let m = plan.apply(tmp.path(), &Manifest::default()).unwrap(); + let written = std::fs::read_to_string(tmp.path().join("subdir/a.txt")).unwrap(); + assert_eq!(written, "hello\n"); + assert_eq!(m.files.get("subdir/a.txt").map(String::as_str), Some("sha256:abcd")); + assert!(m.rendered_by.is_some()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_writes_proposed_file_sibling_and_bumps_manifest() { + let tmp = TempDir::new().unwrap(); + let mut plan = Plan::default(); + plan.push(PlanItem::propose_file("a.txt", "new content\n".into(), "sha256:newt".into())); + let m = plan.apply(tmp.path(), &Manifest::default()).unwrap(); + assert!(tmp.path().join("a.txt.anvil-proposed").is_file()); + assert!(!tmp.path().join("a.txt").exists()); + // The manifest L is bumped to the new template checksum so the + // next run sees the divergence as resolved (LeaveAlone), not as + // a fresh proposal. + assert_eq!(m.files.get("a.txt").map(String::as_str), Some("sha256:newt")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_region_write_splices_host() { + let tmp = TempDir::new().unwrap(); + let mut plan = Plan::default(); + plan.push(PlanItem::write_region( + "Justfile", + "anvil-imports", + "import 'foo'\n".into(), + "user content\n# >>> anvil-managed: anvil-imports\nimport 'foo'\n# <<< anvil-managed: anvil-imports\n".into(), + "sha256:body".into(), + )); + let m = plan.apply(tmp.path(), &Manifest::default()).unwrap(); + let written = std::fs::read_to_string(tmp.path().join("Justfile")).unwrap(); + assert!(written.contains("# >>> anvil-managed: anvil-imports")); + let key = RegionKey { + host: "Justfile".into(), + id: "anvil-imports".into(), + }; + assert_eq!(m.regions.get(&key).map(String::as_str), Some("sha256:body")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_region_propose_writes_sibling_and_bumps_manifest() { + let tmp = TempDir::new().unwrap(); + let mut plan = Plan::default(); + plan.push(PlanItem::propose_region( + "Justfile", + "anvil-imports", + "spliced host content\n".into(), + "sha256:newt".into(), + )); + let m = plan.apply(tmp.path(), &Manifest::default()).unwrap(); + assert!(tmp.path().join("Justfile.anvil-proposed").is_file()); + // Region L is bumped to the new template checksum on propose; + // subsequent runs see LeaveAlone until the template changes. + let key = RegionKey { + host: "Justfile".into(), + id: "anvil-imports".into(), + }; + assert_eq!(m.regions.get(&key).map(String::as_str), Some("sha256:newt")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_insync_refreshes_stale_manifest_l() { + // Regression test: when an older binary recorded an L using + // a different hash function (e.g. pre-line-ending-normalization), + // and the current binary observes F == T (InSync), the manifest + // L gets self-healed to the current T. Without this refresh, + // the next template change would mis-classify as Propose + // because the algorithm would see F ≠ L and assume user + // customization. + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "current content\n").unwrap(); + let mut prev = Manifest::default(); + prev.set_file("a.txt", "sha256:stale-from-older-binary"); + let mut plan = Plan::default(); + plan.push(PlanItem::insync( + Target::File { path: "a.txt".into() }, + "sha256:current-template".into(), + )); + let next = plan.apply(tmp.path(), &prev).unwrap(); + assert_eq!( + next.files.get("a.txt").map(String::as_str), + Some("sha256:current-template"), + "InSync must refresh the manifest L to the current template checksum", + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_leave_alone_does_not_refresh_manifest_l() { + // Dual of the above: LeaveAlone explicitly preserves L. The + // user has diverged from the template; bumping L = T would + // make the next run see F ≠ L (true), L == T (true) → Write, + // which would silently overwrite the user's customization. + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("a.txt"), "user edited\n").unwrap(); + let mut prev = Manifest::default(); + prev.set_file("a.txt", "sha256:original"); + let mut plan = Plan::default(); + plan.push(PlanItem::noop(Target::File { path: "a.txt".into() }, Decision::LeaveAlone)); + let next = plan.apply(tmp.path(), &prev).unwrap(); + assert_eq!( + next.files.get("a.txt").map(String::as_str), + Some("sha256:original"), + "LeaveAlone must preserve the manifest L unchanged", + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_remove_deletes_file_and_purges_manifest() { + // A Remove plan item deletes the file from disk and drops + // the manifest entry. This is the path the new plan_removals + // hook takes for an untouched orphan (replacing the old + // safety-net purge that only touched the manifest). + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("dropped.txt"), "old content\n").unwrap(); + let mut prev = Manifest::default(); + prev.set_file("dropped.txt", "sha256:old"); + let mut plan = Plan::default(); + plan.push(PlanItem::remove_file("dropped.txt")); + let next = plan.apply(tmp.path(), &prev).unwrap(); + assert!(!tmp.path().join("dropped.txt").exists(), "Remove must delete the file from disk"); + assert!(next.files.is_empty(), "Remove must drop the manifest entry: {:?}", next.files); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_remove_region_splices_out_markers_and_body() { + // A Region Remove plan item replaces the host file with + // its spliced-out content (markers + body excised), and + // drops the manifest entry. The spliced_host payload is + // computed by the plan builder via region::remove_region. + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("Justfile"), + "before\n\n# >>> anvil-managed: r\nbody\n# <<< anvil-managed: r\nafter\n", + ) + .unwrap(); + let mut prev = Manifest::default(); + prev.set_region("Justfile", "r", "sha256:body"); + let mut plan = Plan::default(); + plan.push(PlanItem::remove_region("Justfile", "r", "before\nafter\n".to_string())); + let next = plan.apply(tmp.path(), &prev).unwrap(); + let host = std::fs::read_to_string(tmp.path().join("Justfile")).unwrap(); + assert_eq!(host, "before\nafter\n"); + assert!(next.regions.is_empty()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_orphaned_kept_preserves_disk_and_drops_manifest() { + // An OrphanedKept plan item leaves the file/region alone and + // drops the manifest entry — transferring ownership to the + // user. + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("custom.txt"), "user edited\n").unwrap(); + let mut prev = Manifest::default(); + prev.set_file("custom.txt", "sha256:original"); + let mut plan = Plan::default(); + plan.push(PlanItem::orphaned_kept(Target::File { path: "custom.txt".into() })); + let next = plan.apply(tmp.path(), &prev).unwrap(); + let live = std::fs::read_to_string(tmp.path().join("custom.txt")).unwrap(); + assert_eq!(live, "user edited\n"); + assert!(next.files.is_empty()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_remove_missing_file_is_idempotent() { + // Race: the file is already gone (someone deleted it + // externally between the plan build and apply). Remove must + // still complete cleanly and purge the manifest. + let tmp = TempDir::new().unwrap(); + let mut prev = Manifest::default(); + prev.set_file("absent.txt", "sha256:old"); + let mut plan = Plan::default(); + plan.push(PlanItem::remove_file("absent.txt")); + let next = plan.apply(tmp.path(), &prev).unwrap(); + assert!(next.files.is_empty()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn apply_preserves_in_plan_manifest_entries() { + // The dual of the purging test: a manifest entry whose target IS + // in the plan (even as InSync/LeaveAlone) survives. + let tmp = TempDir::new().unwrap(); + let mut prev = Manifest::default(); + prev.set_file("kept.txt", "sha256:k"); + let mut plan = Plan::default(); + plan.push(PlanItem::noop(Target::File { path: "kept.txt".into() }, Decision::LeaveAlone)); + let next = plan.apply(tmp.path(), &prev).unwrap(); + assert_eq!(next.files.get("kept.txt").map(String::as_str), Some("sha256:k")); + } + + #[test] + fn target_label_for_files() { + let t = Target::File { path: "a.txt".into() }; + assert_eq!(t.label(), "a.txt"); + } + + #[test] + fn target_label_for_regions() { + let t = Target::Region { + host: "Justfile".into(), + id: "x".into(), + }; + assert_eq!(t.label(), "Justfile [x]"); + } +} diff --git a/crates/cargo-anvil/src/region.rs b/crates/cargo-anvil/src/region.rs new file mode 100644 index 00000000..1902f61f --- /dev/null +++ b/crates/cargo-anvil/src/region.rs @@ -0,0 +1,490 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Managed-region parser and writer. +//! +//! A managed region is a section of a user-composed file that +//! `cargo-anvil` owns the contents of. It is delimited by sentinel +//! comments: +//! +//! ```text +//! # >>> anvil-managed: +//! …content owned by anvil… +//! # <<< anvil-managed: +//! ``` +//! +//! The user's content outside the sentinels is preserved byte-for-byte. +//! The `id` is globally unique within the catalog (e.g. `anvil-imports`, +//! `anvil-workspace-lints`). +//! +//! Empty body (just the sentinels with no content between them) is the +//! opt-out signal — see [`updates.md §6`](../../docs/design/updates.md). + +use ohno::{AppError, app_err, bail}; + +/// Comment syntax used by the host file. +/// +/// Both supported flavors today use `#`-prefixed comments (Justfiles, +/// TOML, YAML). `//` is reserved for future hosts (e.g. JSON5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommentSyntax { + /// `#`-prefixed line comments — Justfile, TOML, YAML. + Hash, + /// `//`-prefixed line comments — JSON5 and friends. + SlashSlash, +} + +impl CommentSyntax { + fn prefix(self) -> &'static str { + match self { + Self::Hash => "#", + Self::SlashSlash => "//", + } + } +} + +/// One managed region located inside a host file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Region<'a> { + /// The region's stable id (e.g. `anvil-imports`). + pub id: String, + /// Byte range of the opening sentinel line, including the trailing + /// newline (if any). + pub start_line: ByteRange, + /// Byte range of the closing sentinel line, including the trailing + /// newline (if any). + pub end_line: ByteRange, + /// Byte range of the region body — everything between the two + /// sentinels' line spans. + pub body: ByteRange, + /// The full host text (borrowed). Used to extract body content. + text: &'a str, +} + +/// Half-open byte range `[start, end)` into the host text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ByteRange { + /// Inclusive start byte. + pub start: usize, + /// Exclusive end byte. + pub end: usize, +} + +impl<'a> Region<'a> { + /// The current body content, as a string slice into the host text. + #[must_use] + pub fn body_str(&self) -> &'a str { + &self.text[self.body.start..self.body.end] + } + + /// Whether this region is empty (opted out). An empty region is one + /// whose body, after trimming line terminators and whitespace, + /// contains no non-whitespace characters. + #[must_use] + pub fn is_empty(&self) -> bool { + self.body_str().trim().is_empty() + } +} + +/// Locate the named region in `text`. Returns `Ok(None)` if absent. +/// +/// # Errors +/// +/// Returns an error if the region is malformed: multiple opening +/// sentinels for the same id, an opening sentinel with no matching close, +/// or a close before its open. +pub fn find_region<'a>(text: &'a str, id: &str, syntax: CommentSyntax) -> Result>, AppError> { + let opener = format!("{} >>> anvil-managed: {id}", syntax.prefix()); + let closer = format!("{} <<< anvil-managed: {id}", syntax.prefix()); + + let mut start_line: Option = None; + let mut end_line: Option = None; + for line in iterate_lines(text) { + let body = text[line.start..line.end].trim_end_matches(['\n', '\r']); + let trimmed = body.trim(); + if trimmed == opener { + if start_line.is_some() { + bail!("duplicate opening sentinel for region '{id}'"); + } + start_line = Some(line); + continue; + } + if trimmed == closer { + if start_line.is_none() { + bail!("closing sentinel for region '{id}' before its opener"); + } + if end_line.is_some() { + bail!("duplicate closing sentinel for region '{id}'"); + } + end_line = Some(line); + } + } + + match (start_line, end_line) { + (None, None) => Ok(None), + (Some(_), None) => Err(app_err!("region '{id}' has an opening sentinel but no closing sentinel")), + // (None, Some(_)) was already caught above; left as a safety net. + (None, Some(_)) => Err(app_err!("region '{id}' has a closing sentinel with no opener")), + (Some(start), Some(end)) => { + if end.start < start.end { + bail!("closing sentinel for region '{id}' precedes its opener"); + } + let body = ByteRange { + start: start.end, + end: end.start, + }; + Ok(Some(Region { + id: id.to_owned(), + start_line: start, + end_line: end, + body, + text, + })) + } + } +} + +/// Replace the body of region `id` in `text`, or append a new region if +/// none exists. +/// +/// `new_body` is inserted between the sentinel lines verbatim, with a +/// single newline between each sentinel and the body. If `new_body` does +/// not end with `\n`, one is added before the closing sentinel. +/// +/// # Errors +/// +/// Returns an error if an existing region is malformed. +pub fn upsert_region(text: &str, id: &str, new_body: &str, syntax: CommentSyntax) -> Result { + let rendered = render_region(id, new_body, syntax); + + if let Some(region) = find_region(text, id, syntax)? { + let mut out = String::with_capacity(text.len() + rendered.len()); + out.push_str(&text[..region.start_line.start]); + out.push_str(&rendered); + out.push_str(&text[region.end_line.end..]); + return Ok(out); + } + + // No region present — append at the end with one blank line of + // separation if the file is non-empty and doesn't end in two newlines. + let mut out = String::with_capacity(text.len() + rendered.len() + 1); + out.push_str(text); + if !text.is_empty() { + if !text.ends_with('\n') { + out.push('\n'); + } + if !text.ends_with("\n\n") && !text.is_empty() { + out.push('\n'); + } + } + out.push_str(&rendered); + Ok(out) +} + +/// Render an isolated region — sentinels plus body — without splicing it +/// into a host. +#[must_use] +pub fn render_region(id: &str, body: &str, syntax: CommentSyntax) -> String { + let prefix = syntax.prefix(); + let mut out = String::with_capacity(body.len() + 80); + out.push_str(prefix); + out.push_str(" >>> anvil-managed: "); + out.push_str(id); + out.push('\n'); + out.push_str(body); + if !body.is_empty() && !body.ends_with('\n') { + out.push('\n'); + } + out.push_str(prefix); + out.push_str(" <<< anvil-managed: "); + out.push_str(id); + out.push('\n'); + out +} + +/// Splice the named region out of `text`, returning the host content +/// with the markers + body excised entirely. +/// +/// To avoid leaving an asymmetric blank-line gap, one adjacent blank +/// line is consumed: the trailing blank if present, else the leading +/// blank if the region sits at end-of-file. +/// +/// If the region is not present the input is returned unchanged. +/// +/// # Errors +/// +/// Returns an error if the host file contains a malformed region with +/// the requested id (mismatched/missing sentinels). +pub fn remove_region(text: &str, id: &str, syntax: CommentSyntax) -> Result { + let Some(region) = find_region(text, id, syntax)? else { + return Ok(text.to_owned()); + }; + + let mut cut_start = region.start_line.start; + let mut cut_end = region.end_line.end; + + // Prefer to eat the trailing blank line — that mirrors upsert's + // "add one blank line of separation" when the region was first + // inserted, and it preserves a single blank between user content + // when the region sits in the middle of the file. + let trailing_blank = text[cut_end..].starts_with('\n'); + if trailing_blank { + cut_end += 1; + } else { + // Region sits at end-of-file: there's no trailing blank to + // eat. Pull back the leading blank instead so the file doesn't + // end with an orphan blank line where the region used to be. + let prefix = &text[..cut_start]; + if prefix.ends_with("\n\n") { + cut_start -= 1; + } + } + + let mut out = String::with_capacity(text.len() - (cut_end - cut_start)); + out.push_str(&text[..cut_start]); + out.push_str(&text[cut_end..]); + Ok(out) +} + +fn iterate_lines(text: &str) -> LineIter<'_> { + LineIter { text, pos: 0 } +} + +struct LineIter<'a> { + text: &'a str, + pos: usize, +} + +impl Iterator for LineIter<'_> { + type Item = ByteRange; + + fn next(&mut self) -> Option { + if self.pos >= self.text.len() { + return None; + } + let start = self.pos; + let rest = &self.text[start..]; + let end = match rest.find('\n') { + Some(i) => start + i + 1, + None => self.text.len(), + }; + // Progress guard: every yielded line must strictly advance `pos`. + // Catches infinite-loop regressions (and infinite-loop mutants + // generated by `cargo mutants` against the arithmetic / comparison + // operators above) in debug builds. + debug_assert!( + end > start, + "LineIter::next must make progress (start={start}, end={end}, text.len()={})", + self.text.len() + ); + self.pos = end; + Some(ByteRange { start, end }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SYN: CommentSyntax = CommentSyntax::Hash; + + #[test] + fn missing_region_returns_none() { + assert_eq!(find_region("user content\n", "anvil-x", SYN).unwrap(), None); + } + + #[test] + fn finds_region_with_body() { + let text = "before\n\ + # >>> anvil-managed: anvil-x\n\ + body line 1\n\ + body line 2\n\ + # <<< anvil-managed: anvil-x\n\ + after\n"; + let region = find_region(text, "anvil-x", SYN).unwrap().unwrap(); + assert_eq!(region.body_str(), "body line 1\nbody line 2\n"); + assert!(!region.is_empty()); + } + + #[test] + fn finds_empty_region() { + let text = "\ + # >>> anvil-managed: anvil-x\n\ + # <<< anvil-managed: anvil-x\n"; + let region = find_region(text, "anvil-x", SYN).unwrap().unwrap(); + assert_eq!(region.body_str(), ""); + assert!(region.is_empty()); + } + + #[test] + fn region_with_only_whitespace_is_empty() { + let text = "\ + # >>> anvil-managed: anvil-x\n\ + \n\ + \t\n\ + # <<< anvil-managed: anvil-x\n"; + let region = find_region(text, "anvil-x", SYN).unwrap().unwrap(); + assert!(region.is_empty()); + } + + #[test] + fn duplicate_opener_errors() { + let text = "\ + # >>> anvil-managed: x\n\ + # >>> anvil-managed: x\n\ + # <<< anvil-managed: x\n"; + let err = find_region(text, "x", SYN).unwrap_err(); + assert!(err.to_string().contains("duplicate opening sentinel")); + } + + #[test] + fn unterminated_region_errors() { + let text = "# >>> anvil-managed: x\nbody\n"; + let err = find_region(text, "x", SYN).unwrap_err(); + assert!(err.to_string().contains("no closing sentinel")); + } + + #[test] + fn closer_before_opener_errors() { + let text = "# <<< anvil-managed: x\n# >>> anvil-managed: x\n"; + let err = find_region(text, "x", SYN).unwrap_err(); + assert!(err.to_string().contains("before its opener")); + } + + #[test] + fn upsert_replaces_existing_body() { + let text = "before\n\ + # >>> anvil-managed: x\n\ + old body\n\ + # <<< anvil-managed: x\n\ + after\n"; + let new = upsert_region(text, "x", "new body line 1\nnew body line 2\n", SYN).unwrap(); + assert!(new.contains("new body line 1")); + assert!(!new.contains("old body")); + // User content outside the region is preserved byte-for-byte. + assert!(new.starts_with("before\n")); + assert!(new.ends_with("after\n")); + } + + #[test] + fn upsert_appends_when_absent() { + let text = "user file\n"; + let new = upsert_region(text, "x", "body\n", SYN).unwrap(); + assert!(new.starts_with("user file\n")); + assert!(new.contains("# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n")); + } + + #[test] + fn upsert_appends_with_exactly_one_blank_separator() { + // Text ends with single \n: must add one extra blank line so there is + // exactly one blank line between user content and the sentinel. + let text = "user file\n"; + let new = upsert_region(text, "x", "body\n", SYN).unwrap(); + assert_eq!(new, "user file\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); + } + + #[test] + fn upsert_does_not_add_extra_blank_when_text_ends_with_double_newline() { + // Catches mutation of the `&&` in `!ends_with("\n\n") && !is_empty()`: + // if flipped to `||`, an extra blank line would be inserted here. + let text = "user file\n\n"; + let new = upsert_region(text, "x", "body\n", SYN).unwrap(); + assert_eq!(new, "user file\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); + } + + #[test] + fn upsert_into_empty_file() { + let new = upsert_region("", "x", "body\n", SYN).unwrap(); + assert_eq!(new, "# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); + } + + #[test] + fn upsert_empties_region() { + let text = "# >>> anvil-managed: x\nfilled\n# <<< anvil-managed: x\n"; + let new = upsert_region(text, "x", "", SYN).unwrap(); + let region = find_region(&new, "x", SYN).unwrap().unwrap(); + assert!(region.is_empty()); + } + + #[test] + fn render_region_with_empty_body() { + let s = render_region("x", "", SYN); + assert_eq!(s, "# >>> anvil-managed: x\n# <<< anvil-managed: x\n"); + } + + #[test] + fn render_region_adds_trailing_newline() { + let s = render_region("x", "body", SYN); + assert_eq!(s, "# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"); + } + + #[test] + fn remove_region_excises_markers_and_body() { + // The dual of upsert_region: takes a host with a region and + // returns the host without it. Adjacent blank lines on both + // sides of the region are consumed so the spliced result + // doesn't leave a visible gap where the region used to be. + let text = "before\n\ + \n\ + # >>> anvil-managed: x\n\ + body line 1\n\ + body line 2\n\ + # <<< anvil-managed: x\n\ + \n\ + after\n"; + let out = remove_region(text, "x", SYN).unwrap(); + assert_eq!(out, "before\n\nafter\n"); + } + + #[test] + fn remove_region_absent_region_is_a_noop() { + let text = "no region in sight\n"; + let out = remove_region(text, "x", SYN).unwrap(); + assert_eq!(out, text); + } + + #[test] + fn remove_region_at_eof_drops_trailing_blank() { + let text = "before\n\n# >>> anvil-managed: x\nbody\n# <<< anvil-managed: x\n"; + let out = remove_region(text, "x", SYN).unwrap(); + assert_eq!(out, "before\n"); + } + + #[test] + fn slash_slash_syntax_works() { + let text = "// >>> anvil-managed: x\nbody\n// <<< anvil-managed: x\n"; + let region = find_region(text, "x", CommentSyntax::SlashSlash).unwrap().unwrap(); + assert_eq!(region.body_str(), "body\n"); + } + + #[test] + fn hash_syntax_ignores_slash_slash_sentinels() { + let text = "// >>> anvil-managed: x\nbody\n// <<< anvil-managed: x\n"; + assert_eq!(find_region(text, "x", SYN).unwrap(), None); + } + + #[test] + fn finds_multiple_distinct_regions() { + let text = "\ + # >>> anvil-managed: a\n\ + body-a\n\ + # <<< anvil-managed: a\n\ + user content between\n\ + # >>> anvil-managed: b\n\ + body-b\n\ + # <<< anvil-managed: b\n"; + let a = find_region(text, "a", SYN).unwrap().unwrap(); + let b = find_region(text, "b", SYN).unwrap().unwrap(); + assert_eq!(a.body_str(), "body-a\n"); + assert_eq!(b.body_str(), "body-b\n"); + } + + #[test] + fn region_sentinels_indented_still_recognized() { + // Sentinels with leading whitespace should be recognized — useful + // in YAML where indentation matters in the host file. + let text = " # >>> anvil-managed: x\nbody\n # <<< anvil-managed: x\n"; + let region = find_region(text, "x", SYN).unwrap().unwrap(); + assert_eq!(region.body_str(), "body\n"); + } +} diff --git a/crates/cargo-anvil/src/run.rs b/crates/cargo-anvil/src/run.rs new file mode 100644 index 00000000..67c2afa7 --- /dev/null +++ b/crates/cargo-anvil/src/run.rs @@ -0,0 +1,666 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Top-level `update` driver. +//! +//! Orchestrates: workspace discovery, manifest load, backend resolution, +//! emitter invocation, plan accumulation, and final apply/summarize. + +use std::collections::BTreeSet; +use std::path::Path; + +use ohno::AppError; +use tracing::info; + +use crate::backend::{self, Backend}; +use crate::checksum::checksum_str; +use crate::cli::Cli; +use crate::decision::{Decision, decide_removal}; +use crate::emit::{ado, cargo_toml, github, local, shared_configs}; +use crate::io::read_file_if_present; +use crate::manifest::Manifest; +use crate::plan::{Plan, PlanItem, Target}; +use crate::region::{CommentSyntax, find_region, remove_region}; +use crate::workspace::{self, Workspace}; + +/// Outcome of an `update` invocation. +#[derive(Debug)] +pub struct RunOutcome { + /// The plan that was built. + pub plan: Plan, + /// The manifest as it existed before this run (useful for the + /// categorized summary so `Will create` and `Will update` can be + /// distinguished, and so stale entries can be enumerated). + pub previous_manifest: Manifest, + /// Whether the plan was actually applied to disk. + pub applied: bool, + /// The resolved backend set. + pub backends: Vec, +} + +/// Run the parsed CLI. +/// +/// # Errors +/// +/// Returns an error when the underlying update flow fails. +#[mutants::skip] // Thin process-boundary glue (cwd lookup, stdout print, `std::process::exit`); behavior covered by `run_update` tests which exercise every dispatch path. +pub fn run(cli: &Cli) -> Result<(), AppError> { + let outcome = run_update(cli, &std::env::current_dir()?)?; + print!("{}", outcome.plan.summary(Some(&outcome.previous_manifest))); + if cli.dry_run && outcome.plan.has_changes() { + std::process::exit(1); + } + Ok(()) +} + +/// Run the update flow against the workspace containing `start_dir`. +/// +/// Exposed for integration tests that want to drive the algorithm +/// without `std::process::exit`. +/// +/// # Errors +/// +/// Propagates errors from any subsystem (workspace discovery, manifest +/// I/O, emitter, plan application). +pub fn run_update(args: &Cli, start_dir: &Path) -> Result { + let repo_root = workspace::find_workspace_root(start_dir)?; + let ws = workspace::load_workspace(&repo_root)?; + let mut manifest = Manifest::load(&repo_root)?; + + // One-time legacy migration: an earlier version of cargo-anvil + // emitted the Justfile imports region under a lowercase `justfile` + // host path. The canonical capitalization is `Justfile` (matching + // Makefile / Dockerfile / Rakefile convention and the surveyed + // Microsoft Rust repos). For repos whose manifest still carries + // the lowercase entry, transfer it to the canonical case so the + // orphan-detection pass doesn't spuriously try to splice the + // region back out. + local::migrate_legacy_justfile_case(&mut manifest); + + let backends = backend::resolve(&args.backends, args.no_backends, &repo_root)?; + info!( + repo_root = %repo_root.display(), + backends = ?backends.iter().map(|b| b.name()).collect::>(), + dry_run = args.dry_run, + "anvil" + ); + + let plan = build_plan(&repo_root, &ws, &manifest, &backends)?; + + let applied = if args.dry_run { + false + } else { + let next = plan.apply(&repo_root, &manifest)?; + next.save(&repo_root)?; + true + }; + + Ok(RunOutcome { + plan, + previous_manifest: manifest, + applied, + backends, + }) +} + +/// Build the full plan: local files + selected cloud-workflow backends. +fn build_plan(repo_root: &Path, workspace: &Workspace, manifest: &Manifest, backends: &[Backend]) -> Result { + let mut plan = Plan::default(); + + for item in local::plan_local_just_tree(repo_root, manifest)? { + plan.push(item); + } + plan.push(local::plan_justfile_imports(repo_root, manifest)?); + + for item in cargo_toml::plan_cargo_lints(repo_root, workspace, manifest)? { + plan.push(item); + } + for item in shared_configs::plan_shared_configs(repo_root, manifest)? { + plan.push(item); + } + + for backend in backends { + match backend { + Backend::GitHub => { + for item in github::plan_github_backend(repo_root, manifest)? { + plan.push(item); + } + } + Backend::Ado => { + for item in ado::plan_ado_backend(repo_root, manifest)? { + plan.push(item); + } + } + } + } + + plan_removals(repo_root, manifest, &mut plan)?; + + Ok(plan) +} + +/// Scan the previous manifest for entries that the active plan items +/// don't cover. For each, classify as Remove (user untouched since the +/// last render) or `OrphanedKept` (user customized — preserve and +/// transfer ownership). +/// +/// This is what removes orphaned cloud-workflow artifacts, dropped catalog entries, +/// disabled-backend files, and any other previously-tracked item that +/// is no longer in scope. +fn plan_removals(repo_root: &Path, previous: &Manifest, plan: &mut Plan) -> Result<(), AppError> { + let live_files: BTreeSet = plan + .items() + .iter() + .filter_map(|i| match &i.target { + Target::File { path } => Some(path.clone()), + Target::Region { .. } => None, + }) + .collect(); + let live_regions: BTreeSet<(String, String)> = plan + .items() + .iter() + .filter_map(|i| match &i.target { + Target::Region { host, id } => Some((host.clone(), id.clone())), + Target::File { .. } => None, + }) + .collect(); + + for (path, last) in &previous.files { + if live_files.contains(path) { + continue; + } + let disk = read_file_if_present(&repo_root.join(path))?; + let disk_checksum = disk.as_deref().map(checksum_str); + match decide_removal(last, disk_checksum.as_deref()) { + Decision::Remove => plan.push(PlanItem::remove_file(path.clone())), + // `InSync` means the file is already gone but we still want + // to purge the manifest entry, same as a customized orphan — + // both surface as no-op plan items that drop the entry. + Decision::OrphanedKept | Decision::InSync => plan.push(PlanItem::orphaned_kept(Target::File { path: path.clone() })), + other => unreachable!("decide_removal returned {other:?} for a file orphan"), + } + } + + for (key, last) in &previous.regions { + if live_regions.contains(&(key.host.clone(), key.id.clone())) { + continue; + } + let host_path = repo_root.join(&key.host); + let Some(host_text) = read_file_if_present(&host_path)? else { + // Host file is gone entirely; just drop the manifest + // entry. Emit OrphanedKept (no-op apply) so the plan + // can record the transfer of ownership consistently. + plan.push(PlanItem::orphaned_kept(Target::Region { + host: key.host.clone(), + id: key.id.clone(), + })); + continue; + }; + + // CommentSyntax is currently always Hash for managed regions. + // When that assumption changes, the manifest will need to + // record the syntax used. + let syntax = CommentSyntax::Hash; + let region = find_region(&host_text, &key.id, syntax)?; + let body_checksum = region.as_ref().map(|r| checksum_str(r.body_str())); + match decide_removal(last, body_checksum.as_deref()) { + Decision::Remove => { + let spliced = remove_region(&host_text, &key.id, syntax)?; + plan.push(PlanItem::remove_region(key.host.clone(), key.id.clone(), spliced)); + } + Decision::OrphanedKept | Decision::InSync => { + plan.push(PlanItem::orphaned_kept(Target::Region { + host: key.host.clone(), + id: key.id.clone(), + })); + } + other => unreachable!("decide_removal returned {other:?} for a region orphan"), + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + fn empty_workspace() -> TempDir { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n", + ); + write( + &root.join("crates/alpha/Cargo.toml"), + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + write(&root.join("crates/alpha/src/lib.rs"), ""); + tmp + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn first_run_writes_everything_local_only() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let outcome = run_update(&args, tmp.path()).unwrap(); + assert!(outcome.applied); + assert!(outcome.backends.is_empty()); + assert!(outcome.plan.has_changes()); + + for expected in [ + "Justfile", + "justfiles/anvil/mod.just", + "justfiles/anvil/checks.just", + "justfiles/anvil/groups.just", + "justfiles/anvil/tiers.just", + "justfiles/anvil/tools.just", + "justfiles/anvil/versions.just", + "deny.toml", + "rustfmt.toml", + ".delta.toml", + "spellcheck.toml", + "clippy.toml", + ".anvil.lock", + ] { + assert!(tmp.path().join(expected).is_file(), "expected '{expected}' after update"); + } + + let root_manifest = fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!(root_manifest.contains("# >>> anvil-managed: anvil-workspace-lints")); + let member_manifest = fs::read_to_string(tmp.path().join("crates/alpha/Cargo.toml")).unwrap(); + assert!(member_manifest.contains("# >>> anvil-managed: anvil-lints")); + assert!(member_manifest.contains("workspace = true")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn second_run_is_idempotent_and_in_sync() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let _ = run_update(&args, tmp.path()).unwrap(); + let second = run_update(&args, tmp.path()).unwrap(); + assert!(!second.plan.has_changes(), "second run should be a no-op"); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn dry_run_does_not_write() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: true, + }; + let outcome = run_update(&args, tmp.path()).unwrap(); + assert!(!outcome.applied); + assert!(outcome.plan.has_changes()); + assert!(!tmp.path().join("justfiles/anvil/tools.just").exists()); + assert!(!tmp.path().join(".anvil.lock").exists()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn opted_out_region_is_skipped_on_second_run() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let _ = run_update(&args, tmp.path()).unwrap(); + + let path = tmp.path().join("rustfmt.toml"); + let host = fs::read_to_string(&path).unwrap(); + let updated = + crate::region::upsert_region(&host, shared_configs::RUSTFMT_REGION_ID, "", crate::region::CommentSyntax::Hash).unwrap(); + fs::write(&path, updated).unwrap(); + + let outcome = run_update(&args, tmp.path()).unwrap(); + let rustfmt_item = outcome + .plan + .items() + .iter() + .find(|i| { + matches!(&i.target, crate::plan::Target::Region { host, id } + if host == "rustfmt.toml" && id == shared_configs::RUSTFMT_REGION_ID) + }) + .expect("rustfmt region item missing from plan"); + assert_eq!(rustfmt_item.decision, crate::decision::Decision::LeaveAlone); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn user_edit_inside_region_left_alone_when_template_unchanged() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let _ = run_update(&args, tmp.path()).unwrap(); + + let path = tmp.path().join("rustfmt.toml"); + let host = fs::read_to_string(&path).unwrap(); + let updated = crate::region::upsert_region( + &host, + shared_configs::RUSTFMT_REGION_ID, + "edition = \"2021\"\n", + crate::region::CommentSyntax::Hash, + ) + .unwrap(); + fs::write(&path, updated).unwrap(); + + let outcome = run_update(&args, tmp.path()).unwrap(); + let rustfmt_item = outcome + .plan + .items() + .iter() + .find(|i| { + matches!(&i.target, crate::plan::Target::Region { host, id } + if host == "rustfmt.toml" && id == shared_configs::RUSTFMT_REGION_ID) + }) + .unwrap(); + assert_eq!(rustfmt_item.decision, crate::decision::Decision::LeaveAlone); + let final_text = fs::read_to_string(&path).unwrap(); + assert!(final_text.contains("edition = \"2021\"")); + } + + /// Verifies B6: after a Propose decision, the next run sees the + /// divergence as `LeaveAlone` (no re-proposal) until the template + /// itself moves again. + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn propose_burns_through_after_one_run() { + use crate::checksum::checksum_str; + use crate::manifest::{Manifest, RegionKey}; + + let tmp = empty_workspace(); + let args = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + + // First update: write everything. + let _ = run_update(&args, tmp.path()).unwrap(); + + // User edits the rustfmt region. + let path = tmp.path().join("rustfmt.toml"); + let host = fs::read_to_string(&path).unwrap(); + let edited = crate::region::upsert_region( + &host, + shared_configs::RUSTFMT_REGION_ID, + "edition = \"2021\"\n", + crate::region::CommentSyntax::Hash, + ) + .unwrap(); + fs::write(&path, edited).unwrap(); + + // Simulate the template moving on by hand-editing the manifest's + // recorded checksum for the region to a value other than what + // the user has and other than the current template. That way + // the next run sees D ≠ L ≠ T → Propose. + let manifest_path = Manifest::path_for(tmp.path()); + let mut manifest = Manifest::load(tmp.path()).unwrap(); + let key = RegionKey { + host: "rustfmt.toml".to_owned(), + id: shared_configs::RUSTFMT_REGION_ID.to_owned(), + }; + manifest.regions.insert(key, checksum_str("synthetic old template")); + manifest.save(tmp.path()).unwrap(); + let _ = manifest_path; // sanity + + // Second update: should Propose (user diverged + template moved). + let second = run_update(&args, tmp.path()).unwrap(); + let item = second + .plan + .items() + .iter() + .find(|i| { + matches!(&i.target, crate::plan::Target::Region { host, id } + if host == "rustfmt.toml" && id == shared_configs::RUSTFMT_REGION_ID) + }) + .unwrap(); + assert_eq!(item.decision, crate::decision::Decision::Propose); + assert!( + tmp.path().join("rustfmt.toml.anvil-proposed").is_file(), + "expected a proposed sibling after the Propose run" + ); + + // Third update: nothing has changed since the second run; the + // proposal should have been "burned through" and the next run + // should see LeaveAlone (D ≠ L, L = T) — not Propose. + let third = run_update(&args, tmp.path()).unwrap(); + let item = third + .plan + .items() + .iter() + .find(|i| { + matches!(&i.target, crate::plan::Target::Region { host, id } + if host == "rustfmt.toml" && id == shared_configs::RUSTFMT_REGION_ID) + }) + .unwrap(); + assert_eq!( + item.decision, + crate::decision::Decision::LeaveAlone, + "Propose should bump L = T so subsequent runs see LeaveAlone" + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn github_backend_writes_full_dotgithub_tree() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec!["github".to_owned()], + no_backends: false, + dry_run: false, + }; + let outcome = run_update(&args, tmp.path()).unwrap(); + assert!(outcome.applied); + assert_eq!(outcome.backends, vec![Backend::GitHub]); + for expected in [ + ".github/actions/anvil-setup/action.yml", + ".github/actions/anvil-impact/action.yml", + ".github/actions/anvil-pr-fast/action.yml", + ".github/actions/anvil-pr-test/action.yml", + ".github/actions/anvil-pr-runtime-analysis/action.yml", + ".github/actions/anvil-pr-mutants/action.yml", + ".github/actions/anvil-scheduled-test/action.yml", + ".github/actions/anvil-scheduled-advisories/action.yml", + ".github/actions/anvil-scheduled-exhaustive/action.yml", + ".github/workflows/anvil-pr-impl.yml", + ".github/workflows/anvil-scheduled-impl.yml", + ".github/workflows/anvil-pr.yml", + ".github/workflows/anvil-scheduled.yml", + ] { + assert!(tmp.path().join(expected).is_file(), "expected '{expected}' after github update"); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn github_backend_idempotent() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec!["github".to_owned()], + no_backends: false, + dry_run: false, + }; + let _ = run_update(&args, tmp.path()).unwrap(); + let second = run_update(&args, tmp.path()).unwrap(); + assert!( + !second.plan.has_changes(), + "second github run should be a no-op:\n{}", + second.plan.summary(Some(&second.previous_manifest)) + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn ado_backend_writes_full_pipelines_tree() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec!["ado".to_owned()], + no_backends: false, + dry_run: false, + }; + let outcome = run_update(&args, tmp.path()).unwrap(); + assert!(outcome.applied); + assert_eq!(outcome.backends, vec![Backend::Ado]); + for expected in [ + ".pipelines/anvil/steps/setup.yml", + ".pipelines/anvil/steps/impact.yml", + ".pipelines/anvil/steps/advisory-comments.yml", + ".pipelines/anvil/steps/pr-fast.yml", + ".pipelines/anvil/steps/pr-test.yml", + ".pipelines/anvil/steps/pr-runtime-analysis.yml", + ".pipelines/anvil/steps/pr-mutants.yml", + ".pipelines/anvil/steps/scheduled-test.yml", + ".pipelines/anvil/steps/scheduled-advisories.yml", + ".pipelines/anvil/steps/scheduled-exhaustive.yml", + ".pipelines/anvil/pr.yml", + ".pipelines/anvil/scheduled.yml", + ".pipelines/anvil-pr.yml", + ".pipelines/anvil-scheduled.yml", + ] { + assert!(tmp.path().join(expected).is_file(), "expected '{expected}' after ado update"); + } + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn both_backends_idempotent() { + let tmp = empty_workspace(); + let args = Cli { + backends: vec!["github".to_owned(), "ado".to_owned()], + no_backends: false, + dry_run: false, + }; + let _ = run_update(&args, tmp.path()).unwrap(); + let second = run_update(&args, tmp.path()).unwrap(); + assert!(!second.plan.has_changes()); + } + + /// Files that were previously rendered but are no longer in scope + /// (e.g., a backend was disabled) must surface as `Remove` plan items + /// when the on-disk content still matches what we last wrote. This + /// exercises the `plan_removals` path end-to-end. + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn disabling_a_backend_removes_its_orphaned_files() { + use crate::decision::Decision; + + let tmp = empty_workspace(); + + // First: write everything including the github backend. + let with_gh = Cli { + backends: vec!["github".to_owned()], + no_backends: false, + dry_run: false, + }; + let first = run_update(&with_gh, tmp.path()).unwrap(); + assert!(first.applied); + let github_workflow = tmp.path().join(".github/workflows/anvil-pr.yml"); + assert!(github_workflow.is_file()); + + // Second: disable backends. The previously rendered github files + // should now be queued for removal (file unchanged on disk since + // last render → Decision::Remove). + let no_be = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let second = run_update(&no_be, tmp.path()).unwrap(); + + let removed: Vec<&str> = second + .plan + .items() + .iter() + .filter(|i| i.decision == Decision::Remove) + .filter_map(|i| match &i.target { + crate::plan::Target::File { path } => Some(path.as_str()), + crate::plan::Target::Region { .. } => None, + }) + .collect(); + assert!( + removed.contains(&".github/workflows/anvil-pr.yml"), + "expected anvil-pr.yml to be queued for removal; got: {removed:?}" + ); + assert!( + !github_workflow.exists(), + "expected the orphaned github workflow file to actually be removed from disk" + ); + } + + /// User-customized orphans (file no longer in scope, but the on-disk + /// contents diverge from what we last wrote) must be left alone via + /// `OrphanedKept`, not deleted. + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn customized_orphans_are_kept_not_removed() { + use crate::decision::Decision; + + let tmp = empty_workspace(); + let with_gh = Cli { + backends: vec!["github".to_owned()], + no_backends: false, + dry_run: false, + }; + let _ = run_update(&with_gh, tmp.path()).unwrap(); + + let github_workflow = tmp.path().join(".github/workflows/anvil-pr.yml"); + fs::write(&github_workflow, "# user edited this\n").unwrap(); + + let no_be = Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }; + let second = run_update(&no_be, tmp.path()).unwrap(); + + let kept: Vec<&str> = second + .plan + .items() + .iter() + .filter(|i| i.decision == Decision::OrphanedKept) + .filter_map(|i| match &i.target { + crate::plan::Target::File { path } => Some(path.as_str()), + crate::plan::Target::Region { .. } => None, + }) + .collect(); + assert!( + kept.contains(&".github/workflows/anvil-pr.yml"), + "expected customized orphan to surface as OrphanedKept; got: {kept:?}" + ); + assert!(github_workflow.is_file(), "customized orphan must not be deleted from disk"); + assert_eq!( + fs::read_to_string(&github_workflow).unwrap(), + "# user edited this\n", + "customized orphan contents must be preserved" + ); + } +} diff --git a/crates/cargo-anvil/src/workspace.rs b/crates/cargo-anvil/src/workspace.rs new file mode 100644 index 00000000..dcfbedf3 --- /dev/null +++ b/crates/cargo-anvil/src/workspace.rs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Repo discovery and Cargo workspace introspection. +//! +//! `cargo-anvil` operates from the *workspace root*: the directory whose +//! `Cargo.toml` contains a `[workspace]` table (or a single-crate `[package]` +//! that is implicitly its own workspace of one). This module locates that +//! directory by walking up from a starting path, then enumerates the +//! workspace members so emitters can write per-crate managed regions. +//! +//! See [`design.md §6`](../../docs/design/design.md) for the file layout. + +use std::path::{Component, Path, PathBuf}; + +use ohno::{AppError, IntoAppError as _, app_err, bail}; +use toml_edit::DocumentMut; + +/// A discovered Cargo workspace. +#[derive(Debug, Clone)] +pub struct Workspace { + /// Absolute path to the workspace root directory. + pub root: PathBuf, + /// Workspace members. Always at least one entry (the root crate, for + /// single-crate repos that don't declare `[workspace]`, or each explicit + /// member otherwise). + pub members: Vec, + /// Whether the root `Cargo.toml` carries a `[workspace]` table. + /// + /// Used by the emitter for [`design.md §6`]: multi-crate workspaces get + /// `[workspace.lints]` with the catalog plus `[lints] workspace = true` + /// in each member; single-crate repos get the catalog directly in + /// `[lints]`. + /// + /// [`design.md §6`]: ../../docs/design/design.md + pub has_workspace_table: bool, +} + +/// One workspace member. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct WorkspaceMember { + /// Path to the member's `Cargo.toml`, relative to the workspace root, + /// with forward slashes. + pub manifest_relpath: String, +} + +/// Find the workspace root by walking up from `start`. +/// +/// Returns the first ancestor directory whose `Cargo.toml` declares a +/// `[workspace]` table. If no such ancestor exists, falls back to the +/// nearest `Cargo.toml` (single-crate repo). +/// +/// # Errors +/// +/// Returns an error if no `Cargo.toml` is found at or above `start`. +pub fn find_workspace_root(start: &Path) -> Result { + let start = start + .canonicalize() + .into_app_err_with(|| format!("cannot canonicalize start path '{}'", start.display()))?; + + let mut nearest: Option = None; + for ancestor in start.ancestors() { + let manifest = ancestor.join("Cargo.toml"); + if !manifest.is_file() { + continue; + } + let text = std::fs::read_to_string(&manifest).into_app_err_with(|| format!("failed to read {}", manifest.display()))?; + let doc: DocumentMut = text + .parse::() + .into_app_err_with(|| format!("failed to parse {} as TOML", manifest.display()))?; + if doc.get("workspace").is_some() { + return Ok(ancestor.to_path_buf()); + } + if nearest.is_none() { + nearest = Some(ancestor.to_path_buf()); + } + } + + nearest.ok_or_else(|| { + app_err!( + "no Cargo.toml found at or above '{}'; cargo-anvil must be run inside a Cargo workspace", + start.display() + ) + }) +} + +/// Load and parse the workspace at `root`. +/// +/// # Errors +/// +/// Returns an error if the manifest can't be read, parsed, or its +/// `[workspace] members` glob list can't be resolved. +pub fn load_workspace(root: &Path) -> Result { + let manifest_path = root.join("Cargo.toml"); + let text = std::fs::read_to_string(&manifest_path).into_app_err_with(|| format!("failed to read {}", manifest_path.display()))?; + let doc: DocumentMut = text + .parse::() + .into_app_err_with(|| format!("failed to parse {} as TOML", manifest_path.display()))?; + + let has_workspace_table = doc.get("workspace").is_some(); + let members = if has_workspace_table { + resolve_workspace_members(root, &doc)? + } else if doc.get("package").is_some() { + vec![WorkspaceMember { + manifest_relpath: "Cargo.toml".to_owned(), + }] + } else { + bail!( + "{} has neither [workspace] nor [package] — not a recognizable Cargo manifest", + manifest_path.display() + ); + }; + + if members.is_empty() { + bail!( + "workspace at {} resolved to zero members; check `members` in {}", + root.display(), + manifest_path.display() + ); + } + + Ok(Workspace { + root: root.to_path_buf(), + members, + has_workspace_table, + }) +} + +fn resolve_workspace_members(root: &Path, doc: &DocumentMut) -> Result, AppError> { + let members_item = doc + .get("workspace") + .and_then(|w| w.get("members")) + .ok_or_else(|| app_err!("[workspace] is missing the `members` array"))?; + + let array = members_item + .as_array() + .ok_or_else(|| app_err!("[workspace] `members` must be an array"))?; + + let mut out = Vec::new(); + for entry in array { + let pattern = entry.as_str().ok_or_else(|| app_err!("`members` entries must be strings"))?; + expand_member_pattern(root, pattern, &mut out)?; + } + + // De-duplicate by relpath while preserving discovery order. + let mut seen = std::collections::BTreeSet::new(); + out.retain(|m| seen.insert(m.manifest_relpath.clone())); + Ok(out) +} + +/// Expand one entry from `members = [...]`. +/// +/// Supports literal directory names and a single trailing `*` glob in the +/// last path segment (the form Cargo uses in the surveyed repos: `crates/*`). +/// More elaborate globbing isn't observed in the wild and can be added +/// later if needed. +fn expand_member_pattern(root: &Path, pattern: &str, out: &mut Vec) -> Result<(), AppError> { + let pattern = pattern.trim_end_matches('/'); + if pattern.is_empty() { + bail!("workspace member pattern is empty"); + } + + if let Some(parent) = pattern.strip_suffix("/*") { + let parent_path = root.join(parent); + if !parent_path.is_dir() { + // Pattern with no matches is not an error — Cargo itself tolerates this. + return Ok(()); + } + let mut entries: Vec<_> = std::fs::read_dir(&parent_path) + .into_app_err_with(|| format!("failed to read directory {}", parent_path.display()))? + .filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .filter(|e| e.path().join("Cargo.toml").is_file()) + .collect(); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let name = entry.file_name(); + let name_str = name + .to_str() + .ok_or_else(|| app_err!("non-UTF-8 directory name in {}", parent_path.display()))?; + let relpath = format!("{parent}/{name_str}/Cargo.toml"); + out.push(WorkspaceMember { + manifest_relpath: normalize_relpath(&relpath), + }); + } + return Ok(()); + } + + if pattern.contains('*') { + bail!("unsupported glob pattern in workspace members: '{pattern}' (only a trailing '/*' is supported)"); + } + + let manifest = root.join(pattern).join("Cargo.toml"); + if !manifest.is_file() { + bail!("workspace member '{pattern}' has no Cargo.toml at {}", manifest.display()); + } + out.push(WorkspaceMember { + manifest_relpath: normalize_relpath(&format!("{pattern}/Cargo.toml")), + }); + Ok(()) +} + +fn normalize_relpath(relpath: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for component in Path::new(relpath).components() { + if let Component::Normal(s) = component + && let Some(s) = s.to_str() + { + parts.push(s); + } + } + parts.join("/") +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + + fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn discovers_single_crate_workspace() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[package] +name = "demo" +version = "0.1.0" +"#, + ); + write(&root.join("src/lib.rs"), ""); + + let found = find_workspace_root(&root.join("src")).unwrap(); + assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap()); + + let ws = load_workspace(&found).unwrap(); + assert!(!ws.has_workspace_table); + assert_eq!( + ws.members, + vec![WorkspaceMember { + manifest_relpath: "Cargo.toml".into() + }] + ); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn discovers_glob_workspace() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[workspace] +resolver = "2" +members = ["crates/*"] +"#, + ); + write(&root.join("crates/alpha/Cargo.toml"), "[package]\nname='a'\nversion='0.1.0'\n"); + write(&root.join("crates/beta/Cargo.toml"), "[package]\nname='b'\nversion='0.1.0'\n"); + // Non-crate directory in the glob target — should be ignored. + write(&root.join("crates/notacrate/README.md"), "no manifest"); + + let ws = load_workspace(root).unwrap(); + assert!(ws.has_workspace_table); + let paths: Vec<_> = ws.members.iter().map(|m| m.manifest_relpath.as_str()).collect(); + assert_eq!(paths, vec!["crates/alpha/Cargo.toml", "crates/beta/Cargo.toml"]); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn discovers_explicit_members() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[workspace] +members = ["alpha", "nested/beta"] +"#, + ); + write(&root.join("alpha/Cargo.toml"), "[package]\nname='a'\nversion='0.1.0'\n"); + write(&root.join("nested/beta/Cargo.toml"), "[package]\nname='b'\nversion='0.1.0'\n"); + + let ws = load_workspace(root).unwrap(); + let paths: Vec<_> = ws.members.iter().map(|m| m.manifest_relpath.as_str()).collect(); + assert_eq!(paths, vec!["alpha/Cargo.toml", "nested/beta/Cargo.toml"]); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn walks_up_to_workspace_root() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[workspace] +members = ["crates/alpha"] +"#, + ); + write(&root.join("crates/alpha/Cargo.toml"), "[package]\nname='a'\nversion='0.1.0'\n"); + write(&root.join("crates/alpha/src/lib.rs"), ""); + + // Starting deep inside a member should still find the workspace root. + let found = find_workspace_root(&root.join("crates/alpha/src")).unwrap(); + assert_eq!(found.canonicalize().unwrap(), root.canonicalize().unwrap()); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn errors_when_no_cargo_toml_above() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write(&root.join("foo/bar.txt"), ""); + let err = find_workspace_root(&root.join("foo")).unwrap_err(); + assert!(err.to_string().contains("no Cargo.toml")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn errors_when_explicit_member_missing() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[workspace] +members = ["alpha"] +"#, + ); + let err = load_workspace(root).unwrap_err(); + assert!(err.to_string().contains("has no Cargo.toml")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn unsupported_glob_pattern_errors() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + r#"[workspace] +members = ["cra*tes"] +"#, + ); + let err = load_workspace(root).unwrap_err(); + assert!(err.to_string().contains("unsupported glob pattern")); + } + + #[cfg_attr(miri, ignore = "uses filesystem; miri isolation forbids it")] + #[test] + fn manifest_without_workspace_or_package_errors() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write(&root.join("Cargo.toml"), "# empty\n"); + let err = load_workspace(root).unwrap_err(); + assert!(err.to_string().contains("neither [workspace] nor [package]")); + } + + #[test] + fn normalize_relpath_uses_forward_slashes() { + assert_eq!(normalize_relpath("a/b/c"), "a/b/c"); + assert_eq!(normalize_relpath("a\\b\\c"), if cfg!(windows) { "a/b/c" } else { "a\\b\\c" }); + } +} diff --git a/crates/cargo-anvil/templates/ado/pr-root-pipeline.yml b/crates/cargo-anvil/templates/ado/pr-root-pipeline.yml new file mode 100644 index 00000000..88682343 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/pr-root-pipeline.yml @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +trigger: none + +stages: + - template: anvil/pr.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } diff --git a/crates/cargo-anvil/templates/ado/pr-stages.yml b/crates/cargo-anvil/templates/ado/pr-stages.yml new file mode 100644 index 00000000..bf769931 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/pr-stages.yml @@ -0,0 +1,228 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Every job in this stages template is rendered through `steps/job.yml`. +# That wrapper is *user-customizable*: adopters who need to inject 1ESPT +# `templateContext:` blocks, custom SDL knobs, build-provenance attrs, etc. +# edit `steps/job.yml` once and anvil stops overwriting it. This stages +# template stays owned and continues to track new groups, dependsOn rewires, +# impact-output threading changes, etc. +# +# Template-path note: each entry in the `steps:` parameter below contains a +# `template:` reference. ADO resolves template paths relative to the file +# containing the `template:` keyword, which (for parameters defined at the +# call site) is *this* file. So paths like `steps/pr-fast.yml` are correct. +parameters: + - name: linuxPool + type: object + default: { vmImage: ubuntu-latest } + - name: windowsPool + type: object + default: { vmImage: windows-latest } + +stages: + # cargo-delta impact runs per OS so that downstream legs consume an + # impact set computed against THEIR host's cargo-metadata depgraph. + # Without this, an OS-conditional dep change (under + # `[target.'cfg(target_os = ...)'.dependencies]`) computed on Linux + # wouldn't include the cross-OS reverse-deps that only show up in + # the Windows depgraph, so the Windows leg could skip tests that + # ought to run. We pay for one impact stage per OS family; downstream + # stages select the right one per leg. + - stage: impact_linux + displayName: anvil impact (linux) + jobs: + - template: steps/job.yml + parameters: + name: compute + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/impact.yml + + - stage: impact_windows + displayName: anvil impact (windows) + jobs: + - template: steps/job.yml + parameters: + name: compute + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/impact.yml + + - stage: pr_fast + displayName: anvil pr-fast + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # Cross-OS because pr-fast contains compile-sensitive checks + # (clippy, doc-build, udeps, semver-check, external-types) whose + # results can differ across host OS for crates that use + # #[cfg(target_os = ...)] gating. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-fast.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + # Advisory PR comments. Recipes that surface non-blocking findings + # (e.g. cargo-semver-checks) write a markdown body to + # `target/anvil/comments/.md` and exit 0; this step turns + # presence/absence of those files into upserts/closures of a + # sticky PR comment via the ADO REST API. Runs on the canonical + # Linux leg only so the Linux/Windows matrix doesn't race on the + # same thread. Skipped (silently) when the build identity lacks + # "Contribute to pull requests" so adopters who haven't opted in + # to write permissions aren't broken. + - template: steps/advisory-comments.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-fast.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + + # The PR-tier slow checks are split into three independent stages + # (pr_test, pr_runtime_analysis, pr_mutants) so they run in parallel rather + # than sequentially in one job. Each stage owns its own dependsOn + # link to the impact stages and its own variable hookup. ADO has + # no hosted ARM agents, so the matrix stays Linux + Windows x86_64 + # across all three stages. + + - stage: pr_test + displayName: anvil pr-test (tests + coverage) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # pr-test runs llvm-cov / doc-test / examples. Coverage publish + # stays as a sibling task on each OS job -- it's a results + # publish (not a pipeline artifact), so 1ESPT permits it at step + # level and we don't need to route it through `artifacts:`. + # cobertura.xml is produced by the anvil-llvm-cov recipe. + # Publishing from every OS leg matters because OS-gated code is + # only exercised on its native target; a single-leg upload would + # systematically under-report coverage of cfg(target_os = ...) + # branches. ADO's PublishCodeCoverageResults@2 coalesces multiple + # publishes against the same build into one coverage report. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-test.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), ne(variables.include_affected_linux, '--skip')) + displayName: Publish coverage (linux) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-test.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + - task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), ne(variables.include_affected_windows, '--skip')) + displayName: Publish coverage (windows) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + + - stage: pr_runtime_analysis + displayName: anvil pr-runtime-analysis (miri + careful) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-runtime-analysis.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-runtime-analysis.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + + - stage: pr_mutants + displayName: anvil pr-mutants (mutants) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # anvil-mutants-diff self-skips on aarch64-pc-windows-msvc as a + # defence-in-depth measure for adopters with self-hosted ARM pools. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-mutants.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-mutants.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) diff --git a/crates/cargo-anvil/templates/ado/scheduled-root-pipeline.yml b/crates/cargo-anvil/templates/ado/scheduled-root-pipeline.yml new file mode 100644 index 00000000..39f66155 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/scheduled-root-pipeline.yml @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +trigger: none +pr: none + +schedules: + - cron: "0 6 * * *" + displayName: anvil scheduled + branches: + include: [main, master] + always: true + +stages: + - template: anvil/scheduled.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } diff --git a/crates/cargo-anvil/templates/ado/scheduled-stages.yml b/crates/cargo-anvil/templates/ado/scheduled-stages.yml new file mode 100644 index 00000000..ebc9c9f7 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/scheduled-stages.yml @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Every job is rendered through `steps/job.yml` (user-customizable wrapper); +# see pr-stages.yml for the rationale and template-path note. +parameters: + - name: linuxPool + type: object + default: { vmImage: ubuntu-latest } + - name: windowsPool + type: object + default: { vmImage: windows-latest } + +stages: + - stage: scheduled_test + displayName: anvil scheduled-test + jobs: + # Publish coverage from both legs so OS-gated code is fully + # represented (see the pr-stages.yml comment for the rationale). + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-test.yml + - task: PublishCodeCoverageResults@2 + condition: succeededOrFailed() + displayName: Publish coverage (linux) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-test.yml + - task: PublishCodeCoverageResults@2 + condition: succeededOrFailed() + displayName: Publish coverage (windows) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + + - stage: scheduled_advisories + displayName: anvil scheduled-advisories + dependsOn: [] + jobs: + # Cross-OS because clippy and udeps in this group compile per host, + # so cfg-gated code must be linted/scanned on both OSes. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-advisories.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-advisories.yml + + - stage: scheduled_exhaustive + displayName: anvil scheduled-exhaustive + dependsOn: [] + jobs: + # Cross-OS to match oxidizer's policy: full-workspace mutation + # testing + cargo-hack powerset + bench compile checks all benefit + # from running on both OSes for cfg(target_os) coverage. Adopters + # who can't afford the Windows leg override the matrix in their + # root pipeline. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-exhaustive.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-exhaustive.yml diff --git a/crates/cargo-anvil/templates/ado/steps/advisory-comments.yml b/crates/cargo-anvil/templates/ado/steps/advisory-comments.yml new file mode 100644 index 00000000..c7be1b89 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/steps/advisory-comments.yml @@ -0,0 +1,94 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Upsert/clear sticky PR comments for advisory checks. +# +# Convention (see docs/design/checks.md §6): a recipe writes a complete +# markdown body to `target/anvil/comments/.md` when it has +# findings, and removes the file when it does not. The first line of +# the body is an HTML comment marker (``) used +# here to find the existing PR thread to update or close. +# +# This step inspects each known convention file and: +# - if the file exists and no thread matches the marker, POSTs a new +# active thread carrying the file body; +# - if the file exists and a thread matches, PATCHes that thread's +# first comment with the new body; +# - if the file does not exist and a thread matches, sets the +# thread's status to "closed" (ADO has no thread-delete API; closed +# threads collapse in the PR UI). +# +# The step is a no-op when: +# - the build is not a PR build (Build.Reason != PullRequest); +# - the build identity lacks "Contribute to pull requests" on the +# repo (we catch the 401/403 and exit 0 so adopters who haven't +# opted in to write permissions aren't broken). +# +# Adding a new advisory check: extend the `$checks` array below with a +# `@{ name = ''; file = 'target/anvil/comments/.md' }` +# entry. The marker / header convention follows automatically. + +steps: + - task: PowerShell@2 + displayName: anvil advisory PR comments + condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + $checks = @( + @{ name = 'semver'; file = 'target/anvil/comments/semver.md' } + ) + $prId = $env:SYSTEM_PULLREQUEST_PULLREQUESTID + if (-not $prId) { + Write-Host 'advisory-comments: not a PR build; skipping' + exit 0 + } + if (-not $env:SYSTEM_ACCESSTOKEN) { + Write-Host 'advisory-comments: SYSTEM_ACCESSTOKEN not exposed; skipping' + exit 0 + } + $base = "$env:SYSTEM_COLLECTIONURI$env:SYSTEM_TEAMPROJECT/_apis/git/repositories/$env:BUILD_REPOSITORY_ID/pullRequests/$prId" + $headers = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" } + try { + $threads = (Invoke-RestMethod "$base/threads?api-version=7.1" -Headers $headers).value + } catch { + $status = $_.Exception.Response.StatusCode.value__ + if ($status -in 401, 403) { + Write-Host "advisory-comments: build identity lacks 'Contribute to pull requests' (HTTP $status); skipping" + exit 0 + } + throw + } + foreach ($c in $checks) { + $marker = "" + $existing = $threads | Where-Object { + $_.comments -and $_.comments[0].content -like "*$marker*" -and $_.status -ne 'closed' + } | Select-Object -First 1 + if (Test-Path -LiteralPath $c.file) { + $body = Get-Content -LiteralPath $c.file -Raw + if ($existing) { + Write-Host "advisory-comments: updating anvil-$($c.name) (thread $($existing.id))" + $payload = @{ content = $body } | ConvertTo-Json -Depth 5 + Invoke-RestMethod "$base/threads/$($existing.id)/comments/1?api-version=7.1" ` + -Method PATCH -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } else { + Write-Host "advisory-comments: posting anvil-$($c.name)" + $payload = @{ + comments = @(@{ parentCommentId = 0; content = $body; commentType = 1 }) + status = 'active' + } | ConvertTo-Json -Depth 5 + Invoke-RestMethod "$base/threads?api-version=7.1" ` + -Method POST -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } + } elseif ($existing) { + Write-Host "advisory-comments: closing anvil-$($c.name) (thread $($existing.id))" + $payload = @{ status = 'closed' } | ConvertTo-Json + Invoke-RestMethod "$base/threads/$($existing.id)?api-version=7.1" ` + -Method PATCH -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } + } diff --git a/crates/cargo-anvil/templates/ado/steps/group.yml b/crates/cargo-anvil/templates/ado/steps/group.yml new file mode 100644 index 00000000..d96e4f5b --- /dev/null +++ b/crates/cargo-anvil/templates/ado/steps/group.yml @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token __GROUP__ is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +parameters: + - name: include_modified + type: string + default: '' + - name: include_affected + type: string + default: '' + - name: include_required + type: string + default: '' +steps: + - template: setup.yml + parameters: + group: __GROUP__ + - bash: just anvil-__GROUP__ + displayName: anvil-__GROUP__ + env: + # Only anvil-pr-title (in the pr-fast group) consults PR_TITLE, + # but injecting it on every group keeps group.yml uniform across + # groups. ADO sets $(System.PullRequest.Title) to an empty string + # on non-PR builds; anvil-pr-title treats empty as "no title set" + # and skips. + PR_TITLE: $(System.PullRequest.Title) + ANVIL_INCLUDE_MODIFIED: ${{ parameters.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ parameters.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ parameters.include_required }} diff --git a/crates/cargo-anvil/templates/ado/steps/impact.yml b/crates/cargo-anvil/templates/ado/steps/impact.yml new file mode 100644 index 00000000..f29a40e3 --- /dev/null +++ b/crates/cargo-anvil/templates/ado/steps/impact.yml @@ -0,0 +1,117 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Computes per-tier include lists from cargo-delta and publishes them +# as stage outputs for downstream group stages to consume. +# +# Output variables (consumed in pr-stages.yml via +# stageDependencies.impact.compute.outputs['compute.']): +# include_modified - "--package X --package Y" or "--skip" sentinel. +# include_affected - same shape, for the affected set (modified ∪ rev-deps). +# include_required - same shape, for the required set +# (affected ∪ workspace-internal transitive deps). +# +# Unscoped recipes (deny, audit, aprz, pr-title) ignore all three. +steps: + # Reuse the per-job setup (toolchain, cargo cache, just install) so + # cargo + just are on PATH and ~/.cargo/bin is restored from cache. + # group=none skips the full catalog install -- impact only needs + # cargo-delta, which we install on the next step. + - template: setup.yml + parameters: + group: none + - bash: just anvil-tool-cargo-delta-install install + displayName: anvil impact (install cargo-delta) + - bash: | + set -euo pipefail + # Determine the baseline ref: + # * Adopters can override via BASE_REF (full ref name, e.g. + # `origin/release`). + # * On PR build-validation runs, SYSTEM_PULLREQUEST_TARGETBRANCH + # is set to the unqualified target branch (e.g. `main`). + # * Otherwise fall back to `master` so manual / branch cloud-workflow runs + # produce a sensible diff against the default branch. + # The ADO macro form `$(System.PullRequest.TargetBranch)` is unsafe + # here: when the variable is unset (non-PR builds), ADO leaves the + # `$(...)` literal in the script, which bash then interprets as + # command substitution and fails with exit 127. + base="${BASE_REF:-origin/${SYSTEM_PULLREQUEST_TARGETBRANCH:-master}}" + # Two snapshots + impact compare (cargo-delta has no --base + # shortcut). The baseline runs in a worktree at the merge target + # so the checked-out tree is untouched. + # + # GIT_LFS_SKIP_SMUDGE=1 prevents the worktree checkout from trying + # to fetch LFS objects -- the worktree inherits the parent repo's + # git config but NOT the credential helper that authenticated the + # initial checkout, so any LFS-tracked file would otherwise fail + # with "Git credentials ... not found". cargo-delta only needs + # file presence + content hashes; LFS pointer files hash + # consistently across baseline and current, so the impact analysis + # is unaffected. + cargo delta snapshot > "$AGENT_TEMPDIRECTORY/anvil-current.json" + GIT_LFS_SKIP_SMUDGE=1 git worktree add --detach "$AGENT_TEMPDIRECTORY/anvil-baseline" "$base" + ( cd "$AGENT_TEMPDIRECTORY/anvil-baseline" && cargo delta snapshot ) \ + > "$AGENT_TEMPDIRECTORY/anvil-baseline.json" + git worktree remove --force "$AGENT_TEMPDIRECTORY/anvil-baseline" + result="$(cargo delta impact \ + --baseline "$AGENT_TEMPDIRECTORY/anvil-baseline.json" \ + --current "$AGENT_TEMPDIRECTORY/anvil-current.json" \ + --format json)" + # cargo-delta emits TitleCase keys (Modified / Affected / + # Required). Format each tier into the --package args shape + # recipes expect, with --skip as the empty-set sentinel. + # + # cargo-delta's impact output uses *library names* (snake_case) + # rather than cargo *package names* (which may use hyphens). For + # hyphenated packages — e.g. `cargo-anvil` — that means it + # emits `cargo_anvil`, which cargo rejects as a --package + # specification. We build: + # * `pkg_map`: lib-name -> package-name (and identity for + # package-name -> package-name), for translation; + # * `valid_pkgs`: set of all known package names, for + # validation. Names cargo-delta emits that aren't valid + # packages (e.g. directory-leaf ambiguities like `ffi` / + # `ffi_build` in deeply nested workspaces) are dropped with a + # warning rather than failing the whole build. + declare -A pkg_map + declare -A valid_pkgs + while IFS=$'\t' read -r pkg_name lib_name; do + valid_pkgs["$pkg_name"]=1 + pkg_map["$pkg_name"]="$pkg_name" + [ -n "$lib_name" ] && pkg_map["$lib_name"]="$pkg_name" + done < <(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] as $p | ($p.targets[] | select(.kind | index("lib")) | "\($p.name)\t\(.name)"), "\($p.name)\t"') + format_set() { + local field="$1" + local pkgs + pkgs=$(printf '%s' "$result" | jq -r --arg f "$field" '(.[$f] // []) | .[]' 2>/dev/null || true) + if [ -z "$pkgs" ] ; then + printf '%s' "--skip" + else + local out="" + while IFS= read -r pkg ; do + [ -z "$pkg" ] && continue + local mapped="${pkg_map[$pkg]:-$pkg}" + if [ -z "${valid_pkgs[$mapped]:-}" ] ; then + echo "anvil impact: dropping unknown package '$pkg' (-> '$mapped') from $field set; see https://github.com/microsoft/ox-tools/issues for tracking" >&2 + continue + fi + out="$out --package $mapped" + done <-setup`. +parameters: + - name: group + type: string + default: '' +steps: + - bash: echo "##vso[task.setvariable variable=anvil_rustc_version]$(rustc --version | awk '{print $2}')" + displayName: anvil setup (capture rustc version) + - task: Cache@2 + inputs: + # Same key shape as the GitHub side: OS + arch + rustc version + + # lockfile hashes + catalog hash. Including arch keeps any + # future ARM pool an adopter adds from colliding with x86_64 on + # the same OS namespace. + key: 'anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | rust$(anvil_rustc_version) | Cargo.lock | .cargo/config.toml | rust-toolchain.toml | justfiles/anvil/versions.just' + restoreKeys: | + anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | rust$(anvil_rustc_version) + anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" + path: | + $(HOME)/.cargo/registry/cache/ + $(HOME)/.cargo/registry/index/ + $(HOME)/.cargo/bin/ + target/ + displayName: anvil setup (cache cargo home and target) + + # System dependencies for catalog source builds. See the GitHub + # composite setup-action.yml for the rationale — same pattern. + # Detect the distro's package manager so this works on Ubuntu agents + # (apt-get) and Azure Linux 3 agents (tdnf, common on 1ESPT) alike. + - bash: | + set -euo pipefail + if command -v tdnf >/dev/null 2>&1; then + sudo tdnf install -y clang-devel + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y clang-devel + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y libclang-dev + else + echo "No supported package manager found (tdnf/dnf/apt-get)" >&2 + exit 1 + fi + condition: eq(variables['Agent.OS'], 'Linux') + displayName: anvil setup (install libclang on Linux) + + - bash: | + if ! command -v just >/dev/null 2>&1 ; then + cargo install --locked just + fi + displayName: anvil setup (install just) + + # Hand everything else off to the catalog recipe. Idempotent. ADO + # uses the default `install` backend (source builds) because + # cargo-binstall has unresolved compliance issues for internal ADO + # pipelines (GH uses `binstall`). + # + # Selection mirrors setup-action.yml (GH composite): + # group="" -> full catalog (anvil-setup) + # group="none" -> skip; caller installs what it needs + # group= -> only that group's prerequisites + - ${{ if eq(parameters.group, 'none') }}: + - bash: echo "anvil-setup: group=none, skipping tool install" + displayName: anvil setup (group=none -- skip tool install) + - ${{ elseif eq(parameters.group, '') }}: + - bash: just anvil-setup + displayName: anvil setup (install full catalog) + - ${{ else }}: + - bash: just anvil-${{ parameters.group }}-setup + displayName: anvil setup (install ${{ parameters.group }} prerequisites) + diff --git a/crates/cargo-anvil/templates/github/group-action.yml b/crates/cargo-anvil/templates/github/group-action.yml new file mode 100644 index 00000000..4a33d119 --- /dev/null +++ b/crates/cargo-anvil/templates/github/group-action.yml @@ -0,0 +1,44 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token __GROUP__ is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-__GROUP__ +description: Run the __GROUP__ check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: __GROUP__ + - name: Run just anvil-__GROUP__ + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-__GROUP__ diff --git a/crates/cargo-anvil/templates/github/impact-action.yml b/crates/cargo-anvil/templates/github/impact-action.yml new file mode 100644 index 00000000..c51769bf --- /dev/null +++ b/crates/cargo-anvil/templates/github/impact-action.yml @@ -0,0 +1,127 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-impact +description: | + Compute the cargo-delta impact set for this PR and emit per-tier + include lists. + + Outputs: + include_modified - "--package X --package Y" string for crates whose + source files changed in the diff, or "--skip" if + the modified set is empty. + include_affected - same shape, for crates in the affected set + (modified ∪ rev-deps). + include_required - same shape, for crates in the required set + (affected ∪ workspace-internal transitive deps). + + Recipes in checks.just interpret each variable per their tier: + modified-tier recipes (fmt, license-headers, spellcheck, ...) short- + circuit on "--skip"; affected-tier recipes (clippy, tests, ...) and + required-tier recipes (doc, cargo-hack, udeps) splice their include + list into the cargo invocation, defaulting to --workspace when unset + (local runs without impact wiring). + + Unscoped recipes (deny, audit, aprz, pr-title) ignore all three + variables and always run unconditionally. +outputs: + include_modified: + description: Pre-formatted --package args for the modified tier. + value: ${{ steps.compute.outputs.include_modified }} + include_affected: + description: Pre-formatted --package args for the affected tier. + value: ${{ steps.compute.outputs.include_affected }} + include_required: + description: Pre-formatted --package args for the required tier. + value: ${{ steps.compute.outputs.include_required }} +runs: + using: composite + steps: + # anvil-setup with group=none bootstraps the rust toolchain + + # just + binstall + cache, but skips the full catalog install. + # We follow it with just the cargo-delta install (the only tool + # this composite needs). This keeps the impact stage lean -- it's + # the critical-path gating dep for every PR-tier group job. + - uses: ./.github/actions/anvil-setup + with: + group: none + - name: Install cargo-delta + shell: bash + run: just anvil-tool-cargo-delta-install binstall + - id: compute + name: Compute impact + shell: bash + run: | + set -euo pipefail + # GITHUB_BASE_REF is the target-branch name on a PR event + # (e.g. "main"); we resolve it to origin/. Adopters can + # override via the BASE_REF env var. + base="${BASE_REF:-origin/${GITHUB_BASE_REF:-main}}" + # cargo delta has no --base flag; the flow is two snapshots + # (baseline at the merge target + current at HEAD) compared by + # `cargo delta impact`. We use a temporary worktree to snapshot + # the baseline without disturbing the checked-out tree. + cargo delta snapshot > "$RUNNER_TEMP/anvil-current.json" + git worktree add --detach "$RUNNER_TEMP/anvil-baseline" "$base" + ( cd "$RUNNER_TEMP/anvil-baseline" && cargo delta snapshot ) \ + > "$RUNNER_TEMP/anvil-baseline.json" + git worktree remove --force "$RUNNER_TEMP/anvil-baseline" + result="$(cargo delta impact \ + --baseline "$RUNNER_TEMP/anvil-baseline.json" \ + --current "$RUNNER_TEMP/anvil-current.json" \ + --format json)" + # cargo-delta emits TitleCase keys (Modified / Affected / + # Required), not lowercase. Format each tier into the + # `--package X --package Y` shape recipes expect, or the + # literal "--skip" sentinel when the tier is empty. + # + # cargo-delta's impact output uses *library names* (snake_case) + # rather than cargo *package names* (which may use hyphens). For + # hyphenated packages — e.g. `cargo-anvil` — that means it + # emits `cargo_anvil`, which cargo rejects as a --package + # specification. We build: + # * `pkg_map`: lib-name -> package-name (and identity for + # package-name -> package-name), for translation; + # * `valid_pkgs`: set of all known package names, for + # validation. Names cargo-delta emits that aren't valid + # packages (e.g. directory-leaf ambiguities like `ffi` / + # `ffi_build` in deeply nested workspaces) are dropped with a + # warning rather than failing the whole build. + declare -A pkg_map + declare -A valid_pkgs + while IFS=$'\t' read -r pkg_name lib_name; do + valid_pkgs["$pkg_name"]=1 + pkg_map["$pkg_name"]="$pkg_name" + [ -n "$lib_name" ] && pkg_map["$lib_name"]="$pkg_name" + done < <(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] as $p | ($p.targets[] | select(.kind | index("lib")) | "\($p.name)\t\(.name)"), "\($p.name)\t"') + format_set() { + local field="$1" + local pkgs + pkgs=$(printf '%s' "$result" | jq -r --arg f "$field" '(.[$f] // []) | .[]' 2>/dev/null || true) + if [ -z "$pkgs" ] ; then + printf '%s' "--skip" + else + local out="" + while IFS= read -r pkg ; do + [ -z "$pkg" ] && continue + local mapped="${pkg_map[$pkg]:-$pkg}" + if [ -z "${valid_pkgs[$mapped]:-}" ] ; then + echo "anvil impact: dropping unknown package '$pkg' (-> '$mapped') from $field set" >&2 + continue + fi + out="$out --package $mapped" + done <> "$GITHUB_OUTPUT" + echo "include_affected=$(format_set Affected)" >> "$GITHUB_OUTPUT" + echo "include_required=$(format_set Required)" >> "$GITHUB_OUTPUT" diff --git a/crates/cargo-anvil/templates/github/pr-impl-workflow.yml b/crates/cargo-anvil/templates/github/pr-impl-workflow.yml new file mode 100644 index 00000000..7d10cfce --- /dev/null +++ b/crates/cargo-anvil/templates/github/pr-impl-workflow.yml @@ -0,0 +1,215 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: every multi-OS job below hardcodes its OS axis as +# an inline YAML array. Per-leg runner *labels* are inputs (so adopters +# can swap in self-hosted runners), but the OS axis itself is part of +# the workflow's identity — adopters who need a different shape (add +# macOS, drop ARM, mix in exotic targets) fork this file. Input-driven +# matrices were rejected because they added a silent failure mode +# (mis-formatted inputs produced empty matrices) without meaningfully +# expanding what adopters could customize. + +jobs: + # cargo-delta impact runs per OS so that downstream legs consume an + # impact set computed against THEIR host's cargo-metadata depgraph. + # Without this, an OS-conditional dep change (under + # `[target.'cfg(target_os = ...)'.dependencies]`) computed on Linux + # wouldn't include the cross-OS reverse-deps that only show up in + # the Windows depgraph, so the Windows leg could skip tests that + # ought to run. We split per-OS-family (not per-arch) -- arch-only + # cfg gates are rare enough that paying for 4 impact jobs isn't + # justified; arm legs reuse their OS counterpart's impact set. + impact-linux: + runs-on: ${{ inputs.linux_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + impact-windows: + runs-on: ${{ inputs.windows_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + pr-fast: + # Cross-OS / cross-arch because pr-fast contains compile-sensitive + # checks (clippy, doc-build, udeps, semver-check, external-types) + # whose results can differ across host for crates that use + # #[cfg(target_os = ...)] or #[cfg(target_arch = ...)] gating. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-fast + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + # Advisory PR comments. Recipes that surface non-blocking findings + # (e.g. cargo-semver-checks) write a markdown body to + # `target/anvil/comments/.md` and exit 0. The steps below + # turn presence/absence of those files into upserts/deletions of + # a sticky PR comment. We post from the canonical x86_64 Linux leg + # only so the matrix doesn't race on the same comment, and we use + # `always()` so the comment is updated even when an unrelated + # check in pr-fast failed. The `head.repo.full_name == + # github.repository` guard skips fork PRs (which can't be granted + # write tokens). + - name: Upsert anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') != '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + path: target/anvil/comments/semver.md + - name: Clear anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') == '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + delete: true + + pr-test: + # Tests + coverage: llvm-cov / doc-test / examples. + # 4-leg matrix -- compile and runtime behaviour can differ across + # OS and arch for cfg-gated code, so we exercise tests on every leg. + # Coverage uploads from the canonical x86_64 Linux leg only to + # avoid Codecov double-counting. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-test + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm. OS/arch-gated + # code only gets exercised on its native target, so a single- + # leg upload would systematically under-report coverage on + # cfg(target_os = "windows") / cfg(target_arch = "aarch64") + # branches. windows-11-arm is excluded because LLVM-coverage + # instrumentation on that target produces "malformed + # instrumentation profile data: symbol name is empty" errors. + # Codecov coalesces multiple uploads against the same commit; + # the `flags:` tag distinguishes the per-leg slices in the + # Codecov UI without changing the union total. + # lcov.info is produced by the anvil-llvm-cov recipe inside + # anvil-pr-test; if the affected set was empty the recipe + # no-ops and there is no file to upload, so we gate on the + # impact output. + if: matrix.os != 'windows-arm' && ((startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected != '--skip') || (matrix.os == 'windows' && needs.impact-windows.outputs.include_affected != '--skip')) + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: ${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + pr-runtime-analysis: + # Stricter-runtime correctness: miri + careful. + # 4-leg matrix -- both checks compile per host target and can + # surface OS/arch-specific UB. Both are impact-scoped so the + # wall-clock is proportional to the PR's blast radius. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-runtime-analysis + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + + pr-mutants: + # Mutation testing: cargo mutants (diff-scoped against the PR base). + # 4-leg matrix. cargo-mutants doesn't build on aarch64-pc-windows-msvc + # (upstream winapi crate incompat); the anvil-mutants-diff recipe + # self-skips on that target so this is a no-op (not a failure) on + # the windows-arm leg. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/anvil-pr-mutants + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} diff --git a/crates/cargo-anvil/templates/github/pr-root-workflow.yml b/crates/cargo-anvil/templates/github/pr-root-workflow.yml new file mode 100644 index 00000000..297ec04b --- /dev/null +++ b/crates/cargo-anvil/templates/github/pr-root-workflow.yml @@ -0,0 +1,26 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr + +on: + pull_request: {} + merge_group: {} + +permissions: + contents: read + +concurrency: + group: anvil-pr-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + anvil-pr: + uses: ./.github/workflows/anvil-pr-impl.yml + permissions: + contents: read + # Write needed so the pr-fast job can upsert/clear the sticky PR + # comment carrying the cargo-semver-checks advisory (and any + # future advisory checks that emit target/anvil/comments/*). + pull-requests: write + secrets: inherit diff --git a/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml b/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml new file mode 100644 index 00000000..3eae2496 --- /dev/null +++ b/crates/cargo-anvil/templates/github/scheduled-impl-workflow.yml @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: see pr-impl-workflow.yml for the rationale. OS +# matrices are hardcoded; per-leg runner labels are inputs. + +jobs: + scheduled-test: + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-test + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm (see the matching + # comment in pr-impl-workflow.yml for the rationale). + # Multi-flag tag combines the OS with a "scheduled" marker so + # the Codecov UI can distinguish PR-tier uploads from scheduled + # uploads while still tracking each platform separately. + if: matrix.os != 'windows-arm' + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: scheduled,${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + scheduled-advisories: + # Cross-OS / cross-arch because clippy and udeps in this group + # compile per host, so cfg-gated code must be linted/scanned on + # every leg. + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-advisories + + scheduled-exhaustive: + # x86_64-only by design: this group includes mutants-full (which + # doesn't build on aarch64-pc-windows-msvc — winapi crate + # incompatibility) plus cargo-hack feature powerset and bench. + strategy: + fail-fast: false + matrix: + os: [linux, windows] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner || inputs.windows_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-exhaustive diff --git a/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml b/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml new file mode 100644 index 00000000..745c087b --- /dev/null +++ b/crates/cargo-anvil/templates/github/scheduled-root-workflow.yml @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + anvil-scheduled: + uses: ./.github/workflows/anvil-scheduled-impl.yml + permissions: + contents: read + secrets: inherit diff --git a/crates/cargo-anvil/templates/github/setup-action.yml b/crates/cargo-anvil/templates/github/setup-action.yml new file mode 100644 index 00000000..e9f88a57 --- /dev/null +++ b/crates/cargo-anvil/templates/github/setup-action.yml @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-setup +description: Install `just` and the anvil tools/components needed by a specific group (or everything if omitted). +inputs: + group: + description: | + Which anvil group to install setup for (e.g. "pr-fast", + "pr-test", "scheduled-advisories"). Special values: + - "" (default): install the full catalog via + `just anvil-setup` -- use for local "give me everything" + flows. + - "none": skip tool installation entirely; just bootstrap the + rust toolchain + just + binstall + cache. Used by + anvil-impact, which installs only cargo-delta afterwards. + - anything else: install only that group's prerequisites via + `just anvil--setup`. + default: "" + required: false +runs: + using: composite + steps: + - id: rustc-version + shell: bash + run: echo "version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + - name: Restore cargo cache + id: cargo-cache + uses: actions/cache/restore@v4 + with: + # Key on OS + arch + rustc version + lockfile hashes + catalog + # hash so a Rust toolchain bump, a cross-arch matrix leg, or a + # tool-versions update all invalidate the cache cleanly. + # runner.arch resolves to X64 / ARM64 / X86, which keeps the + # x86_64 and aarch64 legs of the same OS from colliding on + # arch-incompatible target/ contents. + # + # ${{ github.job }} discriminates by workflow-job-id (`pr-fast`, + # `pr-test`, `pr-slow`, etc.) so concurrent matrix legs that + # share OS+arch (e.g. pr-fast linux + pr-test linux) don't race + # on the same cache key. Without this, the first-to-finish job + # reserves the key, the others get "Unable to reserve cache: + # another job may be creating this cache" and silently skip + # the save -- leaving the cache empty forever. The restore-keys + # fall back across jobs so the install work is still shared + # across sibling legs on subsequent runs. + key: anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + restore-keys: | + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}- + # `.crates.toml` and `.crates2.json` track which cargo-installed + # tools and versions live in ~/.cargo/bin/. Without them in the + # cache, `cargo install --list` and (downstream) the + # tool-install recipes' early-skip on "installed >= pin" don't + # see the cached binaries — every run then tries to reinstall + # on top of them and fails with "binary X already exists in + # destination". + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + + # System dependencies for the catalog's source builds. + # + # cargo-spellcheck's build script (clang-sys) needs libclang. The + # Ubuntu runner images don't ship it on PATH by default, so the + # source compile fails with "couldn't execute llvm-config". On + # macOS, libclang is bundled with Xcode CLT (always present on + # GH-hosted macos-*). On Windows, the Visual Studio install on + # the windows-* images includes a usable LLVM, so no install is + # needed. + - name: Install libclang (Linux) + if: runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libclang-dev + + # rustup auto-installs the toolchain from rust-toolchain.toml on + # first cargo invocation, but it doesn't pull profile/component + # extras. The `anvil-setup` recipe in step "Install anvil + # toolchains + tools" below handles that exhaustively (the + # per-component install recipes in tools.just install + # default-toolchain components and pinned-nightly components for + # miri/careful/etc.) -- we don't add components inline here anymore. + # This step exists only to ensure rustup itself has the default + # toolchain set up so subsequent cargo / just calls work. + - name: Ensure default toolchain is installed + shell: bash + run: rustup show active-toolchain || rustup default stable + + # The catalog recipe `anvil-setup` (or `anvil--setup` + # when a group is specified via the `group` input) needs `just` to + # run. We bootstrap just here (chicken-and-egg), then hand off + # everything else to it. The setup recipes are idempotent and + # short-circuit on tools already installed at or above the pinned + # version, so re-running on cache-hit runs is cheap. + # Install a prebuilt cargo-binstall binary in seconds. Without this, + # the first `_install-tool` recipe that needs binstall bootstraps it + # via `cargo install --locked cargo-binstall`, which takes ~4 min of + # source compilation on every cold-cache job. The official action + # downloads the release binary from cargo-bins/cargo-binstall, so + # the bootstrap branch in `tools.just` becomes a no-op on GH. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@main + + - name: Install just + shell: bash + run: | + if ! command -v just >/dev/null 2>&1 ; then + cargo binstall --no-confirm --locked just || cargo install --locked just + fi + + - name: Install anvil toolchains + tools + shell: bash + # When `group` is empty (the default), installs the full catalog + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely (used by anvil-impact, which + # only needs cargo-delta and installs it itself afterwards). When + # `group` is anything else, installs only what that group needs + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. + run: | + case "${{ inputs.group }}" in + none) echo "anvil-setup: group=none, skipping tool install" ;; + "") just anvil-setup binstall ;; + *) just "anvil-${{ inputs.group }}-setup" binstall ;; + esac + + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache@v4` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save@v4` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. + - name: Save cargo cache + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + key: ${{ steps.cargo-cache.outputs.cache-primary-key }} + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + target/ diff --git a/crates/cargo-anvil/templates/justfiles/anvil/checks.just b/crates/cargo-anvil/templates/justfiles/anvil/checks.just new file mode 100644 index 00000000..d4abfc29 --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/checks.just @@ -0,0 +1,872 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. +# +# Each check belongs to one of four buckets, which determines how it +# interprets the impact env vars emitted by the cargo-delta impact step +# in cloud workflows: +# +# - "modified": only run when at least one package's source files +# changed in the diff. The check's underlying tool is workspace-wide +# or directory-scoped (cargo fmt --all, cargo heather, cargo +# spellcheck), so it doesn't take --package; we short-circuit on +# the ANVIL_INCLUDE_MODIFIED == "--skip" sentinel. +# +# - "affected": run on the affected set (modified ∪ reverse-deps +# within the workspace). The check's underlying tool takes +# --package; we splice ANVIL_INCLUDE_AFFECTED into the cargo +# invocation, defaulting to --workspace for local invocations where +# no env var is set. +# +# - "required": run on the required set (affected ∪ workspace-internal +# transitive deps). Same splice/default pattern as affected, but +# keyed on ANVIL_INCLUDE_REQUIRED. Used for checks whose tool +# resolves through the dep graph (cargo doc → intra-doc links; +# cargo hack → feature powerset; cargo udeps → unused-deps). +# +# - "unscoped": always run, no env var reference. External-input +# checks (deny, audit, aprz) and PR-context checks (pr-title) live +# here. Scheduled-exhaustive recipes (mutants-full) are also unscoped +# by design. +# +# Local invocation (no impact wiring): all three env vars are unset +# (recipes use the `?? "--workspace"` null-coalescing fallback below); +# modified-tier recipes simply skip the splice and run their +# workspace-wide tool; affected/required-tier recipes splat +# "--workspace" when the env var is unset. +# +# Preparation contract: when a recipe reaches the cargo call, the +# env var is one of: +# +# * unset - local run; the recipe substitutes +# "--workspace" via `?? "--workspace"` +# * "--package A --package B" - emitted by the cloud-workflow impact step when +# the tier has members +# * "--skip" - emitted by the cloud-workflow impact step when +# the tier is empty (recipe exits 0) +# +# This lets the simple recipes splat the var directly with +# & cargo X @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) ... +# and reduces the per-recipe boilerplate to a single one-line skip +# guard plus the cargo invocation. +# +# Modified-tier recipes never splice the env var into cargo (their +# tools are workspace-wide); they only check the skip sentinel. +# +# Every recipe whose body uses multi-line conditionals or env-var +# splicing is annotated with [script("pwsh")]. pwsh is preinstalled on +# Windows (since Windows 10), on GH/ADO hosted Linux + Windows +# runners, and installable on macOS via Homebrew or the upstream +# installer. We chose pwsh over bash because just's shebang dispatch +# requires `cygpath` on Windows (only on PATH from inside Git Bash), +# while [script("pwsh")] works from plain PowerShell with no PATH +# augmentation. The `??` null-coalescing operator used in the splat +# requires pwsh 7+, which is the floor we already require via +# _anvil-require pwsh. +# +# Single-command recipes (cargo deny check, cargo audit) are plain +# just recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# Note: [script(...)] requires `set unstable`. The adopter's root +# justfile must declare it (typically as a top-level line). anvil's +# mod.just does NOT redeclare it, to avoid conflicting with adopters +# who already have it. + +# === pr-fast members ==================================================== + +# Modified tier. cargo-fmt is a rustup component. We invoke it via the +# pinned nightly (see versions.just) because rustfmt.toml uses +# unstable_features = true (imports_granularity, group_imports, +# format_code_in_doc_comments). Floating nightly would mean +# format-drift breaking cloud workflows on rustup updates — the same trap we +# explicitly avoid for udeps/miri/careful/external-types. +[script("pwsh")] +anvil-fmt: anvil-fmt-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo '+{{ rust_nightly }}' fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. Per project policy, clippy runs on the affected set +# rather than the modified set: a change in a crate can introduce a +# clippy issue in a dependent crate (e.g., trait-bound or +# obviously-truthy-condition lints that key off the changed type), so +# we want downstream rev-deps to lint as well. cargo-clippy is a +# rustup component; same reasoning as fmt for the require. +[script("pwsh")] +anvil-clippy: anvil-clippy-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo clippy @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-targets --all-features --locked "--" '-D' 'warnings' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-cargo-sort: anvil-cargo-sort-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo sort --workspace --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-license-headers: anvil-license-headers-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo heather + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-cyclic-deps: anvil-ensure-no-cyclic-deps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-cyclic-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-default-features: anvil-ensure-no-default-features-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-default-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Required tier. cargo doc resolves intra-doc links through the dep +# graph, so a dep changing its public API can break doc-build in a +# crate that wasn't itself modified. +[script("pwsh")] +anvil-doc-build: anvil-doc-build-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + $env:RUSTDOCFLAGS = '-D warnings' + & cargo doc @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features --no-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +# +# cargo-doc2readme regenerates a crate's README.md from its rustdoc. +# Two extension points users frequently need: +# * Workspace-level template (`crates/README.j2` or `README.j2` at repo +# root): a Tera template applied to every crate's README. anvil +# auto-detects it and passes `--template` to the per-crate runs. +# * Per-crate opt-out: hand-crafted READMEs (e.g. a tool crate whose +# README is more freeform than the lib docs) opt out by adding +# `[package.metadata.ox-gen-readme]\ndisable = true` to their +# Cargo.toml. anvil skips those crates. +# +# Bin-only crates have no library rustdoc to base a README on, so they +# are skipped as well (cargo doc2readme requires a library target). +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: per-crate +# iteration over library targets, cargo-metadata-driven opt-outs +# (publish=false, [package.metadata.ox-gen-readme] disable), per-crate +# Push-Location into the crate dir (cargo-doc2readme is CWD-sensitive +# rather than --manifest-path-driven), and per-crate template-path +# resolution. The ANVIL_INCLUDE_MODIFIED value would still need to +# be intersected with the lib-crate set rather than splatted into cargo. +[script("pwsh")] +anvil-readme-check: anvil-readme-check-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-readme-check: no modified packages; skipping' + exit 0 + } + # Detect a workspace-level README template. Two conventional + # locations: crates/README.j2 (cargo-workspaces idiom) or + # README.j2 at repo root. + $template = $null + foreach ($candidate in 'crates/README.j2', 'README.j2') { + if (Test-Path $candidate) { $template = (Resolve-Path $candidate).Path; break } + } + # Iterate library crates. Filter by impact set when set, then drop + # bin-only crates and opt-outs. + $pkg = @(if ($env:ANVIL_INCLUDE_MODIFIED) { -split $env:ANVIL_INCLUDE_MODIFIED } else { '--workspace' }) + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $byName = @{} + foreach ($p in $meta.packages) { $byName[$p.name] = $p } + $candidates = if ($pkg -contains '--workspace') { + @($meta.packages | ForEach-Object { $_.name }) + } else { + $names = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + $names += $pkg[$i + 1]; $i++ + } + } + $names + } + $hadFailure = $false + foreach ($name in $candidates) { + $p = $byName[$name] + if (-not $p) { continue } + if (-not ($p.targets | Where-Object { $_.kind -contains 'lib' })) { continue } + # Skip private crates (publish = false). They aren't released + # and rarely have a polished README. Mirrors the + # `cargo workspaces exec --ignore-private` idiom adopters + # commonly use. + if ($p.publish -is [array] -and $p.publish.Count -eq 0) { + Write-Host "anvil-readme-check: $name (skipped: publish = false)" + continue + } + $disabled = $false + if ($p.metadata -and $p.metadata.'ox-gen-readme' -and $p.metadata.'ox-gen-readme'.disable) { + $disabled = $true + } + if ($disabled) { + Write-Host "anvil-readme-check: $name (opted out via [package.metadata.ox-gen-readme])" + continue + } + Write-Host "anvil-readme-check: $name" + # cargo doc2readme writes / compares relative to its CWD (not + # --manifest-path), so chdir into the crate before invoking + # --check. We also compute a per-crate relative path to the + # workspace-level template so the same template file works for + # every crate (parallels the cargo-workspaces idiom). + $crateDir = Split-Path -Parent $p.manifest_path + Push-Location $crateDir + try { + $relTemplate = if ($template) { + Resolve-Path -Relative -LiteralPath $template + } else { + $null + } + $args = @('doc2readme', '--check') + if ($relTemplate) { $args += @('--template', $relTemplate) } + & cargo @args + if ($LASTEXITCODE -ne 0) { $hadFailure = $true } + } finally { + Pop-Location + } + } + if ($hadFailure) { exit 1 } + +# Modified tier. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# If `.spelling` is present, we generate `target/spelling.dic` from it +# automatically; otherwise we run cargo-spellcheck against whatever +# the repo has already set up. +[script("pwsh")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-spellcheck: no modified packages; skipping' + exit 0 + } + if (Test-Path '.spelling') { + $output_file = 'target/spelling.dic' + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = $lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + } + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Unscoped (PR title is not source-related). +# +# Validates that $env:PR_TITLE matches Conventional Commits when set; +# no-op when unset. Cloud workflows inject PR_TITLE explicitly (GH Actions: +# ${{ github.event.pull_request.title }}; ADO: $(System.PullRequest.Title)) +# so the check has authoritative input there. Locally it skips silently -- +# there is no reliable way to recover the PR title for the ADO backend +# (no equivalent of `gh pr view`), so the recipe stays simple and +# defers the check to cloud workflows. +[script("pwsh")] +anvil-pr-title: anvil-pr-title-validate-prereqs + $title = $env:PR_TITLE + if (-not $title) { + Write-Host 'anvil-pr-title: PR_TITLE env var not set; skipping (check runs in cloud workflows)' + exit 0 + } + if ($title -notmatch '^(feat|fix|chore|docs|refactor|test|build|cloud workflows|perf|revert)(\([^)]+\))?!?: .+') { + Write-Error "PR title '$title' does not match Conventional Commits" + exit 1 + } + +# Unscoped (consults external advisory DB; reads Cargo.lock, not +# workspace members). Single command — inherits adopter's default shell. +anvil-deny: anvil-deny-validate-prereqs + cargo deny check + +# Unscoped (consults external advisory DB; reads Cargo.lock). +anvil-audit: anvil-audit-validate-prereqs + cargo audit + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Deliberately omits `--all-targets`: with `--all-targets`, a dep +# that's listed in BOTH `[dependencies]` and `[dev-dependencies]` and +# used only by tests is reported as "all used" because the dev-deps +# target satisfies the lookup, masking the unused entry in main +# `[dependencies]`. Restricting to the default targets (lib + bins) +# matches main repo cloud workflows' check and surfaces the real bug. +[script("pwsh")] +anvil-udeps: anvil-udeps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' udeps @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier (compares published API of changed crates against the +# baseline; only changed crates' surface is at risk). +# +# Several real-world conditions produce errors that aren't actually +# SemVer violations: +# - bin-only crates have no API to compare ("no library targets found"). +# - crates not yet published to crates.io ("not found in registry"). +# - crates where the published baseline lacks a lib target the +# current source has (bin -> bin+lib transition). +# We pre-filter to library-bearing crates from cargo metadata, then +# run cargo-semver-checks per-package and tolerate the +# "no-comparable-baseline" failure modes. +# +# Findings policy: this recipe is *advisory*. Real SemVer findings do +# NOT fail the recipe -- breaking changes between unreleased commits +# are normal (the major-version bump happens at release time, not on +# every PR). Instead, when there are findings we write a markdown +# advisory body to `target/anvil/comments/semver.md`; when the +# tree is clean we remove that file. cloud-workflow wiring (GH: +# marocchino/sticky-pull-request-comment; ADO: pwsh + REST API) +# inspects the file after the recipe and upserts / clears a sticky +# PR comment accordingly. Local invocation gets the same file +# written under target/ for inspection. +# +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-metadata +# filter to library crates (cargo-semver-checks --workspace fails on +# bin-only workspaces), intersect ANVIL_INCLUDE_AFFECTED with that +# set, then per-crate invocation with selective error tolerance for +# unpublished crates ("not found in registry") and bin->bin+lib +# transitions ("no library targets found"). +[script("pwsh")] +anvil-semver-check: anvil-semver-check-validate-prereqs + $ErrorActionPreference = 'Stop' + $commentFile = 'target/anvil/comments/semver.md' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-semver-check: no affected packages; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build the candidate package list. Always iterate per-package over + # library crates only -- cargo-semver-checks --workspace would fail + # on workspaces that contain bin-only crates ("no library targets + # found"), and we want the same tolerance for unpublished / bin->lib- + # transition crates regardless of whether we got here via impact- + # scoping (cloud workflows) or full-workspace fallback (local). + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $true + } + } + if ($pkg -contains '--workspace') { + $packages = @($libPkgs.Keys) + } else { + $packages = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs[$pkg[$i + 1]]) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-semver-check: no affected library crates; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $findings = New-Object System.Collections.Generic.List[string] + foreach ($p in $packages) { + Write-Host "anvil-semver-check: $p" + $output = (& cargo semver-checks --package $p 2>&1) | Out-String + if ($LASTEXITCODE -ne 0) { + if ($output -match 'not found in registry|no library targets found') { + Write-Host " $p has no comparable baseline; skipping (likely unpublished or bin->lib transition)" -ForegroundColor Yellow + } else { + Write-Host $output + # Append a per-crate findings block. Using one-line-at-a-time + # appends keeps the markdown free of pwsh backtick-escape + # gymnastics (single-quoted literals + the natural `n join + # produce clean LF newlines and unambiguous triple-backticks). + $findings.Add('### `' + $p + '`') | Out-Null + $findings.Add('') | Out-Null + $findings.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $findings.Add($line.TrimEnd()) | Out-Null + } + $findings.Add('```') | Out-Null + $findings.Add('') | Out-Null + } + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: Potential breaking changes detected') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # Advisory: always exit 0. cloud-workflow wiring posts/clears the PR comment. + exit 0 + +# Affected tier (lints public API of changed crates and rev-deps). +# +# cargo-check-external-types is per-manifest: no --package/--workspace, +# only --manifest-path. Iterate the affected library crates and run +# the tool once each, pointing at the crate's Cargo.toml. Bin-only +# crates have no public API surface and are skipped. +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-check- +# external-types is per-manifest (no --package/--workspace), so we +# build a name->manifest map from cargo metadata, filter to lib crates, +# intersect with ANVIL_INCLUDE_AFFECTED, and call the tool once per +# crate. Hard-fails on errors (no tolerance, unlike semver-check). +[script("pwsh")] +anvil-external-types: anvil-external-types-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-external-types: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build pkg-name -> manifest-path map, restricted to library crates. + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $p.manifest_path + } + } + # Decide which packages to check. + $packages = @() + if ($pkg -contains '--workspace') { + $packages = $libPkgs.Keys + } else { + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs.ContainsKey($pkg[$i + 1])) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-external-types: no affected library crates; skipping' + exit 0 + } + $failed = $false + foreach ($p in $packages) { + Write-Host "anvil-external-types: $p" + # cargo-check-external-types requires nightly rustdoc (uses + # unstable -Z flags) AND pins a specific rustdoc-types schema + # version. We pin nightly narrowly to the schema this tool + # version expects via `rust_nightly_external_types` in + # versions.just — bump that pin alongside any cargo-check- + # external-types upgrade. No tolerance for schema mismatches: + # if it fails, the pin or the tool needs to move. + & cargo '+{{ rust_nightly_external_types }}' check-external-types --manifest-path $libPkgs[$p] + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + +# Unscoped (consults external risk DB). +anvil-aprz: anvil-aprz-validate-prereqs + cargo aprz deps --error-if-high-risk --console appraisal + +# === pr-test members ==================================================== + +# Affected tier. +[script("pwsh")] +anvil-llvm-cov: anvil-llvm-cov-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-llvm-cov: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # cargo-llvm-cov doesn't work on aarch64-pc-windows-msvc: the + # llvm-profdata that ships with the rust toolchain there fails + # to merge the .profraw set ("no profile can be merged"). Fall + # back to plain `cargo nextest run` on that target so we still + # get test execution; coverage data from this leg wouldn't have + # been used anyway (coverage upload is gated on Linux only). + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-llvm-cov: aarch64-pc-windows-msvc -- skipping coverage; running plain nextest' + & cargo nextest run @pkg --all-features --locked + exit $LASTEXITCODE + } + # cargo llvm-cov writes the .profraw set into target/llvm-cov-target/ + # but the *report* output directory (target/coverage/) is something + # we choose and must exist before --output-path runs. + [System.IO.Directory]::CreateDirectory('target/coverage') | Out-Null + [System.IO.Directory]::CreateDirectory('target/coverage/html') | Out-Null + # Wipe stale .profraw data so the report reflects only this run. + cargo llvm-cov clean --workspace --profraw-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Instrument and run tests; defer report generation so we can emit + # multiple formats from the same profraw set without re-running. + # Note: nextest's exit-4 ("no tests to run") IS treated as a + # failure here -- a llvm-cov run that finds no tests almost + # always means a config mistake (wrong package filter, missing + # test target, etc.), not a legitimate empty set. anvil-miri + # is the exception (see its comment): miri-skipped tests are an + # expected design point for FS-heavy crates. + & cargo llvm-cov nextest @pkg --all-features --locked --no-report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # lcov.info feeds Codecov on GitHub; cobertura.xml feeds + # PublishCodeCoverageResults@2 on Azure DevOps. + cargo llvm-cov report --lcov --output-path target/coverage/lcov.info + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Local-only HTML viewer (no cloud-workflow consumer); cheap once the data exists. + cargo llvm-cov report --html --output-dir target/coverage/html + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-doc-test: anvil-doc-test-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo test --doc @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-examples: anvil-examples-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo build @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --examples --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === pr-mutants member ==================================================== + +# Affected tier. cargo-mutants does its own diff-scoping via --in-diff; +# the affected-tier guard is the wiring-layer's coarse filter (if no +# affected packages exist, the entire mutants run is pointless). +# +# Skip on aarch64-pc-windows-msvc: cargo-mutants doesn't build there +# (upstream winapi incompatibility), so `_anvil-require cargo-mutants` +# would fail. The merged pr-slow group runs on all four OS legs; mutants +# is the only sub-recipe that can't follow, so it bails out early on the +# affected leg. Coverage on the other three legs is unchanged. +[script("pwsh")] +anvil-mutants-diff: anvil-mutants-diff-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs aarch64-pc-windows-msvc -- cargo-mutants does not build here (winapi); skipping' + exit 0 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no affected packages; skipping' + exit 0 + } + # Resolve BASE_REF: env override > origin/main > origin/master. + $base = $null + if ($env:BASE_REF) { + $base = $env:BASE_REF + } else { + foreach ($candidate in @('origin/main', 'origin/master')) { + git rev-parse --verify $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + } + if (-not $base) { + Write-Error 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no BASE_REF set and neither origin/main nor origin/master is available. Set BASE_REF to the branch to diff against.' + exit 1 + } + # cargo-mutants --in-diff takes a FILE path containing a unified + # diff, not a git revision range. Write the diff to a temp file + # first. RUNNER_TEMP (GH) and AGENT_TEMPDIRECTORY (ADO) point at + # the job's scratch dir; fall back to the system temp dir locally. + $tmp_dir = $env:RUNNER_TEMP + if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } + if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } + $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' + git diff "$base..HEAD" --output=$diff_path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-runtime members ============================================ + +# Nightly checks always run full-workspace; impact env vars aren't set +# by the scheduled workflow, so the affected-tier default (--workspace) +# applies. Skip guards are still included for local diff-scoped runs. + +[script("pwsh")] +anvil-miri: anvil-miri-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + # `--no-tests=pass`: miri-only exception. Tests that touch the + # filesystem, spawn subprocesses, or use other miri-incompatible + # APIs commonly carry `#[cfg_attr(miri, ignore)]` (this is the + # canonical opt-out for build-tooling / CLI crates). A crate + # whose test set ends up entirely-skipped under miri legitimately + # produces zero runnable tests; nextest's default exit-4 ("no + # tests to run") would fail the recipe in that case. We treat + # empty test runs as success for miri only. Other nextest-using + # recipes (llvm-cov) keep exit-4 as a failure because zero tests + # there almost always indicates a config mistake. + & cargo '+{{ rust_nightly }}' miri nextest run --no-tests=pass @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +[script("pwsh")] +anvil-careful: anvil-careful-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' careful test @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-exhaustive members ========================================= + +# Unscoped. Scheduled-exhaustive deliberately runs over the whole +# workspace regardless of diff. +anvil-mutants-full: anvil-mutants-full-validate-prereqs + cargo mutants --workspace --no-shuffle --jobs 0 + +# Required tier. cargo-hack's feature powerset cascades through dep +# features, so the required set (workspace-internal transitive deps) +# is the right scope. +[script("pwsh")] +anvil-cargo-hack: anvil-cargo-hack-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo hack @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --feature-powerset --depth 2 check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-bench: anvil-bench-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo bench @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --no-run + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ============================================================================ +# Per-check setup + validate-prereqs +# ============================================================================ +# +# Each check has matching `*-setup` and `*-validate-prereqs` recipes +# that install / verify the tools and components it needs. The setup +# recipes accept an `installer=install|install` parameter that +# forwards to the underlying tool-install recipes; the validate-prereqs +# recipes take no parameters. +# +# These are the building blocks for `anvil--setup` +# (groups.just) and `anvil--setup` (tiers.just) ΓÇö each +# group/tier-level recipe is just a fan-out over the per-check +# setup/validate-prereqs of its members. + +# --- pr-fast members --- + +[group("anvil-setup")] +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install + +[group("anvil-setup")] +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs + +[group("anvil-setup")] +anvil-clippy-setup installer="install": anvil-component-default-clippy-install + +[group("anvil-setup")] +anvil-clippy-validate-prereqs: anvil-component-default-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-sort-setup installer="install": (anvil-tool-cargo-sort-install installer) + +[group("anvil-setup")] +anvil-cargo-sort-validate-prereqs: anvil-tool-cargo-sort-validate-prereqs + +[group("anvil-setup")] +anvil-license-headers-setup installer="install": (anvil-tool-cargo-heather-install installer) + +[group("anvil-setup")] +anvil-license-headers-validate-prereqs: anvil-tool-cargo-heather-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-setup installer="install": (anvil-tool-cargo-ensure-no-cyclic-deps-install installer) + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-validate-prereqs: anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-default-features-setup installer="install": (anvil-tool-cargo-ensure-no-default-features-install installer) + +[group("anvil-setup")] +anvil-ensure-no-default-features-validate-prereqs: anvil-tool-cargo-ensure-no-default-features-validate-prereqs + +# doc-build, examples and doc-test are pure cargo built-ins; the rust +# toolchain (rustc + cargo) is the only prerequisite, and we already +# rely on it being present everywhere anvil runs. +[group("anvil-setup")] +anvil-doc-build-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-build-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-readme-check-setup installer="install": (anvil-tool-cargo-doc2readme-install installer) + +[group("anvil-setup")] +anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereqs + +# cargo-spellcheck has a build-time libclang dependency; the system +# deps check runs first so adopters get a clear hint instead of a +# cryptic clang-sys build error mid-install. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": anvil-system-deps-check (anvil-tool-cargo-spellcheck-install installer) + +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +# pr-title is a pwsh script; no cargo tool to install. +[group("anvil-setup")] +anvil-pr-title-setup installer="install": anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-pr-title-validate-prereqs: anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-deny-setup installer="install": (anvil-tool-cargo-deny-install installer) + +[group("anvil-setup")] +anvil-deny-validate-prereqs: anvil-tool-cargo-deny-validate-prereqs + +[group("anvil-setup")] +anvil-audit-setup installer="install": (anvil-tool-cargo-audit-install installer) + +[group("anvil-setup")] +anvil-audit-validate-prereqs: anvil-tool-cargo-audit-validate-prereqs + +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +[group("anvil-setup")] +anvil-external-types-setup installer="install": anvil-toolchain-nightly-external-types-install (anvil-tool-cargo-check-external-types-install installer) + +[group("anvil-setup")] +anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-validate-prereqs anvil-tool-cargo-check-external-types-validate-prereqs + +[group("anvil-setup")] +anvil-aprz-setup installer="install": (anvil-tool-cargo-aprz-install installer) + +[group("anvil-setup")] +anvil-aprz-validate-prereqs: anvil-tool-cargo-aprz-validate-prereqs + +# --- pr-test members (shared with scheduled-test) --- + +[group("anvil-setup")] +anvil-llvm-cov-setup installer="install": (anvil-tool-cargo-llvm-cov-install installer) (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-llvm-cov-validate-prereqs: anvil-tool-cargo-llvm-cov-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-validate-prereqs: anvil-tool-rustc-validate-prereqs + +# --- pr-runtime-analysis members --- + +[group("anvil-setup")] +anvil-miri-setup installer="install": anvil-component-nightly-miri-install anvil-component-nightly-rust-src-install (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-miri-validate-prereqs: anvil-component-nightly-miri-validate-prereqs anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-careful-setup installer="install": anvil-component-nightly-rust-src-install (anvil-tool-cargo-careful-install installer) + +[group("anvil-setup")] +anvil-careful-validate-prereqs: anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-careful-validate-prereqs + +# --- pr-mutants members --- + +[group("anvil-setup")] +anvil-mutants-diff-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-diff-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +# --- scheduled-exhaustive members (mutants-full reuses cargo-mutants; +# cargo-hack and bench are dedicated tools) --- + +[group("anvil-setup")] +anvil-mutants-full-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-full-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-hack-setup installer="install": (anvil-tool-cargo-hack-install installer) + +[group("anvil-setup")] +anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs + +# bench uses cargo-built-ins (cargo bench --no-run + plain bench runs); +# no extra tool install needed beyond the rust toolchain. +[group("anvil-setup")] +anvil-bench-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-bench-validate-prereqs: anvil-tool-rustc-validate-prereqs \ No newline at end of file diff --git a/crates/cargo-anvil/templates/justfiles/anvil/groups.just b/crates/cargo-anvil/templates/justfiles/anvil/groups.just new file mode 100644 index 00000000..7e12a684 --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/groups.just @@ -0,0 +1,206 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. + +# Each group is one cloud-workflow job. Within a group, checks run sequentially. + +# PR groups +# =========================================================================== + +[group("anvil")] +anvil-pr-fast: \ + anvil-fmt \ + anvil-clippy \ + anvil-cargo-sort \ + anvil-license-headers \ + anvil-ensure-no-cyclic-deps \ + anvil-ensure-no-default-features \ + anvil-doc-build \ + anvil-readme-check \ + anvil-spellcheck \ + anvil-pr-title \ + anvil-deny \ + anvil-audit \ + anvil-udeps \ + anvil-semver-check \ + anvil-external-types \ + anvil-aprz + +# pr-slow is the single PR-tier group for everything that takes more +# than ~30s per crate (tests, stricter runtimes, mutation testing). +# It's internally split into three sub-recipes so individual concerns +# can be invoked locally without dragging the others along: +# +# slow1: tests + coverage (replaces the former pr-test group) +# slow2: stricter-runtime correctness (miri, careful) +# slow3: mutation testing +# +# Cloud workflows run pr-slow as ONE job per OS leg -- the sub-recipes run +# sequentially within. This trades per-leg wall-clock for fewer +# orchestration jobs and a flatter PR check graph. Individual sub- +# recipes are runnable on their own locally: +# +# $ just anvil-pr-test # tests + coverage only +# $ just anvil-pr-runtime-analysis # miri + careful only +# $ just anvil-pr-mutants # mutants only +[group("anvil")] +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants + +[group("anvil")] +anvil-pr-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-pr-runtime-analysis: \ + anvil-miri \ + anvil-careful + +[group("anvil")] +anvil-pr-mutants: anvil-mutants-diff + +# Scheduled groups +# =========================================================================== + +[group("anvil")] +anvil-scheduled-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-scheduled-advisories: \ + anvil-deny \ + anvil-audit \ + anvil-aprz \ + anvil-clippy + +[group("anvil")] +anvil-scheduled-exhaustive: \ + anvil-mutants-full \ + anvil-cargo-hack \ + anvil-bench +# Group-level setup + validate-prereqs +# =========================================================================== +# +# Per-group recipes that fan out to the per-check setup/validate-prereqs +# from checks.just. Setup recipes accept `installer="install"|"binstall"`; +# validate-prereqs recipes take no parameters. + +[group("anvil-setup")] +anvil-pr-fast-setup installer="install": \ + (anvil-fmt-setup installer) \ + (anvil-clippy-setup installer) \ + (anvil-cargo-sort-setup installer) \ + (anvil-license-headers-setup installer) \ + (anvil-ensure-no-cyclic-deps-setup installer) \ + (anvil-ensure-no-default-features-setup installer) \ + (anvil-doc-build-setup installer) \ + (anvil-readme-check-setup installer) \ + (anvil-spellcheck-setup installer) \ + (anvil-pr-title-setup installer) \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-udeps-setup installer) \ + (anvil-semver-check-setup installer) \ + (anvil-external-types-setup installer) \ + (anvil-aprz-setup installer) + +[group("anvil-setup")] +anvil-pr-fast-validate-prereqs: \ + anvil-fmt-validate-prereqs \ + anvil-clippy-validate-prereqs \ + anvil-cargo-sort-validate-prereqs \ + anvil-license-headers-validate-prereqs \ + anvil-ensure-no-cyclic-deps-validate-prereqs \ + anvil-ensure-no-default-features-validate-prereqs \ + anvil-doc-build-validate-prereqs \ + anvil-readme-check-validate-prereqs \ + anvil-spellcheck-validate-prereqs \ + anvil-pr-title-validate-prereqs \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-udeps-validate-prereqs \ + anvil-semver-check-validate-prereqs \ + anvil-external-types-validate-prereqs \ + anvil-aprz-validate-prereqs + +[group("anvil-setup")] +anvil-pr-slow-setup installer="install": \ + (anvil-pr-test-setup installer) \ + (anvil-pr-runtime-analysis-setup installer) \ + (anvil-pr-mutants-setup installer) + +[group("anvil-setup")] +anvil-pr-slow-validate-prereqs: \ + anvil-pr-test-validate-prereqs \ + anvil-pr-runtime-analysis-validate-prereqs \ + anvil-pr-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-pr-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-pr-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-pr-runtime-analysis-setup installer="install": \ + (anvil-miri-setup installer) \ + (anvil-careful-setup installer) + +[group("anvil-setup")] +anvil-pr-runtime-analysis-validate-prereqs: \ + anvil-miri-validate-prereqs \ + anvil-careful-validate-prereqs + +[group("anvil-setup")] +anvil-pr-mutants-setup installer="install": (anvil-mutants-diff-setup installer) + +[group("anvil-setup")] +anvil-pr-mutants-validate-prereqs: anvil-mutants-diff-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-scheduled-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-advisories-setup installer="install": \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-aprz-setup installer) \ + (anvil-clippy-setup installer) + +[group("anvil-setup")] +anvil-scheduled-advisories-validate-prereqs: \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-aprz-validate-prereqs \ + anvil-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-exhaustive-setup installer="install": \ + (anvil-mutants-full-setup installer) \ + (anvil-cargo-hack-setup installer) \ + (anvil-bench-setup installer) + +[group("anvil-setup")] +anvil-scheduled-exhaustive-validate-prereqs: \ + anvil-mutants-full-validate-prereqs \ + anvil-cargo-hack-validate-prereqs \ + anvil-bench-validate-prereqs \ No newline at end of file diff --git a/crates/cargo-anvil/templates/justfiles/anvil/mod.just b/crates/cargo-anvil/templates/justfiles/anvil/mod.just new file mode 100644 index 00000000..bd2ca810 --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/mod.just @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Entry point for the anvil just recipe tree. The user's root Justfile +# imports this single file; everything else lives in sibling .just files +# pulled in here. + +# All multi-statement recipes in this tree use [script("pwsh")]. pwsh +# is preinstalled on Windows (10+), GH/ADO hosted Linux + Windows +# runners, and is installable on macOS/Linux via Homebrew / upstream +# installer. We chose pwsh over bash because: +# +# - just's shebang dispatch (#!/usr/bin/env bash) requires `cygpath` +# on Windows, which is only on PATH inside Git Bash. Plain +# PowerShell can't run shebang recipes. +# - just's [script("bash")] attribute passes Windows tempfile paths +# to bash unescaped, and bash interprets the backslashes as escape +# characters — every recipe fails with a mangled path. +# - [script("pwsh")] works from plain PowerShell with no PATH +# augmentation and no path translation. pwsh handles Windows +# paths natively. +# +# The existing `_anvil-require pwsh` recipe already established +# pwsh as a tools-floor requirement, so requiring it as the recipe +# interpreter is consistent. +# +# Single-command recipes (e.g. `cargo deny check`) are plain just +# recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# IMPORTANT: [script(...)] requires `set unstable`. The adopter's +# root justfile must declare it (typically as a top-level line). +# anvil's mod.just does NOT redeclare it, to avoid conflicting +# with adopters who already have it. + +import 'checks.just' +import 'groups.just' +import 'tiers.just' +import 'tools.just' +import 'versions.just' + +# Friendly default: `just anvil` runs the PR tier. +alias anvil := anvil-pr diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tiers.just b/crates/cargo-anvil/templates/justfiles/anvil/tiers.just new file mode 100644 index 00000000..7fcf378a --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/tiers.just @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the tier structure. + +# PR tier: every check that should run on every pull request, split +# into two groups so the fast checks aren't blocked behind the slow +# ones in cloud workflows. +[group("anvil")] +anvil-pr: \ + anvil-pr-fast \ + anvil-pr-slow + +# scheduled tier: full-workspace re-runs of things that can change +# without a commit (advisories, flakes) + the truly expensive +# exhaustive checks that don't fit in a PR budget. Runs on a schedule +# against `main`, not on PRs. +[group("anvil")] +anvil-scheduled: \ + anvil-scheduled-test \ + anvil-scheduled-advisories \ + anvil-scheduled-exhaustive + +# Full tier: PR + scheduled, end-to-end. Useful before tagging a release. +[group("anvil")] +anvil-full: \ + anvil-pr \ + anvil-scheduled + +# Tier-level + global setup + validate-prereqs +# =========================================================================== +# +# Per-tier recipes that fan out to per-group setup/validate-prereqs from +# groups.just. The global `anvil-setup` / `anvil-validate-prereqs` +# recipes are the catch-all entry points that install / verify everything. +# +# Cloud workflows typically invoke only the per-group setup it needs (e.g. the +# `anvil-pr-fast` composite action / step template runs +# `anvil-pr-fast-setup` rather than the global `anvil-setup`). +# Local users who want "install everything" run `just anvil-setup`. + +[group("anvil-setup")] +anvil-pr-setup installer="install": \ + (anvil-pr-fast-setup installer) \ + (anvil-pr-slow-setup installer) + +[group("anvil-setup")] +anvil-pr-validate-prereqs: \ + anvil-pr-fast-validate-prereqs \ + anvil-pr-slow-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-setup installer="install": \ + (anvil-scheduled-test-setup installer) \ + (anvil-scheduled-advisories-setup installer) \ + (anvil-scheduled-exhaustive-setup installer) + +[group("anvil-setup")] +anvil-scheduled-validate-prereqs: \ + anvil-scheduled-test-validate-prereqs \ + anvil-scheduled-advisories-validate-prereqs \ + anvil-scheduled-exhaustive-validate-prereqs + +[group("anvil-setup")] +anvil-full-setup installer="install": \ + (anvil-pr-setup installer) \ + (anvil-scheduled-setup installer) + +[group("anvil-setup")] +anvil-full-validate-prereqs: \ + anvil-pr-validate-prereqs \ + anvil-scheduled-validate-prereqs + +# Global aliases. `anvil-setup` (no suffix) installs everything the +# catalog knows about; `anvil-validate-prereqs` verifies every tool +# and component is present at or above its pinned version. +[group("anvil-setup")] +anvil-setup installer="install": (anvil-full-setup installer) + +[group("anvil-setup")] +anvil-validate-prereqs: anvil-full-validate-prereqs \ No newline at end of file diff --git a/crates/cargo-anvil/templates/justfiles/anvil/tools.just b/crates/cargo-anvil/templates/justfiles/anvil/tools.just new file mode 100644 index 00000000..dc2fa058 --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/tools.just @@ -0,0 +1,481 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/local.md for the policy. + +# ============================================================================ +# System-level prerequisites (libclang, etc.) +# ============================================================================ + +# Public: probe for system-level prerequisites that catalog tools need +# to BUILD from source. The `binstall` install path downloads pre-built +# binaries and does not need these, so this check is primarily relevant +# for the source-build `install` path (used by local devs and the ADO +# backend) and is best-effort (skipped silently) for `binstall`. +# +# Scope policy: only system libs that an anvil catalog tool DIRECTLY +# requires. We do not try to be a general-purpose dev-env doctor. +# Current entries: +# - libclang: required by cargo-spellcheck (clang-sys / hunspell-rs) +# at build time. +# +# Detection uses presence-only probes (file existence in standard install +# dirs + `LIBCLANG_PATH` env var). No version checks -- system libs upgrade +# independently and any reasonably modern libclang works for clang-sys. +# +# On missing deps the recipe prints copy-paste install hints per OS / +# package manager and exits non-zero. No auto-install: admin / sudo and +# package-manager choice stay with the user. +[group("anvil-setup")] +[script("pwsh")] +anvil-system-deps-check: + $ErrorActionPreference = 'Stop' + $missing = @() + + # libclang: required to BUILD cargo-spellcheck from source. + $haveLibclang = $false + $libclangFiles = @('libclang.dll', 'libclang.so', 'libclang.so.1', 'libclang.dylib') + if ($env:LIBCLANG_PATH) { + foreach ($f in $libclangFiles) { + if (Test-Path (Join-Path $env:LIBCLANG_PATH $f)) { $haveLibclang = $true; break } + } + } + if (-not $haveLibclang) { + $probes = if ($IsWindows) { + @( + 'C:\Program Files\LLVM\bin\libclang.dll', + "$env:USERPROFILE\scoop\apps\llvm\current\bin\libclang.dll" + ) + } elseif ($IsMacOS) { + @( + '/usr/local/opt/llvm/lib/libclang.dylib', + '/opt/homebrew/opt/llvm/lib/libclang.dylib' + ) + } else { + @( + '/usr/lib/x86_64-linux-gnu/libclang.so.1', + '/usr/lib/aarch64-linux-gnu/libclang.so.1', + '/usr/lib64/libclang.so', + '/usr/lib64/libclang.so.1' + ) + } + foreach ($p in $probes) { + if (Get-Item -LiteralPath $p -ErrorAction SilentlyContinue) { $haveLibclang = $true; break } + } + # Linux distros often add a version suffix (libclang-19.so etc.); glob fallback. + if (-not $haveLibclang -and -not $IsWindows -and -not $IsMacOS) { + $glob = Get-ChildItem -Path '/usr/lib','/usr/lib64','/usr/lib/x86_64-linux-gnu','/usr/lib/aarch64-linux-gnu' -Filter 'libclang*.so*' -ErrorAction SilentlyContinue + if ($glob) { $haveLibclang = $true } + } + } + if (-not $haveLibclang) { + $missing += [pscustomobject]@{ + name = 'libclang' + why = 'cargo-spellcheck (clang-sys/hunspell-rs) needs libclang at build time' + hints = @( + 'Linux (Ubuntu/Debian): sudo apt-get install -y libclang-dev' + 'Linux (Azure Linux/RHEL): sudo tdnf install -y clang-devel' + 'macOS (homebrew): brew install llvm' + 'Windows (scoop): scoop install llvm # no admin' + 'Windows (winget, admin): winget install LLVM.LLVM' + ) + } + } + + if ($missing.Count -gt 0) { + Write-Host '' + foreach ($m in $missing) { + Write-Host "anvil: missing system dependency '$($m.name)'" -ForegroundColor Yellow + Write-Host " why: $($m.why)" + Write-Host ' install:' + foreach ($h in $m.hints) { Write-Host " $h" } + Write-Host '' + } + Write-Host "If the dep is installed but not on the standard search path, set LIBCLANG_PATH and re-run." -ForegroundColor Yellow + exit 1 + } + +# ============================================================================ +# rustc + pwsh validate-prereqs (no install -- system prereqs) +# ============================================================================ +# +# rustc is installed via rustup (https://rustup.rs); pwsh is installed +# via the platform's package manager. Both are presumed present on any +# machine running anvil; the validate recipes just surface a friendly +# error if not. + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-rustc-validate-prereqs: + if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Error 'anvil: rustc not found. Install via rustup: https://rustup.rs' + exit 1 + } + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-pwsh-validate-prereqs: + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $hint = if ($IsMacOS) { + 'brew install --cask powershell' + } elseif ($IsLinux) { + 'see https://github.com/PowerShell/PowerShell' + } else { + 'winget install --id Microsoft.PowerShell' + } + Write-Error "anvil: pwsh (PowerShell Core) not found. Install: $hint" + exit 1 + } + +# ============================================================================ +# Private helpers (install/check primitives) +# ============================================================================ + +# _install-tool: install a cargo subcommand at exactly the pinned version, +# or no-op if it is already installed at or above that version. The +# `installer` parameter selects between: +# - "install" (cargo install --locked, pure-source). Default. +# - "binstall" (cargo binstall --no-confirm --locked, with cargo install +# fallback if binstall fails). Bootstraps cargo-binstall +# itself if not on PATH. +[script("pwsh")] +_install-tool name version installer: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + $installer = '{{installer}}' + + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool: unknown installer '$installer' (expected 'install' or 'binstall')" + exit 2 + } + + # Already at or above the pin: skip. We don't downgrade tools the + # user upgraded for their own reasons; the validate side uses + # `installed >= pin`, so newer is fine. The early-exit is also what + # makes the actions/cache restore actually useful -- without it, + # every post-restore run would try to re-install on top of the + # cached binaries and fail with "binary already exists in destination". + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if ($installed) { + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. + } + } + + Write-Host "Installing $name =$version (installer: $installer)" + if ($installer -eq 'binstall') { + if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { + Write-Host ' Bootstrapping cargo-binstall' + cargo install --locked cargo-binstall + if ($LASTEXITCODE -ne 0) { + Write-Error 'cargo-binstall bootstrap failed' + exit $LASTEXITCODE + } + } + cargo binstall --no-confirm --locked $name --version "=$version" + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow + } + cargo install --locked $name --version "=$version" + if ($LASTEXITCODE -ne 0) { + Write-Error "$name install FAILED" + exit $LASTEXITCODE + } + +# _check-tool: verify a cargo subcommand is installed at or above the +# pinned version. Errors with an install hint on missing or too-old. +[script("pwsh")] +_check-tool name version: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if (-not $installed) { + Write-Error "anvil: required tool '$name' not found. Install: cargo install --locked --version =$version $name" + exit 1 + } + try { + $installedV = [version]$installed + $pinV = [version]$version + } catch { + Write-Error "anvil: cannot compare versions for '$name' (installed=$installed pin=$version). Reinstall: cargo install --locked --version =$version $name" + exit 1 + } + if ($installedV -lt $pinV) { + Write-Error "anvil: '$name' v$installed is older than the required minimum v$version. Upgrade: cargo install --locked --version =$version $name" + exit 1 + } + +# _install-toolchain: install a specific rustup toolchain (no components). +[script("pwsh")] +_install-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + Write-Host "rustup toolchain install $toolchain --profile minimal --no-self-update" + rustup toolchain install $toolchain --profile minimal --no-self-update + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-toolchain: verify a rustup toolchain is installed. +[script("pwsh")] +_check-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + rustup which --toolchain $toolchain rustc 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + +# _install-component: add a component to a toolchain. The toolchain is +# either the literal "default" (current rustup default) or a specific +# pinned toolchain string (which must already be installed -- the +# per-component setup recipes ensure this via a dependency on +# anvil--install). +[script("pwsh")] +_install-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + if ($toolchain -eq 'default') { + Write-Host "rustup component add $component (default toolchain)" + rustup component add $component + } else { + Write-Host "rustup component add --toolchain $toolchain $component" + rustup component add --toolchain $toolchain $component + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install component '$component' on toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-component: verify a component is installed on a toolchain. +[script("pwsh")] +_check-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + $output = if ($toolchain -eq 'default') { + rustup component list --installed 2>$null + } else { + rustup component list --installed --toolchain $toolchain 2>$null + } + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + $found = $output | Where-Object { $_ -like "$component*" } + if (-not $found) { + $cmd = if ($toolchain -eq 'default') { "rustup component add $component" } else { "rustup component add --toolchain $toolchain $component" } + Write-Error "anvil: component '$component' not installed on toolchain '$toolchain'. Run: $cmd" + exit 1 + } + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +[group("anvil-setup")] +anvil-toolchain-nightly-install: (_install-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-validate-prereqs: (_check-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-install: (_install-toolchain rust_nightly_external_types) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-validate-prereqs: (_check-toolchain rust_nightly_external_types) + +# ============================================================================ +# Rustup components +# ============================================================================ +# +# Default-toolchain components are installed via `rustup component add` +# (no toolchain spec). Nightly components depend on the toolchain +# being installed first (via the relevant anvil--install +# recipe) and then add the component. + +[group("anvil-setup")] +anvil-component-default-clippy-install: (_install-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-clippy-validate-prereqs: (_check-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-rustfmt-install: (_install-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-default-rustfmt-validate-prereqs: (_check-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-miri-install: anvil-toolchain-nightly-install (_install-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-miri-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rust-src") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rust-src") + +# ============================================================================ +# Cargo subcommands +# ============================================================================ +# +# One pair (install + validate-prereqs) per tool, alphabetical. +# Version pins live in versions.just (one cargo__version variable +# per tool). The install recipes accept an `installer="install"|"binstall"` +# parameter; the validate-prereqs recipes do not (they only read state). + +[group("anvil-setup")] +anvil-tool-cargo-aprz-install installer="install": (_install-tool "cargo-aprz" cargo_aprz_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-aprz-validate-prereqs: (_check-tool "cargo-aprz" cargo_aprz_version) + +[group("anvil-setup")] +anvil-tool-cargo-audit-install installer="install": (_install-tool "cargo-audit" cargo_audit_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-audit-validate-prereqs: (_check-tool "cargo-audit" cargo_audit_version) + +[group("anvil-setup")] +anvil-tool-cargo-careful-install installer="install": (_install-tool "cargo-careful" cargo_careful_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-careful-validate-prereqs: (_check-tool "cargo-careful" cargo_careful_version) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-install installer="install": (_install-tool "cargo-check-external-types" cargo_check_external_types_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-validate-prereqs: (_check-tool "cargo-check-external-types" cargo_check_external_types_version) + +[group("anvil-setup")] +anvil-tool-cargo-delta-install installer="install": (_install-tool "cargo-delta" cargo_delta_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-delta-validate-prereqs: (_check-tool "cargo-delta" cargo_delta_version) + +[group("anvil-setup")] +anvil-tool-cargo-deny-install installer="install": (_install-tool "cargo-deny" cargo_deny_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-deny-validate-prereqs: (_check-tool "cargo-deny" cargo_deny_version) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-doc2readme" cargo_doc2readme_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs: (_check-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-install installer="install": (_install-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-validate-prereqs: (_check-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version) + +[group("anvil-setup")] +anvil-tool-cargo-hack-install installer="install": (_install-tool "cargo-hack" cargo_hack_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-hack-validate-prereqs: (_check-tool "cargo-hack" cargo_hack_version) + +[group("anvil-setup")] +anvil-tool-cargo-heather-install installer="install": (_install-tool "cargo-heather" cargo_heather_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-heather-validate-prereqs: (_check-tool "cargo-heather" cargo_heather_version) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-install installer="install": (_install-tool "cargo-llvm-cov" cargo_llvm_cov_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-validate-prereqs: (_check-tool "cargo-llvm-cov" cargo_llvm_cov_version) + +# cargo-mutants doesn't build on aarch64-pc-windows-msvc (upstream +# winapi crate incompat). The install recipe self-skips on that target +# so per-group setup recipes that depend on it (pr-mutants-setup, +# scheduled-exhaustive-setup) don't fail on that platform. The +# mutants-diff / mutants-full check recipes also self-skip on the same +# target, so the check is effectively a no-op end-to-end there. +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-install installer="install": + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-install: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _install-tool cargo-mutants {{cargo_mutants_version}} {{installer}} + exit $LASTEXITCODE + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-validate-prereqs: + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-validate-prereqs: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _check-tool cargo-mutants {{cargo_mutants_version}} + exit $LASTEXITCODE + +[group("anvil-setup")] +anvil-tool-cargo-nextest-install installer="install": (_install-tool "cargo-nextest" cargo_nextest_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-nextest-validate-prereqs: (_check-tool "cargo-nextest" cargo_nextest_version) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-install installer="install": (_install-tool "cargo-semver-checks" cargo_semver_checks_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-validate-prereqs: (_check-tool "cargo-semver-checks" cargo_semver_checks_version) + +[group("anvil-setup")] +anvil-tool-cargo-sort-install installer="install": (_install-tool "cargo-sort" cargo_sort_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-sort-validate-prereqs: (_check-tool "cargo-sort" cargo_sort_version) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-install installer="install": (_install-tool "cargo-spellcheck" cargo_spellcheck_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-validate-prereqs: (_check-tool "cargo-spellcheck" cargo_spellcheck_version) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-install installer="install": (_install-tool "cargo-udeps" cargo_udeps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_version) diff --git a/crates/cargo-anvil/templates/justfiles/anvil/versions.just b/crates/cargo-anvil/templates/justfiles/anvil/versions.just new file mode 100644 index 00000000..730c3059 --- /dev/null +++ b/crates/cargo-anvil/templates/justfiles/anvil/versions.just @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Pinned versions used by the anvil check tree. +# +# Everything anvil installs (cargo subcommands + rustup toolchains) +# is pinned here. The pinning policy: +# - On install (`-install` recipes): exactly this version (`=` for +# cargo subcommands, exact ref for rustup toolchains). Pulling +# "latest-matching" at install time is a cloud-workflow reproducibility risk -- +# an upstream release between yesterday's green build and today's +# PR can break things (cargo-spellcheck 0.15.7's em-dash regression +# is the canonical case). The `=` constraint locks the install to +# the version the catalog was validated against. +# - On validate-prereqs (`-validate-prereqs` recipes): the installed +# version must be `>= `. A user who has manually upgraded a +# tool for their own reasons (e.g. needing an unreleased bugfix) +# is not downgraded by setup. The validate gate uses +# `installed >= pin`, so newer is fine. +# +# To bump a pin, edit the version in place and re-run +# `cargo anvil`. The dirty-file flow preserves the edit on +# subsequent runs. To add a new tool, append a variable and the matching +# `-install`/`-validate-prereqs` pair in `tools.just`. To remove a tool, +# delete its variable and recipes (and any check that depends on them). + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +# General nightly used by udeps, miri, careful, and any future +# nightly-dependent check. Bumped on a regular cadence (monthly is a +# reasonable default) when an adopter has time to absorb formatting / +# lint / API-surface drift. Pin only -- do not use bare `nightly` here. +rust_nightly := "nightly-2026-02-10" + +# Pinned narrowly to the rustdoc JSON schema version that the currently +# selected cargo-check-external-types release accepts. cargo-check- +# external-types embeds a specific rustdoc-types crate version; if the +# nightly's emitted JSON format_version drifts past it, every run fails +# with "produces JSON format version X, but this tool requires +# format version Y" -- which is a tooling-incompat, not a real API +# violation. Bump this alongside any cargo-check-external-types upgrade. +rust_nightly_external_types := "nightly-2025-10-18" + +# ============================================================================ +# Cargo subcommands +# ============================================================================ + +cargo_aprz_version := "1.0.0" +cargo_audit_version := "0.22.2" +cargo_careful_version := "0.4.9" +cargo_check_external_types_version := "0.4.0" +cargo_delta_version := "0.3.1" +cargo_deny_version := "0.19.0" +cargo_doc2readme_version := "0.6.4" +cargo_ensure_no_cyclic_deps_version := "0.2.0" +cargo_ensure_no_default_features_version := "1.0.0" +cargo_hack_version := "0.6.41" +cargo_heather_version := "0.2.1" +cargo_llvm_cov_version := "0.8.4" +cargo_mutants_version := "26.1.2" +cargo_nextest_version := "0.9.122" +cargo_semver_checks_version := "0.46.0" +cargo_sort_version := "2.0.2" +cargo_spellcheck_version := "0.15.7" +cargo_udeps_version := "0.1.60" diff --git a/crates/cargo-anvil/templates/regions/cargo-lints-body.toml b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml new file mode 100644 index 00000000..3b412dde --- /dev/null +++ b/crates/cargo-anvil/templates/regions/cargo-lints-body.toml @@ -0,0 +1,81 @@ +# Catalog of opinionated lints, in dotted-key form so users can extend the +# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. +# The host-specific table header (`[workspace.lints]` or `[lints]`) is +# prepended by cargo-anvil based on whether the manifest is a workspace +# root or a single-crate Cargo.toml. + +# --- rust ------------------------------------------------------------------ +rust.ambiguous_negative_literals = "warn" +rust.missing_debug_implementations = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.trivial_numeric_casts = "warn" +rust.unsafe_op_in_unsafe_fn = "warn" +rust.unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# line (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +rust.unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', +] } + +# --- rustdoc --------------------------------------------------------------- +rustdoc.broken_intra_doc_links = "warn" +rustdoc.missing_crate_level_docs = "warn" +rustdoc.unescaped_backticks = "warn" + +# --- clippy: category gates (priority -1 so per-lint allows can override) -- +clippy.cargo = { level = "warn", priority = -1 } +clippy.complexity = { level = "warn", priority = -1 } +clippy.correctness = { level = "warn", priority = -1 } +clippy.nursery = { level = "warn", priority = -1 } +clippy.pedantic = { level = "warn", priority = -1 } +clippy.perf = { level = "warn", priority = -1 } +clippy.style = { level = "warn", priority = -1 } +clippy.suspicious = { level = "warn", priority = -1 } + +# --- clippy: opinionated additions ----------------------------------------- +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +clippy.allow_attributes = "warn" +clippy.allow_attributes_without_reason = "warn" +clippy.as_pointer_underscore = "warn" +clippy.assertions_on_result_states = "warn" +clippy.clone_on_ref_ptr = "warn" +clippy.deref_by_slicing = "warn" +clippy.disallowed_script_idents = "warn" +clippy.empty_drop = "warn" +clippy.empty_enum_variants_with_brackets = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.if_then_some_else_none = "warn" +clippy.map_err_ignore = "warn" +clippy.multiple_unsafe_ops_per_block = "warn" +clippy.redundant_type_annotations = "warn" +clippy.renamed_function_params = "warn" +clippy.semicolon_outside_block = "warn" +clippy.undocumented_unsafe_blocks = "warn" +clippy.unnecessary_safety_comment = "warn" +clippy.unnecessary_safety_doc = "warn" +clippy.unneeded_field_pattern = "warn" +clippy.unused_result_ok = "warn" +clippy.unwrap_used = "warn" + +# --- clippy: opinionated suppressions of category-enabled lints ------------ +clippy.missing_const_for_fn = "allow" +clippy.multiple_crate_versions = "allow" +clippy.option_if_let_else = "allow" +clippy.redundant_pub_crate = "allow" +clippy.should_panic_without_expect = "allow" +clippy.significant_drop_tightening = "allow" +# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 +clippy.wildcard_imports = "allow" + diff --git a/crates/cargo-anvil/templates/regions/cargo-member-lints.toml b/crates/cargo-anvil/templates/regions/cargo-member-lints.toml new file mode 100644 index 00000000..2930a79e --- /dev/null +++ b/crates/cargo-anvil/templates/regions/cargo-member-lints.toml @@ -0,0 +1,2 @@ +[lints] +workspace = true diff --git a/crates/cargo-anvil/templates/regions/clippy.toml b/crates/cargo-anvil/templates/regions/clippy.toml new file mode 100644 index 00000000..8d5be624 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/clippy.toml @@ -0,0 +1,31 @@ +# Fine-tuning settings for clippy lints. These cannot be expressed in +# Cargo.toml's [lints] table (which only carries level: warn/allow/deny); +# they configure lint *behavior* and live in clippy.toml only. + +# Absolute paths up to 3 segments are clarifying — e.g. `std::sync::Mutex` +# vs `tokio::sync::Mutex` disambiguates the source. Beyond 3 segments +# we prefer imports or aliases for readability. +absolute-paths-max-segments = 3 + +# Workspace code is internal. Clippy should suggest the most correct +# fix without worrying about non-breaking-change rules, which only +# matter for published library APIs. +avoid-breaking-exported-api = false + +# Required companion for the clippy.semicolon_outside_block lint we +# ship in the catalog. Without this, the lint fires on multiline-block +# forms that are common Rust style. +semicolon-outside-block-ignore-multiline = true + +# Required companions for the clippy.unwrap_used lint we ship. +# Test code asserts via unwrap()/panic!() — that's how #[test] reports +# failure. Without these, every test triggers the lint. +allow-panic-in-tests = true +allow-unwrap-in-tests = true + +# Aspirational: when clippy.wildcard_imports is re-enabled (currently +# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# we want the stricter variant that warns on ALL wildcard imports +# including prelude. Setting it now means flipping the lint level to +# warn later is a one-line change with no tuning afterthought. +warn-on-all-wildcard-imports = true diff --git a/crates/cargo-anvil/templates/regions/delta.toml b/crates/cargo-anvil/templates/regions/delta.toml new file mode 100644 index 00000000..64f8e163 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/delta.toml @@ -0,0 +1,8 @@ +[delta] +# Include the workspace root files that should invalidate every member's +# impact analysis when changed (lockfile, root manifest, toolchain). +root-files = [ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", +] diff --git a/crates/cargo-anvil/templates/regions/deny.toml b/crates/cargo-anvil/templates/regions/deny.toml new file mode 100644 index 00000000..9478a796 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/deny.toml @@ -0,0 +1,28 @@ +[advisories] +yanked = "deny" +# Scope of unmaintained-crate checks: "all" surfaces transitive +# dependencies too; tighten to "workspace" if the noise is high. +unmaintained = "all" + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.93 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/crates/cargo-anvil/templates/regions/justfile-imports.just b/crates/cargo-anvil/templates/regions/justfile-imports.just new file mode 100644 index 00000000..46447ca5 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/justfile-imports.just @@ -0,0 +1 @@ +import 'justfiles/anvil/mod.just' diff --git a/crates/cargo-anvil/templates/regions/rustfmt.toml b/crates/cargo-anvil/templates/regions/rustfmt.toml new file mode 100644 index 00000000..e72703e7 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/rustfmt.toml @@ -0,0 +1,18 @@ +edition = "2024" +max_width = 140 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +# The following options require nightly rustfmt. anvil-fmt invokes +# `cargo +{{ rust_nightly }} fmt`; see justfiles/anvil/versions.just +# for the pin and docs/design/local.md#nightly-pinning for the policy. +unstable_features = true +# Format Rust code blocks inside `///` doc comments. Catches stale +# examples that drift from the prose. +format_code_in_doc_comments = true +# One use-statement per module (vs collapsed `a::{b, c, d}` form). +# Diffs touch only the lines that actually changed. +imports_granularity = "Module" +# Group imports: std, then external crates, then crate-internal. Matches +# the convention used across the surveyed Microsoft Rust repos. +group_imports = "StdExternalCrate" diff --git a/crates/cargo-anvil/templates/regions/spellcheck.toml b/crates/cargo-anvil/templates/regions/spellcheck.toml new file mode 100644 index 00000000..a1c69b88 --- /dev/null +++ b/crates/cargo-anvil/templates/regions/spellcheck.toml @@ -0,0 +1,61 @@ +# Check spelling in code comments marked as dev/developer comments +# (e.g., `// TODO:`, `// FIXME:`). Set to false to skip them. +dev_comments = false + +# Whether to skip spell checking README files. Set to false to include +# README files in spell checking. +skip_readme = false + +[Hunspell] +# Language dictionary. "en_US" uses the built-in English (US) dictionary. +lang = "en_US" + +# Directories searched for `extra_dictionaries` paths. The default +# repo-root entry lets adopters keep their custom dictionary next to +# the .spelling source. +search_dirs = ["."] + +# Additional dictionary files loaded after the language dictionary. +# Format: first line is the word count, remaining lines are sorted +# words (one per line). `target/spelling.dic` is generated by the +# `anvil-spellcheck` recipe from the repo's `.spelling` file. +extra_dictionaries = ["target/spelling.dic"] + +# Don't consult OS-provided dictionaries. Keeps results consistent +# across Linux/macOS/Windows runners. +skip_os_lookups = true + +# Use cargo-spellcheck's built-in language dictionaries (independent of +# system hunspell installation). Required for the cross-platform +# reproducibility guarantee above. +use_builtin = true + +# Token-boundary characters. Override the upstream default to add +# typographic punctuation we use in prose (em-dash, en-dash, arrows, +# minus sign). Without these, cargo-spellcheck 0.15.7 tokenises text +# like `runtime — it` as three tokens including the em-dash itself, +# then fails its dictionary lookup and flags the em-dash as a +# "possible spelling mistake". Upstream default keeps figure-dash +# (U+2012) and the ASCII hyphen but omits the rest; this list is a +# superset, so the only behavioural change is that the added chars +# now act as token boundaries. +# +# Encoded as \uXXXX escapes for grep-ability and to keep the file +# 7-bit ASCII: +# ASCII punctuation (default): ",;:.!?#(){}[]|/_- +# Dashes & minus : \u2012 figure-dash (default) +# \u2013 en-dash (added) +# \u2014 em-dash (added) +# \u2015 horizontal-bar (added) +# \u2212 minus-sign (added) +# Arrows : \u2190 leftwards-arrow (added) +# \u2192 rightwards-arrow (added) +# ASCII punctuation (default): ' ` & @ +# Misc (default) : \u00A7 section, \u00B6 pilcrow, \u2026 ellipsis +tokenization_splitchars = "\",;:.!?#(){}[]|/_-\u2012\u2013\u2014\u2015\u2190\u2192\u2212'`&@\u00A7\u00B6\u2026" + +[Hunspell.quirks] +# Treat CamelCase identifiers as concatenations of dictionary words +# (e.g., `TcpStream` = `Tcp` + `Stream`). Lowers false-positive rate +# substantially on Rust codebases. +allow_concatenation = true diff --git a/crates/cargo-anvil/tests/fixtures/customized/Cargo.toml b/crates/cargo-anvil/tests/fixtures/customized/Cargo.toml new file mode 100644 index 00000000..a848b85b --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/customized/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["crates/*"] diff --git a/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/Cargo.toml b/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/Cargo.toml new file mode 100644 index 00000000..aefa864a --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" diff --git a/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/src/lib.rs b/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/src/lib.rs new file mode 100644 index 00000000..22bde8d0 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/customized/crates/alpha/src/lib.rs @@ -0,0 +1 @@ +// stub diff --git a/crates/cargo-anvil/tests/fixtures/migration/Cargo.toml b/crates/cargo-anvil/tests/fixtures/migration/Cargo.toml new file mode 100644 index 00000000..ad126206 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/migration/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +edition = "2024" + +# Pre-existing user customization that anvil must not touch. +[profile.release] +lto = "thin" +codegen-units = 1 diff --git a/crates/cargo-anvil/tests/fixtures/migration/Justfile b/crates/cargo-anvil/tests/fixtures/migration/Justfile new file mode 100644 index 00000000..b2b1e92e --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/migration/Justfile @@ -0,0 +1,8 @@ +# Pre-existing user Justfile. Ox-check should splice its imports +# region without touching these recipes. + +default: + @echo "user default recipe" + +my-custom-recipe: + @echo "user content preserved" diff --git a/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/Cargo.toml b/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/Cargo.toml new file mode 100644 index 00000000..42c9fd53 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true diff --git a/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/src/lib.rs b/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/src/lib.rs new file mode 100644 index 00000000..22bde8d0 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/migration/crates/alpha/src/lib.rs @@ -0,0 +1 @@ +// stub diff --git a/crates/cargo-anvil/tests/fixtures/migration/deny.toml b/crates/cargo-anvil/tests/fixtures/migration/deny.toml new file mode 100644 index 00000000..3c22232e --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/migration/deny.toml @@ -0,0 +1,5 @@ +# Pre-existing user deny.toml. Ox-check should splice its managed +# region without dropping these user-authored entries. + +[advisories] +ignore = ["RUSTSEC-9999-0001"] diff --git a/crates/cargo-anvil/tests/fixtures/opt-outs/Cargo.toml b/crates/cargo-anvil/tests/fixtures/opt-outs/Cargo.toml new file mode 100644 index 00000000..a848b85b --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/opt-outs/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +resolver = "2" +members = ["crates/*"] diff --git a/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/Cargo.toml b/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/Cargo.toml new file mode 100644 index 00000000..aefa864a --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" diff --git a/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/src/lib.rs b/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/src/lib.rs new file mode 100644 index 00000000..22bde8d0 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/opt-outs/crates/alpha/src/lib.rs @@ -0,0 +1 @@ +// stub diff --git a/crates/cargo-anvil/tests/fixtures/single-crate/Cargo.toml b/crates/cargo-anvil/tests/fixtures/single-crate/Cargo.toml new file mode 100644 index 00000000..1cc03faa --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/single-crate/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "single-crate-fixture" +version = "0.1.0" +edition = "2024" diff --git a/crates/cargo-anvil/tests/fixtures/single-crate/src/lib.rs b/crates/cargo-anvil/tests/fixtures/single-crate/src/lib.rs new file mode 100644 index 00000000..22bde8d0 --- /dev/null +++ b/crates/cargo-anvil/tests/fixtures/single-crate/src/lib.rs @@ -0,0 +1 @@ +// stub diff --git a/crates/cargo-anvil/tests/schemas.rs b/crates/cargo-anvil/tests/schemas.rs new file mode 100644 index 00000000..edb102f2 --- /dev/null +++ b/crates/cargo-anvil/tests/schemas.rs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) +#![allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" +)] + +//! Schema validation for emitted files. +//! +//! Each test generates the full output of `cargo anvil` for a +//! particular backend into a tempdir, then runs an external schema +//! validator (`actionlint`, `taplo`, `just`) over the relevant files. +//! +//! If the validator isn't installed, the test is skipped — never failed. +//! in cloud workflows we enforce installation via the `anvil-tools-install` recipe; +//! locally the test suite degrades gracefully. +//! +//! See `crates/cargo-anvil/docs/verification.md` for the +//! schema-validation strategy. + +#![expect( + clippy::unwrap_used, + clippy::panic, + reason = "integration tests favor concise assertions over Result plumbing" +)] + +use std::path::Path; +use std::process::{Command, Output}; + +use cargo_anvil::cli::Cli; +use cargo_anvil::run::run_update; +use tempfile::TempDir; + +fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +fn empty_workspace() -> TempDir { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n", + ); + write( + &root.join("crates/alpha/Cargo.toml"), + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + write(&root.join("crates/alpha/src/lib.rs"), ""); + tmp +} + +fn run_with_backend(backend: &str) -> TempDir { + let tmp = empty_workspace(); + let args = Cli { + backends: vec![backend.to_owned()], + no_backends: false, + dry_run: false, + }; + run_update(&args, tmp.path()).unwrap(); + tmp +} + +fn try_run(cmd: &mut Command) -> Option { + match cmd.output() { + Ok(o) => Some(o), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => panic!("unexpected error spawning validator: {e}"), + } +} + +#[test] +fn taplo_validates_emitted_toml_files() { + let tmp = run_with_backend("github"); + let mut cmd = Command::new("taplo"); + cmd.args(["check", "Cargo.toml", "deny.toml", "rustfmt.toml", ".delta.toml", ".anvil.lock"]) + .current_dir(tmp.path()); + + let Some(out) = try_run(&mut cmd) else { + eprintln!("skipping: taplo not installed"); + return; + }; + assert!( + out.status.success(), + "taplo rejected emitted TOML:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn actionlint_validates_emitted_workflows() { + let tmp = run_with_backend("github"); + let mut cmd = Command::new("actionlint"); + cmd.current_dir(tmp.path()); + let Some(out) = try_run(&mut cmd) else { + eprintln!("skipping: actionlint not installed"); + return; + }; + assert!( + out.status.success(), + "actionlint rejected emitted workflows:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn just_lists_emitted_recipes() { + let tmp = run_with_backend("github"); + let mut cmd = Command::new("just"); + cmd.args(["--justfile", "Justfile", "--list"]).current_dir(tmp.path()); + let Some(out) = try_run(&mut cmd) else { + eprintln!("skipping: just not installed"); + return; + }; + assert!( + out.status.success(), + "just --list failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let listing = String::from_utf8_lossy(&out.stdout); + for expected in ["anvil", "anvil-pr", "anvil-pr-fast", "anvil-scheduled", "anvil-clippy"] { + assert!( + listing.contains(expected), + "`just --list` did not contain recipe '{expected}':\n{listing}" + ); + } +} + +#[test] +fn ado_yaml_emitted_files_have_consistent_indent() { + let tmp = run_with_backend("ado"); + let root = tmp.path().join(".pipelines"); + let mut count = 0_usize; + for entry in walkdir::WalkDir::new(&root) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file() && e.path().extension().is_some_and(|s| s == "yml")) + { + count += 1; + let text = std::fs::read_to_string(entry.path()).unwrap(); + assert!(!text.contains('\t'), "tab indentation in {}", entry.path().display()); + for line in text.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + let indent = line.len() - line.trim_start_matches(' ').len(); + assert_eq!( + indent % 2, + 0, + "non-aligned indent ({indent} spaces) in {}: {line}", + entry.path().display() + ); + } + } + assert!(count >= 11, "expected at least 11 emitted .pipelines yml files, got {count}"); +} diff --git a/crates/cargo-anvil/tests/snapshots.rs b/crates/cargo-anvil/tests/snapshots.rs new file mode 100644 index 00000000..89ede644 --- /dev/null +++ b/crates/cargo-anvil/tests/snapshots.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) +#![allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" +)] + +//! Snapshot tests for the full emitted file tree. +//! +//! For a small set of representative input combinations, run +//! `cargo anvil` against a bare-workspace tempdir, collect +//! every file anvil produced (sorted by path), and snapshot the +//! whole tree as one string. Snapshots live under `tests/snapshots/` +//! and are reviewed via `cargo insta review`. +//! +//! Coverage rationale: the imperative tests in `src/run.rs` pin the +//! algorithm (which decisions are taken, which paths exist); these +//! snapshot tests pin the *byte-exact emitted content* so template +//! edits surface as reviewable diffs. The two layers are +//! complementary — neither subsumes the other. + +#![expect(clippy::unwrap_used, reason = "integration tests favor concise assertions over Result plumbing")] + +use std::path::{Path, PathBuf}; + +use cargo_anvil::cli::Cli; +use cargo_anvil::manifest::MANIFEST_FILE_NAME; +use cargo_anvil::run::run_update; +use tempfile::TempDir; + +fn write(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, contents).unwrap(); +} + +/// Bare workspace fixture: one root manifest + one member crate, with +/// nothing else in the tree. Everything anvil produces is therefore +/// strictly its own output. +fn bare_workspace() -> TempDir { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + write( + &root.join("Cargo.toml"), + "[workspace]\nresolver = \"2\"\nmembers = [\"crates/*\"]\n", + ); + write( + &root.join("crates/alpha/Cargo.toml"), + "[package]\nname = \"alpha\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ); + write(&root.join("crates/alpha/src/lib.rs"), ""); + tmp +} + +/// Walk the workspace, collect every file produced or modified by +/// anvil, and render them into a single deterministic string. +/// +/// The manifest (`.anvil.lock`) is filtered out: it carries the +/// `rendered_by` version which would churn on every crate-version bump, +/// drowning the actual content review in noise. The schema-validation +/// test suite already asserts the manifest is valid TOML. +fn render_tree(root: &Path) -> String { + let mut paths: Vec = walkdir::WalkDir::new(root) + .into_iter() + .filter_map(Result::ok) + .filter(|e| e.file_type().is_file()) + .map(walkdir::DirEntry::into_path) + .filter(|p| p.file_name().and_then(|n| n.to_str()) != Some(MANIFEST_FILE_NAME)) + .collect(); + paths.sort(); + + let mut out = String::new(); + for path in paths { + let rel = path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"); + let body = std::fs::read_to_string(&path).unwrap(); + out.push_str("=== "); + out.push_str(&rel); + out.push_str(" ===\n"); + out.push_str(&body); + if !body.ends_with('\n') { + out.push('\n'); + } + out.push('\n'); + } + out +} + +fn run(args: &Cli, tmp: &TempDir) { + run_update(args, tmp.path()).unwrap(); +} + +#[test] +fn local_only_tree() { + let tmp = bare_workspace(); + run( + &Cli { + backends: vec![], + no_backends: true, + dry_run: false, + }, + &tmp, + ); + insta::assert_snapshot!("local_only", render_tree(tmp.path())); +} + +#[test] +fn github_backend_tree() { + let tmp = bare_workspace(); + run( + &Cli { + backends: vec!["github".to_owned()], + no_backends: false, + dry_run: false, + }, + &tmp, + ); + insta::assert_snapshot!("github_backend", render_tree(tmp.path())); +} + +#[test] +fn ado_backend_tree() { + let tmp = bare_workspace(); + run( + &Cli { + backends: vec!["ado".to_owned()], + no_backends: false, + dry_run: false, + }, + &tmp, + ); + insta::assert_snapshot!("ado_backend", render_tree(tmp.path())); +} diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap new file mode 100644 index 00000000..02b1a7e9 --- /dev/null +++ b/crates/cargo-anvil/tests/snapshots/snapshots__ado_backend.snap @@ -0,0 +1,2973 @@ +--- +source: crates/cargo-anvil/tests/snapshots.rs +expression: render_tree(tmp.path()) +--- +=== .delta.toml === +# >>> anvil-managed: anvil-delta +[delta] +# Include the workspace root files that should invalidate every member's +# impact analysis when changed (lockfile, root manifest, toolchain). +root-files = [ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", +] +# <<< anvil-managed: anvil-delta + +=== .pipelines/anvil/pr.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Every job in this stages template is rendered through `steps/job.yml`. +# That wrapper is *user-customizable*: adopters who need to inject 1ESPT +# `templateContext:` blocks, custom SDL knobs, build-provenance attrs, etc. +# edit `steps/job.yml` once and anvil stops overwriting it. This stages +# template stays owned and continues to track new groups, dependsOn rewires, +# impact-output threading changes, etc. +# +# Template-path note: each entry in the `steps:` parameter below contains a +# `template:` reference. ADO resolves template paths relative to the file +# containing the `template:` keyword, which (for parameters defined at the +# call site) is *this* file. So paths like `steps/pr-fast.yml` are correct. +parameters: + - name: linuxPool + type: object + default: { vmImage: ubuntu-latest } + - name: windowsPool + type: object + default: { vmImage: windows-latest } + +stages: + # cargo-delta impact runs per OS so that downstream legs consume an + # impact set computed against THEIR host's cargo-metadata depgraph. + # Without this, an OS-conditional dep change (under + # `[target.'cfg(target_os = ...)'.dependencies]`) computed on Linux + # wouldn't include the cross-OS reverse-deps that only show up in + # the Windows depgraph, so the Windows leg could skip tests that + # ought to run. We pay for one impact stage per OS family; downstream + # stages select the right one per leg. + - stage: impact_linux + displayName: anvil impact (linux) + jobs: + - template: steps/job.yml + parameters: + name: compute + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/impact.yml + + - stage: impact_windows + displayName: anvil impact (windows) + jobs: + - template: steps/job.yml + parameters: + name: compute + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/impact.yml + + - stage: pr_fast + displayName: anvil pr-fast + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # Cross-OS because pr-fast contains compile-sensitive checks + # (clippy, doc-build, udeps, semver-check, external-types) whose + # results can differ across host OS for crates that use + # #[cfg(target_os = ...)] gating. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-fast.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + # Advisory PR comments. Recipes that surface non-blocking findings + # (e.g. cargo-semver-checks) write a markdown body to + # `target/anvil/comments/.md` and exit 0; this step turns + # presence/absence of those files into upserts/closures of a + # sticky PR comment via the ADO REST API. Runs on the canonical + # Linux leg only so the Linux/Windows matrix doesn't race on the + # same thread. Skipped (silently) when the build identity lacks + # "Contribute to pull requests" so adopters who haven't opted in + # to write permissions aren't broken. + - template: steps/advisory-comments.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-fast.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + + # The PR-tier slow checks are split into three independent stages + # (pr_test, pr_runtime_analysis, pr_mutants) so they run in parallel rather + # than sequentially in one job. Each stage owns its own dependsOn + # link to the impact stages and its own variable hookup. ADO has + # no hosted ARM agents, so the matrix stays Linux + Windows x86_64 + # across all three stages. + + - stage: pr_test + displayName: anvil pr-test (tests + coverage) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # pr-test runs llvm-cov / doc-test / examples. Coverage publish + # stays as a sibling task on each OS job -- it's a results + # publish (not a pipeline artifact), so 1ESPT permits it at step + # level and we don't need to route it through `artifacts:`. + # cobertura.xml is produced by the anvil-llvm-cov recipe. + # Publishing from every OS leg matters because OS-gated code is + # only exercised on its native target; a single-leg upload would + # systematically under-report coverage of cfg(target_os = ...) + # branches. ADO's PublishCodeCoverageResults@2 coalesces multiple + # publishes against the same build into one coverage report. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-test.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), ne(variables.include_affected_linux, '--skip')) + displayName: Publish coverage (linux) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-test.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + - task: PublishCodeCoverageResults@2 + condition: and(succeededOrFailed(), ne(variables.include_affected_windows, '--skip')) + displayName: Publish coverage (windows) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + + - stage: pr_runtime_analysis + displayName: anvil pr-runtime-analysis (miri + careful) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-runtime-analysis.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-runtime-analysis.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + + - stage: pr_mutants + displayName: anvil pr-mutants (mutants) + dependsOn: [impact_linux, impact_windows] + condition: succeededOrFailed() + variables: + include_modified_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_modified'] ] + include_affected_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_affected'] ] + include_required_linux: $[ stageDependencies.impact_linux.compute.outputs['compute.include_required'] ] + include_modified_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_modified'] ] + include_affected_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_affected'] ] + include_required_windows: $[ stageDependencies.impact_windows.compute.outputs['compute.include_required'] ] + jobs: + # anvil-mutants-diff self-skips on aarch64-pc-windows-msvc as a + # defence-in-depth measure for adopters with self-hosted ARM pools. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/pr-mutants.yml + parameters: + include_modified: $(include_modified_linux) + include_affected: $(include_affected_linux) + include_required: $(include_required_linux) + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/pr-mutants.yml + parameters: + include_modified: $(include_modified_windows) + include_affected: $(include_affected_windows) + include_required: $(include_required_windows) + +=== .pipelines/anvil/scheduled.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Every job is rendered through `steps/job.yml` (user-customizable wrapper); +# see pr-stages.yml for the rationale and template-path note. +parameters: + - name: linuxPool + type: object + default: { vmImage: ubuntu-latest } + - name: windowsPool + type: object + default: { vmImage: windows-latest } + +stages: + - stage: scheduled_test + displayName: anvil scheduled-test + jobs: + # Publish coverage from both legs so OS-gated code is fully + # represented (see the pr-stages.yml comment for the rationale). + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-test.yml + - task: PublishCodeCoverageResults@2 + condition: succeededOrFailed() + displayName: Publish coverage (linux) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-test.yml + - task: PublishCodeCoverageResults@2 + condition: succeededOrFailed() + displayName: Publish coverage (windows) + inputs: + summaryFileLocation: target/coverage/cobertura.xml + failIfCoverageEmpty: false + + - stage: scheduled_advisories + displayName: anvil scheduled-advisories + dependsOn: [] + jobs: + # Cross-OS because clippy and udeps in this group compile per host, + # so cfg-gated code must be linted/scanned on both OSes. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-advisories.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-advisories.yml + + - stage: scheduled_exhaustive + displayName: anvil scheduled-exhaustive + dependsOn: [] + jobs: + # Cross-OS to match oxidizer's policy: full-workspace mutation + # testing + cargo-hack powerset + bench compile checks all benefit + # from running on both OSes for cfg(target_os) coverage. Adopters + # who can't afford the Windows leg override the matrix in their + # root pipeline. + - template: steps/job.yml + parameters: + name: linux + pool: ${{ parameters.linuxPool }} + steps: + - template: steps/scheduled-exhaustive.yml + - template: steps/job.yml + parameters: + name: windows + pool: ${{ parameters.windowsPool }} + steps: + - template: steps/scheduled-exhaustive.yml + +=== .pipelines/anvil/steps/advisory-comments.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Upsert/clear sticky PR comments for advisory checks. +# +# Convention (see docs/design/checks.md §6): a recipe writes a complete +# markdown body to `target/anvil/comments/.md` when it has +# findings, and removes the file when it does not. The first line of +# the body is an HTML comment marker (``) used +# here to find the existing PR thread to update or close. +# +# This step inspects each known convention file and: +# - if the file exists and no thread matches the marker, POSTs a new +# active thread carrying the file body; +# - if the file exists and a thread matches, PATCHes that thread's +# first comment with the new body; +# - if the file does not exist and a thread matches, sets the +# thread's status to "closed" (ADO has no thread-delete API; closed +# threads collapse in the PR UI). +# +# The step is a no-op when: +# - the build is not a PR build (Build.Reason != PullRequest); +# - the build identity lacks "Contribute to pull requests" on the +# repo (we catch the 401/403 and exit 0 so adopters who haven't +# opted in to write permissions aren't broken). +# +# Adding a new advisory check: extend the `$checks` array below with a +# `@{ name = ''; file = 'target/anvil/comments/.md' }` +# entry. The marker / header convention follows automatically. + +steps: + - task: PowerShell@2 + displayName: anvil advisory PR comments + condition: and(succeededOrFailed(), eq(variables['Build.Reason'], 'PullRequest')) + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + inputs: + targetType: inline + pwsh: true + script: | + $ErrorActionPreference = 'Stop' + $checks = @( + @{ name = 'semver'; file = 'target/anvil/comments/semver.md' } + ) + $prId = $env:SYSTEM_PULLREQUEST_PULLREQUESTID + if (-not $prId) { + Write-Host 'advisory-comments: not a PR build; skipping' + exit 0 + } + if (-not $env:SYSTEM_ACCESSTOKEN) { + Write-Host 'advisory-comments: SYSTEM_ACCESSTOKEN not exposed; skipping' + exit 0 + } + $base = "$env:SYSTEM_COLLECTIONURI$env:SYSTEM_TEAMPROJECT/_apis/git/repositories/$env:BUILD_REPOSITORY_ID/pullRequests/$prId" + $headers = @{ Authorization = "Bearer $env:SYSTEM_ACCESSTOKEN" } + try { + $threads = (Invoke-RestMethod "$base/threads?api-version=7.1" -Headers $headers).value + } catch { + $status = $_.Exception.Response.StatusCode.value__ + if ($status -in 401, 403) { + Write-Host "advisory-comments: build identity lacks 'Contribute to pull requests' (HTTP $status); skipping" + exit 0 + } + throw + } + foreach ($c in $checks) { + $marker = "" + $existing = $threads | Where-Object { + $_.comments -and $_.comments[0].content -like "*$marker*" -and $_.status -ne 'closed' + } | Select-Object -First 1 + if (Test-Path -LiteralPath $c.file) { + $body = Get-Content -LiteralPath $c.file -Raw + if ($existing) { + Write-Host "advisory-comments: updating anvil-$($c.name) (thread $($existing.id))" + $payload = @{ content = $body } | ConvertTo-Json -Depth 5 + Invoke-RestMethod "$base/threads/$($existing.id)/comments/1?api-version=7.1" ` + -Method PATCH -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } else { + Write-Host "advisory-comments: posting anvil-$($c.name)" + $payload = @{ + comments = @(@{ parentCommentId = 0; content = $body; commentType = 1 }) + status = 'active' + } | ConvertTo-Json -Depth 5 + Invoke-RestMethod "$base/threads?api-version=7.1" ` + -Method POST -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } + } elseif ($existing) { + Write-Host "advisory-comments: closing anvil-$($c.name) (thread $($existing.id))" + $payload = @{ status = 'closed' } | ConvertTo-Json + Invoke-RestMethod "$base/threads/$($existing.id)?api-version=7.1" ` + -Method PATCH -Headers $headers -ContentType 'application/json; charset=utf-8' -Body $payload | Out-Null + } + } + +=== .pipelines/anvil/steps/impact.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Computes per-tier include lists from cargo-delta and publishes them +# as stage outputs for downstream group stages to consume. +# +# Output variables (consumed in pr-stages.yml via +# stageDependencies.impact.compute.outputs['compute.']): +# include_modified - "--package X --package Y" or "--skip" sentinel. +# include_affected - same shape, for the affected set (modified ∪ rev-deps). +# include_required - same shape, for the required set +# (affected ∪ workspace-internal transitive deps). +# +# Unscoped recipes (deny, audit, aprz, pr-title) ignore all three. +steps: + # Reuse the per-job setup (toolchain, cargo cache, just install) so + # cargo + just are on PATH and ~/.cargo/bin is restored from cache. + # group=none skips the full catalog install -- impact only needs + # cargo-delta, which we install on the next step. + - template: setup.yml + parameters: + group: none + - bash: just anvil-tool-cargo-delta-install install + displayName: anvil impact (install cargo-delta) + - bash: | + set -euo pipefail + # Determine the baseline ref: + # * Adopters can override via BASE_REF (full ref name, e.g. + # `origin/release`). + # * On PR build-validation runs, SYSTEM_PULLREQUEST_TARGETBRANCH + # is set to the unqualified target branch (e.g. `main`). + # * Otherwise fall back to `master` so manual / branch cloud-workflow runs + # produce a sensible diff against the default branch. + # The ADO macro form `$(System.PullRequest.TargetBranch)` is unsafe + # here: when the variable is unset (non-PR builds), ADO leaves the + # `$(...)` literal in the script, which bash then interprets as + # command substitution and fails with exit 127. + base="${BASE_REF:-origin/${SYSTEM_PULLREQUEST_TARGETBRANCH:-master}}" + # Two snapshots + impact compare (cargo-delta has no --base + # shortcut). The baseline runs in a worktree at the merge target + # so the checked-out tree is untouched. + # + # GIT_LFS_SKIP_SMUDGE=1 prevents the worktree checkout from trying + # to fetch LFS objects -- the worktree inherits the parent repo's + # git config but NOT the credential helper that authenticated the + # initial checkout, so any LFS-tracked file would otherwise fail + # with "Git credentials ... not found". cargo-delta only needs + # file presence + content hashes; LFS pointer files hash + # consistently across baseline and current, so the impact analysis + # is unaffected. + cargo delta snapshot > "$AGENT_TEMPDIRECTORY/anvil-current.json" + GIT_LFS_SKIP_SMUDGE=1 git worktree add --detach "$AGENT_TEMPDIRECTORY/anvil-baseline" "$base" + ( cd "$AGENT_TEMPDIRECTORY/anvil-baseline" && cargo delta snapshot ) \ + > "$AGENT_TEMPDIRECTORY/anvil-baseline.json" + git worktree remove --force "$AGENT_TEMPDIRECTORY/anvil-baseline" + result="$(cargo delta impact \ + --baseline "$AGENT_TEMPDIRECTORY/anvil-baseline.json" \ + --current "$AGENT_TEMPDIRECTORY/anvil-current.json" \ + --format json)" + # cargo-delta emits TitleCase keys (Modified / Affected / + # Required). Format each tier into the --package args shape + # recipes expect, with --skip as the empty-set sentinel. + # + # cargo-delta's impact output uses *library names* (snake_case) + # rather than cargo *package names* (which may use hyphens). For + # hyphenated packages — e.g. `cargo-anvil` — that means it + # emits `cargo_anvil`, which cargo rejects as a --package + # specification. We build: + # * `pkg_map`: lib-name -> package-name (and identity for + # package-name -> package-name), for translation; + # * `valid_pkgs`: set of all known package names, for + # validation. Names cargo-delta emits that aren't valid + # packages (e.g. directory-leaf ambiguities like `ffi` / + # `ffi_build` in deeply nested workspaces) are dropped with a + # warning rather than failing the whole build. + declare -A pkg_map + declare -A valid_pkgs + while IFS=$'\t' read -r pkg_name lib_name; do + valid_pkgs["$pkg_name"]=1 + pkg_map["$pkg_name"]="$pkg_name" + [ -n "$lib_name" ] && pkg_map["$lib_name"]="$pkg_name" + done < <(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] as $p | ($p.targets[] | select(.kind | index("lib")) | "\($p.name)\t\(.name)"), "\($p.name)\t"') + format_set() { + local field="$1" + local pkgs + pkgs=$(printf '%s' "$result" | jq -r --arg f "$field" '(.[$f] // []) | .[]' 2>/dev/null || true) + if [ -z "$pkgs" ] ; then + printf '%s' "--skip" + else + local out="" + while IFS= read -r pkg ; do + [ -z "$pkg" ] && continue + local mapped="${pkg_map[$pkg]:-$pkg}" + if [ -z "${valid_pkgs[$mapped]:-}" ] ; then + echo "anvil impact: dropping unknown package '$pkg' (-> '$mapped') from $field set; see https://github.com/microsoft/ox-tools/issues for tracking" >&2 + continue + fi + out="$out --package $mapped" + done <-setup`. +parameters: + - name: group + type: string + default: '' +steps: + - bash: echo "##vso[task.setvariable variable=anvil_rustc_version]$(rustc --version | awk '{print $2}')" + displayName: anvil setup (capture rustc version) + - task: Cache@2 + inputs: + # Same key shape as the GitHub side: OS + arch + rustc version + + # lockfile hashes + catalog hash. Including arch keeps any + # future ARM pool an adopter adds from colliding with x86_64 on + # the same OS namespace. + key: 'anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | rust$(anvil_rustc_version) | Cargo.lock | .cargo/config.toml | rust-toolchain.toml | justfiles/anvil/versions.just' + restoreKeys: | + anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" | rust$(anvil_rustc_version) + anvil-v1 | "$(Agent.OS)" | "$(Agent.OSArchitecture)" + path: | + $(HOME)/.cargo/registry/cache/ + $(HOME)/.cargo/registry/index/ + $(HOME)/.cargo/bin/ + target/ + displayName: anvil setup (cache cargo home and target) + + # System dependencies for catalog source builds. See the GitHub + # composite setup-action.yml for the rationale — same pattern. + # Detect the distro's package manager so this works on Ubuntu agents + # (apt-get) and Azure Linux 3 agents (tdnf, common on 1ESPT) alike. + - bash: | + set -euo pipefail + if command -v tdnf >/dev/null 2>&1; then + sudo tdnf install -y clang-devel + elif command -v dnf >/dev/null 2>&1; then + sudo dnf install -y clang-devel + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y libclang-dev + else + echo "No supported package manager found (tdnf/dnf/apt-get)" >&2 + exit 1 + fi + condition: eq(variables['Agent.OS'], 'Linux') + displayName: anvil setup (install libclang on Linux) + + - bash: | + if ! command -v just >/dev/null 2>&1 ; then + cargo install --locked just + fi + displayName: anvil setup (install just) + + # Hand everything else off to the catalog recipe. Idempotent. ADO + # uses the default `install` backend (source builds) because + # cargo-binstall has unresolved compliance issues for internal ADO + # pipelines (GH uses `binstall`). + # + # Selection mirrors setup-action.yml (GH composite): + # group="" -> full catalog (anvil-setup) + # group="none" -> skip; caller installs what it needs + # group= -> only that group's prerequisites + - ${{ if eq(parameters.group, 'none') }}: + - bash: echo "anvil-setup: group=none, skipping tool install" + displayName: anvil setup (group=none -- skip tool install) + - ${{ elseif eq(parameters.group, '') }}: + - bash: just anvil-setup + displayName: anvil setup (install full catalog) + - ${{ else }}: + - bash: just anvil-${{ parameters.group }}-setup + displayName: anvil setup (install ${{ parameters.group }} prerequisites) + + +=== .pipelines/anvil-pr.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +trigger: none + +stages: + - template: anvil/pr.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } + +=== .pipelines/anvil-scheduled.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +trigger: none +pr: none + +schedules: + - cron: "0 6 * * *" + displayName: anvil scheduled + branches: + include: [main, master] + always: true + +stages: + - template: anvil/scheduled.yml + parameters: + linuxPool: { vmImage: ubuntu-latest } + windowsPool: { vmImage: windows-latest } + +=== Cargo.toml === +[workspace] +resolver = "2" +members = ["crates/*"] + +# >>> anvil-managed: anvil-workspace-lints +[workspace.lints] +# Catalog of opinionated lints, in dotted-key form so users can extend the +# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. +# The host-specific table header (`[workspace.lints]` or `[lints]`) is +# prepended by cargo-anvil based on whether the manifest is a workspace +# root or a single-crate Cargo.toml. + +# --- rust ------------------------------------------------------------------ +rust.ambiguous_negative_literals = "warn" +rust.missing_debug_implementations = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.trivial_numeric_casts = "warn" +rust.unsafe_op_in_unsafe_fn = "warn" +rust.unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# line (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +rust.unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', +] } + +# --- rustdoc --------------------------------------------------------------- +rustdoc.broken_intra_doc_links = "warn" +rustdoc.missing_crate_level_docs = "warn" +rustdoc.unescaped_backticks = "warn" + +# --- clippy: category gates (priority -1 so per-lint allows can override) -- +clippy.cargo = { level = "warn", priority = -1 } +clippy.complexity = { level = "warn", priority = -1 } +clippy.correctness = { level = "warn", priority = -1 } +clippy.nursery = { level = "warn", priority = -1 } +clippy.pedantic = { level = "warn", priority = -1 } +clippy.perf = { level = "warn", priority = -1 } +clippy.style = { level = "warn", priority = -1 } +clippy.suspicious = { level = "warn", priority = -1 } + +# --- clippy: opinionated additions ----------------------------------------- +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +clippy.allow_attributes = "warn" +clippy.allow_attributes_without_reason = "warn" +clippy.as_pointer_underscore = "warn" +clippy.assertions_on_result_states = "warn" +clippy.clone_on_ref_ptr = "warn" +clippy.deref_by_slicing = "warn" +clippy.disallowed_script_idents = "warn" +clippy.empty_drop = "warn" +clippy.empty_enum_variants_with_brackets = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.if_then_some_else_none = "warn" +clippy.map_err_ignore = "warn" +clippy.multiple_unsafe_ops_per_block = "warn" +clippy.redundant_type_annotations = "warn" +clippy.renamed_function_params = "warn" +clippy.semicolon_outside_block = "warn" +clippy.undocumented_unsafe_blocks = "warn" +clippy.unnecessary_safety_comment = "warn" +clippy.unnecessary_safety_doc = "warn" +clippy.unneeded_field_pattern = "warn" +clippy.unused_result_ok = "warn" +clippy.unwrap_used = "warn" + +# --- clippy: opinionated suppressions of category-enabled lints ------------ +clippy.missing_const_for_fn = "allow" +clippy.multiple_crate_versions = "allow" +clippy.option_if_let_else = "allow" +clippy.redundant_pub_crate = "allow" +clippy.should_panic_without_expect = "allow" +clippy.significant_drop_tightening = "allow" +# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 +clippy.wildcard_imports = "allow" + +# <<< anvil-managed: anvil-workspace-lints + +=== Justfile === +# >>> anvil-managed: anvil-imports +import 'justfiles/anvil/mod.just' +# <<< anvil-managed: anvil-imports + +=== clippy.toml === +# >>> anvil-managed: anvil-clippy +# Fine-tuning settings for clippy lints. These cannot be expressed in +# Cargo.toml's [lints] table (which only carries level: warn/allow/deny); +# they configure lint *behavior* and live in clippy.toml only. + +# Absolute paths up to 3 segments are clarifying — e.g. `std::sync::Mutex` +# vs `tokio::sync::Mutex` disambiguates the source. Beyond 3 segments +# we prefer imports or aliases for readability. +absolute-paths-max-segments = 3 + +# Workspace code is internal. Clippy should suggest the most correct +# fix without worrying about non-breaking-change rules, which only +# matter for published library APIs. +avoid-breaking-exported-api = false + +# Required companion for the clippy.semicolon_outside_block lint we +# ship in the catalog. Without this, the lint fires on multiline-block +# forms that are common Rust style. +semicolon-outside-block-ignore-multiline = true + +# Required companions for the clippy.unwrap_used lint we ship. +# Test code asserts via unwrap()/panic!() — that's how #[test] reports +# failure. Without these, every test triggers the lint. +allow-panic-in-tests = true +allow-unwrap-in-tests = true + +# Aspirational: when clippy.wildcard_imports is re-enabled (currently +# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# we want the stricter variant that warns on ALL wildcard imports +# including prelude. Setting it now means flipping the lint level to +# warn later is a one-line change with no tuning afterthought. +warn-on-all-wildcard-imports = true +# <<< anvil-managed: anvil-clippy + +=== crates/alpha/Cargo.toml === +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" + +# >>> anvil-managed: anvil-lints +[lints] +workspace = true +# <<< anvil-managed: anvil-lints + +=== crates/alpha/src/lib.rs === + + +=== deny.toml === +# >>> anvil-managed: anvil-deny +[advisories] +yanked = "deny" +# Scope of unmaintained-crate checks: "all" surfaces transitive +# dependencies too; tighten to "workspace" if the noise is high. +unmaintained = "all" + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.93 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# <<< anvil-managed: anvil-deny + +=== justfiles/anvil/checks.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. +# +# Each check belongs to one of four buckets, which determines how it +# interprets the impact env vars emitted by the cargo-delta impact step +# in cloud workflows: +# +# - "modified": only run when at least one package's source files +# changed in the diff. The check's underlying tool is workspace-wide +# or directory-scoped (cargo fmt --all, cargo heather, cargo +# spellcheck), so it doesn't take --package; we short-circuit on +# the ANVIL_INCLUDE_MODIFIED == "--skip" sentinel. +# +# - "affected": run on the affected set (modified ∪ reverse-deps +# within the workspace). The check's underlying tool takes +# --package; we splice ANVIL_INCLUDE_AFFECTED into the cargo +# invocation, defaulting to --workspace for local invocations where +# no env var is set. +# +# - "required": run on the required set (affected ∪ workspace-internal +# transitive deps). Same splice/default pattern as affected, but +# keyed on ANVIL_INCLUDE_REQUIRED. Used for checks whose tool +# resolves through the dep graph (cargo doc → intra-doc links; +# cargo hack → feature powerset; cargo udeps → unused-deps). +# +# - "unscoped": always run, no env var reference. External-input +# checks (deny, audit, aprz) and PR-context checks (pr-title) live +# here. Scheduled-exhaustive recipes (mutants-full) are also unscoped +# by design. +# +# Local invocation (no impact wiring): all three env vars are unset +# (recipes use the `?? "--workspace"` null-coalescing fallback below); +# modified-tier recipes simply skip the splice and run their +# workspace-wide tool; affected/required-tier recipes splat +# "--workspace" when the env var is unset. +# +# Preparation contract: when a recipe reaches the cargo call, the +# env var is one of: +# +# * unset - local run; the recipe substitutes +# "--workspace" via `?? "--workspace"` +# * "--package A --package B" - emitted by the cloud-workflow impact step when +# the tier has members +# * "--skip" - emitted by the cloud-workflow impact step when +# the tier is empty (recipe exits 0) +# +# This lets the simple recipes splat the var directly with +# & cargo X @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) ... +# and reduces the per-recipe boilerplate to a single one-line skip +# guard plus the cargo invocation. +# +# Modified-tier recipes never splice the env var into cargo (their +# tools are workspace-wide); they only check the skip sentinel. +# +# Every recipe whose body uses multi-line conditionals or env-var +# splicing is annotated with [script("pwsh")]. pwsh is preinstalled on +# Windows (since Windows 10), on GH/ADO hosted Linux + Windows +# runners, and installable on macOS via Homebrew or the upstream +# installer. We chose pwsh over bash because just's shebang dispatch +# requires `cygpath` on Windows (only on PATH from inside Git Bash), +# while [script("pwsh")] works from plain PowerShell with no PATH +# augmentation. The `??` null-coalescing operator used in the splat +# requires pwsh 7+, which is the floor we already require via +# _anvil-require pwsh. +# +# Single-command recipes (cargo deny check, cargo audit) are plain +# just recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# Note: [script(...)] requires `set unstable`. The adopter's root +# justfile must declare it (typically as a top-level line). anvil's +# mod.just does NOT redeclare it, to avoid conflicting with adopters +# who already have it. + +# === pr-fast members ==================================================== + +# Modified tier. cargo-fmt is a rustup component. We invoke it via the +# pinned nightly (see versions.just) because rustfmt.toml uses +# unstable_features = true (imports_granularity, group_imports, +# format_code_in_doc_comments). Floating nightly would mean +# format-drift breaking cloud workflows on rustup updates — the same trap we +# explicitly avoid for udeps/miri/careful/external-types. +[script("pwsh")] +anvil-fmt: anvil-fmt-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo '+{{ rust_nightly }}' fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. Per project policy, clippy runs on the affected set +# rather than the modified set: a change in a crate can introduce a +# clippy issue in a dependent crate (e.g., trait-bound or +# obviously-truthy-condition lints that key off the changed type), so +# we want downstream rev-deps to lint as well. cargo-clippy is a +# rustup component; same reasoning as fmt for the require. +[script("pwsh")] +anvil-clippy: anvil-clippy-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo clippy @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-targets --all-features --locked "--" '-D' 'warnings' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-cargo-sort: anvil-cargo-sort-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo sort --workspace --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-license-headers: anvil-license-headers-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo heather + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-cyclic-deps: anvil-ensure-no-cyclic-deps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-cyclic-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-default-features: anvil-ensure-no-default-features-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-default-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Required tier. cargo doc resolves intra-doc links through the dep +# graph, so a dep changing its public API can break doc-build in a +# crate that wasn't itself modified. +[script("pwsh")] +anvil-doc-build: anvil-doc-build-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + $env:RUSTDOCFLAGS = '-D warnings' + & cargo doc @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features --no-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +# +# cargo-doc2readme regenerates a crate's README.md from its rustdoc. +# Two extension points users frequently need: +# * Workspace-level template (`crates/README.j2` or `README.j2` at repo +# root): a Tera template applied to every crate's README. anvil +# auto-detects it and passes `--template` to the per-crate runs. +# * Per-crate opt-out: hand-crafted READMEs (e.g. a tool crate whose +# README is more freeform than the lib docs) opt out by adding +# `[package.metadata.ox-gen-readme]\ndisable = true` to their +# Cargo.toml. anvil skips those crates. +# +# Bin-only crates have no library rustdoc to base a README on, so they +# are skipped as well (cargo doc2readme requires a library target). +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: per-crate +# iteration over library targets, cargo-metadata-driven opt-outs +# (publish=false, [package.metadata.ox-gen-readme] disable), per-crate +# Push-Location into the crate dir (cargo-doc2readme is CWD-sensitive +# rather than --manifest-path-driven), and per-crate template-path +# resolution. The ANVIL_INCLUDE_MODIFIED value would still need to +# be intersected with the lib-crate set rather than splatted into cargo. +[script("pwsh")] +anvil-readme-check: anvil-readme-check-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-readme-check: no modified packages; skipping' + exit 0 + } + # Detect a workspace-level README template. Two conventional + # locations: crates/README.j2 (cargo-workspaces idiom) or + # README.j2 at repo root. + $template = $null + foreach ($candidate in 'crates/README.j2', 'README.j2') { + if (Test-Path $candidate) { $template = (Resolve-Path $candidate).Path; break } + } + # Iterate library crates. Filter by impact set when set, then drop + # bin-only crates and opt-outs. + $pkg = @(if ($env:ANVIL_INCLUDE_MODIFIED) { -split $env:ANVIL_INCLUDE_MODIFIED } else { '--workspace' }) + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $byName = @{} + foreach ($p in $meta.packages) { $byName[$p.name] = $p } + $candidates = if ($pkg -contains '--workspace') { + @($meta.packages | ForEach-Object { $_.name }) + } else { + $names = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + $names += $pkg[$i + 1]; $i++ + } + } + $names + } + $hadFailure = $false + foreach ($name in $candidates) { + $p = $byName[$name] + if (-not $p) { continue } + if (-not ($p.targets | Where-Object { $_.kind -contains 'lib' })) { continue } + # Skip private crates (publish = false). They aren't released + # and rarely have a polished README. Mirrors the + # `cargo workspaces exec --ignore-private` idiom adopters + # commonly use. + if ($p.publish -is [array] -and $p.publish.Count -eq 0) { + Write-Host "anvil-readme-check: $name (skipped: publish = false)" + continue + } + $disabled = $false + if ($p.metadata -and $p.metadata.'ox-gen-readme' -and $p.metadata.'ox-gen-readme'.disable) { + $disabled = $true + } + if ($disabled) { + Write-Host "anvil-readme-check: $name (opted out via [package.metadata.ox-gen-readme])" + continue + } + Write-Host "anvil-readme-check: $name" + # cargo doc2readme writes / compares relative to its CWD (not + # --manifest-path), so chdir into the crate before invoking + # --check. We also compute a per-crate relative path to the + # workspace-level template so the same template file works for + # every crate (parallels the cargo-workspaces idiom). + $crateDir = Split-Path -Parent $p.manifest_path + Push-Location $crateDir + try { + $relTemplate = if ($template) { + Resolve-Path -Relative -LiteralPath $template + } else { + $null + } + $args = @('doc2readme', '--check') + if ($relTemplate) { $args += @('--template', $relTemplate) } + & cargo @args + if ($LASTEXITCODE -ne 0) { $hadFailure = $true } + } finally { + Pop-Location + } + } + if ($hadFailure) { exit 1 } + +# Modified tier. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# If `.spelling` is present, we generate `target/spelling.dic` from it +# automatically; otherwise we run cargo-spellcheck against whatever +# the repo has already set up. +[script("pwsh")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-spellcheck: no modified packages; skipping' + exit 0 + } + if (Test-Path '.spelling') { + $output_file = 'target/spelling.dic' + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = $lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + } + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Unscoped (PR title is not source-related). +# +# Validates that $env:PR_TITLE matches Conventional Commits when set; +# no-op when unset. Cloud workflows inject PR_TITLE explicitly (GH Actions: +# ${{ github.event.pull_request.title }}; ADO: $(System.PullRequest.Title)) +# so the check has authoritative input there. Locally it skips silently -- +# there is no reliable way to recover the PR title for the ADO backend +# (no equivalent of `gh pr view`), so the recipe stays simple and +# defers the check to cloud workflows. +[script("pwsh")] +anvil-pr-title: anvil-pr-title-validate-prereqs + $title = $env:PR_TITLE + if (-not $title) { + Write-Host 'anvil-pr-title: PR_TITLE env var not set; skipping (check runs in cloud workflows)' + exit 0 + } + if ($title -notmatch '^(feat|fix|chore|docs|refactor|test|build|cloud workflows|perf|revert)(\([^)]+\))?!?: .+') { + Write-Error "PR title '$title' does not match Conventional Commits" + exit 1 + } + +# Unscoped (consults external advisory DB; reads Cargo.lock, not +# workspace members). Single command — inherits adopter's default shell. +anvil-deny: anvil-deny-validate-prereqs + cargo deny check + +# Unscoped (consults external advisory DB; reads Cargo.lock). +anvil-audit: anvil-audit-validate-prereqs + cargo audit + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Deliberately omits `--all-targets`: with `--all-targets`, a dep +# that's listed in BOTH `[dependencies]` and `[dev-dependencies]` and +# used only by tests is reported as "all used" because the dev-deps +# target satisfies the lookup, masking the unused entry in main +# `[dependencies]`. Restricting to the default targets (lib + bins) +# matches main repo cloud workflows' check and surfaces the real bug. +[script("pwsh")] +anvil-udeps: anvil-udeps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' udeps @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier (compares published API of changed crates against the +# baseline; only changed crates' surface is at risk). +# +# Several real-world conditions produce errors that aren't actually +# SemVer violations: +# - bin-only crates have no API to compare ("no library targets found"). +# - crates not yet published to crates.io ("not found in registry"). +# - crates where the published baseline lacks a lib target the +# current source has (bin -> bin+lib transition). +# We pre-filter to library-bearing crates from cargo metadata, then +# run cargo-semver-checks per-package and tolerate the +# "no-comparable-baseline" failure modes. +# +# Findings policy: this recipe is *advisory*. Real SemVer findings do +# NOT fail the recipe -- breaking changes between unreleased commits +# are normal (the major-version bump happens at release time, not on +# every PR). Instead, when there are findings we write a markdown +# advisory body to `target/anvil/comments/semver.md`; when the +# tree is clean we remove that file. cloud-workflow wiring (GH: +# marocchino/sticky-pull-request-comment; ADO: pwsh + REST API) +# inspects the file after the recipe and upserts / clears a sticky +# PR comment accordingly. Local invocation gets the same file +# written under target/ for inspection. +# +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-metadata +# filter to library crates (cargo-semver-checks --workspace fails on +# bin-only workspaces), intersect ANVIL_INCLUDE_AFFECTED with that +# set, then per-crate invocation with selective error tolerance for +# unpublished crates ("not found in registry") and bin->bin+lib +# transitions ("no library targets found"). +[script("pwsh")] +anvil-semver-check: anvil-semver-check-validate-prereqs + $ErrorActionPreference = 'Stop' + $commentFile = 'target/anvil/comments/semver.md' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-semver-check: no affected packages; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build the candidate package list. Always iterate per-package over + # library crates only -- cargo-semver-checks --workspace would fail + # on workspaces that contain bin-only crates ("no library targets + # found"), and we want the same tolerance for unpublished / bin->lib- + # transition crates regardless of whether we got here via impact- + # scoping (cloud workflows) or full-workspace fallback (local). + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $true + } + } + if ($pkg -contains '--workspace') { + $packages = @($libPkgs.Keys) + } else { + $packages = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs[$pkg[$i + 1]]) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-semver-check: no affected library crates; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $findings = New-Object System.Collections.Generic.List[string] + foreach ($p in $packages) { + Write-Host "anvil-semver-check: $p" + $output = (& cargo semver-checks --package $p 2>&1) | Out-String + if ($LASTEXITCODE -ne 0) { + if ($output -match 'not found in registry|no library targets found') { + Write-Host " $p has no comparable baseline; skipping (likely unpublished or bin->lib transition)" -ForegroundColor Yellow + } else { + Write-Host $output + # Append a per-crate findings block. Using one-line-at-a-time + # appends keeps the markdown free of pwsh backtick-escape + # gymnastics (single-quoted literals + the natural `n join + # produce clean LF newlines and unambiguous triple-backticks). + $findings.Add('### `' + $p + '`') | Out-Null + $findings.Add('') | Out-Null + $findings.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $findings.Add($line.TrimEnd()) | Out-Null + } + $findings.Add('```') | Out-Null + $findings.Add('') | Out-Null + } + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: Potential breaking changes detected') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # Advisory: always exit 0. cloud-workflow wiring posts/clears the PR comment. + exit 0 + +# Affected tier (lints public API of changed crates and rev-deps). +# +# cargo-check-external-types is per-manifest: no --package/--workspace, +# only --manifest-path. Iterate the affected library crates and run +# the tool once each, pointing at the crate's Cargo.toml. Bin-only +# crates have no public API surface and are skipped. +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-check- +# external-types is per-manifest (no --package/--workspace), so we +# build a name->manifest map from cargo metadata, filter to lib crates, +# intersect with ANVIL_INCLUDE_AFFECTED, and call the tool once per +# crate. Hard-fails on errors (no tolerance, unlike semver-check). +[script("pwsh")] +anvil-external-types: anvil-external-types-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-external-types: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build pkg-name -> manifest-path map, restricted to library crates. + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $p.manifest_path + } + } + # Decide which packages to check. + $packages = @() + if ($pkg -contains '--workspace') { + $packages = $libPkgs.Keys + } else { + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs.ContainsKey($pkg[$i + 1])) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-external-types: no affected library crates; skipping' + exit 0 + } + $failed = $false + foreach ($p in $packages) { + Write-Host "anvil-external-types: $p" + # cargo-check-external-types requires nightly rustdoc (uses + # unstable -Z flags) AND pins a specific rustdoc-types schema + # version. We pin nightly narrowly to the schema this tool + # version expects via `rust_nightly_external_types` in + # versions.just — bump that pin alongside any cargo-check- + # external-types upgrade. No tolerance for schema mismatches: + # if it fails, the pin or the tool needs to move. + & cargo '+{{ rust_nightly_external_types }}' check-external-types --manifest-path $libPkgs[$p] + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + +# Unscoped (consults external risk DB). +anvil-aprz: anvil-aprz-validate-prereqs + cargo aprz deps --error-if-high-risk --console appraisal + +# === pr-test members ==================================================== + +# Affected tier. +[script("pwsh")] +anvil-llvm-cov: anvil-llvm-cov-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-llvm-cov: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # cargo-llvm-cov doesn't work on aarch64-pc-windows-msvc: the + # llvm-profdata that ships with the rust toolchain there fails + # to merge the .profraw set ("no profile can be merged"). Fall + # back to plain `cargo nextest run` on that target so we still + # get test execution; coverage data from this leg wouldn't have + # been used anyway (coverage upload is gated on Linux only). + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-llvm-cov: aarch64-pc-windows-msvc -- skipping coverage; running plain nextest' + & cargo nextest run @pkg --all-features --locked + exit $LASTEXITCODE + } + # cargo llvm-cov writes the .profraw set into target/llvm-cov-target/ + # but the *report* output directory (target/coverage/) is something + # we choose and must exist before --output-path runs. + [System.IO.Directory]::CreateDirectory('target/coverage') | Out-Null + [System.IO.Directory]::CreateDirectory('target/coverage/html') | Out-Null + # Wipe stale .profraw data so the report reflects only this run. + cargo llvm-cov clean --workspace --profraw-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Instrument and run tests; defer report generation so we can emit + # multiple formats from the same profraw set without re-running. + # Note: nextest's exit-4 ("no tests to run") IS treated as a + # failure here -- a llvm-cov run that finds no tests almost + # always means a config mistake (wrong package filter, missing + # test target, etc.), not a legitimate empty set. anvil-miri + # is the exception (see its comment): miri-skipped tests are an + # expected design point for FS-heavy crates. + & cargo llvm-cov nextest @pkg --all-features --locked --no-report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # lcov.info feeds Codecov on GitHub; cobertura.xml feeds + # PublishCodeCoverageResults@2 on Azure DevOps. + cargo llvm-cov report --lcov --output-path target/coverage/lcov.info + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Local-only HTML viewer (no cloud-workflow consumer); cheap once the data exists. + cargo llvm-cov report --html --output-dir target/coverage/html + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-doc-test: anvil-doc-test-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo test --doc @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-examples: anvil-examples-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo build @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --examples --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === pr-mutants member ==================================================== + +# Affected tier. cargo-mutants does its own diff-scoping via --in-diff; +# the affected-tier guard is the wiring-layer's coarse filter (if no +# affected packages exist, the entire mutants run is pointless). +# +# Skip on aarch64-pc-windows-msvc: cargo-mutants doesn't build there +# (upstream winapi incompatibility), so `_anvil-require cargo-mutants` +# would fail. The merged pr-slow group runs on all four OS legs; mutants +# is the only sub-recipe that can't follow, so it bails out early on the +# affected leg. Coverage on the other three legs is unchanged. +[script("pwsh")] +anvil-mutants-diff: anvil-mutants-diff-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs aarch64-pc-windows-msvc -- cargo-mutants does not build here (winapi); skipping' + exit 0 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no affected packages; skipping' + exit 0 + } + # Resolve BASE_REF: env override > origin/main > origin/master. + $base = $null + if ($env:BASE_REF) { + $base = $env:BASE_REF + } else { + foreach ($candidate in @('origin/main', 'origin/master')) { + git rev-parse --verify $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + } + if (-not $base) { + Write-Error 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no BASE_REF set and neither origin/main nor origin/master is available. Set BASE_REF to the branch to diff against.' + exit 1 + } + # cargo-mutants --in-diff takes a FILE path containing a unified + # diff, not a git revision range. Write the diff to a temp file + # first. RUNNER_TEMP (GH) and AGENT_TEMPDIRECTORY (ADO) point at + # the job's scratch dir; fall back to the system temp dir locally. + $tmp_dir = $env:RUNNER_TEMP + if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } + if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } + $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' + git diff "$base..HEAD" --output=$diff_path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-runtime members ============================================ + +# Nightly checks always run full-workspace; impact env vars aren't set +# by the scheduled workflow, so the affected-tier default (--workspace) +# applies. Skip guards are still included for local diff-scoped runs. + +[script("pwsh")] +anvil-miri: anvil-miri-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + # `--no-tests=pass`: miri-only exception. Tests that touch the + # filesystem, spawn subprocesses, or use other miri-incompatible + # APIs commonly carry `#[cfg_attr(miri, ignore)]` (this is the + # canonical opt-out for build-tooling / CLI crates). A crate + # whose test set ends up entirely-skipped under miri legitimately + # produces zero runnable tests; nextest's default exit-4 ("no + # tests to run") would fail the recipe in that case. We treat + # empty test runs as success for miri only. Other nextest-using + # recipes (llvm-cov) keep exit-4 as a failure because zero tests + # there almost always indicates a config mistake. + & cargo '+{{ rust_nightly }}' miri nextest run --no-tests=pass @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +[script("pwsh")] +anvil-careful: anvil-careful-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' careful test @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-exhaustive members ========================================= + +# Unscoped. Scheduled-exhaustive deliberately runs over the whole +# workspace regardless of diff. +anvil-mutants-full: anvil-mutants-full-validate-prereqs + cargo mutants --workspace --no-shuffle --jobs 0 + +# Required tier. cargo-hack's feature powerset cascades through dep +# features, so the required set (workspace-internal transitive deps) +# is the right scope. +[script("pwsh")] +anvil-cargo-hack: anvil-cargo-hack-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo hack @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --feature-powerset --depth 2 check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-bench: anvil-bench-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo bench @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --no-run + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ============================================================================ +# Per-check setup + validate-prereqs +# ============================================================================ +# +# Each check has matching `*-setup` and `*-validate-prereqs` recipes +# that install / verify the tools and components it needs. The setup +# recipes accept an `installer=install|install` parameter that +# forwards to the underlying tool-install recipes; the validate-prereqs +# recipes take no parameters. +# +# These are the building blocks for `anvil--setup` +# (groups.just) and `anvil--setup` (tiers.just) ΓÇö each +# group/tier-level recipe is just a fan-out over the per-check +# setup/validate-prereqs of its members. + +# --- pr-fast members --- + +[group("anvil-setup")] +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install + +[group("anvil-setup")] +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs + +[group("anvil-setup")] +anvil-clippy-setup installer="install": anvil-component-default-clippy-install + +[group("anvil-setup")] +anvil-clippy-validate-prereqs: anvil-component-default-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-sort-setup installer="install": (anvil-tool-cargo-sort-install installer) + +[group("anvil-setup")] +anvil-cargo-sort-validate-prereqs: anvil-tool-cargo-sort-validate-prereqs + +[group("anvil-setup")] +anvil-license-headers-setup installer="install": (anvil-tool-cargo-heather-install installer) + +[group("anvil-setup")] +anvil-license-headers-validate-prereqs: anvil-tool-cargo-heather-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-setup installer="install": (anvil-tool-cargo-ensure-no-cyclic-deps-install installer) + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-validate-prereqs: anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-default-features-setup installer="install": (anvil-tool-cargo-ensure-no-default-features-install installer) + +[group("anvil-setup")] +anvil-ensure-no-default-features-validate-prereqs: anvil-tool-cargo-ensure-no-default-features-validate-prereqs + +# doc-build, examples and doc-test are pure cargo built-ins; the rust +# toolchain (rustc + cargo) is the only prerequisite, and we already +# rely on it being present everywhere anvil runs. +[group("anvil-setup")] +anvil-doc-build-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-build-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-readme-check-setup installer="install": (anvil-tool-cargo-doc2readme-install installer) + +[group("anvil-setup")] +anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereqs + +# cargo-spellcheck has a build-time libclang dependency; the system +# deps check runs first so adopters get a clear hint instead of a +# cryptic clang-sys build error mid-install. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": anvil-system-deps-check (anvil-tool-cargo-spellcheck-install installer) + +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +# pr-title is a pwsh script; no cargo tool to install. +[group("anvil-setup")] +anvil-pr-title-setup installer="install": anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-pr-title-validate-prereqs: anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-deny-setup installer="install": (anvil-tool-cargo-deny-install installer) + +[group("anvil-setup")] +anvil-deny-validate-prereqs: anvil-tool-cargo-deny-validate-prereqs + +[group("anvil-setup")] +anvil-audit-setup installer="install": (anvil-tool-cargo-audit-install installer) + +[group("anvil-setup")] +anvil-audit-validate-prereqs: anvil-tool-cargo-audit-validate-prereqs + +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +[group("anvil-setup")] +anvil-external-types-setup installer="install": anvil-toolchain-nightly-external-types-install (anvil-tool-cargo-check-external-types-install installer) + +[group("anvil-setup")] +anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-validate-prereqs anvil-tool-cargo-check-external-types-validate-prereqs + +[group("anvil-setup")] +anvil-aprz-setup installer="install": (anvil-tool-cargo-aprz-install installer) + +[group("anvil-setup")] +anvil-aprz-validate-prereqs: anvil-tool-cargo-aprz-validate-prereqs + +# --- pr-test members (shared with scheduled-test) --- + +[group("anvil-setup")] +anvil-llvm-cov-setup installer="install": (anvil-tool-cargo-llvm-cov-install installer) (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-llvm-cov-validate-prereqs: anvil-tool-cargo-llvm-cov-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-validate-prereqs: anvil-tool-rustc-validate-prereqs + +# --- pr-runtime-analysis members --- + +[group("anvil-setup")] +anvil-miri-setup installer="install": anvil-component-nightly-miri-install anvil-component-nightly-rust-src-install (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-miri-validate-prereqs: anvil-component-nightly-miri-validate-prereqs anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-careful-setup installer="install": anvil-component-nightly-rust-src-install (anvil-tool-cargo-careful-install installer) + +[group("anvil-setup")] +anvil-careful-validate-prereqs: anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-careful-validate-prereqs + +# --- pr-mutants members --- + +[group("anvil-setup")] +anvil-mutants-diff-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-diff-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +# --- scheduled-exhaustive members (mutants-full reuses cargo-mutants; +# cargo-hack and bench are dedicated tools) --- + +[group("anvil-setup")] +anvil-mutants-full-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-full-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-hack-setup installer="install": (anvil-tool-cargo-hack-install installer) + +[group("anvil-setup")] +anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs + +# bench uses cargo-built-ins (cargo bench --no-run + plain bench runs); +# no extra tool install needed beyond the rust toolchain. +[group("anvil-setup")] +anvil-bench-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-bench-validate-prereqs: anvil-tool-rustc-validate-prereqs + +=== justfiles/anvil/groups.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. + +# Each group is one cloud-workflow job. Within a group, checks run sequentially. + +# PR groups +# =========================================================================== + +[group("anvil")] +anvil-pr-fast: \ + anvil-fmt \ + anvil-clippy \ + anvil-cargo-sort \ + anvil-license-headers \ + anvil-ensure-no-cyclic-deps \ + anvil-ensure-no-default-features \ + anvil-doc-build \ + anvil-readme-check \ + anvil-spellcheck \ + anvil-pr-title \ + anvil-deny \ + anvil-audit \ + anvil-udeps \ + anvil-semver-check \ + anvil-external-types \ + anvil-aprz + +# pr-slow is the single PR-tier group for everything that takes more +# than ~30s per crate (tests, stricter runtimes, mutation testing). +# It's internally split into three sub-recipes so individual concerns +# can be invoked locally without dragging the others along: +# +# slow1: tests + coverage (replaces the former pr-test group) +# slow2: stricter-runtime correctness (miri, careful) +# slow3: mutation testing +# +# Cloud workflows run pr-slow as ONE job per OS leg -- the sub-recipes run +# sequentially within. This trades per-leg wall-clock for fewer +# orchestration jobs and a flatter PR check graph. Individual sub- +# recipes are runnable on their own locally: +# +# $ just anvil-pr-test # tests + coverage only +# $ just anvil-pr-runtime-analysis # miri + careful only +# $ just anvil-pr-mutants # mutants only +[group("anvil")] +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants + +[group("anvil")] +anvil-pr-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-pr-runtime-analysis: \ + anvil-miri \ + anvil-careful + +[group("anvil")] +anvil-pr-mutants: anvil-mutants-diff + +# Scheduled groups +# =========================================================================== + +[group("anvil")] +anvil-scheduled-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-scheduled-advisories: \ + anvil-deny \ + anvil-audit \ + anvil-aprz \ + anvil-clippy + +[group("anvil")] +anvil-scheduled-exhaustive: \ + anvil-mutants-full \ + anvil-cargo-hack \ + anvil-bench +# Group-level setup + validate-prereqs +# =========================================================================== +# +# Per-group recipes that fan out to the per-check setup/validate-prereqs +# from checks.just. Setup recipes accept `installer="install"|"binstall"`; +# validate-prereqs recipes take no parameters. + +[group("anvil-setup")] +anvil-pr-fast-setup installer="install": \ + (anvil-fmt-setup installer) \ + (anvil-clippy-setup installer) \ + (anvil-cargo-sort-setup installer) \ + (anvil-license-headers-setup installer) \ + (anvil-ensure-no-cyclic-deps-setup installer) \ + (anvil-ensure-no-default-features-setup installer) \ + (anvil-doc-build-setup installer) \ + (anvil-readme-check-setup installer) \ + (anvil-spellcheck-setup installer) \ + (anvil-pr-title-setup installer) \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-udeps-setup installer) \ + (anvil-semver-check-setup installer) \ + (anvil-external-types-setup installer) \ + (anvil-aprz-setup installer) + +[group("anvil-setup")] +anvil-pr-fast-validate-prereqs: \ + anvil-fmt-validate-prereqs \ + anvil-clippy-validate-prereqs \ + anvil-cargo-sort-validate-prereqs \ + anvil-license-headers-validate-prereqs \ + anvil-ensure-no-cyclic-deps-validate-prereqs \ + anvil-ensure-no-default-features-validate-prereqs \ + anvil-doc-build-validate-prereqs \ + anvil-readme-check-validate-prereqs \ + anvil-spellcheck-validate-prereqs \ + anvil-pr-title-validate-prereqs \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-udeps-validate-prereqs \ + anvil-semver-check-validate-prereqs \ + anvil-external-types-validate-prereqs \ + anvil-aprz-validate-prereqs + +[group("anvil-setup")] +anvil-pr-slow-setup installer="install": \ + (anvil-pr-test-setup installer) \ + (anvil-pr-runtime-analysis-setup installer) \ + (anvil-pr-mutants-setup installer) + +[group("anvil-setup")] +anvil-pr-slow-validate-prereqs: \ + anvil-pr-test-validate-prereqs \ + anvil-pr-runtime-analysis-validate-prereqs \ + anvil-pr-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-pr-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-pr-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-pr-runtime-analysis-setup installer="install": \ + (anvil-miri-setup installer) \ + (anvil-careful-setup installer) + +[group("anvil-setup")] +anvil-pr-runtime-analysis-validate-prereqs: \ + anvil-miri-validate-prereqs \ + anvil-careful-validate-prereqs + +[group("anvil-setup")] +anvil-pr-mutants-setup installer="install": (anvil-mutants-diff-setup installer) + +[group("anvil-setup")] +anvil-pr-mutants-validate-prereqs: anvil-mutants-diff-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-scheduled-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-advisories-setup installer="install": \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-aprz-setup installer) \ + (anvil-clippy-setup installer) + +[group("anvil-setup")] +anvil-scheduled-advisories-validate-prereqs: \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-aprz-validate-prereqs \ + anvil-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-exhaustive-setup installer="install": \ + (anvil-mutants-full-setup installer) \ + (anvil-cargo-hack-setup installer) \ + (anvil-bench-setup installer) + +[group("anvil-setup")] +anvil-scheduled-exhaustive-validate-prereqs: \ + anvil-mutants-full-validate-prereqs \ + anvil-cargo-hack-validate-prereqs \ + anvil-bench-validate-prereqs + +=== justfiles/anvil/mod.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Entry point for the anvil just recipe tree. The user's root Justfile +# imports this single file; everything else lives in sibling .just files +# pulled in here. + +# All multi-statement recipes in this tree use [script("pwsh")]. pwsh +# is preinstalled on Windows (10+), GH/ADO hosted Linux + Windows +# runners, and is installable on macOS/Linux via Homebrew / upstream +# installer. We chose pwsh over bash because: +# +# - just's shebang dispatch (#!/usr/bin/env bash) requires `cygpath` +# on Windows, which is only on PATH inside Git Bash. Plain +# PowerShell can't run shebang recipes. +# - just's [script("bash")] attribute passes Windows tempfile paths +# to bash unescaped, and bash interprets the backslashes as escape +# characters — every recipe fails with a mangled path. +# - [script("pwsh")] works from plain PowerShell with no PATH +# augmentation and no path translation. pwsh handles Windows +# paths natively. +# +# The existing `_anvil-require pwsh` recipe already established +# pwsh as a tools-floor requirement, so requiring it as the recipe +# interpreter is consistent. +# +# Single-command recipes (e.g. `cargo deny check`) are plain just +# recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# IMPORTANT: [script(...)] requires `set unstable`. The adopter's +# root justfile must declare it (typically as a top-level line). +# anvil's mod.just does NOT redeclare it, to avoid conflicting +# with adopters who already have it. + +import 'checks.just' +import 'groups.just' +import 'tiers.just' +import 'tools.just' +import 'versions.just' + +# Friendly default: `just anvil` runs the PR tier. +alias anvil := anvil-pr + +=== justfiles/anvil/tiers.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the tier structure. + +# PR tier: every check that should run on every pull request, split +# into two groups so the fast checks aren't blocked behind the slow +# ones in cloud workflows. +[group("anvil")] +anvil-pr: \ + anvil-pr-fast \ + anvil-pr-slow + +# scheduled tier: full-workspace re-runs of things that can change +# without a commit (advisories, flakes) + the truly expensive +# exhaustive checks that don't fit in a PR budget. Runs on a schedule +# against `main`, not on PRs. +[group("anvil")] +anvil-scheduled: \ + anvil-scheduled-test \ + anvil-scheduled-advisories \ + anvil-scheduled-exhaustive + +# Full tier: PR + scheduled, end-to-end. Useful before tagging a release. +[group("anvil")] +anvil-full: \ + anvil-pr \ + anvil-scheduled + +# Tier-level + global setup + validate-prereqs +# =========================================================================== +# +# Per-tier recipes that fan out to per-group setup/validate-prereqs from +# groups.just. The global `anvil-setup` / `anvil-validate-prereqs` +# recipes are the catch-all entry points that install / verify everything. +# +# Cloud workflows typically invoke only the per-group setup it needs (e.g. the +# `anvil-pr-fast` composite action / step template runs +# `anvil-pr-fast-setup` rather than the global `anvil-setup`). +# Local users who want "install everything" run `just anvil-setup`. + +[group("anvil-setup")] +anvil-pr-setup installer="install": \ + (anvil-pr-fast-setup installer) \ + (anvil-pr-slow-setup installer) + +[group("anvil-setup")] +anvil-pr-validate-prereqs: \ + anvil-pr-fast-validate-prereqs \ + anvil-pr-slow-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-setup installer="install": \ + (anvil-scheduled-test-setup installer) \ + (anvil-scheduled-advisories-setup installer) \ + (anvil-scheduled-exhaustive-setup installer) + +[group("anvil-setup")] +anvil-scheduled-validate-prereqs: \ + anvil-scheduled-test-validate-prereqs \ + anvil-scheduled-advisories-validate-prereqs \ + anvil-scheduled-exhaustive-validate-prereqs + +[group("anvil-setup")] +anvil-full-setup installer="install": \ + (anvil-pr-setup installer) \ + (anvil-scheduled-setup installer) + +[group("anvil-setup")] +anvil-full-validate-prereqs: \ + anvil-pr-validate-prereqs \ + anvil-scheduled-validate-prereqs + +# Global aliases. `anvil-setup` (no suffix) installs everything the +# catalog knows about; `anvil-validate-prereqs` verifies every tool +# and component is present at or above its pinned version. +[group("anvil-setup")] +anvil-setup installer="install": (anvil-full-setup installer) + +[group("anvil-setup")] +anvil-validate-prereqs: anvil-full-validate-prereqs + +=== justfiles/anvil/tools.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/local.md for the policy. + +# ============================================================================ +# System-level prerequisites (libclang, etc.) +# ============================================================================ + +# Public: probe for system-level prerequisites that catalog tools need +# to BUILD from source. The `binstall` install path downloads pre-built +# binaries and does not need these, so this check is primarily relevant +# for the source-build `install` path (used by local devs and the ADO +# backend) and is best-effort (skipped silently) for `binstall`. +# +# Scope policy: only system libs that an anvil catalog tool DIRECTLY +# requires. We do not try to be a general-purpose dev-env doctor. +# Current entries: +# - libclang: required by cargo-spellcheck (clang-sys / hunspell-rs) +# at build time. +# +# Detection uses presence-only probes (file existence in standard install +# dirs + `LIBCLANG_PATH` env var). No version checks -- system libs upgrade +# independently and any reasonably modern libclang works for clang-sys. +# +# On missing deps the recipe prints copy-paste install hints per OS / +# package manager and exits non-zero. No auto-install: admin / sudo and +# package-manager choice stay with the user. +[group("anvil-setup")] +[script("pwsh")] +anvil-system-deps-check: + $ErrorActionPreference = 'Stop' + $missing = @() + + # libclang: required to BUILD cargo-spellcheck from source. + $haveLibclang = $false + $libclangFiles = @('libclang.dll', 'libclang.so', 'libclang.so.1', 'libclang.dylib') + if ($env:LIBCLANG_PATH) { + foreach ($f in $libclangFiles) { + if (Test-Path (Join-Path $env:LIBCLANG_PATH $f)) { $haveLibclang = $true; break } + } + } + if (-not $haveLibclang) { + $probes = if ($IsWindows) { + @( + 'C:\Program Files\LLVM\bin\libclang.dll', + "$env:USERPROFILE\scoop\apps\llvm\current\bin\libclang.dll" + ) + } elseif ($IsMacOS) { + @( + '/usr/local/opt/llvm/lib/libclang.dylib', + '/opt/homebrew/opt/llvm/lib/libclang.dylib' + ) + } else { + @( + '/usr/lib/x86_64-linux-gnu/libclang.so.1', + '/usr/lib/aarch64-linux-gnu/libclang.so.1', + '/usr/lib64/libclang.so', + '/usr/lib64/libclang.so.1' + ) + } + foreach ($p in $probes) { + if (Get-Item -LiteralPath $p -ErrorAction SilentlyContinue) { $haveLibclang = $true; break } + } + # Linux distros often add a version suffix (libclang-19.so etc.); glob fallback. + if (-not $haveLibclang -and -not $IsWindows -and -not $IsMacOS) { + $glob = Get-ChildItem -Path '/usr/lib','/usr/lib64','/usr/lib/x86_64-linux-gnu','/usr/lib/aarch64-linux-gnu' -Filter 'libclang*.so*' -ErrorAction SilentlyContinue + if ($glob) { $haveLibclang = $true } + } + } + if (-not $haveLibclang) { + $missing += [pscustomobject]@{ + name = 'libclang' + why = 'cargo-spellcheck (clang-sys/hunspell-rs) needs libclang at build time' + hints = @( + 'Linux (Ubuntu/Debian): sudo apt-get install -y libclang-dev' + 'Linux (Azure Linux/RHEL): sudo tdnf install -y clang-devel' + 'macOS (homebrew): brew install llvm' + 'Windows (scoop): scoop install llvm # no admin' + 'Windows (winget, admin): winget install LLVM.LLVM' + ) + } + } + + if ($missing.Count -gt 0) { + Write-Host '' + foreach ($m in $missing) { + Write-Host "anvil: missing system dependency '$($m.name)'" -ForegroundColor Yellow + Write-Host " why: $($m.why)" + Write-Host ' install:' + foreach ($h in $m.hints) { Write-Host " $h" } + Write-Host '' + } + Write-Host "If the dep is installed but not on the standard search path, set LIBCLANG_PATH and re-run." -ForegroundColor Yellow + exit 1 + } + +# ============================================================================ +# rustc + pwsh validate-prereqs (no install -- system prereqs) +# ============================================================================ +# +# rustc is installed via rustup (https://rustup.rs); pwsh is installed +# via the platform's package manager. Both are presumed present on any +# machine running anvil; the validate recipes just surface a friendly +# error if not. + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-rustc-validate-prereqs: + if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Error 'anvil: rustc not found. Install via rustup: https://rustup.rs' + exit 1 + } + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-pwsh-validate-prereqs: + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $hint = if ($IsMacOS) { + 'brew install --cask powershell' + } elseif ($IsLinux) { + 'see https://github.com/PowerShell/PowerShell' + } else { + 'winget install --id Microsoft.PowerShell' + } + Write-Error "anvil: pwsh (PowerShell Core) not found. Install: $hint" + exit 1 + } + +# ============================================================================ +# Private helpers (install/check primitives) +# ============================================================================ + +# _install-tool: install a cargo subcommand at exactly the pinned version, +# or no-op if it is already installed at or above that version. The +# `installer` parameter selects between: +# - "install" (cargo install --locked, pure-source). Default. +# - "binstall" (cargo binstall --no-confirm --locked, with cargo install +# fallback if binstall fails). Bootstraps cargo-binstall +# itself if not on PATH. +[script("pwsh")] +_install-tool name version installer: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + $installer = '{{installer}}' + + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool: unknown installer '$installer' (expected 'install' or 'binstall')" + exit 2 + } + + # Already at or above the pin: skip. We don't downgrade tools the + # user upgraded for their own reasons; the validate side uses + # `installed >= pin`, so newer is fine. The early-exit is also what + # makes the actions/cache restore actually useful -- without it, + # every post-restore run would try to re-install on top of the + # cached binaries and fail with "binary already exists in destination". + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if ($installed) { + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. + } + } + + Write-Host "Installing $name =$version (installer: $installer)" + if ($installer -eq 'binstall') { + if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { + Write-Host ' Bootstrapping cargo-binstall' + cargo install --locked cargo-binstall + if ($LASTEXITCODE -ne 0) { + Write-Error 'cargo-binstall bootstrap failed' + exit $LASTEXITCODE + } + } + cargo binstall --no-confirm --locked $name --version "=$version" + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow + } + cargo install --locked $name --version "=$version" + if ($LASTEXITCODE -ne 0) { + Write-Error "$name install FAILED" + exit $LASTEXITCODE + } + +# _check-tool: verify a cargo subcommand is installed at or above the +# pinned version. Errors with an install hint on missing or too-old. +[script("pwsh")] +_check-tool name version: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if (-not $installed) { + Write-Error "anvil: required tool '$name' not found. Install: cargo install --locked --version =$version $name" + exit 1 + } + try { + $installedV = [version]$installed + $pinV = [version]$version + } catch { + Write-Error "anvil: cannot compare versions for '$name' (installed=$installed pin=$version). Reinstall: cargo install --locked --version =$version $name" + exit 1 + } + if ($installedV -lt $pinV) { + Write-Error "anvil: '$name' v$installed is older than the required minimum v$version. Upgrade: cargo install --locked --version =$version $name" + exit 1 + } + +# _install-toolchain: install a specific rustup toolchain (no components). +[script("pwsh")] +_install-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + Write-Host "rustup toolchain install $toolchain --profile minimal --no-self-update" + rustup toolchain install $toolchain --profile minimal --no-self-update + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-toolchain: verify a rustup toolchain is installed. +[script("pwsh")] +_check-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + rustup which --toolchain $toolchain rustc 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + +# _install-component: add a component to a toolchain. The toolchain is +# either the literal "default" (current rustup default) or a specific +# pinned toolchain string (which must already be installed -- the +# per-component setup recipes ensure this via a dependency on +# anvil--install). +[script("pwsh")] +_install-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + if ($toolchain -eq 'default') { + Write-Host "rustup component add $component (default toolchain)" + rustup component add $component + } else { + Write-Host "rustup component add --toolchain $toolchain $component" + rustup component add --toolchain $toolchain $component + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install component '$component' on toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-component: verify a component is installed on a toolchain. +[script("pwsh")] +_check-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + $output = if ($toolchain -eq 'default') { + rustup component list --installed 2>$null + } else { + rustup component list --installed --toolchain $toolchain 2>$null + } + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + $found = $output | Where-Object { $_ -like "$component*" } + if (-not $found) { + $cmd = if ($toolchain -eq 'default') { "rustup component add $component" } else { "rustup component add --toolchain $toolchain $component" } + Write-Error "anvil: component '$component' not installed on toolchain '$toolchain'. Run: $cmd" + exit 1 + } + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +[group("anvil-setup")] +anvil-toolchain-nightly-install: (_install-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-validate-prereqs: (_check-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-install: (_install-toolchain rust_nightly_external_types) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-validate-prereqs: (_check-toolchain rust_nightly_external_types) + +# ============================================================================ +# Rustup components +# ============================================================================ +# +# Default-toolchain components are installed via `rustup component add` +# (no toolchain spec). Nightly components depend on the toolchain +# being installed first (via the relevant anvil--install +# recipe) and then add the component. + +[group("anvil-setup")] +anvil-component-default-clippy-install: (_install-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-clippy-validate-prereqs: (_check-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-rustfmt-install: (_install-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-default-rustfmt-validate-prereqs: (_check-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-miri-install: anvil-toolchain-nightly-install (_install-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-miri-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rust-src") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rust-src") + +# ============================================================================ +# Cargo subcommands +# ============================================================================ +# +# One pair (install + validate-prereqs) per tool, alphabetical. +# Version pins live in versions.just (one cargo__version variable +# per tool). The install recipes accept an `installer="install"|"binstall"` +# parameter; the validate-prereqs recipes do not (they only read state). + +[group("anvil-setup")] +anvil-tool-cargo-aprz-install installer="install": (_install-tool "cargo-aprz" cargo_aprz_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-aprz-validate-prereqs: (_check-tool "cargo-aprz" cargo_aprz_version) + +[group("anvil-setup")] +anvil-tool-cargo-audit-install installer="install": (_install-tool "cargo-audit" cargo_audit_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-audit-validate-prereqs: (_check-tool "cargo-audit" cargo_audit_version) + +[group("anvil-setup")] +anvil-tool-cargo-careful-install installer="install": (_install-tool "cargo-careful" cargo_careful_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-careful-validate-prereqs: (_check-tool "cargo-careful" cargo_careful_version) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-install installer="install": (_install-tool "cargo-check-external-types" cargo_check_external_types_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-validate-prereqs: (_check-tool "cargo-check-external-types" cargo_check_external_types_version) + +[group("anvil-setup")] +anvil-tool-cargo-delta-install installer="install": (_install-tool "cargo-delta" cargo_delta_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-delta-validate-prereqs: (_check-tool "cargo-delta" cargo_delta_version) + +[group("anvil-setup")] +anvil-tool-cargo-deny-install installer="install": (_install-tool "cargo-deny" cargo_deny_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-deny-validate-prereqs: (_check-tool "cargo-deny" cargo_deny_version) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-doc2readme" cargo_doc2readme_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs: (_check-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-install installer="install": (_install-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-validate-prereqs: (_check-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version) + +[group("anvil-setup")] +anvil-tool-cargo-hack-install installer="install": (_install-tool "cargo-hack" cargo_hack_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-hack-validate-prereqs: (_check-tool "cargo-hack" cargo_hack_version) + +[group("anvil-setup")] +anvil-tool-cargo-heather-install installer="install": (_install-tool "cargo-heather" cargo_heather_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-heather-validate-prereqs: (_check-tool "cargo-heather" cargo_heather_version) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-install installer="install": (_install-tool "cargo-llvm-cov" cargo_llvm_cov_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-validate-prereqs: (_check-tool "cargo-llvm-cov" cargo_llvm_cov_version) + +# cargo-mutants doesn't build on aarch64-pc-windows-msvc (upstream +# winapi crate incompat). The install recipe self-skips on that target +# so per-group setup recipes that depend on it (pr-mutants-setup, +# scheduled-exhaustive-setup) don't fail on that platform. The +# mutants-diff / mutants-full check recipes also self-skip on the same +# target, so the check is effectively a no-op end-to-end there. +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-install installer="install": + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-install: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _install-tool cargo-mutants {{cargo_mutants_version}} {{installer}} + exit $LASTEXITCODE + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-validate-prereqs: + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-validate-prereqs: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _check-tool cargo-mutants {{cargo_mutants_version}} + exit $LASTEXITCODE + +[group("anvil-setup")] +anvil-tool-cargo-nextest-install installer="install": (_install-tool "cargo-nextest" cargo_nextest_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-nextest-validate-prereqs: (_check-tool "cargo-nextest" cargo_nextest_version) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-install installer="install": (_install-tool "cargo-semver-checks" cargo_semver_checks_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-validate-prereqs: (_check-tool "cargo-semver-checks" cargo_semver_checks_version) + +[group("anvil-setup")] +anvil-tool-cargo-sort-install installer="install": (_install-tool "cargo-sort" cargo_sort_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-sort-validate-prereqs: (_check-tool "cargo-sort" cargo_sort_version) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-install installer="install": (_install-tool "cargo-spellcheck" cargo_spellcheck_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-validate-prereqs: (_check-tool "cargo-spellcheck" cargo_spellcheck_version) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-install installer="install": (_install-tool "cargo-udeps" cargo_udeps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_version) + +=== justfiles/anvil/versions.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Pinned versions used by the anvil check tree. +# +# Everything anvil installs (cargo subcommands + rustup toolchains) +# is pinned here. The pinning policy: +# - On install (`-install` recipes): exactly this version (`=` for +# cargo subcommands, exact ref for rustup toolchains). Pulling +# "latest-matching" at install time is a cloud-workflow reproducibility risk -- +# an upstream release between yesterday's green build and today's +# PR can break things (cargo-spellcheck 0.15.7's em-dash regression +# is the canonical case). The `=` constraint locks the install to +# the version the catalog was validated against. +# - On validate-prereqs (`-validate-prereqs` recipes): the installed +# version must be `>= `. A user who has manually upgraded a +# tool for their own reasons (e.g. needing an unreleased bugfix) +# is not downgraded by setup. The validate gate uses +# `installed >= pin`, so newer is fine. +# +# To bump a pin, edit the version in place and re-run +# `cargo anvil`. The dirty-file flow preserves the edit on +# subsequent runs. To add a new tool, append a variable and the matching +# `-install`/`-validate-prereqs` pair in `tools.just`. To remove a tool, +# delete its variable and recipes (and any check that depends on them). + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +# General nightly used by udeps, miri, careful, and any future +# nightly-dependent check. Bumped on a regular cadence (monthly is a +# reasonable default) when an adopter has time to absorb formatting / +# lint / API-surface drift. Pin only -- do not use bare `nightly` here. +rust_nightly := "nightly-2026-02-10" + +# Pinned narrowly to the rustdoc JSON schema version that the currently +# selected cargo-check-external-types release accepts. cargo-check- +# external-types embeds a specific rustdoc-types crate version; if the +# nightly's emitted JSON format_version drifts past it, every run fails +# with "produces JSON format version X, but this tool requires +# format version Y" -- which is a tooling-incompat, not a real API +# violation. Bump this alongside any cargo-check-external-types upgrade. +rust_nightly_external_types := "nightly-2025-10-18" + +# ============================================================================ +# Cargo subcommands +# ============================================================================ + +cargo_aprz_version := "1.0.0" +cargo_audit_version := "0.22.2" +cargo_careful_version := "0.4.9" +cargo_check_external_types_version := "0.4.0" +cargo_delta_version := "0.3.1" +cargo_deny_version := "0.19.0" +cargo_doc2readme_version := "0.6.4" +cargo_ensure_no_cyclic_deps_version := "0.2.0" +cargo_ensure_no_default_features_version := "1.0.0" +cargo_hack_version := "0.6.41" +cargo_heather_version := "0.2.1" +cargo_llvm_cov_version := "0.8.4" +cargo_mutants_version := "26.1.2" +cargo_nextest_version := "0.9.122" +cargo_semver_checks_version := "0.46.0" +cargo_sort_version := "2.0.2" +cargo_spellcheck_version := "0.15.7" +cargo_udeps_version := "0.1.60" + +=== rustfmt.toml === +# >>> anvil-managed: anvil-rustfmt +edition = "2024" +max_width = 140 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +# The following options require nightly rustfmt. anvil-fmt invokes +# `cargo +{{ rust_nightly }} fmt`; see justfiles/anvil/versions.just +# for the pin and docs/design/local.md#nightly-pinning for the policy. +unstable_features = true +# Format Rust code blocks inside `///` doc comments. Catches stale +# examples that drift from the prose. +format_code_in_doc_comments = true +# One use-statement per module (vs collapsed `a::{b, c, d}` form). +# Diffs touch only the lines that actually changed. +imports_granularity = "Module" +# Group imports: std, then external crates, then crate-internal. Matches +# the convention used across the surveyed Microsoft Rust repos. +group_imports = "StdExternalCrate" +# <<< anvil-managed: anvil-rustfmt + +=== spellcheck.toml === +# >>> anvil-managed: anvil-spellcheck +# Check spelling in code comments marked as dev/developer comments +# (e.g., `// TODO:`, `// FIXME:`). Set to false to skip them. +dev_comments = false + +# Whether to skip spell checking README files. Set to false to include +# README files in spell checking. +skip_readme = false + +[Hunspell] +# Language dictionary. "en_US" uses the built-in English (US) dictionary. +lang = "en_US" + +# Directories searched for `extra_dictionaries` paths. The default +# repo-root entry lets adopters keep their custom dictionary next to +# the .spelling source. +search_dirs = ["."] + +# Additional dictionary files loaded after the language dictionary. +# Format: first line is the word count, remaining lines are sorted +# words (one per line). `target/spelling.dic` is generated by the +# `anvil-spellcheck` recipe from the repo's `.spelling` file. +extra_dictionaries = ["target/spelling.dic"] + +# Don't consult OS-provided dictionaries. Keeps results consistent +# across Linux/macOS/Windows runners. +skip_os_lookups = true + +# Use cargo-spellcheck's built-in language dictionaries (independent of +# system hunspell installation). Required for the cross-platform +# reproducibility guarantee above. +use_builtin = true + +# Token-boundary characters. Override the upstream default to add +# typographic punctuation we use in prose (em-dash, en-dash, arrows, +# minus sign). Without these, cargo-spellcheck 0.15.7 tokenises text +# like `runtime — it` as three tokens including the em-dash itself, +# then fails its dictionary lookup and flags the em-dash as a +# "possible spelling mistake". Upstream default keeps figure-dash +# (U+2012) and the ASCII hyphen but omits the rest; this list is a +# superset, so the only behavioural change is that the added chars +# now act as token boundaries. +# +# Encoded as \uXXXX escapes for grep-ability and to keep the file +# 7-bit ASCII: +# ASCII punctuation (default): ",;:.!?#(){}[]|/_- +# Dashes & minus : \u2012 figure-dash (default) +# \u2013 en-dash (added) +# \u2014 em-dash (added) +# \u2015 horizontal-bar (added) +# \u2212 minus-sign (added) +# Arrows : \u2190 leftwards-arrow (added) +# \u2192 rightwards-arrow (added) +# ASCII punctuation (default): ' ` & @ +# Misc (default) : \u00A7 section, \u00B6 pilcrow, \u2026 ellipsis +tokenization_splitchars = "\",;:.!?#(){}[]|/_-\u2012\u2013\u2014\u2015\u2190\u2192\u2212'`&@\u00A7\u00B6\u2026" + +[Hunspell.quirks] +# Treat CamelCase identifiers as concatenations of dictionary words +# (e.g., `TcpStream` = `Tcp` + `Stream`). Lowers false-positive rate +# substantially on Rust codebases. +allow_concatenation = true +# <<< anvil-managed: anvil-spellcheck diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap new file mode 100644 index 00000000..42e3bd9a --- /dev/null +++ b/crates/cargo-anvil/tests/snapshots/snapshots__github_backend.snap @@ -0,0 +1,3015 @@ +--- +source: crates/cargo-anvil/tests/snapshots.rs +expression: render_tree(tmp.path()) +--- +=== .delta.toml === +# >>> anvil-managed: anvil-delta +[delta] +# Include the workspace root files that should invalidate every member's +# impact analysis when changed (lockfile, root manifest, toolchain). +root-files = [ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", +] +# <<< anvil-managed: anvil-delta + +=== .github/actions/anvil-impact/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-impact +description: | + Compute the cargo-delta impact set for this PR and emit per-tier + include lists. + + Outputs: + include_modified - "--package X --package Y" string for crates whose + source files changed in the diff, or "--skip" if + the modified set is empty. + include_affected - same shape, for crates in the affected set + (modified ∪ rev-deps). + include_required - same shape, for crates in the required set + (affected ∪ workspace-internal transitive deps). + + Recipes in checks.just interpret each variable per their tier: + modified-tier recipes (fmt, license-headers, spellcheck, ...) short- + circuit on "--skip"; affected-tier recipes (clippy, tests, ...) and + required-tier recipes (doc, cargo-hack, udeps) splice their include + list into the cargo invocation, defaulting to --workspace when unset + (local runs without impact wiring). + + Unscoped recipes (deny, audit, aprz, pr-title) ignore all three + variables and always run unconditionally. +outputs: + include_modified: + description: Pre-formatted --package args for the modified tier. + value: ${{ steps.compute.outputs.include_modified }} + include_affected: + description: Pre-formatted --package args for the affected tier. + value: ${{ steps.compute.outputs.include_affected }} + include_required: + description: Pre-formatted --package args for the required tier. + value: ${{ steps.compute.outputs.include_required }} +runs: + using: composite + steps: + # anvil-setup with group=none bootstraps the rust toolchain + + # just + binstall + cache, but skips the full catalog install. + # We follow it with just the cargo-delta install (the only tool + # this composite needs). This keeps the impact stage lean -- it's + # the critical-path gating dep for every PR-tier group job. + - uses: ./.github/actions/anvil-setup + with: + group: none + - name: Install cargo-delta + shell: bash + run: just anvil-tool-cargo-delta-install binstall + - id: compute + name: Compute impact + shell: bash + run: | + set -euo pipefail + # GITHUB_BASE_REF is the target-branch name on a PR event + # (e.g. "main"); we resolve it to origin/. Adopters can + # override via the BASE_REF env var. + base="${BASE_REF:-origin/${GITHUB_BASE_REF:-main}}" + # cargo delta has no --base flag; the flow is two snapshots + # (baseline at the merge target + current at HEAD) compared by + # `cargo delta impact`. We use a temporary worktree to snapshot + # the baseline without disturbing the checked-out tree. + cargo delta snapshot > "$RUNNER_TEMP/anvil-current.json" + git worktree add --detach "$RUNNER_TEMP/anvil-baseline" "$base" + ( cd "$RUNNER_TEMP/anvil-baseline" && cargo delta snapshot ) \ + > "$RUNNER_TEMP/anvil-baseline.json" + git worktree remove --force "$RUNNER_TEMP/anvil-baseline" + result="$(cargo delta impact \ + --baseline "$RUNNER_TEMP/anvil-baseline.json" \ + --current "$RUNNER_TEMP/anvil-current.json" \ + --format json)" + # cargo-delta emits TitleCase keys (Modified / Affected / + # Required), not lowercase. Format each tier into the + # `--package X --package Y` shape recipes expect, or the + # literal "--skip" sentinel when the tier is empty. + # + # cargo-delta's impact output uses *library names* (snake_case) + # rather than cargo *package names* (which may use hyphens). For + # hyphenated packages — e.g. `cargo-anvil` — that means it + # emits `cargo_anvil`, which cargo rejects as a --package + # specification. We build: + # * `pkg_map`: lib-name -> package-name (and identity for + # package-name -> package-name), for translation; + # * `valid_pkgs`: set of all known package names, for + # validation. Names cargo-delta emits that aren't valid + # packages (e.g. directory-leaf ambiguities like `ffi` / + # `ffi_build` in deeply nested workspaces) are dropped with a + # warning rather than failing the whole build. + declare -A pkg_map + declare -A valid_pkgs + while IFS=$'\t' read -r pkg_name lib_name; do + valid_pkgs["$pkg_name"]=1 + pkg_map["$pkg_name"]="$pkg_name" + [ -n "$lib_name" ] && pkg_map["$lib_name"]="$pkg_name" + done < <(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] as $p | ($p.targets[] | select(.kind | index("lib")) | "\($p.name)\t\(.name)"), "\($p.name)\t"') + format_set() { + local field="$1" + local pkgs + pkgs=$(printf '%s' "$result" | jq -r --arg f "$field" '(.[$f] // []) | .[]' 2>/dev/null || true) + if [ -z "$pkgs" ] ; then + printf '%s' "--skip" + else + local out="" + while IFS= read -r pkg ; do + [ -z "$pkg" ] && continue + local mapped="${pkg_map[$pkg]:-$pkg}" + if [ -z "${valid_pkgs[$mapped]:-}" ] ; then + echo "anvil impact: dropping unknown package '$pkg' (-> '$mapped') from $field set" >&2 + continue + fi + out="$out --package $mapped" + done <> "$GITHUB_OUTPUT" + echo "include_affected=$(format_set Affected)" >> "$GITHUB_OUTPUT" + echo "include_required=$(format_set Required)" >> "$GITHUB_OUTPUT" + +=== .github/actions/anvil-pr-fast/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-fast is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-fast +description: Run the pr-fast check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-fast + - name: Run just anvil-pr-fast + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-fast + +=== .github/actions/anvil-pr-mutants/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-mutants is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-mutants +description: Run the pr-mutants check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-mutants + - name: Run just anvil-pr-mutants + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-mutants + +=== .github/actions/anvil-pr-runtime-analysis/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-runtime-analysis is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-runtime-analysis +description: Run the pr-runtime-analysis check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-runtime-analysis + - name: Run just anvil-pr-runtime-analysis + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-runtime-analysis + +=== .github/actions/anvil-pr-test/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token pr-test is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-pr-test +description: Run the pr-test check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: pr-test + - name: Run just anvil-pr-test + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-pr-test + +=== .github/actions/anvil-scheduled-advisories/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-advisories is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-advisories +description: Run the scheduled-advisories check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-advisories + - name: Run just anvil-scheduled-advisories + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-advisories + +=== .github/actions/anvil-scheduled-exhaustive/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-exhaustive is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-exhaustive +description: Run the scheduled-exhaustive check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-exhaustive + - name: Run just anvil-scheduled-exhaustive + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-exhaustive + +=== .github/actions/anvil-scheduled-test/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# The token scheduled-test is substituted by cargo-anvil at emit time with +# the concrete check-group name (pr-fast, pr-test, scheduled-runtime, ...). +name: anvil-scheduled-test +description: Run the scheduled-test check group. +inputs: + include_modified: + description: | + Pre-formatted --package args (e.g. "--package alpha --package beta") + for the modified tier, or the sentinel "--skip" when nothing + modified. Local invocations leave it unset; recipes default to + --workspace. + default: "" + required: false + include_affected: + description: | + Same shape as include_modified, but for the affected tier + (modified ∪ rev-deps). + default: "" + required: false + include_required: + description: | + Same shape as include_modified, but for the required tier + (affected ∪ workspace-internal transitive deps). + default: "" + required: false +runs: + using: composite + steps: + - uses: ./.github/actions/anvil-setup + with: + group: scheduled-test + - name: Run just anvil-scheduled-test + shell: bash + env: + ANVIL_INCLUDE_MODIFIED: ${{ inputs.include_modified }} + ANVIL_INCLUDE_AFFECTED: ${{ inputs.include_affected }} + ANVIL_INCLUDE_REQUIRED: ${{ inputs.include_required }} + # cargo-aprz hits the GitHub API (unauthenticated = 60 req/hr); pass the + # built-in token so it can use the 1000 req/hr authenticated quota. + GITHUB_TOKEN: ${{ github.token }} + run: just anvil-scheduled-test + +=== .github/actions/anvil-setup/action.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-setup +description: Install `just` and the anvil tools/components needed by a specific group (or everything if omitted). +inputs: + group: + description: | + Which anvil group to install setup for (e.g. "pr-fast", + "pr-test", "scheduled-advisories"). Special values: + - "" (default): install the full catalog via + `just anvil-setup` -- use for local "give me everything" + flows. + - "none": skip tool installation entirely; just bootstrap the + rust toolchain + just + binstall + cache. Used by + anvil-impact, which installs only cargo-delta afterwards. + - anything else: install only that group's prerequisites via + `just anvil--setup`. + default: "" + required: false +runs: + using: composite + steps: + - id: rustc-version + shell: bash + run: echo "version=$(rustc --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + - name: Restore cargo cache + id: cargo-cache + uses: actions/cache/restore@v4 + with: + # Key on OS + arch + rustc version + lockfile hashes + catalog + # hash so a Rust toolchain bump, a cross-arch matrix leg, or a + # tool-versions update all invalidate the cache cleanly. + # runner.arch resolves to X64 / ARM64 / X86, which keeps the + # x86_64 and aarch64 legs of the same OS from colliding on + # arch-incompatible target/ contents. + # + # ${{ github.job }} discriminates by workflow-job-id (`pr-fast`, + # `pr-test`, `pr-slow`, etc.) so concurrent matrix legs that + # share OS+arch (e.g. pr-fast linux + pr-test linux) don't race + # on the same cache key. Without this, the first-to-finish job + # reserves the key, the others get "Unable to reserve cache: + # another job may be creating this cache" and silently skip + # the save -- leaving the cache empty forever. The restore-keys + # fall back across jobs so the install work is still shared + # across sibling legs on subsequent runs. + key: anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}-${{ github.job }} + restore-keys: | + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}-${{ hashFiles('Cargo.lock', '.cargo/config.toml', 'rust-toolchain.toml', 'justfiles/anvil/versions.just') }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}-rust${{ steps.rustc-version.outputs.version }}- + anvil-v1-${{ runner.os }}-${{ runner.arch }}- + # `.crates.toml` and `.crates2.json` track which cargo-installed + # tools and versions live in ~/.cargo/bin/. Without them in the + # cache, `cargo install --list` and (downstream) the + # tool-install recipes' early-skip on "installed >= pin" don't + # see the cached binaries — every run then tries to reinstall + # on top of them and fails with "binary X already exists in + # destination". + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + + # System dependencies for the catalog's source builds. + # + # cargo-spellcheck's build script (clang-sys) needs libclang. The + # Ubuntu runner images don't ship it on PATH by default, so the + # source compile fails with "couldn't execute llvm-config". On + # macOS, libclang is bundled with Xcode CLT (always present on + # GH-hosted macos-*). On Windows, the Visual Studio install on + # the windows-* images includes a usable LLVM, so no install is + # needed. + - name: Install libclang (Linux) + if: runner.os == 'Linux' + shell: bash + run: sudo apt-get update && sudo apt-get install -y libclang-dev + + # rustup auto-installs the toolchain from rust-toolchain.toml on + # first cargo invocation, but it doesn't pull profile/component + # extras. The `anvil-setup` recipe in step "Install anvil + # toolchains + tools" below handles that exhaustively (the + # per-component install recipes in tools.just install + # default-toolchain components and pinned-nightly components for + # miri/careful/etc.) -- we don't add components inline here anymore. + # This step exists only to ensure rustup itself has the default + # toolchain set up so subsequent cargo / just calls work. + - name: Ensure default toolchain is installed + shell: bash + run: rustup show active-toolchain || rustup default stable + + # The catalog recipe `anvil-setup` (or `anvil--setup` + # when a group is specified via the `group` input) needs `just` to + # run. We bootstrap just here (chicken-and-egg), then hand off + # everything else to it. The setup recipes are idempotent and + # short-circuit on tools already installed at or above the pinned + # version, so re-running on cache-hit runs is cheap. + # Install a prebuilt cargo-binstall binary in seconds. Without this, + # the first `_install-tool` recipe that needs binstall bootstraps it + # via `cargo install --locked cargo-binstall`, which takes ~4 min of + # source compilation on every cold-cache job. The official action + # downloads the release binary from cargo-bins/cargo-binstall, so + # the bootstrap branch in `tools.just` becomes a no-op on GH. + - name: Install cargo-binstall + uses: cargo-bins/cargo-binstall@main + + - name: Install just + shell: bash + run: | + if ! command -v just >/dev/null 2>&1 ; then + cargo binstall --no-confirm --locked just || cargo install --locked just + fi + + - name: Install anvil toolchains + tools + shell: bash + # When `group` is empty (the default), installs the full catalog + # via `just anvil-setup binstall`. When `group` is "none", + # skips tool installation entirely (used by anvil-impact, which + # only needs cargo-delta and installs it itself afterwards). When + # `group` is anything else, installs only what that group needs + # via `just anvil--setup binstall`. + # + # binstall path downloads prebuilt tool binaries from each tool's + # GitHub Releases when available (~1 min cold, vs ~30 min for + # source builds). cargo-binstall has unresolved compliance issues + # for ADO pipelines, so the ADO backend uses the default `install` + # path; GH uses `binstall`. + run: | + case "${{ inputs.group }}" in + none) echo "anvil-setup: group=none, skipping tool install" ;; + "") just anvil-setup binstall ;; + *) just "anvil-${{ inputs.group }}-setup" binstall ;; + esac + + # Save the cache as the LAST step of setup, regardless of whether + # any earlier install step partially failed. `actions/cache@v4` + # used to support this via `save-always: true`, but that knob is + # deprecated as of 2025 with the explicit message "does not work + # as intended" — failing runs simply don't save. The supported + # replacement is to call `actions/cache/save@v4` directly as its + # own step with `if: always()`. + # + # Without this, a single catalog issue that fails the install + # step locks the cache empty forever (chicken-and-egg: failed + # run -> no save -> next run cold-starts -> still fails -> still + # no save). Tool binaries installed by anvil-tools-install are + # immutable once on disk, so partial state is strictly better + # than nothing — and subsequent runs accumulate into the cache + # until the catalog is complete. + - name: Save cargo cache + if: always() && steps.cargo-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + key: ${{ steps.cargo-cache.outputs.cache-primary-key }} + path: | + ~/.cargo/registry/cache/ + ~/.cargo/registry/index/ + ~/.cargo/bin/ + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + target/ + target/ + +=== .github/workflows/anvil-pr-impl.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: every multi-OS job below hardcodes its OS axis as +# an inline YAML array. Per-leg runner *labels* are inputs (so adopters +# can swap in self-hosted runners), but the OS axis itself is part of +# the workflow's identity — adopters who need a different shape (add +# macOS, drop ARM, mix in exotic targets) fork this file. Input-driven +# matrices were rejected because they added a silent failure mode +# (mis-formatted inputs produced empty matrices) without meaningfully +# expanding what adopters could customize. + +jobs: + # cargo-delta impact runs per OS so that downstream legs consume an + # impact set computed against THEIR host's cargo-metadata depgraph. + # Without this, an OS-conditional dep change (under + # `[target.'cfg(target_os = ...)'.dependencies]`) computed on Linux + # wouldn't include the cross-OS reverse-deps that only show up in + # the Windows depgraph, so the Windows leg could skip tests that + # ought to run. We split per-OS-family (not per-arch) -- arch-only + # cfg gates are rare enough that paying for 4 impact jobs isn't + # justified; arm legs reuse their OS counterpart's impact set. + impact-linux: + runs-on: ${{ inputs.linux_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + impact-windows: + runs-on: ${{ inputs.windows_runner }} + outputs: + include_modified: ${{ steps.delta.outputs.include_modified }} + include_affected: ${{ steps.delta.outputs.include_affected }} + include_required: ${{ steps.delta.outputs.include_required }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: delta + uses: ./.github/actions/anvil-impact + + pr-fast: + # Cross-OS / cross-arch because pr-fast contains compile-sensitive + # checks (clippy, doc-build, udeps, semver-check, external-types) + # whose results can differ across host for crates that use + # #[cfg(target_os = ...)] or #[cfg(target_arch = ...)] gating. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-fast + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + # Advisory PR comments. Recipes that surface non-blocking findings + # (e.g. cargo-semver-checks) write a markdown body to + # `target/anvil/comments/.md` and exit 0. The steps below + # turn presence/absence of those files into upserts/deletions of + # a sticky PR comment. We post from the canonical x86_64 Linux leg + # only so the matrix doesn't race on the same comment, and we use + # `always()` so the comment is updated even when an unrelated + # check in pr-fast failed. The `head.repo.full_name == + # github.repository` guard skips fork PRs (which can't be granted + # write tokens). + - name: Upsert anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') != '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + path: target/anvil/comments/semver.md + - name: Clear anvil-semver advisory + if: always() && github.event_name == 'pull_request' && matrix.os == 'linux' && github.event.pull_request.head.repo.full_name == github.repository && hashFiles('target/anvil/comments/semver.md') == '' + uses: marocchino/sticky-pull-request-comment@v3 + with: + header: anvil-semver + delete: true + + pr-test: + # Tests + coverage: llvm-cov / doc-test / examples. + # 4-leg matrix -- compile and runtime behaviour can differ across + # OS and arch for cfg-gated code, so we exercise tests on every leg. + # Coverage uploads from the canonical x86_64 Linux leg only to + # avoid Codecov double-counting. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-test + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm. OS/arch-gated + # code only gets exercised on its native target, so a single- + # leg upload would systematically under-report coverage on + # cfg(target_os = "windows") / cfg(target_arch = "aarch64") + # branches. windows-11-arm is excluded because LLVM-coverage + # instrumentation on that target produces "malformed + # instrumentation profile data: symbol name is empty" errors. + # Codecov coalesces multiple uploads against the same commit; + # the `flags:` tag distinguishes the per-leg slices in the + # Codecov UI without changing the union total. + # lcov.info is produced by the anvil-llvm-cov recipe inside + # anvil-pr-test; if the affected set was empty the recipe + # no-ops and there is no file to upload, so we gate on the + # impact output. + if: matrix.os != 'windows-arm' && ((startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected != '--skip') || (matrix.os == 'windows' && needs.impact-windows.outputs.include_affected != '--skip')) + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: ${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + pr-runtime-analysis: + # Stricter-runtime correctness: miri + careful. + # 4-leg matrix -- both checks compile per host target and can + # surface OS/arch-specific UB. Both are impact-scoped so the + # wall-clock is proportional to the PR's blast radius. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-pr-runtime-analysis + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + + pr-mutants: + # Mutation testing: cargo mutants (diff-scoped against the PR base). + # 4-leg matrix. cargo-mutants doesn't build on aarch64-pc-windows-msvc + # (upstream winapi crate incompat); the anvil-mutants-diff recipe + # self-skips on that target so this is a no-op (not a failure) on + # the windows-arm leg. + needs: [impact-linux, impact-windows] + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: ./.github/actions/anvil-pr-mutants + with: + include_modified: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_modified || needs.impact-windows.outputs.include_modified }} + include_affected: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_affected || needs.impact-windows.outputs.include_affected }} + include_required: ${{ startsWith(matrix.os, 'linux') && needs.impact-linux.outputs.include_required || needs.impact-windows.outputs.include_required }} + env: + BASE_REF: ${{ github.event.pull_request.base.sha }} + +=== .github/workflows/anvil-pr.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-pr + +on: + pull_request: {} + merge_group: {} + +permissions: + contents: read + +concurrency: + group: anvil-pr-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + anvil-pr: + uses: ./.github/workflows/anvil-pr-impl.yml + permissions: + contents: read + # Write needed so the pr-fast job can upsert/clear the sticky PR + # comment carrying the cargo-semver-checks advisory (and any + # future advisory checks that emit target/anvil/comments/*). + pull-requests: write + secrets: inherit + +=== .github/workflows/anvil-scheduled-impl.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled-impl + +on: + workflow_call: + inputs: + linux_runner: + description: Runner label for x86_64 Linux jobs. + type: string + default: ubuntu-latest + windows_runner: + description: Runner label for x86_64 Windows jobs. + type: string + default: windows-latest + linux_arm_runner: + description: Runner label for aarch64 Linux jobs. + type: string + default: ubuntu-24.04-arm + windows_arm_runner: + description: Runner label for aarch64 Windows jobs. + type: string + default: windows-11-arm + secrets: + CODECOV_TOKEN: + description: | + Codecov upload token. Optional for public repos that have OIDC + configured at Codecov; required for private repos. + required: false + +# Note on matrices: see pr-impl-workflow.yml for the rationale. OS +# matrices are hardcoded; per-leg runner labels are inputs. + +jobs: + scheduled-test: + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-test + - name: Upload coverage to Codecov + # Upload from every leg except windows-11-arm (see the matching + # comment in pr-impl-workflow.yml for the rationale). + # Multi-flag tag combines the OS with a "scheduled" marker so + # the Codecov UI can distinguish PR-tier uploads from scheduled + # uploads while still tracking each platform separately. + if: matrix.os != 'windows-arm' + uses: codecov/codecov-action@v5 + with: + files: target/coverage/lcov.info + flags: scheduled,${{ matrix.os }} + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false + + scheduled-advisories: + # Cross-OS / cross-arch because clippy and udeps in this group + # compile per host, so cfg-gated code must be linted/scanned on + # every leg. + strategy: + fail-fast: false + matrix: + os: [linux, windows, linux-arm, windows-arm] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner + || matrix.os == 'windows' && inputs.windows_runner + || matrix.os == 'linux-arm' && inputs.linux_arm_runner + || inputs.windows_arm_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-advisories + + scheduled-exhaustive: + # x86_64-only by design: this group includes mutants-full (which + # doesn't build on aarch64-pc-windows-msvc — winapi crate + # incompatibility) plus cargo-hack feature powerset and bench. + strategy: + fail-fast: false + matrix: + os: [linux, windows] + runs-on: ${{ matrix.os == 'linux' && inputs.linux_runner || inputs.windows_runner }} + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/anvil-scheduled-exhaustive + +=== .github/workflows/anvil-scheduled.yml === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +name: anvil-scheduled + +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + anvil-scheduled: + uses: ./.github/workflows/anvil-scheduled-impl.yml + permissions: + contents: read + secrets: inherit + +=== Cargo.toml === +[workspace] +resolver = "2" +members = ["crates/*"] + +# >>> anvil-managed: anvil-workspace-lints +[workspace.lints] +# Catalog of opinionated lints, in dotted-key form so users can extend the +# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. +# The host-specific table header (`[workspace.lints]` or `[lints]`) is +# prepended by cargo-anvil based on whether the manifest is a workspace +# root or a single-crate Cargo.toml. + +# --- rust ------------------------------------------------------------------ +rust.ambiguous_negative_literals = "warn" +rust.missing_debug_implementations = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.trivial_numeric_casts = "warn" +rust.unsafe_op_in_unsafe_fn = "warn" +rust.unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# line (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +rust.unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', +] } + +# --- rustdoc --------------------------------------------------------------- +rustdoc.broken_intra_doc_links = "warn" +rustdoc.missing_crate_level_docs = "warn" +rustdoc.unescaped_backticks = "warn" + +# --- clippy: category gates (priority -1 so per-lint allows can override) -- +clippy.cargo = { level = "warn", priority = -1 } +clippy.complexity = { level = "warn", priority = -1 } +clippy.correctness = { level = "warn", priority = -1 } +clippy.nursery = { level = "warn", priority = -1 } +clippy.pedantic = { level = "warn", priority = -1 } +clippy.perf = { level = "warn", priority = -1 } +clippy.style = { level = "warn", priority = -1 } +clippy.suspicious = { level = "warn", priority = -1 } + +# --- clippy: opinionated additions ----------------------------------------- +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +clippy.allow_attributes = "warn" +clippy.allow_attributes_without_reason = "warn" +clippy.as_pointer_underscore = "warn" +clippy.assertions_on_result_states = "warn" +clippy.clone_on_ref_ptr = "warn" +clippy.deref_by_slicing = "warn" +clippy.disallowed_script_idents = "warn" +clippy.empty_drop = "warn" +clippy.empty_enum_variants_with_brackets = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.if_then_some_else_none = "warn" +clippy.map_err_ignore = "warn" +clippy.multiple_unsafe_ops_per_block = "warn" +clippy.redundant_type_annotations = "warn" +clippy.renamed_function_params = "warn" +clippy.semicolon_outside_block = "warn" +clippy.undocumented_unsafe_blocks = "warn" +clippy.unnecessary_safety_comment = "warn" +clippy.unnecessary_safety_doc = "warn" +clippy.unneeded_field_pattern = "warn" +clippy.unused_result_ok = "warn" +clippy.unwrap_used = "warn" + +# --- clippy: opinionated suppressions of category-enabled lints ------------ +clippy.missing_const_for_fn = "allow" +clippy.multiple_crate_versions = "allow" +clippy.option_if_let_else = "allow" +clippy.redundant_pub_crate = "allow" +clippy.should_panic_without_expect = "allow" +clippy.significant_drop_tightening = "allow" +# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 +clippy.wildcard_imports = "allow" + +# <<< anvil-managed: anvil-workspace-lints + +=== Justfile === +# >>> anvil-managed: anvil-imports +import 'justfiles/anvil/mod.just' +# <<< anvil-managed: anvil-imports + +=== clippy.toml === +# >>> anvil-managed: anvil-clippy +# Fine-tuning settings for clippy lints. These cannot be expressed in +# Cargo.toml's [lints] table (which only carries level: warn/allow/deny); +# they configure lint *behavior* and live in clippy.toml only. + +# Absolute paths up to 3 segments are clarifying — e.g. `std::sync::Mutex` +# vs `tokio::sync::Mutex` disambiguates the source. Beyond 3 segments +# we prefer imports or aliases for readability. +absolute-paths-max-segments = 3 + +# Workspace code is internal. Clippy should suggest the most correct +# fix without worrying about non-breaking-change rules, which only +# matter for published library APIs. +avoid-breaking-exported-api = false + +# Required companion for the clippy.semicolon_outside_block lint we +# ship in the catalog. Without this, the lint fires on multiline-block +# forms that are common Rust style. +semicolon-outside-block-ignore-multiline = true + +# Required companions for the clippy.unwrap_used lint we ship. +# Test code asserts via unwrap()/panic!() — that's how #[test] reports +# failure. Without these, every test triggers the lint. +allow-panic-in-tests = true +allow-unwrap-in-tests = true + +# Aspirational: when clippy.wildcard_imports is re-enabled (currently +# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# we want the stricter variant that warns on ALL wildcard imports +# including prelude. Setting it now means flipping the lint level to +# warn later is a one-line change with no tuning afterthought. +warn-on-all-wildcard-imports = true +# <<< anvil-managed: anvil-clippy + +=== crates/alpha/Cargo.toml === +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" + +# >>> anvil-managed: anvil-lints +[lints] +workspace = true +# <<< anvil-managed: anvil-lints + +=== crates/alpha/src/lib.rs === + + +=== deny.toml === +# >>> anvil-managed: anvil-deny +[advisories] +yanked = "deny" +# Scope of unmaintained-crate checks: "all" surfaces transitive +# dependencies too; tighten to "workspace" if the noise is high. +unmaintained = "all" + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.93 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# <<< anvil-managed: anvil-deny + +=== justfiles/anvil/checks.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. +# +# Each check belongs to one of four buckets, which determines how it +# interprets the impact env vars emitted by the cargo-delta impact step +# in cloud workflows: +# +# - "modified": only run when at least one package's source files +# changed in the diff. The check's underlying tool is workspace-wide +# or directory-scoped (cargo fmt --all, cargo heather, cargo +# spellcheck), so it doesn't take --package; we short-circuit on +# the ANVIL_INCLUDE_MODIFIED == "--skip" sentinel. +# +# - "affected": run on the affected set (modified ∪ reverse-deps +# within the workspace). The check's underlying tool takes +# --package; we splice ANVIL_INCLUDE_AFFECTED into the cargo +# invocation, defaulting to --workspace for local invocations where +# no env var is set. +# +# - "required": run on the required set (affected ∪ workspace-internal +# transitive deps). Same splice/default pattern as affected, but +# keyed on ANVIL_INCLUDE_REQUIRED. Used for checks whose tool +# resolves through the dep graph (cargo doc → intra-doc links; +# cargo hack → feature powerset; cargo udeps → unused-deps). +# +# - "unscoped": always run, no env var reference. External-input +# checks (deny, audit, aprz) and PR-context checks (pr-title) live +# here. Scheduled-exhaustive recipes (mutants-full) are also unscoped +# by design. +# +# Local invocation (no impact wiring): all three env vars are unset +# (recipes use the `?? "--workspace"` null-coalescing fallback below); +# modified-tier recipes simply skip the splice and run their +# workspace-wide tool; affected/required-tier recipes splat +# "--workspace" when the env var is unset. +# +# Preparation contract: when a recipe reaches the cargo call, the +# env var is one of: +# +# * unset - local run; the recipe substitutes +# "--workspace" via `?? "--workspace"` +# * "--package A --package B" - emitted by the cloud-workflow impact step when +# the tier has members +# * "--skip" - emitted by the cloud-workflow impact step when +# the tier is empty (recipe exits 0) +# +# This lets the simple recipes splat the var directly with +# & cargo X @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) ... +# and reduces the per-recipe boilerplate to a single one-line skip +# guard plus the cargo invocation. +# +# Modified-tier recipes never splice the env var into cargo (their +# tools are workspace-wide); they only check the skip sentinel. +# +# Every recipe whose body uses multi-line conditionals or env-var +# splicing is annotated with [script("pwsh")]. pwsh is preinstalled on +# Windows (since Windows 10), on GH/ADO hosted Linux + Windows +# runners, and installable on macOS via Homebrew or the upstream +# installer. We chose pwsh over bash because just's shebang dispatch +# requires `cygpath` on Windows (only on PATH from inside Git Bash), +# while [script("pwsh")] works from plain PowerShell with no PATH +# augmentation. The `??` null-coalescing operator used in the splat +# requires pwsh 7+, which is the floor we already require via +# _anvil-require pwsh. +# +# Single-command recipes (cargo deny check, cargo audit) are plain +# just recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# Note: [script(...)] requires `set unstable`. The adopter's root +# justfile must declare it (typically as a top-level line). anvil's +# mod.just does NOT redeclare it, to avoid conflicting with adopters +# who already have it. + +# === pr-fast members ==================================================== + +# Modified tier. cargo-fmt is a rustup component. We invoke it via the +# pinned nightly (see versions.just) because rustfmt.toml uses +# unstable_features = true (imports_granularity, group_imports, +# format_code_in_doc_comments). Floating nightly would mean +# format-drift breaking cloud workflows on rustup updates — the same trap we +# explicitly avoid for udeps/miri/careful/external-types. +[script("pwsh")] +anvil-fmt: anvil-fmt-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo '+{{ rust_nightly }}' fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. Per project policy, clippy runs on the affected set +# rather than the modified set: a change in a crate can introduce a +# clippy issue in a dependent crate (e.g., trait-bound or +# obviously-truthy-condition lints that key off the changed type), so +# we want downstream rev-deps to lint as well. cargo-clippy is a +# rustup component; same reasoning as fmt for the require. +[script("pwsh")] +anvil-clippy: anvil-clippy-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo clippy @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-targets --all-features --locked "--" '-D' 'warnings' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-cargo-sort: anvil-cargo-sort-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo sort --workspace --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-license-headers: anvil-license-headers-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo heather + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-cyclic-deps: anvil-ensure-no-cyclic-deps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-cyclic-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-default-features: anvil-ensure-no-default-features-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-default-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Required tier. cargo doc resolves intra-doc links through the dep +# graph, so a dep changing its public API can break doc-build in a +# crate that wasn't itself modified. +[script("pwsh")] +anvil-doc-build: anvil-doc-build-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + $env:RUSTDOCFLAGS = '-D warnings' + & cargo doc @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features --no-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +# +# cargo-doc2readme regenerates a crate's README.md from its rustdoc. +# Two extension points users frequently need: +# * Workspace-level template (`crates/README.j2` or `README.j2` at repo +# root): a Tera template applied to every crate's README. anvil +# auto-detects it and passes `--template` to the per-crate runs. +# * Per-crate opt-out: hand-crafted READMEs (e.g. a tool crate whose +# README is more freeform than the lib docs) opt out by adding +# `[package.metadata.ox-gen-readme]\ndisable = true` to their +# Cargo.toml. anvil skips those crates. +# +# Bin-only crates have no library rustdoc to base a README on, so they +# are skipped as well (cargo doc2readme requires a library target). +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: per-crate +# iteration over library targets, cargo-metadata-driven opt-outs +# (publish=false, [package.metadata.ox-gen-readme] disable), per-crate +# Push-Location into the crate dir (cargo-doc2readme is CWD-sensitive +# rather than --manifest-path-driven), and per-crate template-path +# resolution. The ANVIL_INCLUDE_MODIFIED value would still need to +# be intersected with the lib-crate set rather than splatted into cargo. +[script("pwsh")] +anvil-readme-check: anvil-readme-check-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-readme-check: no modified packages; skipping' + exit 0 + } + # Detect a workspace-level README template. Two conventional + # locations: crates/README.j2 (cargo-workspaces idiom) or + # README.j2 at repo root. + $template = $null + foreach ($candidate in 'crates/README.j2', 'README.j2') { + if (Test-Path $candidate) { $template = (Resolve-Path $candidate).Path; break } + } + # Iterate library crates. Filter by impact set when set, then drop + # bin-only crates and opt-outs. + $pkg = @(if ($env:ANVIL_INCLUDE_MODIFIED) { -split $env:ANVIL_INCLUDE_MODIFIED } else { '--workspace' }) + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $byName = @{} + foreach ($p in $meta.packages) { $byName[$p.name] = $p } + $candidates = if ($pkg -contains '--workspace') { + @($meta.packages | ForEach-Object { $_.name }) + } else { + $names = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + $names += $pkg[$i + 1]; $i++ + } + } + $names + } + $hadFailure = $false + foreach ($name in $candidates) { + $p = $byName[$name] + if (-not $p) { continue } + if (-not ($p.targets | Where-Object { $_.kind -contains 'lib' })) { continue } + # Skip private crates (publish = false). They aren't released + # and rarely have a polished README. Mirrors the + # `cargo workspaces exec --ignore-private` idiom adopters + # commonly use. + if ($p.publish -is [array] -and $p.publish.Count -eq 0) { + Write-Host "anvil-readme-check: $name (skipped: publish = false)" + continue + } + $disabled = $false + if ($p.metadata -and $p.metadata.'ox-gen-readme' -and $p.metadata.'ox-gen-readme'.disable) { + $disabled = $true + } + if ($disabled) { + Write-Host "anvil-readme-check: $name (opted out via [package.metadata.ox-gen-readme])" + continue + } + Write-Host "anvil-readme-check: $name" + # cargo doc2readme writes / compares relative to its CWD (not + # --manifest-path), so chdir into the crate before invoking + # --check. We also compute a per-crate relative path to the + # workspace-level template so the same template file works for + # every crate (parallels the cargo-workspaces idiom). + $crateDir = Split-Path -Parent $p.manifest_path + Push-Location $crateDir + try { + $relTemplate = if ($template) { + Resolve-Path -Relative -LiteralPath $template + } else { + $null + } + $args = @('doc2readme', '--check') + if ($relTemplate) { $args += @('--template', $relTemplate) } + & cargo @args + if ($LASTEXITCODE -ne 0) { $hadFailure = $true } + } finally { + Pop-Location + } + } + if ($hadFailure) { exit 1 } + +# Modified tier. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# If `.spelling` is present, we generate `target/spelling.dic` from it +# automatically; otherwise we run cargo-spellcheck against whatever +# the repo has already set up. +[script("pwsh")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-spellcheck: no modified packages; skipping' + exit 0 + } + if (Test-Path '.spelling') { + $output_file = 'target/spelling.dic' + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = $lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + } + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Unscoped (PR title is not source-related). +# +# Validates that $env:PR_TITLE matches Conventional Commits when set; +# no-op when unset. Cloud workflows inject PR_TITLE explicitly (GH Actions: +# ${{ github.event.pull_request.title }}; ADO: $(System.PullRequest.Title)) +# so the check has authoritative input there. Locally it skips silently -- +# there is no reliable way to recover the PR title for the ADO backend +# (no equivalent of `gh pr view`), so the recipe stays simple and +# defers the check to cloud workflows. +[script("pwsh")] +anvil-pr-title: anvil-pr-title-validate-prereqs + $title = $env:PR_TITLE + if (-not $title) { + Write-Host 'anvil-pr-title: PR_TITLE env var not set; skipping (check runs in cloud workflows)' + exit 0 + } + if ($title -notmatch '^(feat|fix|chore|docs|refactor|test|build|cloud workflows|perf|revert)(\([^)]+\))?!?: .+') { + Write-Error "PR title '$title' does not match Conventional Commits" + exit 1 + } + +# Unscoped (consults external advisory DB; reads Cargo.lock, not +# workspace members). Single command — inherits adopter's default shell. +anvil-deny: anvil-deny-validate-prereqs + cargo deny check + +# Unscoped (consults external advisory DB; reads Cargo.lock). +anvil-audit: anvil-audit-validate-prereqs + cargo audit + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Deliberately omits `--all-targets`: with `--all-targets`, a dep +# that's listed in BOTH `[dependencies]` and `[dev-dependencies]` and +# used only by tests is reported as "all used" because the dev-deps +# target satisfies the lookup, masking the unused entry in main +# `[dependencies]`. Restricting to the default targets (lib + bins) +# matches main repo cloud workflows' check and surfaces the real bug. +[script("pwsh")] +anvil-udeps: anvil-udeps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' udeps @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier (compares published API of changed crates against the +# baseline; only changed crates' surface is at risk). +# +# Several real-world conditions produce errors that aren't actually +# SemVer violations: +# - bin-only crates have no API to compare ("no library targets found"). +# - crates not yet published to crates.io ("not found in registry"). +# - crates where the published baseline lacks a lib target the +# current source has (bin -> bin+lib transition). +# We pre-filter to library-bearing crates from cargo metadata, then +# run cargo-semver-checks per-package and tolerate the +# "no-comparable-baseline" failure modes. +# +# Findings policy: this recipe is *advisory*. Real SemVer findings do +# NOT fail the recipe -- breaking changes between unreleased commits +# are normal (the major-version bump happens at release time, not on +# every PR). Instead, when there are findings we write a markdown +# advisory body to `target/anvil/comments/semver.md`; when the +# tree is clean we remove that file. cloud-workflow wiring (GH: +# marocchino/sticky-pull-request-comment; ADO: pwsh + REST API) +# inspects the file after the recipe and upserts / clears a sticky +# PR comment accordingly. Local invocation gets the same file +# written under target/ for inspection. +# +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-metadata +# filter to library crates (cargo-semver-checks --workspace fails on +# bin-only workspaces), intersect ANVIL_INCLUDE_AFFECTED with that +# set, then per-crate invocation with selective error tolerance for +# unpublished crates ("not found in registry") and bin->bin+lib +# transitions ("no library targets found"). +[script("pwsh")] +anvil-semver-check: anvil-semver-check-validate-prereqs + $ErrorActionPreference = 'Stop' + $commentFile = 'target/anvil/comments/semver.md' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-semver-check: no affected packages; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build the candidate package list. Always iterate per-package over + # library crates only -- cargo-semver-checks --workspace would fail + # on workspaces that contain bin-only crates ("no library targets + # found"), and we want the same tolerance for unpublished / bin->lib- + # transition crates regardless of whether we got here via impact- + # scoping (cloud workflows) or full-workspace fallback (local). + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $true + } + } + if ($pkg -contains '--workspace') { + $packages = @($libPkgs.Keys) + } else { + $packages = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs[$pkg[$i + 1]]) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-semver-check: no affected library crates; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $findings = New-Object System.Collections.Generic.List[string] + foreach ($p in $packages) { + Write-Host "anvil-semver-check: $p" + $output = (& cargo semver-checks --package $p 2>&1) | Out-String + if ($LASTEXITCODE -ne 0) { + if ($output -match 'not found in registry|no library targets found') { + Write-Host " $p has no comparable baseline; skipping (likely unpublished or bin->lib transition)" -ForegroundColor Yellow + } else { + Write-Host $output + # Append a per-crate findings block. Using one-line-at-a-time + # appends keeps the markdown free of pwsh backtick-escape + # gymnastics (single-quoted literals + the natural `n join + # produce clean LF newlines and unambiguous triple-backticks). + $findings.Add('### `' + $p + '`') | Out-Null + $findings.Add('') | Out-Null + $findings.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $findings.Add($line.TrimEnd()) | Out-Null + } + $findings.Add('```') | Out-Null + $findings.Add('') | Out-Null + } + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: Potential breaking changes detected') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # Advisory: always exit 0. cloud-workflow wiring posts/clears the PR comment. + exit 0 + +# Affected tier (lints public API of changed crates and rev-deps). +# +# cargo-check-external-types is per-manifest: no --package/--workspace, +# only --manifest-path. Iterate the affected library crates and run +# the tool once each, pointing at the crate's Cargo.toml. Bin-only +# crates have no public API surface and are skipped. +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-check- +# external-types is per-manifest (no --package/--workspace), so we +# build a name->manifest map from cargo metadata, filter to lib crates, +# intersect with ANVIL_INCLUDE_AFFECTED, and call the tool once per +# crate. Hard-fails on errors (no tolerance, unlike semver-check). +[script("pwsh")] +anvil-external-types: anvil-external-types-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-external-types: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build pkg-name -> manifest-path map, restricted to library crates. + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $p.manifest_path + } + } + # Decide which packages to check. + $packages = @() + if ($pkg -contains '--workspace') { + $packages = $libPkgs.Keys + } else { + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs.ContainsKey($pkg[$i + 1])) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-external-types: no affected library crates; skipping' + exit 0 + } + $failed = $false + foreach ($p in $packages) { + Write-Host "anvil-external-types: $p" + # cargo-check-external-types requires nightly rustdoc (uses + # unstable -Z flags) AND pins a specific rustdoc-types schema + # version. We pin nightly narrowly to the schema this tool + # version expects via `rust_nightly_external_types` in + # versions.just — bump that pin alongside any cargo-check- + # external-types upgrade. No tolerance for schema mismatches: + # if it fails, the pin or the tool needs to move. + & cargo '+{{ rust_nightly_external_types }}' check-external-types --manifest-path $libPkgs[$p] + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + +# Unscoped (consults external risk DB). +anvil-aprz: anvil-aprz-validate-prereqs + cargo aprz deps --error-if-high-risk --console appraisal + +# === pr-test members ==================================================== + +# Affected tier. +[script("pwsh")] +anvil-llvm-cov: anvil-llvm-cov-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-llvm-cov: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # cargo-llvm-cov doesn't work on aarch64-pc-windows-msvc: the + # llvm-profdata that ships with the rust toolchain there fails + # to merge the .profraw set ("no profile can be merged"). Fall + # back to plain `cargo nextest run` on that target so we still + # get test execution; coverage data from this leg wouldn't have + # been used anyway (coverage upload is gated on Linux only). + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-llvm-cov: aarch64-pc-windows-msvc -- skipping coverage; running plain nextest' + & cargo nextest run @pkg --all-features --locked + exit $LASTEXITCODE + } + # cargo llvm-cov writes the .profraw set into target/llvm-cov-target/ + # but the *report* output directory (target/coverage/) is something + # we choose and must exist before --output-path runs. + [System.IO.Directory]::CreateDirectory('target/coverage') | Out-Null + [System.IO.Directory]::CreateDirectory('target/coverage/html') | Out-Null + # Wipe stale .profraw data so the report reflects only this run. + cargo llvm-cov clean --workspace --profraw-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Instrument and run tests; defer report generation so we can emit + # multiple formats from the same profraw set without re-running. + # Note: nextest's exit-4 ("no tests to run") IS treated as a + # failure here -- a llvm-cov run that finds no tests almost + # always means a config mistake (wrong package filter, missing + # test target, etc.), not a legitimate empty set. anvil-miri + # is the exception (see its comment): miri-skipped tests are an + # expected design point for FS-heavy crates. + & cargo llvm-cov nextest @pkg --all-features --locked --no-report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # lcov.info feeds Codecov on GitHub; cobertura.xml feeds + # PublishCodeCoverageResults@2 on Azure DevOps. + cargo llvm-cov report --lcov --output-path target/coverage/lcov.info + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Local-only HTML viewer (no cloud-workflow consumer); cheap once the data exists. + cargo llvm-cov report --html --output-dir target/coverage/html + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-doc-test: anvil-doc-test-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo test --doc @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-examples: anvil-examples-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo build @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --examples --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === pr-mutants member ==================================================== + +# Affected tier. cargo-mutants does its own diff-scoping via --in-diff; +# the affected-tier guard is the wiring-layer's coarse filter (if no +# affected packages exist, the entire mutants run is pointless). +# +# Skip on aarch64-pc-windows-msvc: cargo-mutants doesn't build there +# (upstream winapi incompatibility), so `_anvil-require cargo-mutants` +# would fail. The merged pr-slow group runs on all four OS legs; mutants +# is the only sub-recipe that can't follow, so it bails out early on the +# affected leg. Coverage on the other three legs is unchanged. +[script("pwsh")] +anvil-mutants-diff: anvil-mutants-diff-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs aarch64-pc-windows-msvc -- cargo-mutants does not build here (winapi); skipping' + exit 0 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no affected packages; skipping' + exit 0 + } + # Resolve BASE_REF: env override > origin/main > origin/master. + $base = $null + if ($env:BASE_REF) { + $base = $env:BASE_REF + } else { + foreach ($candidate in @('origin/main', 'origin/master')) { + git rev-parse --verify $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + } + if (-not $base) { + Write-Error 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no BASE_REF set and neither origin/main nor origin/master is available. Set BASE_REF to the branch to diff against.' + exit 1 + } + # cargo-mutants --in-diff takes a FILE path containing a unified + # diff, not a git revision range. Write the diff to a temp file + # first. RUNNER_TEMP (GH) and AGENT_TEMPDIRECTORY (ADO) point at + # the job's scratch dir; fall back to the system temp dir locally. + $tmp_dir = $env:RUNNER_TEMP + if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } + if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } + $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' + git diff "$base..HEAD" --output=$diff_path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-runtime members ============================================ + +# Nightly checks always run full-workspace; impact env vars aren't set +# by the scheduled workflow, so the affected-tier default (--workspace) +# applies. Skip guards are still included for local diff-scoped runs. + +[script("pwsh")] +anvil-miri: anvil-miri-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + # `--no-tests=pass`: miri-only exception. Tests that touch the + # filesystem, spawn subprocesses, or use other miri-incompatible + # APIs commonly carry `#[cfg_attr(miri, ignore)]` (this is the + # canonical opt-out for build-tooling / CLI crates). A crate + # whose test set ends up entirely-skipped under miri legitimately + # produces zero runnable tests; nextest's default exit-4 ("no + # tests to run") would fail the recipe in that case. We treat + # empty test runs as success for miri only. Other nextest-using + # recipes (llvm-cov) keep exit-4 as a failure because zero tests + # there almost always indicates a config mistake. + & cargo '+{{ rust_nightly }}' miri nextest run --no-tests=pass @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +[script("pwsh")] +anvil-careful: anvil-careful-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' careful test @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-exhaustive members ========================================= + +# Unscoped. Scheduled-exhaustive deliberately runs over the whole +# workspace regardless of diff. +anvil-mutants-full: anvil-mutants-full-validate-prereqs + cargo mutants --workspace --no-shuffle --jobs 0 + +# Required tier. cargo-hack's feature powerset cascades through dep +# features, so the required set (workspace-internal transitive deps) +# is the right scope. +[script("pwsh")] +anvil-cargo-hack: anvil-cargo-hack-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo hack @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --feature-powerset --depth 2 check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-bench: anvil-bench-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo bench @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --no-run + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ============================================================================ +# Per-check setup + validate-prereqs +# ============================================================================ +# +# Each check has matching `*-setup` and `*-validate-prereqs` recipes +# that install / verify the tools and components it needs. The setup +# recipes accept an `installer=install|install` parameter that +# forwards to the underlying tool-install recipes; the validate-prereqs +# recipes take no parameters. +# +# These are the building blocks for `anvil--setup` +# (groups.just) and `anvil--setup` (tiers.just) ΓÇö each +# group/tier-level recipe is just a fan-out over the per-check +# setup/validate-prereqs of its members. + +# --- pr-fast members --- + +[group("anvil-setup")] +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install + +[group("anvil-setup")] +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs + +[group("anvil-setup")] +anvil-clippy-setup installer="install": anvil-component-default-clippy-install + +[group("anvil-setup")] +anvil-clippy-validate-prereqs: anvil-component-default-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-sort-setup installer="install": (anvil-tool-cargo-sort-install installer) + +[group("anvil-setup")] +anvil-cargo-sort-validate-prereqs: anvil-tool-cargo-sort-validate-prereqs + +[group("anvil-setup")] +anvil-license-headers-setup installer="install": (anvil-tool-cargo-heather-install installer) + +[group("anvil-setup")] +anvil-license-headers-validate-prereqs: anvil-tool-cargo-heather-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-setup installer="install": (anvil-tool-cargo-ensure-no-cyclic-deps-install installer) + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-validate-prereqs: anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-default-features-setup installer="install": (anvil-tool-cargo-ensure-no-default-features-install installer) + +[group("anvil-setup")] +anvil-ensure-no-default-features-validate-prereqs: anvil-tool-cargo-ensure-no-default-features-validate-prereqs + +# doc-build, examples and doc-test are pure cargo built-ins; the rust +# toolchain (rustc + cargo) is the only prerequisite, and we already +# rely on it being present everywhere anvil runs. +[group("anvil-setup")] +anvil-doc-build-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-build-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-readme-check-setup installer="install": (anvil-tool-cargo-doc2readme-install installer) + +[group("anvil-setup")] +anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereqs + +# cargo-spellcheck has a build-time libclang dependency; the system +# deps check runs first so adopters get a clear hint instead of a +# cryptic clang-sys build error mid-install. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": anvil-system-deps-check (anvil-tool-cargo-spellcheck-install installer) + +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +# pr-title is a pwsh script; no cargo tool to install. +[group("anvil-setup")] +anvil-pr-title-setup installer="install": anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-pr-title-validate-prereqs: anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-deny-setup installer="install": (anvil-tool-cargo-deny-install installer) + +[group("anvil-setup")] +anvil-deny-validate-prereqs: anvil-tool-cargo-deny-validate-prereqs + +[group("anvil-setup")] +anvil-audit-setup installer="install": (anvil-tool-cargo-audit-install installer) + +[group("anvil-setup")] +anvil-audit-validate-prereqs: anvil-tool-cargo-audit-validate-prereqs + +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +[group("anvil-setup")] +anvil-external-types-setup installer="install": anvil-toolchain-nightly-external-types-install (anvil-tool-cargo-check-external-types-install installer) + +[group("anvil-setup")] +anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-validate-prereqs anvil-tool-cargo-check-external-types-validate-prereqs + +[group("anvil-setup")] +anvil-aprz-setup installer="install": (anvil-tool-cargo-aprz-install installer) + +[group("anvil-setup")] +anvil-aprz-validate-prereqs: anvil-tool-cargo-aprz-validate-prereqs + +# --- pr-test members (shared with scheduled-test) --- + +[group("anvil-setup")] +anvil-llvm-cov-setup installer="install": (anvil-tool-cargo-llvm-cov-install installer) (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-llvm-cov-validate-prereqs: anvil-tool-cargo-llvm-cov-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-validate-prereqs: anvil-tool-rustc-validate-prereqs + +# --- pr-runtime-analysis members --- + +[group("anvil-setup")] +anvil-miri-setup installer="install": anvil-component-nightly-miri-install anvil-component-nightly-rust-src-install (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-miri-validate-prereqs: anvil-component-nightly-miri-validate-prereqs anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-careful-setup installer="install": anvil-component-nightly-rust-src-install (anvil-tool-cargo-careful-install installer) + +[group("anvil-setup")] +anvil-careful-validate-prereqs: anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-careful-validate-prereqs + +# --- pr-mutants members --- + +[group("anvil-setup")] +anvil-mutants-diff-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-diff-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +# --- scheduled-exhaustive members (mutants-full reuses cargo-mutants; +# cargo-hack and bench are dedicated tools) --- + +[group("anvil-setup")] +anvil-mutants-full-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-full-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-hack-setup installer="install": (anvil-tool-cargo-hack-install installer) + +[group("anvil-setup")] +anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs + +# bench uses cargo-built-ins (cargo bench --no-run + plain bench runs); +# no extra tool install needed beyond the rust toolchain. +[group("anvil-setup")] +anvil-bench-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-bench-validate-prereqs: anvil-tool-rustc-validate-prereqs + +=== justfiles/anvil/groups.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. + +# Each group is one cloud-workflow job. Within a group, checks run sequentially. + +# PR groups +# =========================================================================== + +[group("anvil")] +anvil-pr-fast: \ + anvil-fmt \ + anvil-clippy \ + anvil-cargo-sort \ + anvil-license-headers \ + anvil-ensure-no-cyclic-deps \ + anvil-ensure-no-default-features \ + anvil-doc-build \ + anvil-readme-check \ + anvil-spellcheck \ + anvil-pr-title \ + anvil-deny \ + anvil-audit \ + anvil-udeps \ + anvil-semver-check \ + anvil-external-types \ + anvil-aprz + +# pr-slow is the single PR-tier group for everything that takes more +# than ~30s per crate (tests, stricter runtimes, mutation testing). +# It's internally split into three sub-recipes so individual concerns +# can be invoked locally without dragging the others along: +# +# slow1: tests + coverage (replaces the former pr-test group) +# slow2: stricter-runtime correctness (miri, careful) +# slow3: mutation testing +# +# Cloud workflows run pr-slow as ONE job per OS leg -- the sub-recipes run +# sequentially within. This trades per-leg wall-clock for fewer +# orchestration jobs and a flatter PR check graph. Individual sub- +# recipes are runnable on their own locally: +# +# $ just anvil-pr-test # tests + coverage only +# $ just anvil-pr-runtime-analysis # miri + careful only +# $ just anvil-pr-mutants # mutants only +[group("anvil")] +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants + +[group("anvil")] +anvil-pr-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-pr-runtime-analysis: \ + anvil-miri \ + anvil-careful + +[group("anvil")] +anvil-pr-mutants: anvil-mutants-diff + +# Scheduled groups +# =========================================================================== + +[group("anvil")] +anvil-scheduled-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-scheduled-advisories: \ + anvil-deny \ + anvil-audit \ + anvil-aprz \ + anvil-clippy + +[group("anvil")] +anvil-scheduled-exhaustive: \ + anvil-mutants-full \ + anvil-cargo-hack \ + anvil-bench +# Group-level setup + validate-prereqs +# =========================================================================== +# +# Per-group recipes that fan out to the per-check setup/validate-prereqs +# from checks.just. Setup recipes accept `installer="install"|"binstall"`; +# validate-prereqs recipes take no parameters. + +[group("anvil-setup")] +anvil-pr-fast-setup installer="install": \ + (anvil-fmt-setup installer) \ + (anvil-clippy-setup installer) \ + (anvil-cargo-sort-setup installer) \ + (anvil-license-headers-setup installer) \ + (anvil-ensure-no-cyclic-deps-setup installer) \ + (anvil-ensure-no-default-features-setup installer) \ + (anvil-doc-build-setup installer) \ + (anvil-readme-check-setup installer) \ + (anvil-spellcheck-setup installer) \ + (anvil-pr-title-setup installer) \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-udeps-setup installer) \ + (anvil-semver-check-setup installer) \ + (anvil-external-types-setup installer) \ + (anvil-aprz-setup installer) + +[group("anvil-setup")] +anvil-pr-fast-validate-prereqs: \ + anvil-fmt-validate-prereqs \ + anvil-clippy-validate-prereqs \ + anvil-cargo-sort-validate-prereqs \ + anvil-license-headers-validate-prereqs \ + anvil-ensure-no-cyclic-deps-validate-prereqs \ + anvil-ensure-no-default-features-validate-prereqs \ + anvil-doc-build-validate-prereqs \ + anvil-readme-check-validate-prereqs \ + anvil-spellcheck-validate-prereqs \ + anvil-pr-title-validate-prereqs \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-udeps-validate-prereqs \ + anvil-semver-check-validate-prereqs \ + anvil-external-types-validate-prereqs \ + anvil-aprz-validate-prereqs + +[group("anvil-setup")] +anvil-pr-slow-setup installer="install": \ + (anvil-pr-test-setup installer) \ + (anvil-pr-runtime-analysis-setup installer) \ + (anvil-pr-mutants-setup installer) + +[group("anvil-setup")] +anvil-pr-slow-validate-prereqs: \ + anvil-pr-test-validate-prereqs \ + anvil-pr-runtime-analysis-validate-prereqs \ + anvil-pr-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-pr-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-pr-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-pr-runtime-analysis-setup installer="install": \ + (anvil-miri-setup installer) \ + (anvil-careful-setup installer) + +[group("anvil-setup")] +anvil-pr-runtime-analysis-validate-prereqs: \ + anvil-miri-validate-prereqs \ + anvil-careful-validate-prereqs + +[group("anvil-setup")] +anvil-pr-mutants-setup installer="install": (anvil-mutants-diff-setup installer) + +[group("anvil-setup")] +anvil-pr-mutants-validate-prereqs: anvil-mutants-diff-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-scheduled-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-advisories-setup installer="install": \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-aprz-setup installer) \ + (anvil-clippy-setup installer) + +[group("anvil-setup")] +anvil-scheduled-advisories-validate-prereqs: \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-aprz-validate-prereqs \ + anvil-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-exhaustive-setup installer="install": \ + (anvil-mutants-full-setup installer) \ + (anvil-cargo-hack-setup installer) \ + (anvil-bench-setup installer) + +[group("anvil-setup")] +anvil-scheduled-exhaustive-validate-prereqs: \ + anvil-mutants-full-validate-prereqs \ + anvil-cargo-hack-validate-prereqs \ + anvil-bench-validate-prereqs + +=== justfiles/anvil/mod.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Entry point for the anvil just recipe tree. The user's root Justfile +# imports this single file; everything else lives in sibling .just files +# pulled in here. + +# All multi-statement recipes in this tree use [script("pwsh")]. pwsh +# is preinstalled on Windows (10+), GH/ADO hosted Linux + Windows +# runners, and is installable on macOS/Linux via Homebrew / upstream +# installer. We chose pwsh over bash because: +# +# - just's shebang dispatch (#!/usr/bin/env bash) requires `cygpath` +# on Windows, which is only on PATH inside Git Bash. Plain +# PowerShell can't run shebang recipes. +# - just's [script("bash")] attribute passes Windows tempfile paths +# to bash unescaped, and bash interprets the backslashes as escape +# characters — every recipe fails with a mangled path. +# - [script("pwsh")] works from plain PowerShell with no PATH +# augmentation and no path translation. pwsh handles Windows +# paths natively. +# +# The existing `_anvil-require pwsh` recipe already established +# pwsh as a tools-floor requirement, so requiring it as the recipe +# interpreter is consistent. +# +# Single-command recipes (e.g. `cargo deny check`) are plain just +# recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# IMPORTANT: [script(...)] requires `set unstable`. The adopter's +# root justfile must declare it (typically as a top-level line). +# anvil's mod.just does NOT redeclare it, to avoid conflicting +# with adopters who already have it. + +import 'checks.just' +import 'groups.just' +import 'tiers.just' +import 'tools.just' +import 'versions.just' + +# Friendly default: `just anvil` runs the PR tier. +alias anvil := anvil-pr + +=== justfiles/anvil/tiers.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the tier structure. + +# PR tier: every check that should run on every pull request, split +# into two groups so the fast checks aren't blocked behind the slow +# ones in cloud workflows. +[group("anvil")] +anvil-pr: \ + anvil-pr-fast \ + anvil-pr-slow + +# scheduled tier: full-workspace re-runs of things that can change +# without a commit (advisories, flakes) + the truly expensive +# exhaustive checks that don't fit in a PR budget. Runs on a schedule +# against `main`, not on PRs. +[group("anvil")] +anvil-scheduled: \ + anvil-scheduled-test \ + anvil-scheduled-advisories \ + anvil-scheduled-exhaustive + +# Full tier: PR + scheduled, end-to-end. Useful before tagging a release. +[group("anvil")] +anvil-full: \ + anvil-pr \ + anvil-scheduled + +# Tier-level + global setup + validate-prereqs +# =========================================================================== +# +# Per-tier recipes that fan out to per-group setup/validate-prereqs from +# groups.just. The global `anvil-setup` / `anvil-validate-prereqs` +# recipes are the catch-all entry points that install / verify everything. +# +# Cloud workflows typically invoke only the per-group setup it needs (e.g. the +# `anvil-pr-fast` composite action / step template runs +# `anvil-pr-fast-setup` rather than the global `anvil-setup`). +# Local users who want "install everything" run `just anvil-setup`. + +[group("anvil-setup")] +anvil-pr-setup installer="install": \ + (anvil-pr-fast-setup installer) \ + (anvil-pr-slow-setup installer) + +[group("anvil-setup")] +anvil-pr-validate-prereqs: \ + anvil-pr-fast-validate-prereqs \ + anvil-pr-slow-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-setup installer="install": \ + (anvil-scheduled-test-setup installer) \ + (anvil-scheduled-advisories-setup installer) \ + (anvil-scheduled-exhaustive-setup installer) + +[group("anvil-setup")] +anvil-scheduled-validate-prereqs: \ + anvil-scheduled-test-validate-prereqs \ + anvil-scheduled-advisories-validate-prereqs \ + anvil-scheduled-exhaustive-validate-prereqs + +[group("anvil-setup")] +anvil-full-setup installer="install": \ + (anvil-pr-setup installer) \ + (anvil-scheduled-setup installer) + +[group("anvil-setup")] +anvil-full-validate-prereqs: \ + anvil-pr-validate-prereqs \ + anvil-scheduled-validate-prereqs + +# Global aliases. `anvil-setup` (no suffix) installs everything the +# catalog knows about; `anvil-validate-prereqs` verifies every tool +# and component is present at or above its pinned version. +[group("anvil-setup")] +anvil-setup installer="install": (anvil-full-setup installer) + +[group("anvil-setup")] +anvil-validate-prereqs: anvil-full-validate-prereqs + +=== justfiles/anvil/tools.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/local.md for the policy. + +# ============================================================================ +# System-level prerequisites (libclang, etc.) +# ============================================================================ + +# Public: probe for system-level prerequisites that catalog tools need +# to BUILD from source. The `binstall` install path downloads pre-built +# binaries and does not need these, so this check is primarily relevant +# for the source-build `install` path (used by local devs and the ADO +# backend) and is best-effort (skipped silently) for `binstall`. +# +# Scope policy: only system libs that an anvil catalog tool DIRECTLY +# requires. We do not try to be a general-purpose dev-env doctor. +# Current entries: +# - libclang: required by cargo-spellcheck (clang-sys / hunspell-rs) +# at build time. +# +# Detection uses presence-only probes (file existence in standard install +# dirs + `LIBCLANG_PATH` env var). No version checks -- system libs upgrade +# independently and any reasonably modern libclang works for clang-sys. +# +# On missing deps the recipe prints copy-paste install hints per OS / +# package manager and exits non-zero. No auto-install: admin / sudo and +# package-manager choice stay with the user. +[group("anvil-setup")] +[script("pwsh")] +anvil-system-deps-check: + $ErrorActionPreference = 'Stop' + $missing = @() + + # libclang: required to BUILD cargo-spellcheck from source. + $haveLibclang = $false + $libclangFiles = @('libclang.dll', 'libclang.so', 'libclang.so.1', 'libclang.dylib') + if ($env:LIBCLANG_PATH) { + foreach ($f in $libclangFiles) { + if (Test-Path (Join-Path $env:LIBCLANG_PATH $f)) { $haveLibclang = $true; break } + } + } + if (-not $haveLibclang) { + $probes = if ($IsWindows) { + @( + 'C:\Program Files\LLVM\bin\libclang.dll', + "$env:USERPROFILE\scoop\apps\llvm\current\bin\libclang.dll" + ) + } elseif ($IsMacOS) { + @( + '/usr/local/opt/llvm/lib/libclang.dylib', + '/opt/homebrew/opt/llvm/lib/libclang.dylib' + ) + } else { + @( + '/usr/lib/x86_64-linux-gnu/libclang.so.1', + '/usr/lib/aarch64-linux-gnu/libclang.so.1', + '/usr/lib64/libclang.so', + '/usr/lib64/libclang.so.1' + ) + } + foreach ($p in $probes) { + if (Get-Item -LiteralPath $p -ErrorAction SilentlyContinue) { $haveLibclang = $true; break } + } + # Linux distros often add a version suffix (libclang-19.so etc.); glob fallback. + if (-not $haveLibclang -and -not $IsWindows -and -not $IsMacOS) { + $glob = Get-ChildItem -Path '/usr/lib','/usr/lib64','/usr/lib/x86_64-linux-gnu','/usr/lib/aarch64-linux-gnu' -Filter 'libclang*.so*' -ErrorAction SilentlyContinue + if ($glob) { $haveLibclang = $true } + } + } + if (-not $haveLibclang) { + $missing += [pscustomobject]@{ + name = 'libclang' + why = 'cargo-spellcheck (clang-sys/hunspell-rs) needs libclang at build time' + hints = @( + 'Linux (Ubuntu/Debian): sudo apt-get install -y libclang-dev' + 'Linux (Azure Linux/RHEL): sudo tdnf install -y clang-devel' + 'macOS (homebrew): brew install llvm' + 'Windows (scoop): scoop install llvm # no admin' + 'Windows (winget, admin): winget install LLVM.LLVM' + ) + } + } + + if ($missing.Count -gt 0) { + Write-Host '' + foreach ($m in $missing) { + Write-Host "anvil: missing system dependency '$($m.name)'" -ForegroundColor Yellow + Write-Host " why: $($m.why)" + Write-Host ' install:' + foreach ($h in $m.hints) { Write-Host " $h" } + Write-Host '' + } + Write-Host "If the dep is installed but not on the standard search path, set LIBCLANG_PATH and re-run." -ForegroundColor Yellow + exit 1 + } + +# ============================================================================ +# rustc + pwsh validate-prereqs (no install -- system prereqs) +# ============================================================================ +# +# rustc is installed via rustup (https://rustup.rs); pwsh is installed +# via the platform's package manager. Both are presumed present on any +# machine running anvil; the validate recipes just surface a friendly +# error if not. + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-rustc-validate-prereqs: + if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Error 'anvil: rustc not found. Install via rustup: https://rustup.rs' + exit 1 + } + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-pwsh-validate-prereqs: + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $hint = if ($IsMacOS) { + 'brew install --cask powershell' + } elseif ($IsLinux) { + 'see https://github.com/PowerShell/PowerShell' + } else { + 'winget install --id Microsoft.PowerShell' + } + Write-Error "anvil: pwsh (PowerShell Core) not found. Install: $hint" + exit 1 + } + +# ============================================================================ +# Private helpers (install/check primitives) +# ============================================================================ + +# _install-tool: install a cargo subcommand at exactly the pinned version, +# or no-op if it is already installed at or above that version. The +# `installer` parameter selects between: +# - "install" (cargo install --locked, pure-source). Default. +# - "binstall" (cargo binstall --no-confirm --locked, with cargo install +# fallback if binstall fails). Bootstraps cargo-binstall +# itself if not on PATH. +[script("pwsh")] +_install-tool name version installer: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + $installer = '{{installer}}' + + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool: unknown installer '$installer' (expected 'install' or 'binstall')" + exit 2 + } + + # Already at or above the pin: skip. We don't downgrade tools the + # user upgraded for their own reasons; the validate side uses + # `installed >= pin`, so newer is fine. The early-exit is also what + # makes the actions/cache restore actually useful -- without it, + # every post-restore run would try to re-install on top of the + # cached binaries and fail with "binary already exists in destination". + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if ($installed) { + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. + } + } + + Write-Host "Installing $name =$version (installer: $installer)" + if ($installer -eq 'binstall') { + if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { + Write-Host ' Bootstrapping cargo-binstall' + cargo install --locked cargo-binstall + if ($LASTEXITCODE -ne 0) { + Write-Error 'cargo-binstall bootstrap failed' + exit $LASTEXITCODE + } + } + cargo binstall --no-confirm --locked $name --version "=$version" + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow + } + cargo install --locked $name --version "=$version" + if ($LASTEXITCODE -ne 0) { + Write-Error "$name install FAILED" + exit $LASTEXITCODE + } + +# _check-tool: verify a cargo subcommand is installed at or above the +# pinned version. Errors with an install hint on missing or too-old. +[script("pwsh")] +_check-tool name version: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if (-not $installed) { + Write-Error "anvil: required tool '$name' not found. Install: cargo install --locked --version =$version $name" + exit 1 + } + try { + $installedV = [version]$installed + $pinV = [version]$version + } catch { + Write-Error "anvil: cannot compare versions for '$name' (installed=$installed pin=$version). Reinstall: cargo install --locked --version =$version $name" + exit 1 + } + if ($installedV -lt $pinV) { + Write-Error "anvil: '$name' v$installed is older than the required minimum v$version. Upgrade: cargo install --locked --version =$version $name" + exit 1 + } + +# _install-toolchain: install a specific rustup toolchain (no components). +[script("pwsh")] +_install-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + Write-Host "rustup toolchain install $toolchain --profile minimal --no-self-update" + rustup toolchain install $toolchain --profile minimal --no-self-update + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-toolchain: verify a rustup toolchain is installed. +[script("pwsh")] +_check-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + rustup which --toolchain $toolchain rustc 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + +# _install-component: add a component to a toolchain. The toolchain is +# either the literal "default" (current rustup default) or a specific +# pinned toolchain string (which must already be installed -- the +# per-component setup recipes ensure this via a dependency on +# anvil--install). +[script("pwsh")] +_install-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + if ($toolchain -eq 'default') { + Write-Host "rustup component add $component (default toolchain)" + rustup component add $component + } else { + Write-Host "rustup component add --toolchain $toolchain $component" + rustup component add --toolchain $toolchain $component + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install component '$component' on toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-component: verify a component is installed on a toolchain. +[script("pwsh")] +_check-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + $output = if ($toolchain -eq 'default') { + rustup component list --installed 2>$null + } else { + rustup component list --installed --toolchain $toolchain 2>$null + } + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + $found = $output | Where-Object { $_ -like "$component*" } + if (-not $found) { + $cmd = if ($toolchain -eq 'default') { "rustup component add $component" } else { "rustup component add --toolchain $toolchain $component" } + Write-Error "anvil: component '$component' not installed on toolchain '$toolchain'. Run: $cmd" + exit 1 + } + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +[group("anvil-setup")] +anvil-toolchain-nightly-install: (_install-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-validate-prereqs: (_check-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-install: (_install-toolchain rust_nightly_external_types) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-validate-prereqs: (_check-toolchain rust_nightly_external_types) + +# ============================================================================ +# Rustup components +# ============================================================================ +# +# Default-toolchain components are installed via `rustup component add` +# (no toolchain spec). Nightly components depend on the toolchain +# being installed first (via the relevant anvil--install +# recipe) and then add the component. + +[group("anvil-setup")] +anvil-component-default-clippy-install: (_install-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-clippy-validate-prereqs: (_check-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-rustfmt-install: (_install-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-default-rustfmt-validate-prereqs: (_check-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-miri-install: anvil-toolchain-nightly-install (_install-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-miri-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rust-src") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rust-src") + +# ============================================================================ +# Cargo subcommands +# ============================================================================ +# +# One pair (install + validate-prereqs) per tool, alphabetical. +# Version pins live in versions.just (one cargo__version variable +# per tool). The install recipes accept an `installer="install"|"binstall"` +# parameter; the validate-prereqs recipes do not (they only read state). + +[group("anvil-setup")] +anvil-tool-cargo-aprz-install installer="install": (_install-tool "cargo-aprz" cargo_aprz_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-aprz-validate-prereqs: (_check-tool "cargo-aprz" cargo_aprz_version) + +[group("anvil-setup")] +anvil-tool-cargo-audit-install installer="install": (_install-tool "cargo-audit" cargo_audit_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-audit-validate-prereqs: (_check-tool "cargo-audit" cargo_audit_version) + +[group("anvil-setup")] +anvil-tool-cargo-careful-install installer="install": (_install-tool "cargo-careful" cargo_careful_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-careful-validate-prereqs: (_check-tool "cargo-careful" cargo_careful_version) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-install installer="install": (_install-tool "cargo-check-external-types" cargo_check_external_types_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-validate-prereqs: (_check-tool "cargo-check-external-types" cargo_check_external_types_version) + +[group("anvil-setup")] +anvil-tool-cargo-delta-install installer="install": (_install-tool "cargo-delta" cargo_delta_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-delta-validate-prereqs: (_check-tool "cargo-delta" cargo_delta_version) + +[group("anvil-setup")] +anvil-tool-cargo-deny-install installer="install": (_install-tool "cargo-deny" cargo_deny_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-deny-validate-prereqs: (_check-tool "cargo-deny" cargo_deny_version) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-doc2readme" cargo_doc2readme_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs: (_check-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-install installer="install": (_install-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-validate-prereqs: (_check-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version) + +[group("anvil-setup")] +anvil-tool-cargo-hack-install installer="install": (_install-tool "cargo-hack" cargo_hack_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-hack-validate-prereqs: (_check-tool "cargo-hack" cargo_hack_version) + +[group("anvil-setup")] +anvil-tool-cargo-heather-install installer="install": (_install-tool "cargo-heather" cargo_heather_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-heather-validate-prereqs: (_check-tool "cargo-heather" cargo_heather_version) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-install installer="install": (_install-tool "cargo-llvm-cov" cargo_llvm_cov_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-validate-prereqs: (_check-tool "cargo-llvm-cov" cargo_llvm_cov_version) + +# cargo-mutants doesn't build on aarch64-pc-windows-msvc (upstream +# winapi crate incompat). The install recipe self-skips on that target +# so per-group setup recipes that depend on it (pr-mutants-setup, +# scheduled-exhaustive-setup) don't fail on that platform. The +# mutants-diff / mutants-full check recipes also self-skip on the same +# target, so the check is effectively a no-op end-to-end there. +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-install installer="install": + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-install: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _install-tool cargo-mutants {{cargo_mutants_version}} {{installer}} + exit $LASTEXITCODE + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-validate-prereqs: + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-validate-prereqs: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _check-tool cargo-mutants {{cargo_mutants_version}} + exit $LASTEXITCODE + +[group("anvil-setup")] +anvil-tool-cargo-nextest-install installer="install": (_install-tool "cargo-nextest" cargo_nextest_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-nextest-validate-prereqs: (_check-tool "cargo-nextest" cargo_nextest_version) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-install installer="install": (_install-tool "cargo-semver-checks" cargo_semver_checks_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-validate-prereqs: (_check-tool "cargo-semver-checks" cargo_semver_checks_version) + +[group("anvil-setup")] +anvil-tool-cargo-sort-install installer="install": (_install-tool "cargo-sort" cargo_sort_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-sort-validate-prereqs: (_check-tool "cargo-sort" cargo_sort_version) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-install installer="install": (_install-tool "cargo-spellcheck" cargo_spellcheck_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-validate-prereqs: (_check-tool "cargo-spellcheck" cargo_spellcheck_version) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-install installer="install": (_install-tool "cargo-udeps" cargo_udeps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_version) + +=== justfiles/anvil/versions.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Pinned versions used by the anvil check tree. +# +# Everything anvil installs (cargo subcommands + rustup toolchains) +# is pinned here. The pinning policy: +# - On install (`-install` recipes): exactly this version (`=` for +# cargo subcommands, exact ref for rustup toolchains). Pulling +# "latest-matching" at install time is a cloud-workflow reproducibility risk -- +# an upstream release between yesterday's green build and today's +# PR can break things (cargo-spellcheck 0.15.7's em-dash regression +# is the canonical case). The `=` constraint locks the install to +# the version the catalog was validated against. +# - On validate-prereqs (`-validate-prereqs` recipes): the installed +# version must be `>= `. A user who has manually upgraded a +# tool for their own reasons (e.g. needing an unreleased bugfix) +# is not downgraded by setup. The validate gate uses +# `installed >= pin`, so newer is fine. +# +# To bump a pin, edit the version in place and re-run +# `cargo anvil`. The dirty-file flow preserves the edit on +# subsequent runs. To add a new tool, append a variable and the matching +# `-install`/`-validate-prereqs` pair in `tools.just`. To remove a tool, +# delete its variable and recipes (and any check that depends on them). + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +# General nightly used by udeps, miri, careful, and any future +# nightly-dependent check. Bumped on a regular cadence (monthly is a +# reasonable default) when an adopter has time to absorb formatting / +# lint / API-surface drift. Pin only -- do not use bare `nightly` here. +rust_nightly := "nightly-2026-02-10" + +# Pinned narrowly to the rustdoc JSON schema version that the currently +# selected cargo-check-external-types release accepts. cargo-check- +# external-types embeds a specific rustdoc-types crate version; if the +# nightly's emitted JSON format_version drifts past it, every run fails +# with "produces JSON format version X, but this tool requires +# format version Y" -- which is a tooling-incompat, not a real API +# violation. Bump this alongside any cargo-check-external-types upgrade. +rust_nightly_external_types := "nightly-2025-10-18" + +# ============================================================================ +# Cargo subcommands +# ============================================================================ + +cargo_aprz_version := "1.0.0" +cargo_audit_version := "0.22.2" +cargo_careful_version := "0.4.9" +cargo_check_external_types_version := "0.4.0" +cargo_delta_version := "0.3.1" +cargo_deny_version := "0.19.0" +cargo_doc2readme_version := "0.6.4" +cargo_ensure_no_cyclic_deps_version := "0.2.0" +cargo_ensure_no_default_features_version := "1.0.0" +cargo_hack_version := "0.6.41" +cargo_heather_version := "0.2.1" +cargo_llvm_cov_version := "0.8.4" +cargo_mutants_version := "26.1.2" +cargo_nextest_version := "0.9.122" +cargo_semver_checks_version := "0.46.0" +cargo_sort_version := "2.0.2" +cargo_spellcheck_version := "0.15.7" +cargo_udeps_version := "0.1.60" + +=== rustfmt.toml === +# >>> anvil-managed: anvil-rustfmt +edition = "2024" +max_width = 140 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +# The following options require nightly rustfmt. anvil-fmt invokes +# `cargo +{{ rust_nightly }} fmt`; see justfiles/anvil/versions.just +# for the pin and docs/design/local.md#nightly-pinning for the policy. +unstable_features = true +# Format Rust code blocks inside `///` doc comments. Catches stale +# examples that drift from the prose. +format_code_in_doc_comments = true +# One use-statement per module (vs collapsed `a::{b, c, d}` form). +# Diffs touch only the lines that actually changed. +imports_granularity = "Module" +# Group imports: std, then external crates, then crate-internal. Matches +# the convention used across the surveyed Microsoft Rust repos. +group_imports = "StdExternalCrate" +# <<< anvil-managed: anvil-rustfmt + +=== spellcheck.toml === +# >>> anvil-managed: anvil-spellcheck +# Check spelling in code comments marked as dev/developer comments +# (e.g., `// TODO:`, `// FIXME:`). Set to false to skip them. +dev_comments = false + +# Whether to skip spell checking README files. Set to false to include +# README files in spell checking. +skip_readme = false + +[Hunspell] +# Language dictionary. "en_US" uses the built-in English (US) dictionary. +lang = "en_US" + +# Directories searched for `extra_dictionaries` paths. The default +# repo-root entry lets adopters keep their custom dictionary next to +# the .spelling source. +search_dirs = ["."] + +# Additional dictionary files loaded after the language dictionary. +# Format: first line is the word count, remaining lines are sorted +# words (one per line). `target/spelling.dic` is generated by the +# `anvil-spellcheck` recipe from the repo's `.spelling` file. +extra_dictionaries = ["target/spelling.dic"] + +# Don't consult OS-provided dictionaries. Keeps results consistent +# across Linux/macOS/Windows runners. +skip_os_lookups = true + +# Use cargo-spellcheck's built-in language dictionaries (independent of +# system hunspell installation). Required for the cross-platform +# reproducibility guarantee above. +use_builtin = true + +# Token-boundary characters. Override the upstream default to add +# typographic punctuation we use in prose (em-dash, en-dash, arrows, +# minus sign). Without these, cargo-spellcheck 0.15.7 tokenises text +# like `runtime — it` as three tokens including the em-dash itself, +# then fails its dictionary lookup and flags the em-dash as a +# "possible spelling mistake". Upstream default keeps figure-dash +# (U+2012) and the ASCII hyphen but omits the rest; this list is a +# superset, so the only behavioural change is that the added chars +# now act as token boundaries. +# +# Encoded as \uXXXX escapes for grep-ability and to keep the file +# 7-bit ASCII: +# ASCII punctuation (default): ",;:.!?#(){}[]|/_- +# Dashes & minus : \u2012 figure-dash (default) +# \u2013 en-dash (added) +# \u2014 em-dash (added) +# \u2015 horizontal-bar (added) +# \u2212 minus-sign (added) +# Arrows : \u2190 leftwards-arrow (added) +# \u2192 rightwards-arrow (added) +# ASCII punctuation (default): ' ` & @ +# Misc (default) : \u00A7 section, \u00B6 pilcrow, \u2026 ellipsis +tokenization_splitchars = "\",;:.!?#(){}[]|/_-\u2012\u2013\u2014\u2015\u2190\u2192\u2212'`&@\u00A7\u00B6\u2026" + +[Hunspell.quirks] +# Treat CamelCase identifiers as concatenations of dictionary words +# (e.g., `TcpStream` = `Tcp` + `Stream`). Lowers false-positive rate +# substantially on Rust codebases. +allow_concatenation = true +# <<< anvil-managed: anvil-spellcheck diff --git a/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap new file mode 100644 index 00000000..0204d86f --- /dev/null +++ b/crates/cargo-anvil/tests/snapshots/snapshots__local_only.snap @@ -0,0 +1,2041 @@ +--- +source: crates/cargo-anvil/tests/snapshots.rs +expression: render_tree(tmp.path()) +--- +=== .delta.toml === +# >>> anvil-managed: anvil-delta +[delta] +# Include the workspace root files that should invalidate every member's +# impact analysis when changed (lockfile, root manifest, toolchain). +root-files = [ + "Cargo.lock", + "Cargo.toml", + "rust-toolchain.toml", +] +# <<< anvil-managed: anvil-delta + +=== Cargo.toml === +[workspace] +resolver = "2" +members = ["crates/*"] + +# >>> anvil-managed: anvil-workspace-lints +[workspace.lints] +# Catalog of opinionated lints, in dotted-key form so users can extend the +# same scope (`[workspace.lints]` or `[lints]`) outside the sentinels. +# The host-specific table header (`[workspace.lints]` or `[lints]`) is +# prepended by cargo-anvil based on whether the manifest is a workspace +# root or a single-crate Cargo.toml. + +# --- rust ------------------------------------------------------------------ +rust.ambiguous_negative_literals = "warn" +rust.missing_debug_implementations = "warn" +rust.redundant_imports = "warn" +rust.redundant_lifetimes = "warn" +rust.trivial_numeric_casts = "warn" +rust.unsafe_op_in_unsafe_fn = "warn" +rust.unused_lifetimes = "warn" +# `unexpected_cfgs` is on-by-default at warn since Rust 1.80; combined +# with the catalog's `-D warnings` cloud-workflow policy, any custom cfg name +# becomes a hard build failure. Pre-declare the cfgs that +# `cargo llvm-cov` sets so the recommended coverage-exclusion pattern +# `#[cfg_attr(coverage_nightly, coverage(off))]` works out of the box. +# Adopters who need additional cfg names take ownership of this one +# line (edit the check-cfg array); anvil's drift detector will +# emit a `.anvil-proposed` sibling on future catalog bumps so the +# customization is preserved. +rust.unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(coverage,coverage_nightly)', +] } + +# --- rustdoc --------------------------------------------------------------- +rustdoc.broken_intra_doc_links = "warn" +rustdoc.missing_crate_level_docs = "warn" +rustdoc.unescaped_backticks = "warn" + +# --- clippy: category gates (priority -1 so per-lint allows can override) -- +clippy.cargo = { level = "warn", priority = -1 } +clippy.complexity = { level = "warn", priority = -1 } +clippy.correctness = { level = "warn", priority = -1 } +clippy.nursery = { level = "warn", priority = -1 } +clippy.pedantic = { level = "warn", priority = -1 } +clippy.perf = { level = "warn", priority = -1 } +clippy.style = { level = "warn", priority = -1 } +clippy.suspicious = { level = "warn", priority = -1 } + +# --- clippy: opinionated additions ----------------------------------------- +# Two-repo consensus (oxidizer + oxidizer-github). Restriction-group +# lints that catch real code-smell cases. Adding a workspace-wide lint +# means adopters can only opt out per-crate or by taking ownership of +# this region; only enable when the consensus is strong enough to +# justify that cost. +clippy.allow_attributes = "warn" +clippy.allow_attributes_without_reason = "warn" +clippy.as_pointer_underscore = "warn" +clippy.assertions_on_result_states = "warn" +clippy.clone_on_ref_ptr = "warn" +clippy.deref_by_slicing = "warn" +clippy.disallowed_script_idents = "warn" +clippy.empty_drop = "warn" +clippy.empty_enum_variants_with_brackets = "warn" +clippy.fn_to_numeric_cast_any = "warn" +clippy.if_then_some_else_none = "warn" +clippy.map_err_ignore = "warn" +clippy.multiple_unsafe_ops_per_block = "warn" +clippy.redundant_type_annotations = "warn" +clippy.renamed_function_params = "warn" +clippy.semicolon_outside_block = "warn" +clippy.undocumented_unsafe_blocks = "warn" +clippy.unnecessary_safety_comment = "warn" +clippy.unnecessary_safety_doc = "warn" +clippy.unneeded_field_pattern = "warn" +clippy.unused_result_ok = "warn" +clippy.unwrap_used = "warn" + +# --- clippy: opinionated suppressions of category-enabled lints ------------ +clippy.missing_const_for_fn = "allow" +clippy.multiple_crate_versions = "allow" +clippy.option_if_let_else = "allow" +clippy.redundant_pub_crate = "allow" +clippy.should_panic_without_expect = "allow" +clippy.significant_drop_tightening = "allow" +# Blocked by Clippy bug: https://github.com/rust-lang/rust-clippy/issues/15036 +clippy.wildcard_imports = "allow" + +# <<< anvil-managed: anvil-workspace-lints + +=== Justfile === +# >>> anvil-managed: anvil-imports +import 'justfiles/anvil/mod.just' +# <<< anvil-managed: anvil-imports + +=== clippy.toml === +# >>> anvil-managed: anvil-clippy +# Fine-tuning settings for clippy lints. These cannot be expressed in +# Cargo.toml's [lints] table (which only carries level: warn/allow/deny); +# they configure lint *behavior* and live in clippy.toml only. + +# Absolute paths up to 3 segments are clarifying — e.g. `std::sync::Mutex` +# vs `tokio::sync::Mutex` disambiguates the source. Beyond 3 segments +# we prefer imports or aliases for readability. +absolute-paths-max-segments = 3 + +# Workspace code is internal. Clippy should suggest the most correct +# fix without worrying about non-breaking-change rules, which only +# matter for published library APIs. +avoid-breaking-exported-api = false + +# Required companion for the clippy.semicolon_outside_block lint we +# ship in the catalog. Without this, the lint fires on multiline-block +# forms that are common Rust style. +semicolon-outside-block-ignore-multiline = true + +# Required companions for the clippy.unwrap_used lint we ship. +# Test code asserts via unwrap()/panic!() — that's how #[test] reports +# failure. Without these, every test triggers the lint. +allow-panic-in-tests = true +allow-unwrap-in-tests = true + +# Aspirational: when clippy.wildcard_imports is re-enabled (currently +# allowed in cargo-lints-body.toml due to upstream bug rust-clippy#15036), +# we want the stricter variant that warns on ALL wildcard imports +# including prelude. Setting it now means flipping the lint level to +# warn later is a one-line change with no tuning afterthought. +warn-on-all-wildcard-imports = true +# <<< anvil-managed: anvil-clippy + +=== crates/alpha/Cargo.toml === +[package] +name = "alpha" +version = "0.1.0" +edition = "2024" + +# >>> anvil-managed: anvil-lints +[lints] +workspace = true +# <<< anvil-managed: anvil-lints + +=== crates/alpha/src/lib.rs === + + +=== deny.toml === +# >>> anvil-managed: anvil-deny +[advisories] +yanked = "deny" +# Scope of unmaintained-crate checks: "all" surfaces transitive +# dependencies too; tighten to "workspace" if the noise is high. +unmaintained = "all" + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "MPL-2.0", + "Unicode-DFS-2016", + "Unicode-3.0", + "Zlib", +] +confidence-threshold = 0.93 + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# <<< anvil-managed: anvil-deny + +=== justfiles/anvil/checks.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. +# +# Each check belongs to one of four buckets, which determines how it +# interprets the impact env vars emitted by the cargo-delta impact step +# in cloud workflows: +# +# - "modified": only run when at least one package's source files +# changed in the diff. The check's underlying tool is workspace-wide +# or directory-scoped (cargo fmt --all, cargo heather, cargo +# spellcheck), so it doesn't take --package; we short-circuit on +# the ANVIL_INCLUDE_MODIFIED == "--skip" sentinel. +# +# - "affected": run on the affected set (modified ∪ reverse-deps +# within the workspace). The check's underlying tool takes +# --package; we splice ANVIL_INCLUDE_AFFECTED into the cargo +# invocation, defaulting to --workspace for local invocations where +# no env var is set. +# +# - "required": run on the required set (affected ∪ workspace-internal +# transitive deps). Same splice/default pattern as affected, but +# keyed on ANVIL_INCLUDE_REQUIRED. Used for checks whose tool +# resolves through the dep graph (cargo doc → intra-doc links; +# cargo hack → feature powerset; cargo udeps → unused-deps). +# +# - "unscoped": always run, no env var reference. External-input +# checks (deny, audit, aprz) and PR-context checks (pr-title) live +# here. Scheduled-exhaustive recipes (mutants-full) are also unscoped +# by design. +# +# Local invocation (no impact wiring): all three env vars are unset +# (recipes use the `?? "--workspace"` null-coalescing fallback below); +# modified-tier recipes simply skip the splice and run their +# workspace-wide tool; affected/required-tier recipes splat +# "--workspace" when the env var is unset. +# +# Preparation contract: when a recipe reaches the cargo call, the +# env var is one of: +# +# * unset - local run; the recipe substitutes +# "--workspace" via `?? "--workspace"` +# * "--package A --package B" - emitted by the cloud-workflow impact step when +# the tier has members +# * "--skip" - emitted by the cloud-workflow impact step when +# the tier is empty (recipe exits 0) +# +# This lets the simple recipes splat the var directly with +# & cargo X @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) ... +# and reduces the per-recipe boilerplate to a single one-line skip +# guard plus the cargo invocation. +# +# Modified-tier recipes never splice the env var into cargo (their +# tools are workspace-wide); they only check the skip sentinel. +# +# Every recipe whose body uses multi-line conditionals or env-var +# splicing is annotated with [script("pwsh")]. pwsh is preinstalled on +# Windows (since Windows 10), on GH/ADO hosted Linux + Windows +# runners, and installable on macOS via Homebrew or the upstream +# installer. We chose pwsh over bash because just's shebang dispatch +# requires `cygpath` on Windows (only on PATH from inside Git Bash), +# while [script("pwsh")] works from plain PowerShell with no PATH +# augmentation. The `??` null-coalescing operator used in the splat +# requires pwsh 7+, which is the floor we already require via +# _anvil-require pwsh. +# +# Single-command recipes (cargo deny check, cargo audit) are plain +# just recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# Note: [script(...)] requires `set unstable`. The adopter's root +# justfile must declare it (typically as a top-level line). anvil's +# mod.just does NOT redeclare it, to avoid conflicting with adopters +# who already have it. + +# === pr-fast members ==================================================== + +# Modified tier. cargo-fmt is a rustup component. We invoke it via the +# pinned nightly (see versions.just) because rustfmt.toml uses +# unstable_features = true (imports_granularity, group_imports, +# format_code_in_doc_comments). Floating nightly would mean +# format-drift breaking cloud workflows on rustup updates — the same trap we +# explicitly avoid for udeps/miri/careful/external-types. +[script("pwsh")] +anvil-fmt: anvil-fmt-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo '+{{ rust_nightly }}' fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. Per project policy, clippy runs on the affected set +# rather than the modified set: a change in a crate can introduce a +# clippy issue in a dependent crate (e.g., trait-bound or +# obviously-truthy-condition lints that key off the changed type), so +# we want downstream rev-deps to lint as well. cargo-clippy is a +# rustup component; same reasoning as fmt for the require. +[script("pwsh")] +anvil-clippy: anvil-clippy-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo clippy @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-targets --all-features --locked "--" '-D' 'warnings' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-cargo-sort: anvil-cargo-sort-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo sort --workspace --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-license-headers: anvil-license-headers-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo heather + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-cyclic-deps: anvil-ensure-no-cyclic-deps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-cyclic-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-default-features: anvil-ensure-no-default-features-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-default-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Required tier. cargo doc resolves intra-doc links through the dep +# graph, so a dep changing its public API can break doc-build in a +# crate that wasn't itself modified. +[script("pwsh")] +anvil-doc-build: anvil-doc-build-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + $env:RUSTDOCFLAGS = '-D warnings' + & cargo doc @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features --no-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +# +# cargo-doc2readme regenerates a crate's README.md from its rustdoc. +# Two extension points users frequently need: +# * Workspace-level template (`crates/README.j2` or `README.j2` at repo +# root): a Tera template applied to every crate's README. anvil +# auto-detects it and passes `--template` to the per-crate runs. +# * Per-crate opt-out: hand-crafted READMEs (e.g. a tool crate whose +# README is more freeform than the lib docs) opt out by adding +# `[package.metadata.ox-gen-readme]\ndisable = true` to their +# Cargo.toml. anvil skips those crates. +# +# Bin-only crates have no library rustdoc to base a README on, so they +# are skipped as well (cargo doc2readme requires a library target). +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: per-crate +# iteration over library targets, cargo-metadata-driven opt-outs +# (publish=false, [package.metadata.ox-gen-readme] disable), per-crate +# Push-Location into the crate dir (cargo-doc2readme is CWD-sensitive +# rather than --manifest-path-driven), and per-crate template-path +# resolution. The ANVIL_INCLUDE_MODIFIED value would still need to +# be intersected with the lib-crate set rather than splatted into cargo. +[script("pwsh")] +anvil-readme-check: anvil-readme-check-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-readme-check: no modified packages; skipping' + exit 0 + } + # Detect a workspace-level README template. Two conventional + # locations: crates/README.j2 (cargo-workspaces idiom) or + # README.j2 at repo root. + $template = $null + foreach ($candidate in 'crates/README.j2', 'README.j2') { + if (Test-Path $candidate) { $template = (Resolve-Path $candidate).Path; break } + } + # Iterate library crates. Filter by impact set when set, then drop + # bin-only crates and opt-outs. + $pkg = @(if ($env:ANVIL_INCLUDE_MODIFIED) { -split $env:ANVIL_INCLUDE_MODIFIED } else { '--workspace' }) + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $byName = @{} + foreach ($p in $meta.packages) { $byName[$p.name] = $p } + $candidates = if ($pkg -contains '--workspace') { + @($meta.packages | ForEach-Object { $_.name }) + } else { + $names = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + $names += $pkg[$i + 1]; $i++ + } + } + $names + } + $hadFailure = $false + foreach ($name in $candidates) { + $p = $byName[$name] + if (-not $p) { continue } + if (-not ($p.targets | Where-Object { $_.kind -contains 'lib' })) { continue } + # Skip private crates (publish = false). They aren't released + # and rarely have a polished README. Mirrors the + # `cargo workspaces exec --ignore-private` idiom adopters + # commonly use. + if ($p.publish -is [array] -and $p.publish.Count -eq 0) { + Write-Host "anvil-readme-check: $name (skipped: publish = false)" + continue + } + $disabled = $false + if ($p.metadata -and $p.metadata.'ox-gen-readme' -and $p.metadata.'ox-gen-readme'.disable) { + $disabled = $true + } + if ($disabled) { + Write-Host "anvil-readme-check: $name (opted out via [package.metadata.ox-gen-readme])" + continue + } + Write-Host "anvil-readme-check: $name" + # cargo doc2readme writes / compares relative to its CWD (not + # --manifest-path), so chdir into the crate before invoking + # --check. We also compute a per-crate relative path to the + # workspace-level template so the same template file works for + # every crate (parallels the cargo-workspaces idiom). + $crateDir = Split-Path -Parent $p.manifest_path + Push-Location $crateDir + try { + $relTemplate = if ($template) { + Resolve-Path -Relative -LiteralPath $template + } else { + $null + } + $args = @('doc2readme', '--check') + if ($relTemplate) { $args += @('--template', $relTemplate) } + & cargo @args + if ($LASTEXITCODE -ne 0) { $hadFailure = $true } + } finally { + Pop-Location + } + } + if ($hadFailure) { exit 1 } + +# Modified tier. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# If `.spelling` is present, we generate `target/spelling.dic` from it +# automatically; otherwise we run cargo-spellcheck against whatever +# the repo has already set up. +[script("pwsh")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-spellcheck: no modified packages; skipping' + exit 0 + } + if (Test-Path '.spelling') { + $output_file = 'target/spelling.dic' + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = $lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + } + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Unscoped (PR title is not source-related). +# +# Validates that $env:PR_TITLE matches Conventional Commits when set; +# no-op when unset. Cloud workflows inject PR_TITLE explicitly (GH Actions: +# ${{ github.event.pull_request.title }}; ADO: $(System.PullRequest.Title)) +# so the check has authoritative input there. Locally it skips silently -- +# there is no reliable way to recover the PR title for the ADO backend +# (no equivalent of `gh pr view`), so the recipe stays simple and +# defers the check to cloud workflows. +[script("pwsh")] +anvil-pr-title: anvil-pr-title-validate-prereqs + $title = $env:PR_TITLE + if (-not $title) { + Write-Host 'anvil-pr-title: PR_TITLE env var not set; skipping (check runs in cloud workflows)' + exit 0 + } + if ($title -notmatch '^(feat|fix|chore|docs|refactor|test|build|cloud workflows|perf|revert)(\([^)]+\))?!?: .+') { + Write-Error "PR title '$title' does not match Conventional Commits" + exit 1 + } + +# Unscoped (consults external advisory DB; reads Cargo.lock, not +# workspace members). Single command — inherits adopter's default shell. +anvil-deny: anvil-deny-validate-prereqs + cargo deny check + +# Unscoped (consults external advisory DB; reads Cargo.lock). +anvil-audit: anvil-audit-validate-prereqs + cargo audit + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Deliberately omits `--all-targets`: with `--all-targets`, a dep +# that's listed in BOTH `[dependencies]` and `[dev-dependencies]` and +# used only by tests is reported as "all used" because the dev-deps +# target satisfies the lookup, masking the unused entry in main +# `[dependencies]`. Restricting to the default targets (lib + bins) +# matches main repo cloud workflows' check and surfaces the real bug. +[script("pwsh")] +anvil-udeps: anvil-udeps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' udeps @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier (compares published API of changed crates against the +# baseline; only changed crates' surface is at risk). +# +# Several real-world conditions produce errors that aren't actually +# SemVer violations: +# - bin-only crates have no API to compare ("no library targets found"). +# - crates not yet published to crates.io ("not found in registry"). +# - crates where the published baseline lacks a lib target the +# current source has (bin -> bin+lib transition). +# We pre-filter to library-bearing crates from cargo metadata, then +# run cargo-semver-checks per-package and tolerate the +# "no-comparable-baseline" failure modes. +# +# Findings policy: this recipe is *advisory*. Real SemVer findings do +# NOT fail the recipe -- breaking changes between unreleased commits +# are normal (the major-version bump happens at release time, not on +# every PR). Instead, when there are findings we write a markdown +# advisory body to `target/anvil/comments/semver.md`; when the +# tree is clean we remove that file. cloud-workflow wiring (GH: +# marocchino/sticky-pull-request-comment; ADO: pwsh + REST API) +# inspects the file after the recipe and upserts / clears a sticky +# PR comment accordingly. Local invocation gets the same file +# written under target/ for inspection. +# +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-metadata +# filter to library crates (cargo-semver-checks --workspace fails on +# bin-only workspaces), intersect ANVIL_INCLUDE_AFFECTED with that +# set, then per-crate invocation with selective error tolerance for +# unpublished crates ("not found in registry") and bin->bin+lib +# transitions ("no library targets found"). +[script("pwsh")] +anvil-semver-check: anvil-semver-check-validate-prereqs + $ErrorActionPreference = 'Stop' + $commentFile = 'target/anvil/comments/semver.md' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-semver-check: no affected packages; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build the candidate package list. Always iterate per-package over + # library crates only -- cargo-semver-checks --workspace would fail + # on workspaces that contain bin-only crates ("no library targets + # found"), and we want the same tolerance for unpublished / bin->lib- + # transition crates regardless of whether we got here via impact- + # scoping (cloud workflows) or full-workspace fallback (local). + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $true + } + } + if ($pkg -contains '--workspace') { + $packages = @($libPkgs.Keys) + } else { + $packages = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs[$pkg[$i + 1]]) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-semver-check: no affected library crates; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $findings = New-Object System.Collections.Generic.List[string] + foreach ($p in $packages) { + Write-Host "anvil-semver-check: $p" + $output = (& cargo semver-checks --package $p 2>&1) | Out-String + if ($LASTEXITCODE -ne 0) { + if ($output -match 'not found in registry|no library targets found') { + Write-Host " $p has no comparable baseline; skipping (likely unpublished or bin->lib transition)" -ForegroundColor Yellow + } else { + Write-Host $output + # Append a per-crate findings block. Using one-line-at-a-time + # appends keeps the markdown free of pwsh backtick-escape + # gymnastics (single-quoted literals + the natural `n join + # produce clean LF newlines and unambiguous triple-backticks). + $findings.Add('### `' + $p + '`') | Out-Null + $findings.Add('') | Out-Null + $findings.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $findings.Add($line.TrimEnd()) | Out-Null + } + $findings.Add('```') | Out-Null + $findings.Add('') | Out-Null + } + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: Potential breaking changes detected') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # Advisory: always exit 0. cloud-workflow wiring posts/clears the PR comment. + exit 0 + +# Affected tier (lints public API of changed crates and rev-deps). +# +# cargo-check-external-types is per-manifest: no --package/--workspace, +# only --manifest-path. Iterate the affected library crates and run +# the tool once each, pointing at the crate's Cargo.toml. Bin-only +# crates have no public API surface and are skipped. +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-check- +# external-types is per-manifest (no --package/--workspace), so we +# build a name->manifest map from cargo metadata, filter to lib crates, +# intersect with ANVIL_INCLUDE_AFFECTED, and call the tool once per +# crate. Hard-fails on errors (no tolerance, unlike semver-check). +[script("pwsh")] +anvil-external-types: anvil-external-types-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-external-types: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build pkg-name -> manifest-path map, restricted to library crates. + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $p.manifest_path + } + } + # Decide which packages to check. + $packages = @() + if ($pkg -contains '--workspace') { + $packages = $libPkgs.Keys + } else { + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs.ContainsKey($pkg[$i + 1])) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-external-types: no affected library crates; skipping' + exit 0 + } + $failed = $false + foreach ($p in $packages) { + Write-Host "anvil-external-types: $p" + # cargo-check-external-types requires nightly rustdoc (uses + # unstable -Z flags) AND pins a specific rustdoc-types schema + # version. We pin nightly narrowly to the schema this tool + # version expects via `rust_nightly_external_types` in + # versions.just — bump that pin alongside any cargo-check- + # external-types upgrade. No tolerance for schema mismatches: + # if it fails, the pin or the tool needs to move. + & cargo '+{{ rust_nightly_external_types }}' check-external-types --manifest-path $libPkgs[$p] + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + +# Unscoped (consults external risk DB). +anvil-aprz: anvil-aprz-validate-prereqs + cargo aprz deps --error-if-high-risk --console appraisal + +# === pr-test members ==================================================== + +# Affected tier. +[script("pwsh")] +anvil-llvm-cov: anvil-llvm-cov-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-llvm-cov: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # cargo-llvm-cov doesn't work on aarch64-pc-windows-msvc: the + # llvm-profdata that ships with the rust toolchain there fails + # to merge the .profraw set ("no profile can be merged"). Fall + # back to plain `cargo nextest run` on that target so we still + # get test execution; coverage data from this leg wouldn't have + # been used anyway (coverage upload is gated on Linux only). + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-llvm-cov: aarch64-pc-windows-msvc -- skipping coverage; running plain nextest' + & cargo nextest run @pkg --all-features --locked + exit $LASTEXITCODE + } + # cargo llvm-cov writes the .profraw set into target/llvm-cov-target/ + # but the *report* output directory (target/coverage/) is something + # we choose and must exist before --output-path runs. + [System.IO.Directory]::CreateDirectory('target/coverage') | Out-Null + [System.IO.Directory]::CreateDirectory('target/coverage/html') | Out-Null + # Wipe stale .profraw data so the report reflects only this run. + cargo llvm-cov clean --workspace --profraw-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Instrument and run tests; defer report generation so we can emit + # multiple formats from the same profraw set without re-running. + # Note: nextest's exit-4 ("no tests to run") IS treated as a + # failure here -- a llvm-cov run that finds no tests almost + # always means a config mistake (wrong package filter, missing + # test target, etc.), not a legitimate empty set. anvil-miri + # is the exception (see its comment): miri-skipped tests are an + # expected design point for FS-heavy crates. + & cargo llvm-cov nextest @pkg --all-features --locked --no-report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # lcov.info feeds Codecov on GitHub; cobertura.xml feeds + # PublishCodeCoverageResults@2 on Azure DevOps. + cargo llvm-cov report --lcov --output-path target/coverage/lcov.info + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Local-only HTML viewer (no cloud-workflow consumer); cheap once the data exists. + cargo llvm-cov report --html --output-dir target/coverage/html + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-doc-test: anvil-doc-test-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo test --doc @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-examples: anvil-examples-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo build @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --examples --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === pr-mutants member ==================================================== + +# Affected tier. cargo-mutants does its own diff-scoping via --in-diff; +# the affected-tier guard is the wiring-layer's coarse filter (if no +# affected packages exist, the entire mutants run is pointless). +# +# Skip on aarch64-pc-windows-msvc: cargo-mutants doesn't build there +# (upstream winapi incompatibility), so `_anvil-require cargo-mutants` +# would fail. The merged pr-slow group runs on all four OS legs; mutants +# is the only sub-recipe that can't follow, so it bails out early on the +# affected leg. Coverage on the other three legs is unchanged. +[script("pwsh")] +anvil-mutants-diff: anvil-mutants-diff-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs aarch64-pc-windows-msvc -- cargo-mutants does not build here (winapi); skipping' + exit 0 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no affected packages; skipping' + exit 0 + } + # Resolve BASE_REF: env override > origin/main > origin/master. + $base = $null + if ($env:BASE_REF) { + $base = $env:BASE_REF + } else { + foreach ($candidate in @('origin/main', 'origin/master')) { + git rev-parse --verify $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + } + if (-not $base) { + Write-Error 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no BASE_REF set and neither origin/main nor origin/master is available. Set BASE_REF to the branch to diff against.' + exit 1 + } + # cargo-mutants --in-diff takes a FILE path containing a unified + # diff, not a git revision range. Write the diff to a temp file + # first. RUNNER_TEMP (GH) and AGENT_TEMPDIRECTORY (ADO) point at + # the job's scratch dir; fall back to the system temp dir locally. + $tmp_dir = $env:RUNNER_TEMP + if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } + if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } + $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' + git diff "$base..HEAD" --output=$diff_path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-runtime members ============================================ + +# Nightly checks always run full-workspace; impact env vars aren't set +# by the scheduled workflow, so the affected-tier default (--workspace) +# applies. Skip guards are still included for local diff-scoped runs. + +[script("pwsh")] +anvil-miri: anvil-miri-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + # `--no-tests=pass`: miri-only exception. Tests that touch the + # filesystem, spawn subprocesses, or use other miri-incompatible + # APIs commonly carry `#[cfg_attr(miri, ignore)]` (this is the + # canonical opt-out for build-tooling / CLI crates). A crate + # whose test set ends up entirely-skipped under miri legitimately + # produces zero runnable tests; nextest's default exit-4 ("no + # tests to run") would fail the recipe in that case. We treat + # empty test runs as success for miri only. Other nextest-using + # recipes (llvm-cov) keep exit-4 as a failure because zero tests + # there almost always indicates a config mistake. + & cargo '+{{ rust_nightly }}' miri nextest run --no-tests=pass @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +[script("pwsh")] +anvil-careful: anvil-careful-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' careful test @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-exhaustive members ========================================= + +# Unscoped. Scheduled-exhaustive deliberately runs over the whole +# workspace regardless of diff. +anvil-mutants-full: anvil-mutants-full-validate-prereqs + cargo mutants --workspace --no-shuffle --jobs 0 + +# Required tier. cargo-hack's feature powerset cascades through dep +# features, so the required set (workspace-internal transitive deps) +# is the right scope. +[script("pwsh")] +anvil-cargo-hack: anvil-cargo-hack-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo hack @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --feature-powerset --depth 2 check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-bench: anvil-bench-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo bench @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --no-run + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ============================================================================ +# Per-check setup + validate-prereqs +# ============================================================================ +# +# Each check has matching `*-setup` and `*-validate-prereqs` recipes +# that install / verify the tools and components it needs. The setup +# recipes accept an `installer=install|install` parameter that +# forwards to the underlying tool-install recipes; the validate-prereqs +# recipes take no parameters. +# +# These are the building blocks for `anvil--setup` +# (groups.just) and `anvil--setup` (tiers.just) ΓÇö each +# group/tier-level recipe is just a fan-out over the per-check +# setup/validate-prereqs of its members. + +# --- pr-fast members --- + +[group("anvil-setup")] +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install + +[group("anvil-setup")] +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs + +[group("anvil-setup")] +anvil-clippy-setup installer="install": anvil-component-default-clippy-install + +[group("anvil-setup")] +anvil-clippy-validate-prereqs: anvil-component-default-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-sort-setup installer="install": (anvil-tool-cargo-sort-install installer) + +[group("anvil-setup")] +anvil-cargo-sort-validate-prereqs: anvil-tool-cargo-sort-validate-prereqs + +[group("anvil-setup")] +anvil-license-headers-setup installer="install": (anvil-tool-cargo-heather-install installer) + +[group("anvil-setup")] +anvil-license-headers-validate-prereqs: anvil-tool-cargo-heather-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-setup installer="install": (anvil-tool-cargo-ensure-no-cyclic-deps-install installer) + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-validate-prereqs: anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-default-features-setup installer="install": (anvil-tool-cargo-ensure-no-default-features-install installer) + +[group("anvil-setup")] +anvil-ensure-no-default-features-validate-prereqs: anvil-tool-cargo-ensure-no-default-features-validate-prereqs + +# doc-build, examples and doc-test are pure cargo built-ins; the rust +# toolchain (rustc + cargo) is the only prerequisite, and we already +# rely on it being present everywhere anvil runs. +[group("anvil-setup")] +anvil-doc-build-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-build-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-readme-check-setup installer="install": (anvil-tool-cargo-doc2readme-install installer) + +[group("anvil-setup")] +anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereqs + +# cargo-spellcheck has a build-time libclang dependency; the system +# deps check runs first so adopters get a clear hint instead of a +# cryptic clang-sys build error mid-install. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": anvil-system-deps-check (anvil-tool-cargo-spellcheck-install installer) + +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +# pr-title is a pwsh script; no cargo tool to install. +[group("anvil-setup")] +anvil-pr-title-setup installer="install": anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-pr-title-validate-prereqs: anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-deny-setup installer="install": (anvil-tool-cargo-deny-install installer) + +[group("anvil-setup")] +anvil-deny-validate-prereqs: anvil-tool-cargo-deny-validate-prereqs + +[group("anvil-setup")] +anvil-audit-setup installer="install": (anvil-tool-cargo-audit-install installer) + +[group("anvil-setup")] +anvil-audit-validate-prereqs: anvil-tool-cargo-audit-validate-prereqs + +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +[group("anvil-setup")] +anvil-external-types-setup installer="install": anvil-toolchain-nightly-external-types-install (anvil-tool-cargo-check-external-types-install installer) + +[group("anvil-setup")] +anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-validate-prereqs anvil-tool-cargo-check-external-types-validate-prereqs + +[group("anvil-setup")] +anvil-aprz-setup installer="install": (anvil-tool-cargo-aprz-install installer) + +[group("anvil-setup")] +anvil-aprz-validate-prereqs: anvil-tool-cargo-aprz-validate-prereqs + +# --- pr-test members (shared with scheduled-test) --- + +[group("anvil-setup")] +anvil-llvm-cov-setup installer="install": (anvil-tool-cargo-llvm-cov-install installer) (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-llvm-cov-validate-prereqs: anvil-tool-cargo-llvm-cov-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-validate-prereqs: anvil-tool-rustc-validate-prereqs + +# --- pr-runtime-analysis members --- + +[group("anvil-setup")] +anvil-miri-setup installer="install": anvil-component-nightly-miri-install anvil-component-nightly-rust-src-install (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-miri-validate-prereqs: anvil-component-nightly-miri-validate-prereqs anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-careful-setup installer="install": anvil-component-nightly-rust-src-install (anvil-tool-cargo-careful-install installer) + +[group("anvil-setup")] +anvil-careful-validate-prereqs: anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-careful-validate-prereqs + +# --- pr-mutants members --- + +[group("anvil-setup")] +anvil-mutants-diff-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-diff-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +# --- scheduled-exhaustive members (mutants-full reuses cargo-mutants; +# cargo-hack and bench are dedicated tools) --- + +[group("anvil-setup")] +anvil-mutants-full-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-full-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-hack-setup installer="install": (anvil-tool-cargo-hack-install installer) + +[group("anvil-setup")] +anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs + +# bench uses cargo-built-ins (cargo bench --no-run + plain bench runs); +# no extra tool install needed beyond the rust toolchain. +[group("anvil-setup")] +anvil-bench-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-bench-validate-prereqs: anvil-tool-rustc-validate-prereqs + +=== justfiles/anvil/groups.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. + +# Each group is one cloud-workflow job. Within a group, checks run sequentially. + +# PR groups +# =========================================================================== + +[group("anvil")] +anvil-pr-fast: \ + anvil-fmt \ + anvil-clippy \ + anvil-cargo-sort \ + anvil-license-headers \ + anvil-ensure-no-cyclic-deps \ + anvil-ensure-no-default-features \ + anvil-doc-build \ + anvil-readme-check \ + anvil-spellcheck \ + anvil-pr-title \ + anvil-deny \ + anvil-audit \ + anvil-udeps \ + anvil-semver-check \ + anvil-external-types \ + anvil-aprz + +# pr-slow is the single PR-tier group for everything that takes more +# than ~30s per crate (tests, stricter runtimes, mutation testing). +# It's internally split into three sub-recipes so individual concerns +# can be invoked locally without dragging the others along: +# +# slow1: tests + coverage (replaces the former pr-test group) +# slow2: stricter-runtime correctness (miri, careful) +# slow3: mutation testing +# +# Cloud workflows run pr-slow as ONE job per OS leg -- the sub-recipes run +# sequentially within. This trades per-leg wall-clock for fewer +# orchestration jobs and a flatter PR check graph. Individual sub- +# recipes are runnable on their own locally: +# +# $ just anvil-pr-test # tests + coverage only +# $ just anvil-pr-runtime-analysis # miri + careful only +# $ just anvil-pr-mutants # mutants only +[group("anvil")] +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants + +[group("anvil")] +anvil-pr-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-pr-runtime-analysis: \ + anvil-miri \ + anvil-careful + +[group("anvil")] +anvil-pr-mutants: anvil-mutants-diff + +# Scheduled groups +# =========================================================================== + +[group("anvil")] +anvil-scheduled-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-scheduled-advisories: \ + anvil-deny \ + anvil-audit \ + anvil-aprz \ + anvil-clippy + +[group("anvil")] +anvil-scheduled-exhaustive: \ + anvil-mutants-full \ + anvil-cargo-hack \ + anvil-bench +# Group-level setup + validate-prereqs +# =========================================================================== +# +# Per-group recipes that fan out to the per-check setup/validate-prereqs +# from checks.just. Setup recipes accept `installer="install"|"binstall"`; +# validate-prereqs recipes take no parameters. + +[group("anvil-setup")] +anvil-pr-fast-setup installer="install": \ + (anvil-fmt-setup installer) \ + (anvil-clippy-setup installer) \ + (anvil-cargo-sort-setup installer) \ + (anvil-license-headers-setup installer) \ + (anvil-ensure-no-cyclic-deps-setup installer) \ + (anvil-ensure-no-default-features-setup installer) \ + (anvil-doc-build-setup installer) \ + (anvil-readme-check-setup installer) \ + (anvil-spellcheck-setup installer) \ + (anvil-pr-title-setup installer) \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-udeps-setup installer) \ + (anvil-semver-check-setup installer) \ + (anvil-external-types-setup installer) \ + (anvil-aprz-setup installer) + +[group("anvil-setup")] +anvil-pr-fast-validate-prereqs: \ + anvil-fmt-validate-prereqs \ + anvil-clippy-validate-prereqs \ + anvil-cargo-sort-validate-prereqs \ + anvil-license-headers-validate-prereqs \ + anvil-ensure-no-cyclic-deps-validate-prereqs \ + anvil-ensure-no-default-features-validate-prereqs \ + anvil-doc-build-validate-prereqs \ + anvil-readme-check-validate-prereqs \ + anvil-spellcheck-validate-prereqs \ + anvil-pr-title-validate-prereqs \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-udeps-validate-prereqs \ + anvil-semver-check-validate-prereqs \ + anvil-external-types-validate-prereqs \ + anvil-aprz-validate-prereqs + +[group("anvil-setup")] +anvil-pr-slow-setup installer="install": \ + (anvil-pr-test-setup installer) \ + (anvil-pr-runtime-analysis-setup installer) \ + (anvil-pr-mutants-setup installer) + +[group("anvil-setup")] +anvil-pr-slow-validate-prereqs: \ + anvil-pr-test-validate-prereqs \ + anvil-pr-runtime-analysis-validate-prereqs \ + anvil-pr-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-pr-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-pr-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-pr-runtime-analysis-setup installer="install": \ + (anvil-miri-setup installer) \ + (anvil-careful-setup installer) + +[group("anvil-setup")] +anvil-pr-runtime-analysis-validate-prereqs: \ + anvil-miri-validate-prereqs \ + anvil-careful-validate-prereqs + +[group("anvil-setup")] +anvil-pr-mutants-setup installer="install": (anvil-mutants-diff-setup installer) + +[group("anvil-setup")] +anvil-pr-mutants-validate-prereqs: anvil-mutants-diff-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-scheduled-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-advisories-setup installer="install": \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-aprz-setup installer) \ + (anvil-clippy-setup installer) + +[group("anvil-setup")] +anvil-scheduled-advisories-validate-prereqs: \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-aprz-validate-prereqs \ + anvil-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-exhaustive-setup installer="install": \ + (anvil-mutants-full-setup installer) \ + (anvil-cargo-hack-setup installer) \ + (anvil-bench-setup installer) + +[group("anvil-setup")] +anvil-scheduled-exhaustive-validate-prereqs: \ + anvil-mutants-full-validate-prereqs \ + anvil-cargo-hack-validate-prereqs \ + anvil-bench-validate-prereqs + +=== justfiles/anvil/mod.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Entry point for the anvil just recipe tree. The user's root Justfile +# imports this single file; everything else lives in sibling .just files +# pulled in here. + +# All multi-statement recipes in this tree use [script("pwsh")]. pwsh +# is preinstalled on Windows (10+), GH/ADO hosted Linux + Windows +# runners, and is installable on macOS/Linux via Homebrew / upstream +# installer. We chose pwsh over bash because: +# +# - just's shebang dispatch (#!/usr/bin/env bash) requires `cygpath` +# on Windows, which is only on PATH inside Git Bash. Plain +# PowerShell can't run shebang recipes. +# - just's [script("bash")] attribute passes Windows tempfile paths +# to bash unescaped, and bash interprets the backslashes as escape +# characters — every recipe fails with a mangled path. +# - [script("pwsh")] works from plain PowerShell with no PATH +# augmentation and no path translation. pwsh handles Windows +# paths natively. +# +# The existing `_anvil-require pwsh` recipe already established +# pwsh as a tools-floor requirement, so requiring it as the recipe +# interpreter is consistent. +# +# Single-command recipes (e.g. `cargo deny check`) are plain just +# recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# IMPORTANT: [script(...)] requires `set unstable`. The adopter's +# root justfile must declare it (typically as a top-level line). +# anvil's mod.just does NOT redeclare it, to avoid conflicting +# with adopters who already have it. + +import 'checks.just' +import 'groups.just' +import 'tiers.just' +import 'tools.just' +import 'versions.just' + +# Friendly default: `just anvil` runs the PR tier. +alias anvil := anvil-pr + +=== justfiles/anvil/tiers.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the tier structure. + +# PR tier: every check that should run on every pull request, split +# into two groups so the fast checks aren't blocked behind the slow +# ones in cloud workflows. +[group("anvil")] +anvil-pr: \ + anvil-pr-fast \ + anvil-pr-slow + +# scheduled tier: full-workspace re-runs of things that can change +# without a commit (advisories, flakes) + the truly expensive +# exhaustive checks that don't fit in a PR budget. Runs on a schedule +# against `main`, not on PRs. +[group("anvil")] +anvil-scheduled: \ + anvil-scheduled-test \ + anvil-scheduled-advisories \ + anvil-scheduled-exhaustive + +# Full tier: PR + scheduled, end-to-end. Useful before tagging a release. +[group("anvil")] +anvil-full: \ + anvil-pr \ + anvil-scheduled + +# Tier-level + global setup + validate-prereqs +# =========================================================================== +# +# Per-tier recipes that fan out to per-group setup/validate-prereqs from +# groups.just. The global `anvil-setup` / `anvil-validate-prereqs` +# recipes are the catch-all entry points that install / verify everything. +# +# Cloud workflows typically invoke only the per-group setup it needs (e.g. the +# `anvil-pr-fast` composite action / step template runs +# `anvil-pr-fast-setup` rather than the global `anvil-setup`). +# Local users who want "install everything" run `just anvil-setup`. + +[group("anvil-setup")] +anvil-pr-setup installer="install": \ + (anvil-pr-fast-setup installer) \ + (anvil-pr-slow-setup installer) + +[group("anvil-setup")] +anvil-pr-validate-prereqs: \ + anvil-pr-fast-validate-prereqs \ + anvil-pr-slow-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-setup installer="install": \ + (anvil-scheduled-test-setup installer) \ + (anvil-scheduled-advisories-setup installer) \ + (anvil-scheduled-exhaustive-setup installer) + +[group("anvil-setup")] +anvil-scheduled-validate-prereqs: \ + anvil-scheduled-test-validate-prereqs \ + anvil-scheduled-advisories-validate-prereqs \ + anvil-scheduled-exhaustive-validate-prereqs + +[group("anvil-setup")] +anvil-full-setup installer="install": \ + (anvil-pr-setup installer) \ + (anvil-scheduled-setup installer) + +[group("anvil-setup")] +anvil-full-validate-prereqs: \ + anvil-pr-validate-prereqs \ + anvil-scheduled-validate-prereqs + +# Global aliases. `anvil-setup` (no suffix) installs everything the +# catalog knows about; `anvil-validate-prereqs` verifies every tool +# and component is present at or above its pinned version. +[group("anvil-setup")] +anvil-setup installer="install": (anvil-full-setup installer) + +[group("anvil-setup")] +anvil-validate-prereqs: anvil-full-validate-prereqs + +=== justfiles/anvil/tools.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/local.md for the policy. + +# ============================================================================ +# System-level prerequisites (libclang, etc.) +# ============================================================================ + +# Public: probe for system-level prerequisites that catalog tools need +# to BUILD from source. The `binstall` install path downloads pre-built +# binaries and does not need these, so this check is primarily relevant +# for the source-build `install` path (used by local devs and the ADO +# backend) and is best-effort (skipped silently) for `binstall`. +# +# Scope policy: only system libs that an anvil catalog tool DIRECTLY +# requires. We do not try to be a general-purpose dev-env doctor. +# Current entries: +# - libclang: required by cargo-spellcheck (clang-sys / hunspell-rs) +# at build time. +# +# Detection uses presence-only probes (file existence in standard install +# dirs + `LIBCLANG_PATH` env var). No version checks -- system libs upgrade +# independently and any reasonably modern libclang works for clang-sys. +# +# On missing deps the recipe prints copy-paste install hints per OS / +# package manager and exits non-zero. No auto-install: admin / sudo and +# package-manager choice stay with the user. +[group("anvil-setup")] +[script("pwsh")] +anvil-system-deps-check: + $ErrorActionPreference = 'Stop' + $missing = @() + + # libclang: required to BUILD cargo-spellcheck from source. + $haveLibclang = $false + $libclangFiles = @('libclang.dll', 'libclang.so', 'libclang.so.1', 'libclang.dylib') + if ($env:LIBCLANG_PATH) { + foreach ($f in $libclangFiles) { + if (Test-Path (Join-Path $env:LIBCLANG_PATH $f)) { $haveLibclang = $true; break } + } + } + if (-not $haveLibclang) { + $probes = if ($IsWindows) { + @( + 'C:\Program Files\LLVM\bin\libclang.dll', + "$env:USERPROFILE\scoop\apps\llvm\current\bin\libclang.dll" + ) + } elseif ($IsMacOS) { + @( + '/usr/local/opt/llvm/lib/libclang.dylib', + '/opt/homebrew/opt/llvm/lib/libclang.dylib' + ) + } else { + @( + '/usr/lib/x86_64-linux-gnu/libclang.so.1', + '/usr/lib/aarch64-linux-gnu/libclang.so.1', + '/usr/lib64/libclang.so', + '/usr/lib64/libclang.so.1' + ) + } + foreach ($p in $probes) { + if (Get-Item -LiteralPath $p -ErrorAction SilentlyContinue) { $haveLibclang = $true; break } + } + # Linux distros often add a version suffix (libclang-19.so etc.); glob fallback. + if (-not $haveLibclang -and -not $IsWindows -and -not $IsMacOS) { + $glob = Get-ChildItem -Path '/usr/lib','/usr/lib64','/usr/lib/x86_64-linux-gnu','/usr/lib/aarch64-linux-gnu' -Filter 'libclang*.so*' -ErrorAction SilentlyContinue + if ($glob) { $haveLibclang = $true } + } + } + if (-not $haveLibclang) { + $missing += [pscustomobject]@{ + name = 'libclang' + why = 'cargo-spellcheck (clang-sys/hunspell-rs) needs libclang at build time' + hints = @( + 'Linux (Ubuntu/Debian): sudo apt-get install -y libclang-dev' + 'Linux (Azure Linux/RHEL): sudo tdnf install -y clang-devel' + 'macOS (homebrew): brew install llvm' + 'Windows (scoop): scoop install llvm # no admin' + 'Windows (winget, admin): winget install LLVM.LLVM' + ) + } + } + + if ($missing.Count -gt 0) { + Write-Host '' + foreach ($m in $missing) { + Write-Host "anvil: missing system dependency '$($m.name)'" -ForegroundColor Yellow + Write-Host " why: $($m.why)" + Write-Host ' install:' + foreach ($h in $m.hints) { Write-Host " $h" } + Write-Host '' + } + Write-Host "If the dep is installed but not on the standard search path, set LIBCLANG_PATH and re-run." -ForegroundColor Yellow + exit 1 + } + +# ============================================================================ +# rustc + pwsh validate-prereqs (no install -- system prereqs) +# ============================================================================ +# +# rustc is installed via rustup (https://rustup.rs); pwsh is installed +# via the platform's package manager. Both are presumed present on any +# machine running anvil; the validate recipes just surface a friendly +# error if not. + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-rustc-validate-prereqs: + if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Error 'anvil: rustc not found. Install via rustup: https://rustup.rs' + exit 1 + } + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-pwsh-validate-prereqs: + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $hint = if ($IsMacOS) { + 'brew install --cask powershell' + } elseif ($IsLinux) { + 'see https://github.com/PowerShell/PowerShell' + } else { + 'winget install --id Microsoft.PowerShell' + } + Write-Error "anvil: pwsh (PowerShell Core) not found. Install: $hint" + exit 1 + } + +# ============================================================================ +# Private helpers (install/check primitives) +# ============================================================================ + +# _install-tool: install a cargo subcommand at exactly the pinned version, +# or no-op if it is already installed at or above that version. The +# `installer` parameter selects between: +# - "install" (cargo install --locked, pure-source). Default. +# - "binstall" (cargo binstall --no-confirm --locked, with cargo install +# fallback if binstall fails). Bootstraps cargo-binstall +# itself if not on PATH. +[script("pwsh")] +_install-tool name version installer: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + $installer = '{{installer}}' + + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool: unknown installer '$installer' (expected 'install' or 'binstall')" + exit 2 + } + + # Already at or above the pin: skip. We don't downgrade tools the + # user upgraded for their own reasons; the validate side uses + # `installed >= pin`, so newer is fine. The early-exit is also what + # makes the actions/cache restore actually useful -- without it, + # every post-restore run would try to re-install on top of the + # cached binaries and fail with "binary already exists in destination". + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if ($installed) { + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. + } + } + + Write-Host "Installing $name =$version (installer: $installer)" + if ($installer -eq 'binstall') { + if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { + Write-Host ' Bootstrapping cargo-binstall' + cargo install --locked cargo-binstall + if ($LASTEXITCODE -ne 0) { + Write-Error 'cargo-binstall bootstrap failed' + exit $LASTEXITCODE + } + } + cargo binstall --no-confirm --locked $name --version "=$version" + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow + } + cargo install --locked $name --version "=$version" + if ($LASTEXITCODE -ne 0) { + Write-Error "$name install FAILED" + exit $LASTEXITCODE + } + +# _check-tool: verify a cargo subcommand is installed at or above the +# pinned version. Errors with an install hint on missing or too-old. +[script("pwsh")] +_check-tool name version: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if (-not $installed) { + Write-Error "anvil: required tool '$name' not found. Install: cargo install --locked --version =$version $name" + exit 1 + } + try { + $installedV = [version]$installed + $pinV = [version]$version + } catch { + Write-Error "anvil: cannot compare versions for '$name' (installed=$installed pin=$version). Reinstall: cargo install --locked --version =$version $name" + exit 1 + } + if ($installedV -lt $pinV) { + Write-Error "anvil: '$name' v$installed is older than the required minimum v$version. Upgrade: cargo install --locked --version =$version $name" + exit 1 + } + +# _install-toolchain: install a specific rustup toolchain (no components). +[script("pwsh")] +_install-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + Write-Host "rustup toolchain install $toolchain --profile minimal --no-self-update" + rustup toolchain install $toolchain --profile minimal --no-self-update + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-toolchain: verify a rustup toolchain is installed. +[script("pwsh")] +_check-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + rustup which --toolchain $toolchain rustc 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + +# _install-component: add a component to a toolchain. The toolchain is +# either the literal "default" (current rustup default) or a specific +# pinned toolchain string (which must already be installed -- the +# per-component setup recipes ensure this via a dependency on +# anvil--install). +[script("pwsh")] +_install-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + if ($toolchain -eq 'default') { + Write-Host "rustup component add $component (default toolchain)" + rustup component add $component + } else { + Write-Host "rustup component add --toolchain $toolchain $component" + rustup component add --toolchain $toolchain $component + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install component '$component' on toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-component: verify a component is installed on a toolchain. +[script("pwsh")] +_check-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + $output = if ($toolchain -eq 'default') { + rustup component list --installed 2>$null + } else { + rustup component list --installed --toolchain $toolchain 2>$null + } + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + $found = $output | Where-Object { $_ -like "$component*" } + if (-not $found) { + $cmd = if ($toolchain -eq 'default') { "rustup component add $component" } else { "rustup component add --toolchain $toolchain $component" } + Write-Error "anvil: component '$component' not installed on toolchain '$toolchain'. Run: $cmd" + exit 1 + } + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +[group("anvil-setup")] +anvil-toolchain-nightly-install: (_install-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-validate-prereqs: (_check-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-install: (_install-toolchain rust_nightly_external_types) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-validate-prereqs: (_check-toolchain rust_nightly_external_types) + +# ============================================================================ +# Rustup components +# ============================================================================ +# +# Default-toolchain components are installed via `rustup component add` +# (no toolchain spec). Nightly components depend on the toolchain +# being installed first (via the relevant anvil--install +# recipe) and then add the component. + +[group("anvil-setup")] +anvil-component-default-clippy-install: (_install-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-clippy-validate-prereqs: (_check-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-rustfmt-install: (_install-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-default-rustfmt-validate-prereqs: (_check-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-miri-install: anvil-toolchain-nightly-install (_install-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-miri-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rust-src") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rust-src") + +# ============================================================================ +# Cargo subcommands +# ============================================================================ +# +# One pair (install + validate-prereqs) per tool, alphabetical. +# Version pins live in versions.just (one cargo__version variable +# per tool). The install recipes accept an `installer="install"|"binstall"` +# parameter; the validate-prereqs recipes do not (they only read state). + +[group("anvil-setup")] +anvil-tool-cargo-aprz-install installer="install": (_install-tool "cargo-aprz" cargo_aprz_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-aprz-validate-prereqs: (_check-tool "cargo-aprz" cargo_aprz_version) + +[group("anvil-setup")] +anvil-tool-cargo-audit-install installer="install": (_install-tool "cargo-audit" cargo_audit_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-audit-validate-prereqs: (_check-tool "cargo-audit" cargo_audit_version) + +[group("anvil-setup")] +anvil-tool-cargo-careful-install installer="install": (_install-tool "cargo-careful" cargo_careful_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-careful-validate-prereqs: (_check-tool "cargo-careful" cargo_careful_version) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-install installer="install": (_install-tool "cargo-check-external-types" cargo_check_external_types_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-validate-prereqs: (_check-tool "cargo-check-external-types" cargo_check_external_types_version) + +[group("anvil-setup")] +anvil-tool-cargo-delta-install installer="install": (_install-tool "cargo-delta" cargo_delta_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-delta-validate-prereqs: (_check-tool "cargo-delta" cargo_delta_version) + +[group("anvil-setup")] +anvil-tool-cargo-deny-install installer="install": (_install-tool "cargo-deny" cargo_deny_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-deny-validate-prereqs: (_check-tool "cargo-deny" cargo_deny_version) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-doc2readme" cargo_doc2readme_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs: (_check-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-install installer="install": (_install-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-validate-prereqs: (_check-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version) + +[group("anvil-setup")] +anvil-tool-cargo-hack-install installer="install": (_install-tool "cargo-hack" cargo_hack_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-hack-validate-prereqs: (_check-tool "cargo-hack" cargo_hack_version) + +[group("anvil-setup")] +anvil-tool-cargo-heather-install installer="install": (_install-tool "cargo-heather" cargo_heather_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-heather-validate-prereqs: (_check-tool "cargo-heather" cargo_heather_version) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-install installer="install": (_install-tool "cargo-llvm-cov" cargo_llvm_cov_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-validate-prereqs: (_check-tool "cargo-llvm-cov" cargo_llvm_cov_version) + +# cargo-mutants doesn't build on aarch64-pc-windows-msvc (upstream +# winapi crate incompat). The install recipe self-skips on that target +# so per-group setup recipes that depend on it (pr-mutants-setup, +# scheduled-exhaustive-setup) don't fail on that platform. The +# mutants-diff / mutants-full check recipes also self-skip on the same +# target, so the check is effectively a no-op end-to-end there. +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-install installer="install": + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-install: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _install-tool cargo-mutants {{cargo_mutants_version}} {{installer}} + exit $LASTEXITCODE + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-validate-prereqs: + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-validate-prereqs: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _check-tool cargo-mutants {{cargo_mutants_version}} + exit $LASTEXITCODE + +[group("anvil-setup")] +anvil-tool-cargo-nextest-install installer="install": (_install-tool "cargo-nextest" cargo_nextest_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-nextest-validate-prereqs: (_check-tool "cargo-nextest" cargo_nextest_version) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-install installer="install": (_install-tool "cargo-semver-checks" cargo_semver_checks_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-validate-prereqs: (_check-tool "cargo-semver-checks" cargo_semver_checks_version) + +[group("anvil-setup")] +anvil-tool-cargo-sort-install installer="install": (_install-tool "cargo-sort" cargo_sort_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-sort-validate-prereqs: (_check-tool "cargo-sort" cargo_sort_version) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-install installer="install": (_install-tool "cargo-spellcheck" cargo_spellcheck_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-validate-prereqs: (_check-tool "cargo-spellcheck" cargo_spellcheck_version) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-install installer="install": (_install-tool "cargo-udeps" cargo_udeps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_version) + +=== justfiles/anvil/versions.just === +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Pinned versions used by the anvil check tree. +# +# Everything anvil installs (cargo subcommands + rustup toolchains) +# is pinned here. The pinning policy: +# - On install (`-install` recipes): exactly this version (`=` for +# cargo subcommands, exact ref for rustup toolchains). Pulling +# "latest-matching" at install time is a cloud-workflow reproducibility risk -- +# an upstream release between yesterday's green build and today's +# PR can break things (cargo-spellcheck 0.15.7's em-dash regression +# is the canonical case). The `=` constraint locks the install to +# the version the catalog was validated against. +# - On validate-prereqs (`-validate-prereqs` recipes): the installed +# version must be `>= `. A user who has manually upgraded a +# tool for their own reasons (e.g. needing an unreleased bugfix) +# is not downgraded by setup. The validate gate uses +# `installed >= pin`, so newer is fine. +# +# To bump a pin, edit the version in place and re-run +# `cargo anvil`. The dirty-file flow preserves the edit on +# subsequent runs. To add a new tool, append a variable and the matching +# `-install`/`-validate-prereqs` pair in `tools.just`. To remove a tool, +# delete its variable and recipes (and any check that depends on them). + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +# General nightly used by udeps, miri, careful, and any future +# nightly-dependent check. Bumped on a regular cadence (monthly is a +# reasonable default) when an adopter has time to absorb formatting / +# lint / API-surface drift. Pin only -- do not use bare `nightly` here. +rust_nightly := "nightly-2026-02-10" + +# Pinned narrowly to the rustdoc JSON schema version that the currently +# selected cargo-check-external-types release accepts. cargo-check- +# external-types embeds a specific rustdoc-types crate version; if the +# nightly's emitted JSON format_version drifts past it, every run fails +# with "produces JSON format version X, but this tool requires +# format version Y" -- which is a tooling-incompat, not a real API +# violation. Bump this alongside any cargo-check-external-types upgrade. +rust_nightly_external_types := "nightly-2025-10-18" + +# ============================================================================ +# Cargo subcommands +# ============================================================================ + +cargo_aprz_version := "1.0.0" +cargo_audit_version := "0.22.2" +cargo_careful_version := "0.4.9" +cargo_check_external_types_version := "0.4.0" +cargo_delta_version := "0.3.1" +cargo_deny_version := "0.19.0" +cargo_doc2readme_version := "0.6.4" +cargo_ensure_no_cyclic_deps_version := "0.2.0" +cargo_ensure_no_default_features_version := "1.0.0" +cargo_hack_version := "0.6.41" +cargo_heather_version := "0.2.1" +cargo_llvm_cov_version := "0.8.4" +cargo_mutants_version := "26.1.2" +cargo_nextest_version := "0.9.122" +cargo_semver_checks_version := "0.46.0" +cargo_sort_version := "2.0.2" +cargo_spellcheck_version := "0.15.7" +cargo_udeps_version := "0.1.60" + +=== rustfmt.toml === +# >>> anvil-managed: anvil-rustfmt +edition = "2024" +max_width = 140 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +# The following options require nightly rustfmt. anvil-fmt invokes +# `cargo +{{ rust_nightly }} fmt`; see justfiles/anvil/versions.just +# for the pin and docs/design/local.md#nightly-pinning for the policy. +unstable_features = true +# Format Rust code blocks inside `///` doc comments. Catches stale +# examples that drift from the prose. +format_code_in_doc_comments = true +# One use-statement per module (vs collapsed `a::{b, c, d}` form). +# Diffs touch only the lines that actually changed. +imports_granularity = "Module" +# Group imports: std, then external crates, then crate-internal. Matches +# the convention used across the surveyed Microsoft Rust repos. +group_imports = "StdExternalCrate" +# <<< anvil-managed: anvil-rustfmt + +=== spellcheck.toml === +# >>> anvil-managed: anvil-spellcheck +# Check spelling in code comments marked as dev/developer comments +# (e.g., `// TODO:`, `// FIXME:`). Set to false to skip them. +dev_comments = false + +# Whether to skip spell checking README files. Set to false to include +# README files in spell checking. +skip_readme = false + +[Hunspell] +# Language dictionary. "en_US" uses the built-in English (US) dictionary. +lang = "en_US" + +# Directories searched for `extra_dictionaries` paths. The default +# repo-root entry lets adopters keep their custom dictionary next to +# the .spelling source. +search_dirs = ["."] + +# Additional dictionary files loaded after the language dictionary. +# Format: first line is the word count, remaining lines are sorted +# words (one per line). `target/spelling.dic` is generated by the +# `anvil-spellcheck` recipe from the repo's `.spelling` file. +extra_dictionaries = ["target/spelling.dic"] + +# Don't consult OS-provided dictionaries. Keeps results consistent +# across Linux/macOS/Windows runners. +skip_os_lookups = true + +# Use cargo-spellcheck's built-in language dictionaries (independent of +# system hunspell installation). Required for the cross-platform +# reproducibility guarantee above. +use_builtin = true + +# Token-boundary characters. Override the upstream default to add +# typographic punctuation we use in prose (em-dash, en-dash, arrows, +# minus sign). Without these, cargo-spellcheck 0.15.7 tokenises text +# like `runtime — it` as three tokens including the em-dash itself, +# then fails its dictionary lookup and flags the em-dash as a +# "possible spelling mistake". Upstream default keeps figure-dash +# (U+2012) and the ASCII hyphen but omits the rest; this list is a +# superset, so the only behavioural change is that the added chars +# now act as token boundaries. +# +# Encoded as \uXXXX escapes for grep-ability and to keep the file +# 7-bit ASCII: +# ASCII punctuation (default): ",;:.!?#(){}[]|/_- +# Dashes & minus : \u2012 figure-dash (default) +# \u2013 en-dash (added) +# \u2014 em-dash (added) +# \u2015 horizontal-bar (added) +# \u2212 minus-sign (added) +# Arrows : \u2190 leftwards-arrow (added) +# \u2192 rightwards-arrow (added) +# ASCII punctuation (default): ' ` & @ +# Misc (default) : \u00A7 section, \u00B6 pilcrow, \u2026 ellipsis +tokenization_splitchars = "\",;:.!?#(){}[]|/_-\u2012\u2013\u2014\u2015\u2190\u2192\u2212'`&@\u00A7\u00B6\u2026" + +[Hunspell.quirks] +# Treat CamelCase identifiers as concatenations of dictionary words +# (e.g., `TcpStream` = `Tcp` + `Stream`). Lowers false-positive rate +# substantially on Rust codebases. +allow_concatenation = true +# <<< anvil-managed: anvil-spellcheck diff --git a/crates/cargo-anvil/tests/update.rs b/crates/cargo-anvil/tests/update.rs new file mode 100644 index 00000000..016c9d8a --- /dev/null +++ b/crates/cargo-anvil/tests/update.rs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) +#![allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" +)] + +//! Fixture-driven integration tests for `cargo anvil`. +//! +//! Each scenario lives under `tests/fixtures//`. The runner +//! copies the fixture into a temporary directory, invokes +//! `run_update`, and asserts the scenario-specific invariants. +//! +//! Complementary to the in-memory unit tests under `src/run.rs` +//! (which seed file contents inline). These fixtures are reviewable +//! by reading actual files on disk, which helps when designing new +//! migration paths or onboarding scenarios. + +#![expect(clippy::unwrap_used, reason = "integration tests favor concise assertions over Result plumbing")] +#![expect( + clippy::panic, + reason = "integration tests panic on unmet preconditions for readable failure output" +)] +#![expect( + clippy::doc_markdown, + reason = "fixture names like `opt-outs` look like code but are directory names" +)] + +use std::path::{Path, PathBuf}; + +use cargo_anvil::cli::Cli; +use cargo_anvil::decision::Decision; +use cargo_anvil::plan::Target; +use cargo_anvil::run::{RunOutcome, run_update}; +use tempfile::TempDir; + +const FIXTURES_ROOT: &str = env!("CARGO_MANIFEST_DIR"); + +/// Copy a fixture directory tree into a fresh tempdir and return the +/// tempdir handle (which deletes its contents on drop). +fn stage_fixture(name: &str) -> TempDir { + let src: PathBuf = [FIXTURES_ROOT, "tests", "fixtures", name].iter().collect(); + assert!(src.is_dir(), "fixture {name} missing at {}", src.display()); + let tmp = TempDir::new().unwrap(); + copy_tree(&src, tmp.path()); + tmp +} + +fn copy_tree(from: &Path, to: &Path) { + for entry in walkdir::WalkDir::new(from) { + let entry = entry.unwrap(); + let rel = entry.path().strip_prefix(from).unwrap(); + let dest = to.join(rel); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&dest).unwrap(); + } else if entry.file_type().is_file() { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::copy(entry.path(), &dest).unwrap(); + } + } +} + +fn local_only_args() -> Cli { + Cli { + backends: vec![], + no_backends: true, + dry_run: false, + } +} + +fn run(tmp: &TempDir) -> RunOutcome { + run_update(&local_only_args(), tmp.path()).unwrap() +} + +fn region_decision(outcome: &RunOutcome, host: &str, id: &str) -> Decision { + outcome + .plan + .items() + .iter() + .find(|i| matches!(&i.target, Target::Region { host: h, id: rid } if h == host && rid == id)) + .unwrap_or_else(|| panic!("missing region item for {host}#{id}")) + .decision +} + +/// `single-crate`: a manifest with a bare `[package]` and no +/// `[workspace]` should still get the per-crate lints region (not the +/// workspace one), the Justfile imports region, and the full +/// justfiles/anvil/ tree. +#[test] +fn single_crate_emits_crate_lints_and_justfiles() { + let tmp = stage_fixture("single-crate"); + run(&tmp); + + let cargo = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + cargo.contains("anvil-managed: anvil-lints"), + "single-crate fixture should receive the per-crate lints region; got:\n{cargo}" + ); + assert!( + !cargo.contains("anvil-workspace-lints"), + "single-crate fixture must not receive the workspace lints region" + ); + + for rel in [ + "Justfile", + "justfiles/anvil/mod.just", + "justfiles/anvil/tools.just", + "justfiles/anvil/checks.just", + "justfiles/anvil/groups.just", + "justfiles/anvil/tiers.just", + "justfiles/anvil/versions.just", + ] { + assert!(tmp.path().join(rel).is_file(), "expected {rel} to be written"); + } + + // Idempotence: a second run must not change anything. + let outcome2 = run(&tmp); + assert!( + !outcome2.plan.has_changes(), + "second run should be a no-op; plan: {:#?}", + outcome2.plan.items() + ); +} + +/// `opt-outs`: a user who emptied the rustfmt managed region after a +/// first run keeps that opt-out across re-runs (LeaveAlone decision). +#[test] +fn empty_region_is_treated_as_opt_out() { + use cargo_anvil::emit::shared_configs::RUSTFMT_REGION_ID; + use cargo_anvil::region::{CommentSyntax, upsert_region}; + + let tmp = stage_fixture("opt-outs"); + run(&tmp); // seed manifest and templates + + // Simulate the user emptying the managed region. + let rustfmt_path = tmp.path().join("rustfmt.toml"); + let body = std::fs::read_to_string(&rustfmt_path).unwrap(); + let emptied = upsert_region(&body, RUSTFMT_REGION_ID, "", CommentSyntax::Hash).unwrap(); + std::fs::write(&rustfmt_path, &emptied).unwrap(); + + // Re-run and check the rustfmt region is LeaveAlone. + let outcome = run(&tmp); + assert_eq!(region_decision(&outcome, "rustfmt.toml", RUSTFMT_REGION_ID), Decision::LeaveAlone); + let after = std::fs::read_to_string(&rustfmt_path).unwrap(); + assert_eq!(after, emptied, "opt-out region must not be re-populated"); +} + +/// `customized`: a user edit inside a managed region with an unchanged +/// template should be left alone on subsequent runs. +#[test] +fn user_edit_inside_region_is_left_alone() { + use cargo_anvil::emit::shared_configs::RUSTFMT_REGION_ID; + use cargo_anvil::region::{CommentSyntax, upsert_region}; + + let tmp = stage_fixture("customized"); + run(&tmp); + + let rustfmt_path = tmp.path().join("rustfmt.toml"); + let body = std::fs::read_to_string(&rustfmt_path).unwrap(); + let custom = upsert_region(&body, RUSTFMT_REGION_ID, "edition = \"2021\"\n", CommentSyntax::Hash).unwrap(); + std::fs::write(&rustfmt_path, custom).unwrap(); + + let outcome = run(&tmp); + assert_eq!(region_decision(&outcome, "rustfmt.toml", RUSTFMT_REGION_ID), Decision::LeaveAlone); + let after = std::fs::read_to_string(&rustfmt_path).unwrap(); + assert!( + after.contains("edition = \"2021\""), + "user customization must be preserved; got:\n{after}" + ); +} + +/// `migration`: a workspace that already has a hand-written +/// `Justfile`, a `[workspace.lints]` block, and a `deny.toml` should +/// get anvil's regions spliced in without losing any user content. +#[test] +fn migration_preserves_user_content() { + let tmp = stage_fixture("migration"); + run(&tmp); + + let justfile = std::fs::read_to_string(tmp.path().join("Justfile")).unwrap(); + assert!( + justfile.contains("my-custom-recipe"), + "user-authored Justfile recipes must survive migration; got:\n{justfile}" + ); + assert!( + justfile.contains("anvil-imports"), + "anvil imports region must be spliced into the existing Justfile" + ); + + let cargo = std::fs::read_to_string(tmp.path().join("Cargo.toml")).unwrap(); + assert!( + cargo.contains("lto = \"thin\""), + "user-authored [profile.release] must survive migration; got:\n{cargo}" + ); + assert!( + cargo.contains("anvil-workspace-lints"), + "anvil workspace lints region must be spliced into Cargo.toml" + ); + + let deny = std::fs::read_to_string(tmp.path().join("deny.toml")).unwrap(); + assert!( + deny.contains("RUSTSEC-9999-0001"), + "user-authored deny.toml content must survive migration; got:\n{deny}" + ); + assert!(deny.contains("anvil-deny"), "anvil deny region must be spliced into deny.toml"); + + // Idempotence: re-run leaves everything alone. + let outcome2 = run(&tmp); + assert!( + !outcome2.plan.has_changes(), + "second migration run should be a no-op; plan: {:#?}", + outcome2.plan.items() + ); +} diff --git a/crates/cargo-coverage-gate/Cargo.toml b/crates/cargo-coverage-gate/Cargo.toml index 05afa488..62dc4ef3 100644 --- a/crates/cargo-coverage-gate/Cargo.toml +++ b/crates/cargo-coverage-gate/Cargo.toml @@ -43,5 +43,7 @@ assert_cmd = { workspace = true } predicates = { workspace = true } tempfile = { workspace = true } +# >>> anvil-managed: anvil-lints [lints] workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/cargo-coverage-gate/src/lcov_cov.rs b/crates/cargo-coverage-gate/src/lcov_cov.rs index 139d9870..ec1fb69e 100644 --- a/crates/cargo-coverage-gate/src/lcov_cov.rs +++ b/crates/cargo-coverage-gate/src/lcov_cov.rs @@ -163,8 +163,8 @@ end_of_record assert!(err.to_string().contains("lcov tracefile")); } + #[cfg_attr(miri, ignore = "uses real filesystem and open(); miri isolation forbids both")] #[test] - #[cfg_attr(miri, ignore = "uses real filesystem; Miri isolation forbids open()")] fn from_path_reads_from_disk() { let tmp = tempfile::NamedTempFile::new().expect("tempfile"); std::fs::write(tmp.path(), SINGLE_FILE).expect("write fixture"); diff --git a/crates/cargo-coverage-gate/src/workspace.rs b/crates/cargo-coverage-gate/src/workspace.rs index fcb5f826..ee169a63 100644 --- a/crates/cargo-coverage-gate/src/workspace.rs +++ b/crates/cargo-coverage-gate/src/workspace.rs @@ -155,8 +155,8 @@ edition = "2021" ) } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn loads_workspace_with_no_metadata_anywhere() { let tmp = tempfile::tempdir().expect("tempdir"); write_workspace( @@ -179,8 +179,8 @@ edition = "2021" } } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn picks_up_workspace_level_default() { let tmp = tempfile::tempdir().expect("tempdir"); write_workspace( @@ -192,8 +192,8 @@ edition = "2021" assert_eq!(ws.default_min_lines_percent, Some(80.0)); } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn picks_up_per_crate_override() { let tmp = tempfile::tempdir().expect("tempdir"); write_workspace( @@ -209,8 +209,8 @@ edition = "2021" assert_eq!(ws.default_min_lines_percent, Some(80.0)); } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn rejects_out_of_range_per_crate_threshold() { let tmp = tempfile::tempdir().expect("tempdir"); write_workspace( @@ -228,8 +228,8 @@ edition = "2021" assert!(rendered.contains("120"), "rendered: {rendered}"); } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn rejects_negative_workspace_threshold() { let tmp = tempfile::tempdir().expect("tempdir"); let root = r#" @@ -247,8 +247,8 @@ min-lines-percent = -1 assert!(rendered.contains("-1"), "rendered: {rendered}"); } + #[cfg_attr(miri, ignore = "uses filesystem and spawns cargo metadata subprocess; miri allows neither")] #[test] - #[cfg_attr(miri, ignore = "spawns cargo metadata subprocess")] fn rejects_non_numeric_threshold() { let tmp = tempfile::tempdir().expect("tempdir"); let root = r#" diff --git a/crates/cargo-coverage-gate/tests/cli.rs b/crates/cargo-coverage-gate/tests/cli.rs index e326689e..9145bf9b 100644 --- a/crates/cargo-coverage-gate/tests/cli.rs +++ b/crates/cargo-coverage-gate/tests/cli.rs @@ -10,6 +10,7 @@ //! `coverage-gate` token is prepended to the argv because that's what //! cargo's subcommand convention does. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) use std::fs; use std::path::Path; diff --git a/crates/cargo-heather/Cargo.toml b/crates/cargo-heather/Cargo.toml index e6507648..de2a23e4 100644 --- a/crates/cargo-heather/Cargo.toml +++ b/crates/cargo-heather/Cargo.toml @@ -37,5 +37,7 @@ walkdir = { workspace = true } assert_cmd = { workspace = true } tempfile = { workspace = true } +# >>> anvil-managed: anvil-lints [lints] workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/cargo-heather/README.md b/crates/cargo-heather/README.md index 7c553282..118795db 100644 --- a/crates/cargo-heather/README.md +++ b/crates/cargo-heather/README.md @@ -1,9 +1,9 @@
- Cargo Heather Logo + Cargo-Heather Logo -# Cargo Heather +# Cargo-Heather -[![crate.io](https://img.shields.io/crates/v/cargo-heather.svg)](https://crates.io/crates/cargo-heather) +[![crates.io](https://img.shields.io/crates/v/cargo-heather.svg)](https://crates.io/crates/cargo-heather) [![docs.rs](https://docs.rs/cargo-heather/badge.svg)](https://docs.rs/cargo-heather) [![MSRV](https://img.shields.io/crates/msrv/cargo-heather)](https://crates.io/crates/cargo-heather) [![CI](https://github.com/microsoft/ox-tools/actions/workflows/main.yml/badge.svg?event=push)](https://github.com/microsoft/ox-tools/actions/workflows/main.yml) @@ -15,13 +15,17 @@ ## cargo-heather -A cargo sub-command to validate license headers in Rust (`.rs`), TOML (`.toml`), -PowerShell (`.ps1`, `.psd1`, `.psm1`), Just (`justfile`, `*.just`), and env -(`constants.env`) source files. +A `cargo` subcommand to validate license headers in Rust, TOML, +`PowerShell`, Just, and `constants.env` source files. The +`cargo-heather` binary uses the library in this crate to discover +files on disk and apply rewrites; the same library is reusable from +any Rust program. ### Setup -Create a `.cargo-heather.toml` file in your project root, **or** simply set the `license` field in your `Cargo.toml` — the tool will use it automatically when no `.cargo-heather.toml` is present. +Create a `.cargo-heather.toml` file in your project root, **or** +simply set the `license` field in your `Cargo.toml` — the tool will +use it automatically when no `.cargo-heather.toml` is present. #### Using an SPDX License Identifier @@ -40,26 +44,27 @@ All rights reserved. #### Excluding Files and Directories -Use the `exclude` key to skip specific files or directories from scanning. -Entries are **literal paths**. Relative paths are resolved against the project root (the directory -passed to `--project-dir`, or the current directory by default). Glob patterns -and wildcards are **not** supported. +Use the `exclude` key to skip specific files or directories from +scanning. Entries are **literal paths**. Relative paths are resolved +against the project root (the directory passed to `--project-dir`, +or the current directory by default). Glob patterns and wildcards +are **not** supported. ```toml exclude = ["vendor", "generated/bindings.rs"] ``` -A directory entry excludes its entire subtree recursively. Entries that do not -exist on disk produce a warning and are ignored. +A directory entry excludes its entire subtree recursively. Entries +that do not exist on disk produce a warning and are ignored. -> **Note:** `target/`, `.git/`, `.github/`, `.vscode/`, `.idea/`, -> `node_modules/`, and other dot-prefixed directories are already skipped -> automatically. +`target/`, `.git/`, `.github/`, `.vscode/`, `.idea/`, +`node_modules/`, and other dot-prefixed directories are already +skipped automatically. ### Usage ```bash -# Check all supported source files for correct license headers +# Check all source files for correct license headers cargo heather # Automatically fix files by adding/replacing headers @@ -68,15 +73,18 @@ cargo heather --fix #### Options -* `--project-dir ` — Path to the project directory (defaults to current directory) -* `--config ` — Path to the configuration file (defaults to `.cargo-heather.toml` in project directory) -* `--fix` — Fix files by adding or replacing missing/incorrect headers -* `--help` — Print help -* `--version` — Print version +* `--project-dir ` — Path to the project directory (defaults + to the current directory). +* `--config ` — Path to the configuration file (defaults to + `.cargo-heather.toml` in the project directory). +* `--fix` — Fix files by adding or replacing missing/incorrect + headers. +* `--help` — Print help. +* `--version` — Print version. #### Example -```bash +```text $ cargo heather Checking 5 file(s)... MISSING header: src/utils.rs @@ -117,25 +125,87 @@ Fixed 2 file(s). ### How it works -1. **Config loading** — Reads `.cargo-heather.toml` from the project root and resolves the expected header text (from SPDX identifier or custom text). -1. **File scanning** — Walks the project directory for supported source files (see [Supported file kinds](#supported-file-kinds) below), skipping `target/`, hidden directories, and the config file itself. -1. **Header validation** — Extracts the header comment block from each file (placement depends on the file kind) and compares it to the expected header. Reports missing or mismatched headers. -1. **Fix mode** — When `--fix` is passed, automatically prepends the correct header to files that are missing it, or replaces incorrect headers. +1. **Config loading** — Reads `.cargo-heather.toml` from the + project root and resolves the expected header text (from SPDX + identifier or custom text). +1. **File scanning** — Walks the project directory to find all + supported source files, skipping `target/`, hidden directories, + and the config file itself. +1. **Header validation** — Extracts the first comment block from + each file (`//` for Rust, `#` for TOML / `PowerShell` / Just / + env) and compares it to the expected header. Reports missing or + mismatched headers. +1. **Fix mode** — When `--fix` is passed, automatically prepends + the correct header to files that are missing it, or replaces + incorrect headers. + +### Library + +The library is intentionally minimal: a pair of stream-based +functions that operate on any [`std::io::Read`][__link0] / [`std::io::Write`][__link1]. + +* [`check`][__link2] reads content and reports whether the expected header is + present, missing, or mismatched. +* [`fix`][__link3] reads content and writes the fixed-up content. + +Callers are responsible for opening files, deciding which paths to +process, and writing results back to disk. + +```rust +use cargo_heather::{CheckResult, FileKind, check, fix}; + +let input = b"fn main() {}\n"; +let header = "Licensed under the MIT License."; + +// Check whether the header is present. +let result = check(&input[..], header, FileKind::Rust).unwrap(); +assert_eq!(result, CheckResult::Missing); + +// Produce a fixed copy. +let mut output: Vec = Vec::new(); +fix(&input[..], &mut output, header, FileKind::Rust).unwrap(); +assert!(output.starts_with(b"// Licensed under the MIT License.\n")); +``` + +#### Supported file kinds -### Supported file kinds +* [`FileKind::Rust`][__link4] — regular Rust source (`//` comments). +* [`FileKind::Toml`][__link5] — TOML files (`#` comments). +* [`FileKind::PowerShell`][__link6] — `PowerShell` scripts (`.ps1`), data + files (`.psd1`), and module files (`.psm1`) (all use `#` comments). +* [`FileKind::Just`][__link7] — Just recipes (`#` comments). +* [`FileKind::Env`][__link8] — `constants.env` files (`#` comments). +* [`FileKind::CargoScript`][__link9] — Rust script with shebang + `---` + frontmatter; the header lives inside the frontmatter using `#`. -| File | Comment style | Header placement | -| ----------------------------- | ------------- | -------------------------------------------------- | -| `.rs` (regular) | `//` | Top of file | -| `.rs` (cargo-script) | `#` | Inside the `---` frontmatter | -| `.toml` | `#` | Top of file | -| `.ps1`, `.psd1`, `.psm1` | `#` | Top of file, or after a leading `#!` shebang | -| `*.just`, `justfile` | `#` | Top of file | -| `constants.env` | `#` | Top of file | +Use [`FileKind::detect`][__link10] (or [`is_cargo_script`][__link11]) to classify a file +from its path and content before calling [`check`][__link12] / [`fix`][__link13]. +#### License header lookup + +The [`license`][__link14] module maps SPDX identifiers to canonical short +header strings; this is what the binary uses when no custom header +is supplied.
This crate was developed as part of The Oxidizer Project. Browse this crate's source code. + + [__cargo_doc2readme_dependencies_info]: ggGmYW0CYXZlMC43LjJhdIQbFhzZ8rzWNNYbuRaDSGWynFgbH4PMdoT7GNcbVwNPtPjAhvFhYvRhcoQbtpJMWoUHDG0bJGvg_sbiCH8b66-weBRIetcbfF7fD5ccBythZIGDbWNhcmdvLWhlYXRoZXJlMC4zLjBtY2FyZ29faGVhdGhlcg + [__link0]: https://doc.rust-lang.org/stable/std/?search=io::Read + [__link1]: https://doc.rust-lang.org/stable/std/?search=io::Write + [__link10]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::detect + [__link11]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=is_cargo_script + [__link12]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=check + [__link13]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=fix + [__link14]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/license/index.html + [__link2]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=check + [__link3]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=fix + [__link4]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::Rust + [__link5]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::Toml + [__link6]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::PowerShell + [__link7]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::Just + [__link8]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::Env + [__link9]: https://docs.rs/cargo-heather/0.3.0/cargo_heather/?search=FileKind::CargoScript diff --git a/crates/cargo-heather/src/lib.rs b/crates/cargo-heather/src/lib.rs index 4be74f9c..62c90a1e 100644 --- a/crates/cargo-heather/src/lib.rs +++ b/crates/cargo-heather/src/lib.rs @@ -6,14 +6,134 @@ //! # cargo-heather //! -//! Library for validating and rewriting license headers in source files. The -//! accompanying `cargo-heather` binary uses this library to discover files on -//! disk and apply the rewrites. +//! A `cargo` subcommand to validate license headers in Rust, TOML, +//! `PowerShell`, Just, and `constants.env` source files. The +//! `cargo-heather` binary uses the library in this crate to discover +//! files on disk and apply rewrites; the same library is reusable from +//! any Rust program. //! -//! ## Public API +//! ## Setup //! -//! The library is intentionally minimal: a pair of stream-based functions -//! that operate on any [`std::io::Read`] / [`std::io::Write`]. +//! Create a `.cargo-heather.toml` file in your project root, **or** +//! simply set the `license` field in your `Cargo.toml` — the tool will +//! use it automatically when no `.cargo-heather.toml` is present. +//! +//! ### Using an SPDX License Identifier +//! +//! ```toml +//! license = "MIT" +//! ``` +//! +//! ### Using a Custom Header +//! +//! ```toml +//! header = """ +//! Copyright (c) 2024 MyCompany +//! All rights reserved. +//! """ +//! ``` +//! +//! ### Excluding Files and Directories +//! +//! Use the `exclude` key to skip specific files or directories from +//! scanning. Entries are **literal paths**. Relative paths are resolved +//! against the project root (the directory passed to `--project-dir`, +//! or the current directory by default). Glob patterns and wildcards +//! are **not** supported. +//! +//! ```toml +//! exclude = ["vendor", "generated/bindings.rs"] +//! ``` +//! +//! A directory entry excludes its entire subtree recursively. Entries +//! that do not exist on disk produce a warning and are ignored. +//! +//! `target/`, `.git/`, `.github/`, `.vscode/`, `.idea/`, +//! `node_modules/`, and other dot-prefixed directories are already +//! skipped automatically. +//! +//! ## Usage +//! +//! ```bash +//! # Check all source files for correct license headers +//! cargo heather +//! +//! # Automatically fix files by adding/replacing headers +//! cargo heather --fix +//! ``` +//! +//! ### Options +//! +//! - `--project-dir ` — Path to the project directory (defaults +//! to the current directory). +//! - `--config ` — Path to the configuration file (defaults to +//! `.cargo-heather.toml` in the project directory). +//! - `--fix` — Fix files by adding or replacing missing/incorrect +//! headers. +//! - `--help` — Print help. +//! - `--version` — Print version. +//! +//! ### Example +//! +//! ```text +//! $ cargo heather +//! Checking 5 file(s)... +//! MISSING header: src/utils.rs +//! MISMATCH header: src/lib.rs +//! 2 file(s) have missing or incorrect license headers +//! +//! $ cargo heather --fix +//! Checking 5 file(s)... +//! Fixed (added header): src/utils.rs +//! Fixed (replaced header): src/lib.rs +//! Fixed 2 file(s). +//! ``` +//! +//! ## Supported SPDX Identifiers +//! +//! | Identifier | License | +//! | ------------------ | ---------------------------------------------------- | +//! | `MIT` | MIT License | +//! | `Apache-2.0` | Apache License 2.0 | +//! | `GPL-2.0-only` | GNU General Public License v2.0 only | +//! | `GPL-2.0-or-later` | GNU General Public License v2.0 or later | +//! | `GPL-3.0-only` | GNU General Public License v3.0 only | +//! | `GPL-3.0-or-later` | GNU General Public License v3.0 or later | +//! | `LGPL-2.1-only` | GNU Lesser General Public License v2.1 only | +//! | `LGPL-2.1-or-later`| GNU Lesser General Public License v2.1 or later | +//! | `LGPL-3.0-only` | GNU Lesser General Public License v3.0 only | +//! | `LGPL-3.0-or-later`| GNU Lesser General Public License v3.0 or later | +//! | `BSD-2-Clause` | BSD 2-Clause "Simplified" License | +//! | `BSD-3-Clause` | BSD 3-Clause "New" or "Revised" License | +//! | `ISC` | ISC License | +//! | `MPL-2.0` | Mozilla Public License 2.0 | +//! | `AGPL-3.0-only` | GNU Affero General Public License v3.0 only | +//! | `AGPL-3.0-or-later`| GNU Affero General Public License v3.0 or later | +//! | `Unlicense` | The Unlicense | +//! | `BSL-1.0` | Boost Software License 1.0 | +//! | `0BSD` | BSD Zero Clause License | +//! | `Zlib` | zlib License | +//! +//! ## How it works +//! +//! 1. **Config loading** — Reads `.cargo-heather.toml` from the +//! project root and resolves the expected header text (from SPDX +//! identifier or custom text). +//! 2. **File scanning** — Walks the project directory to find all +//! supported source files, skipping `target/`, hidden directories, +//! and the config file itself. +//! 3. **Header validation** — Extracts the first comment block from +//! each file (`//` for Rust, `#` for TOML / `PowerShell` / Just / +//! env) and compares it to the expected header. Reports missing or +//! mismatched headers. +//! 4. **Fix mode** — When `--fix` is passed, automatically prepends +//! the correct header to files that are missing it, or replaces +//! incorrect headers. +//! +//! ## Library +//! +//! The library is intentionally minimal: a pair of stream-based +//! functions that operate on any [`std::io::Read`] / [`std::io::Write`]. //! //! - [`check`] reads content and reports whether the expected header is //! present, missing, or mismatched. @@ -38,7 +158,7 @@ //! assert!(output.starts_with(b"// Licensed under the MIT License.\n")); //! ``` //! -//! ## Supported file kinds +//! ### Supported file kinds //! //! - [`FileKind::Rust`] — regular Rust source (`//` comments). //! - [`FileKind::Toml`] — TOML files (`#` comments). @@ -52,10 +172,11 @@ //! Use [`FileKind::detect`] (or [`is_cargo_script`]) to classify a file //! from its path and content before calling [`check`] / [`fix`]. //! -//! ## License header lookup +//! ### License header lookup //! -//! The [`license`] module maps SPDX identifiers to canonical short header -//! strings; this is what the binary uses when no custom header is supplied. +//! The [`license`] module maps SPDX identifiers to canonical short +//! header strings; this is what the binary uses when no custom header +//! is supplied. #![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-heather/logo.png")] #![doc(html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-heather/favicon.ico")] diff --git a/crates/cargo-heather/tests/common/mod.rs b/crates/cargo-heather/tests/common/mod.rs index b2783652..1c854bdd 100644 --- a/crates/cargo-heather/tests/common/mod.rs +++ b/crates/cargo-heather/tests/common/mod.rs @@ -7,6 +7,7 @@ //! warnings here are spurious — every test file pulls in only what it //! needs. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) #![allow( dead_code, reason = "each integration test file is its own crate; not all helpers are used by every file" diff --git a/crates/cargo-heather/tests/powershell_ms.rs b/crates/cargo-heather/tests/powershell_ms.rs index 461d0e6d..1e56cf64 100644 --- a/crates/cargo-heather/tests/powershell_ms.rs +++ b/crates/cargo-heather/tests/powershell_ms.rs @@ -8,6 +8,7 @@ //! but no license header is present — the fixer must *prepend* the //! license header, not strip and replace the existing comments. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo-heather/tests/rust_apache.rs b/crates/cargo-heather/tests/rust_apache.rs index 47f403fc..8288b817 100644 --- a/crates/cargo-heather/tests/rust_apache.rs +++ b/crates/cargo-heather/tests/rust_apache.rs @@ -4,6 +4,7 @@ //! Fixture-driven integration tests for `.rs` files against a multi-line //! Apache-2.0 license header. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo-heather/tests/rust_mit.rs b/crates/cargo-heather/tests/rust_mit.rs index f93ada05..cbf5d04e 100644 --- a/crates/cargo-heather/tests/rust_mit.rs +++ b/crates/cargo-heather/tests/rust_mit.rs @@ -5,6 +5,7 @@ //! single-line license header. Each test inlines its `INPUT` and //! `EXPECTED` content as raw string literals. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo-heather/tests/rust_ms_mit.rs b/crates/cargo-heather/tests/rust_ms_mit.rs index 051f3af5..22084efd 100644 --- a/crates/cargo-heather/tests/rust_ms_mit.rs +++ b/crates/cargo-heather/tests/rust_ms_mit.rs @@ -5,6 +5,7 @@ //! two-line Microsoft MIT header (`Copyright (c) Microsoft Corporation.` + //! `Licensed under the MIT License.`). +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo-heather/tests/script_mit.rs b/crates/cargo-heather/tests/script_mit.rs index 0ebf40a2..3817a425 100644 --- a/crates/cargo-heather/tests/script_mit.rs +++ b/crates/cargo-heather/tests/script_mit.rs @@ -4,6 +4,7 @@ //! Fixture-driven integration tests for cargo-script files (shebang + //! `---` frontmatter) against an MIT-style single-line license header. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo-heather/tests/toml_mit.rs b/crates/cargo-heather/tests/toml_mit.rs index 516cb6aa..5bdaf2dc 100644 --- a/crates/cargo-heather/tests/toml_mit.rs +++ b/crates/cargo-heather/tests/toml_mit.rs @@ -4,6 +4,7 @@ //! Fixture-driven integration tests for `.toml` files against an MIT-style //! single-line license header. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) mod common; use cargo_heather::{CheckResult, FileKind}; diff --git a/crates/cargo_ensure_no_cyclic_deps/Cargo.toml b/crates/cargo_ensure_no_cyclic_deps/Cargo.toml index c5707690..2e32e419 100644 --- a/crates/cargo_ensure_no_cyclic_deps/Cargo.toml +++ b/crates/cargo_ensure_no_cyclic_deps/Cargo.toml @@ -33,7 +33,8 @@ petgraph = { version = "0.8", default-features = false } [dev-dependencies] assert_cmd = { version = "2.0", default-features = false } predicates = { version = "3.1", default-features = false } -tempfile = { version = "3.13", default-features = false } +# >>> anvil-managed: anvil-lints [lints] workspace = true +# <<< anvil-managed: anvil-lints diff --git a/crates/cargo_ensure_no_cyclic_deps/tests/integration_tests.rs b/crates/cargo_ensure_no_cyclic_deps/tests/integration_tests.rs index 4efaddb3..2f8e4536 100644 --- a/crates/cargo_ensure_no_cyclic_deps/tests/integration_tests.rs +++ b/crates/cargo_ensure_no_cyclic_deps/tests/integration_tests.rs @@ -1,6 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#![cfg(not(miri))] // miri can't sandbox FS ops these tests do (TempDir, assert_cmd, etc.) +#![allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "panic-on-failure idioms are appropriate in tests" +)] + //! Integration test use std::path::PathBuf; diff --git a/deny.toml b/deny.toml index ebc5bcbf..a781cff9 100644 --- a/deny.toml +++ b/deny.toml @@ -1,19 +1,33 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# >>> anvil-managed: anvil-deny +[advisories] +yanked = "deny" +# Scope of unmaintained-crate checks: "all" surfaces transitive +# dependencies too; tighten to "workspace" if the noise is high. +unmaintained = "all" + [licenses] allow = [ - # You can add new entries to the list only from "Permissive OSS licenses" on this Microsoft-internal page: https://docs.opensource.microsoft.com/legal/resources/oss-licenses-by-type/. "MIT", "Apache-2.0", - "ISC", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", + "ISC", + "MPL-2.0", "Unicode-DFS-2016", "Unicode-3.0", "Zlib", - "BSL-1.0", ] +confidence-threshold = 0.93 + +[bans] +multiple-versions = "warn" +wildcards = "deny" -confidence-threshold = 0.8 -unused-allowed-license = "allow" +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# <<< anvil-managed: anvil-deny diff --git a/justfiles/anvil/checks.just b/justfiles/anvil/checks.just new file mode 100644 index 00000000..d4abfc29 --- /dev/null +++ b/justfiles/anvil/checks.just @@ -0,0 +1,872 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. +# +# Each check belongs to one of four buckets, which determines how it +# interprets the impact env vars emitted by the cargo-delta impact step +# in cloud workflows: +# +# - "modified": only run when at least one package's source files +# changed in the diff. The check's underlying tool is workspace-wide +# or directory-scoped (cargo fmt --all, cargo heather, cargo +# spellcheck), so it doesn't take --package; we short-circuit on +# the ANVIL_INCLUDE_MODIFIED == "--skip" sentinel. +# +# - "affected": run on the affected set (modified ∪ reverse-deps +# within the workspace). The check's underlying tool takes +# --package; we splice ANVIL_INCLUDE_AFFECTED into the cargo +# invocation, defaulting to --workspace for local invocations where +# no env var is set. +# +# - "required": run on the required set (affected ∪ workspace-internal +# transitive deps). Same splice/default pattern as affected, but +# keyed on ANVIL_INCLUDE_REQUIRED. Used for checks whose tool +# resolves through the dep graph (cargo doc → intra-doc links; +# cargo hack → feature powerset; cargo udeps → unused-deps). +# +# - "unscoped": always run, no env var reference. External-input +# checks (deny, audit, aprz) and PR-context checks (pr-title) live +# here. Scheduled-exhaustive recipes (mutants-full) are also unscoped +# by design. +# +# Local invocation (no impact wiring): all three env vars are unset +# (recipes use the `?? "--workspace"` null-coalescing fallback below); +# modified-tier recipes simply skip the splice and run their +# workspace-wide tool; affected/required-tier recipes splat +# "--workspace" when the env var is unset. +# +# Preparation contract: when a recipe reaches the cargo call, the +# env var is one of: +# +# * unset - local run; the recipe substitutes +# "--workspace" via `?? "--workspace"` +# * "--package A --package B" - emitted by the cloud-workflow impact step when +# the tier has members +# * "--skip" - emitted by the cloud-workflow impact step when +# the tier is empty (recipe exits 0) +# +# This lets the simple recipes splat the var directly with +# & cargo X @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) ... +# and reduces the per-recipe boilerplate to a single one-line skip +# guard plus the cargo invocation. +# +# Modified-tier recipes never splice the env var into cargo (their +# tools are workspace-wide); they only check the skip sentinel. +# +# Every recipe whose body uses multi-line conditionals or env-var +# splicing is annotated with [script("pwsh")]. pwsh is preinstalled on +# Windows (since Windows 10), on GH/ADO hosted Linux + Windows +# runners, and installable on macOS via Homebrew or the upstream +# installer. We chose pwsh over bash because just's shebang dispatch +# requires `cygpath` on Windows (only on PATH from inside Git Bash), +# while [script("pwsh")] works from plain PowerShell with no PATH +# augmentation. The `??` null-coalescing operator used in the splat +# requires pwsh 7+, which is the floor we already require via +# _anvil-require pwsh. +# +# Single-command recipes (cargo deny check, cargo audit) are plain +# just recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# Note: [script(...)] requires `set unstable`. The adopter's root +# justfile must declare it (typically as a top-level line). anvil's +# mod.just does NOT redeclare it, to avoid conflicting with adopters +# who already have it. + +# === pr-fast members ==================================================== + +# Modified tier. cargo-fmt is a rustup component. We invoke it via the +# pinned nightly (see versions.just) because rustfmt.toml uses +# unstable_features = true (imports_granularity, group_imports, +# format_code_in_doc_comments). Floating nightly would mean +# format-drift breaking cloud workflows on rustup updates — the same trap we +# explicitly avoid for udeps/miri/careful/external-types. +[script("pwsh")] +anvil-fmt: anvil-fmt-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo '+{{ rust_nightly }}' fmt --all --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. Per project policy, clippy runs on the affected set +# rather than the modified set: a change in a crate can introduce a +# clippy issue in a dependent crate (e.g., trait-bound or +# obviously-truthy-condition lints that key off the changed type), so +# we want downstream rev-deps to lint as well. cargo-clippy is a +# rustup component; same reasoning as fmt for the require. +[script("pwsh")] +anvil-clippy: anvil-clippy-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo clippy @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-targets --all-features --locked "--" '-D' 'warnings' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-cargo-sort: anvil-cargo-sort-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo sort --workspace --check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-license-headers: anvil-license-headers-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo heather + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-cyclic-deps: anvil-ensure-no-cyclic-deps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-cyclic-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +[script("pwsh")] +anvil-ensure-no-default-features: anvil-ensure-no-default-features-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { exit 0 } + cargo ensure-no-default-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Required tier. cargo doc resolves intra-doc links through the dep +# graph, so a dep changing its public API can break doc-build in a +# crate that wasn't itself modified. +[script("pwsh")] +anvil-doc-build: anvil-doc-build-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + $env:RUSTDOCFLAGS = '-D warnings' + & cargo doc @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features --no-deps + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Modified tier. +# +# cargo-doc2readme regenerates a crate's README.md from its rustdoc. +# Two extension points users frequently need: +# * Workspace-level template (`crates/README.j2` or `README.j2` at repo +# root): a Tera template applied to every crate's README. anvil +# auto-detects it and passes `--template` to the per-crate runs. +# * Per-crate opt-out: hand-crafted READMEs (e.g. a tool crate whose +# README is more freeform than the lib docs) opt out by adding +# `[package.metadata.ox-gen-readme]\ndisable = true` to their +# Cargo.toml. anvil skips those crates. +# +# Bin-only crates have no library rustdoc to base a README on, so they +# are skipped as well (cargo doc2readme requires a library target). +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: per-crate +# iteration over library targets, cargo-metadata-driven opt-outs +# (publish=false, [package.metadata.ox-gen-readme] disable), per-crate +# Push-Location into the crate dir (cargo-doc2readme is CWD-sensitive +# rather than --manifest-path-driven), and per-crate template-path +# resolution. The ANVIL_INCLUDE_MODIFIED value would still need to +# be intersected with the lib-crate set rather than splatted into cargo. +[script("pwsh")] +anvil-readme-check: anvil-readme-check-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-readme-check: no modified packages; skipping' + exit 0 + } + # Detect a workspace-level README template. Two conventional + # locations: crates/README.j2 (cargo-workspaces idiom) or + # README.j2 at repo root. + $template = $null + foreach ($candidate in 'crates/README.j2', 'README.j2') { + if (Test-Path $candidate) { $template = (Resolve-Path $candidate).Path; break } + } + # Iterate library crates. Filter by impact set when set, then drop + # bin-only crates and opt-outs. + $pkg = @(if ($env:ANVIL_INCLUDE_MODIFIED) { -split $env:ANVIL_INCLUDE_MODIFIED } else { '--workspace' }) + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + $byName = @{} + foreach ($p in $meta.packages) { $byName[$p.name] = $p } + $candidates = if ($pkg -contains '--workspace') { + @($meta.packages | ForEach-Object { $_.name }) + } else { + $names = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + $names += $pkg[$i + 1]; $i++ + } + } + $names + } + $hadFailure = $false + foreach ($name in $candidates) { + $p = $byName[$name] + if (-not $p) { continue } + if (-not ($p.targets | Where-Object { $_.kind -contains 'lib' })) { continue } + # Skip private crates (publish = false). They aren't released + # and rarely have a polished README. Mirrors the + # `cargo workspaces exec --ignore-private` idiom adopters + # commonly use. + if ($p.publish -is [array] -and $p.publish.Count -eq 0) { + Write-Host "anvil-readme-check: $name (skipped: publish = false)" + continue + } + $disabled = $false + if ($p.metadata -and $p.metadata.'ox-gen-readme' -and $p.metadata.'ox-gen-readme'.disable) { + $disabled = $true + } + if ($disabled) { + Write-Host "anvil-readme-check: $name (opted out via [package.metadata.ox-gen-readme])" + continue + } + Write-Host "anvil-readme-check: $name" + # cargo doc2readme writes / compares relative to its CWD (not + # --manifest-path), so chdir into the crate before invoking + # --check. We also compute a per-crate relative path to the + # workspace-level template so the same template file works for + # every crate (parallels the cargo-workspaces idiom). + $crateDir = Split-Path -Parent $p.manifest_path + Push-Location $crateDir + try { + $relTemplate = if ($template) { + Resolve-Path -Relative -LiteralPath $template + } else { + $null + } + $args = @('doc2readme', '--check') + if ($relTemplate) { $args += @('--template', $relTemplate) } + & cargo @args + if ($LASTEXITCODE -ne 0) { $hadFailure = $true } + } finally { + Pop-Location + } + } + if ($hadFailure) { exit 1 } + +# Modified tier. +# +# cargo-spellcheck reads a Hunspell-compatible dictionary file at the +# path configured in spellcheck.toml (typically `extra_dictionaries = +# ["target/spelling.dic"]`). The convention used by the surveyed +# Microsoft Rust repos is to keep the *source* word list in a +# human-edited `.spelling` file at the repo root and preprocess it +# into the .dic format at check time (Hunspell .dic requires: +# alphabetical sort, blank/numeric lines removed, line-count header). +# If `.spelling` is present, we generate `target/spelling.dic` from it +# automatically; otherwise we run cargo-spellcheck against whatever +# the repo has already set up. +[script("pwsh")] +anvil-spellcheck: anvil-spellcheck-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_MODIFIED -eq '--skip') { + Write-Host 'anvil-spellcheck: no modified packages; skipping' + exit 0 + } + if (Test-Path '.spelling') { + $output_file = 'target/spelling.dic' + $lines = Get-Content '.spelling' | Sort-Object + $filtered_lines = $lines | Where-Object { $_ -notmatch '^\d+$' -and $_ -ne '' } + $line_count = $filtered_lines.Count + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($output_file)) | Out-Null + @($line_count) + $filtered_lines | Set-Content $output_file + } + # Pass --cfg explicitly when a spellcheck.toml exists at repo root, + # otherwise cargo-spellcheck falls back to its built-in defaults and + # ignores user-curated dictionaries (`extra_dictionaries`, custom + # hunspell langs, etc.). + if (Test-Path 'spellcheck.toml') { + cargo spellcheck --cfg spellcheck.toml check --code 1 + } else { + cargo spellcheck check --code 1 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Unscoped (PR title is not source-related). +# +# Validates that $env:PR_TITLE matches Conventional Commits when set; +# no-op when unset. Cloud workflows inject PR_TITLE explicitly (GH Actions: +# ${{ github.event.pull_request.title }}; ADO: $(System.PullRequest.Title)) +# so the check has authoritative input there. Locally it skips silently -- +# there is no reliable way to recover the PR title for the ADO backend +# (no equivalent of `gh pr view`), so the recipe stays simple and +# defers the check to cloud workflows. +[script("pwsh")] +anvil-pr-title: anvil-pr-title-validate-prereqs + $title = $env:PR_TITLE + if (-not $title) { + Write-Host 'anvil-pr-title: PR_TITLE env var not set; skipping (check runs in cloud workflows)' + exit 0 + } + if ($title -notmatch '^(feat|fix|chore|docs|refactor|test|build|cloud workflows|perf|revert)(\([^)]+\))?!?: .+') { + Write-Error "PR title '$title' does not match Conventional Commits" + exit 1 + } + +# Unscoped (consults external advisory DB; reads Cargo.lock, not +# workspace members). Single command — inherits adopter's default shell. +anvil-deny: anvil-deny-validate-prereqs + cargo deny check + +# Unscoped (consults external advisory DB; reads Cargo.lock). +anvil-audit: anvil-audit-validate-prereqs + cargo audit + +# Required tier. cargo-udeps detects unused dependencies by resolving +# the full crate graph and seeing which deps are referenced; that's +# precisely what the required tier is for. Pinned to the general +# nightly defined in versions.just. +# +# Deliberately omits `--all-targets`: with `--all-targets`, a dep +# that's listed in BOTH `[dependencies]` and `[dev-dependencies]` and +# used only by tests is reported as "all used" because the dev-deps +# target satisfies the lookup, masking the unused entry in main +# `[dependencies]`. Restricting to the default targets (lib + bins) +# matches main repo cloud workflows' check and surfaces the real bug. +[script("pwsh")] +anvil-udeps: anvil-udeps-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' udeps @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --all-features + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier (compares published API of changed crates against the +# baseline; only changed crates' surface is at risk). +# +# Several real-world conditions produce errors that aren't actually +# SemVer violations: +# - bin-only crates have no API to compare ("no library targets found"). +# - crates not yet published to crates.io ("not found in registry"). +# - crates where the published baseline lacks a lib target the +# current source has (bin -> bin+lib transition). +# We pre-filter to library-bearing crates from cargo metadata, then +# run cargo-semver-checks per-package and tolerate the +# "no-comparable-baseline" failure modes. +# +# Findings policy: this recipe is *advisory*. Real SemVer findings do +# NOT fail the recipe -- breaking changes between unreleased commits +# are normal (the major-version bump happens at release time, not on +# every PR). Instead, when there are findings we write a markdown +# advisory body to `target/anvil/comments/semver.md`; when the +# tree is clean we remove that file. cloud-workflow wiring (GH: +# marocchino/sticky-pull-request-comment; ADO: pwsh + REST API) +# inspects the file after the recipe and upserts / clears a sticky +# PR comment accordingly. Local invocation gets the same file +# written under target/ for inspection. +# +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-metadata +# filter to library crates (cargo-semver-checks --workspace fails on +# bin-only workspaces), intersect ANVIL_INCLUDE_AFFECTED with that +# set, then per-crate invocation with selective error tolerance for +# unpublished crates ("not found in registry") and bin->bin+lib +# transitions ("no library targets found"). +[script("pwsh")] +anvil-semver-check: anvil-semver-check-validate-prereqs + $ErrorActionPreference = 'Stop' + $commentFile = 'target/anvil/comments/semver.md' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-semver-check: no affected packages; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build the candidate package list. Always iterate per-package over + # library crates only -- cargo-semver-checks --workspace would fail + # on workspaces that contain bin-only crates ("no library targets + # found"), and we want the same tolerance for unpublished / bin->lib- + # transition crates regardless of whether we got here via impact- + # scoping (cloud workflows) or full-workspace fallback (local). + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $true + } + } + if ($pkg -contains '--workspace') { + $packages = @($libPkgs.Keys) + } else { + $packages = @() + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs[$pkg[$i + 1]]) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-semver-check: no affected library crates; skipping' + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + exit 0 + } + $findings = New-Object System.Collections.Generic.List[string] + foreach ($p in $packages) { + Write-Host "anvil-semver-check: $p" + $output = (& cargo semver-checks --package $p 2>&1) | Out-String + if ($LASTEXITCODE -ne 0) { + if ($output -match 'not found in registry|no library targets found') { + Write-Host " $p has no comparable baseline; skipping (likely unpublished or bin->lib transition)" -ForegroundColor Yellow + } else { + Write-Host $output + # Append a per-crate findings block. Using one-line-at-a-time + # appends keeps the markdown free of pwsh backtick-escape + # gymnastics (single-quoted literals + the natural `n join + # produce clean LF newlines and unambiguous triple-backticks). + $findings.Add('### `' + $p + '`') | Out-Null + $findings.Add('') | Out-Null + $findings.Add('```') | Out-Null + foreach ($line in ($output.TrimEnd() -split "`r?`n")) { + $findings.Add($line.TrimEnd()) | Out-Null + } + $findings.Add('```') | Out-Null + $findings.Add('') | Out-Null + } + } + } + [System.IO.Directory]::CreateDirectory((Split-Path -Parent $commentFile)) | Out-Null + if ($findings.Count -gt 0) { + # Body starts with an HTML-comment marker so the ADO wiring can + # locate the existing thread on subsequent runs (ADO has no + # native "sticky comment header"; the marker is invisible to + # human readers). Marocchino on GH uses its own `header:` input + # and ignores the marker, but having it in the body keeps a + # single source of truth across backends. + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('') | Out-Null + $lines.Add('## :warning: Potential breaking changes detected') | Out-Null + $lines.Add('') | Out-Null + $lines.Add('`cargo semver-checks` flagged the following on this PR. This is **informational** -- breaking changes between commits are expected; the major-version bump happens at release time, not on every PR.') | Out-Null + $lines.Add('') | Out-Null + foreach ($f in $findings) { $lines.Add($f) | Out-Null } + $body = ($lines -join "`n") + "`n" + Set-Content -LiteralPath $commentFile -Value $body -Encoding UTF8 -NoNewline + Write-Host '' + Write-Host "anvil-semver-check: wrote advisory comment to $commentFile (recipe exits 0; cloud workflows will upsert a PR comment)" -ForegroundColor Yellow + } else { + Remove-Item -LiteralPath $commentFile -ErrorAction SilentlyContinue + } + # Advisory: always exit 0. cloud-workflow wiring posts/clears the PR comment. + exit 0 + +# Affected tier (lints public API of changed crates and rev-deps). +# +# cargo-check-external-types is per-manifest: no --package/--workspace, +# only --manifest-path. Iterate the affected library crates and run +# the tool once each, pointing at the crate's Cargo.toml. Bin-only +# crates have no public API surface and are skipped. +# TODO(anvil-runner): even after a `cargo ox-run` helper absorbs the +# skip/splat preamble, this recipe stays multi-step: cargo-check- +# external-types is per-manifest (no --package/--workspace), so we +# build a name->manifest map from cargo metadata, filter to lib crates, +# intersect with ANVIL_INCLUDE_AFFECTED, and call the tool once per +# crate. Hard-fails on errors (no tolerance, unlike semver-check). +[script("pwsh")] +anvil-external-types: anvil-external-types-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-external-types: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # Build pkg-name -> manifest-path map, restricted to library crates. + $libPkgs = @{} + $meta = cargo metadata --no-deps --format-version 1 | ConvertFrom-Json + foreach ($p in $meta.packages) { + if ($p.targets | Where-Object { $_.kind -contains 'lib' }) { + $libPkgs[$p.name] = $p.manifest_path + } + } + # Decide which packages to check. + $packages = @() + if ($pkg -contains '--workspace') { + $packages = $libPkgs.Keys + } else { + for ($i = 0; $i -lt $pkg.Count; $i++) { + if ($pkg[$i] -eq '--package' -and ($i + 1) -lt $pkg.Count) { + if ($libPkgs.ContainsKey($pkg[$i + 1])) { $packages += $pkg[$i + 1] } + $i++ + } + } + } + if ($packages.Count -eq 0) { + Write-Host 'anvil-external-types: no affected library crates; skipping' + exit 0 + } + $failed = $false + foreach ($p in $packages) { + Write-Host "anvil-external-types: $p" + # cargo-check-external-types requires nightly rustdoc (uses + # unstable -Z flags) AND pins a specific rustdoc-types schema + # version. We pin nightly narrowly to the schema this tool + # version expects via `rust_nightly_external_types` in + # versions.just — bump that pin alongside any cargo-check- + # external-types upgrade. No tolerance for schema mismatches: + # if it fails, the pin or the tool needs to move. + & cargo '+{{ rust_nightly_external_types }}' check-external-types --manifest-path $libPkgs[$p] + if ($LASTEXITCODE -ne 0) { $failed = $true } + } + if ($failed) { exit 1 } + +# Unscoped (consults external risk DB). +anvil-aprz: anvil-aprz-validate-prereqs + cargo aprz deps --error-if-high-risk --console appraisal + +# === pr-test members ==================================================== + +# Affected tier. +[script("pwsh")] +anvil-llvm-cov: anvil-llvm-cov-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-llvm-cov: no affected packages; skipping' + exit 0 + } + $pkg = @(if ($env:ANVIL_INCLUDE_AFFECTED) { -split $env:ANVIL_INCLUDE_AFFECTED } else { '--workspace' }) + # cargo-llvm-cov doesn't work on aarch64-pc-windows-msvc: the + # llvm-profdata that ships with the rust toolchain there fails + # to merge the .profraw set ("no profile can be merged"). Fall + # back to plain `cargo nextest run` on that target so we still + # get test execution; coverage data from this leg wouldn't have + # been used anyway (coverage upload is gated on Linux only). + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-llvm-cov: aarch64-pc-windows-msvc -- skipping coverage; running plain nextest' + & cargo nextest run @pkg --all-features --locked + exit $LASTEXITCODE + } + # cargo llvm-cov writes the .profraw set into target/llvm-cov-target/ + # but the *report* output directory (target/coverage/) is something + # we choose and must exist before --output-path runs. + [System.IO.Directory]::CreateDirectory('target/coverage') | Out-Null + [System.IO.Directory]::CreateDirectory('target/coverage/html') | Out-Null + # Wipe stale .profraw data so the report reflects only this run. + cargo llvm-cov clean --workspace --profraw-only + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Instrument and run tests; defer report generation so we can emit + # multiple formats from the same profraw set without re-running. + # Note: nextest's exit-4 ("no tests to run") IS treated as a + # failure here -- a llvm-cov run that finds no tests almost + # always means a config mistake (wrong package filter, missing + # test target, etc.), not a legitimate empty set. anvil-miri + # is the exception (see its comment): miri-skipped tests are an + # expected design point for FS-heavy crates. + & cargo llvm-cov nextest @pkg --all-features --locked --no-report + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # lcov.info feeds Codecov on GitHub; cobertura.xml feeds + # PublishCodeCoverageResults@2 on Azure DevOps. + cargo llvm-cov report --lcov --output-path target/coverage/lcov.info + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo llvm-cov report --cobertura --output-path target/coverage/cobertura.xml + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + # Local-only HTML viewer (no cloud-workflow consumer); cheap once the data exists. + cargo llvm-cov report --html --output-dir target/coverage/html + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-doc-test: anvil-doc-test-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo test --doc @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-examples: anvil-examples-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo build @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --examples --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === pr-mutants member ==================================================== + +# Affected tier. cargo-mutants does its own diff-scoping via --in-diff; +# the affected-tier guard is the wiring-layer's coarse filter (if no +# affected packages exist, the entire mutants run is pointless). +# +# Skip on aarch64-pc-windows-msvc: cargo-mutants doesn't build there +# (upstream winapi incompatibility), so `_anvil-require cargo-mutants` +# would fail. The merged pr-slow group runs on all four OS legs; mutants +# is the only sub-recipe that can't follow, so it bails out early on the +# affected leg. Coverage on the other three legs is unchanged. +[script("pwsh")] +anvil-mutants-diff: anvil-mutants-diff-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs aarch64-pc-windows-msvc -- cargo-mutants does not build here (winapi); skipping' + exit 0 + } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { + Write-Host 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no affected packages; skipping' + exit 0 + } + # Resolve BASE_REF: env override > origin/main > origin/master. + $base = $null + if ($env:BASE_REF) { + $base = $env:BASE_REF + } else { + foreach ($candidate in @('origin/main', 'origin/master')) { + git rev-parse --verify $candidate 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { + $base = $candidate + break + } + } + } + if (-not $base) { + Write-Error 'anvil-mutants-diff: anvil-mutants-diff-validate-prereqs no BASE_REF set and neither origin/main nor origin/master is available. Set BASE_REF to the branch to diff against.' + exit 1 + } + # cargo-mutants --in-diff takes a FILE path containing a unified + # diff, not a git revision range. Write the diff to a temp file + # first. RUNNER_TEMP (GH) and AGENT_TEMPDIRECTORY (ADO) point at + # the job's scratch dir; fall back to the system temp dir locally. + $tmp_dir = $env:RUNNER_TEMP + if (-not $tmp_dir) { $tmp_dir = $env:AGENT_TEMPDIRECTORY } + if (-not $tmp_dir) { $tmp_dir = [System.IO.Path]::GetTempPath() } + $diff_path = Join-Path $tmp_dir 'anvil-mutants-diff.diff' + git diff "$base..HEAD" --output=$diff_path + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + cargo mutants --in-diff $diff_path --no-shuffle --jobs 0 + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-runtime members ============================================ + +# Nightly checks always run full-workspace; impact env vars aren't set +# by the scheduled workflow, so the affected-tier default (--workspace) +# applies. Skip guards are still included for local diff-scoped runs. + +[script("pwsh")] +anvil-miri: anvil-miri-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + # `--no-tests=pass`: miri-only exception. Tests that touch the + # filesystem, spawn subprocesses, or use other miri-incompatible + # APIs commonly carry `#[cfg_attr(miri, ignore)]` (this is the + # canonical opt-out for build-tooling / CLI crates). A crate + # whose test set ends up entirely-skipped under miri legitimately + # produces zero runnable tests; nextest's default exit-4 ("no + # tests to run") would fail the recipe in that case. We treat + # empty test runs as success for miri only. Other nextest-using + # recipes (llvm-cov) keep exit-4 as a failure because zero tests + # there almost always indicates a config mistake. + & cargo '+{{ rust_nightly }}' miri nextest run --no-tests=pass @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +[script("pwsh")] +anvil-careful: anvil-careful-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo '+{{ rust_nightly }}' careful test @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --locked + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# === nightly-exhaustive members ========================================= + +# Unscoped. Scheduled-exhaustive deliberately runs over the whole +# workspace regardless of diff. +anvil-mutants-full: anvil-mutants-full-validate-prereqs + cargo mutants --workspace --no-shuffle --jobs 0 + +# Required tier. cargo-hack's feature powerset cascades through dep +# features, so the required set (workspace-internal transitive deps) +# is the right scope. +[script("pwsh")] +anvil-cargo-hack: anvil-cargo-hack-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_REQUIRED -eq '--skip') { exit 0 } + & cargo hack @(-split ($env:ANVIL_INCLUDE_REQUIRED ?? "--workspace")) --feature-powerset --depth 2 check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# Affected tier. +[script("pwsh")] +anvil-bench: anvil-bench-validate-prereqs + $ErrorActionPreference = 'Stop' + if ($env:ANVIL_INCLUDE_AFFECTED -eq '--skip') { exit 0 } + & cargo bench @(-split ($env:ANVIL_INCLUDE_AFFECTED ?? "--workspace")) --all-features --no-run + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + +# ============================================================================ +# Per-check setup + validate-prereqs +# ============================================================================ +# +# Each check has matching `*-setup` and `*-validate-prereqs` recipes +# that install / verify the tools and components it needs. The setup +# recipes accept an `installer=install|install` parameter that +# forwards to the underlying tool-install recipes; the validate-prereqs +# recipes take no parameters. +# +# These are the building blocks for `anvil--setup` +# (groups.just) and `anvil--setup` (tiers.just) ΓÇö each +# group/tier-level recipe is just a fan-out over the per-check +# setup/validate-prereqs of its members. + +# --- pr-fast members --- + +[group("anvil-setup")] +anvil-fmt-setup installer="install": anvil-component-nightly-rustfmt-install + +[group("anvil-setup")] +anvil-fmt-validate-prereqs: anvil-component-nightly-rustfmt-validate-prereqs + +[group("anvil-setup")] +anvil-clippy-setup installer="install": anvil-component-default-clippy-install + +[group("anvil-setup")] +anvil-clippy-validate-prereqs: anvil-component-default-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-sort-setup installer="install": (anvil-tool-cargo-sort-install installer) + +[group("anvil-setup")] +anvil-cargo-sort-validate-prereqs: anvil-tool-cargo-sort-validate-prereqs + +[group("anvil-setup")] +anvil-license-headers-setup installer="install": (anvil-tool-cargo-heather-install installer) + +[group("anvil-setup")] +anvil-license-headers-validate-prereqs: anvil-tool-cargo-heather-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-setup installer="install": (anvil-tool-cargo-ensure-no-cyclic-deps-install installer) + +[group("anvil-setup")] +anvil-ensure-no-cyclic-deps-validate-prereqs: anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs + +[group("anvil-setup")] +anvil-ensure-no-default-features-setup installer="install": (anvil-tool-cargo-ensure-no-default-features-install installer) + +[group("anvil-setup")] +anvil-ensure-no-default-features-validate-prereqs: anvil-tool-cargo-ensure-no-default-features-validate-prereqs + +# doc-build, examples and doc-test are pure cargo built-ins; the rust +# toolchain (rustc + cargo) is the only prerequisite, and we already +# rely on it being present everywhere anvil runs. +[group("anvil-setup")] +anvil-doc-build-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-build-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-readme-check-setup installer="install": (anvil-tool-cargo-doc2readme-install installer) + +[group("anvil-setup")] +anvil-readme-check-validate-prereqs: anvil-tool-cargo-doc2readme-validate-prereqs + +# cargo-spellcheck has a build-time libclang dependency; the system +# deps check runs first so adopters get a clear hint instead of a +# cryptic clang-sys build error mid-install. +[group("anvil-setup")] +anvil-spellcheck-setup installer="install": anvil-system-deps-check (anvil-tool-cargo-spellcheck-install installer) + +[group("anvil-setup")] +anvil-spellcheck-validate-prereqs: anvil-tool-cargo-spellcheck-validate-prereqs + +# pr-title is a pwsh script; no cargo tool to install. +[group("anvil-setup")] +anvil-pr-title-setup installer="install": anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-pr-title-validate-prereqs: anvil-tool-pwsh-validate-prereqs + +[group("anvil-setup")] +anvil-deny-setup installer="install": (anvil-tool-cargo-deny-install installer) + +[group("anvil-setup")] +anvil-deny-validate-prereqs: anvil-tool-cargo-deny-validate-prereqs + +[group("anvil-setup")] +anvil-audit-setup installer="install": (anvil-tool-cargo-audit-install installer) + +[group("anvil-setup")] +anvil-audit-validate-prereqs: anvil-tool-cargo-audit-validate-prereqs + +[group("anvil-setup")] +anvil-udeps-setup installer="install": anvil-toolchain-nightly-install (anvil-tool-cargo-udeps-install installer) + +[group("anvil-setup")] +anvil-udeps-validate-prereqs: anvil-toolchain-nightly-validate-prereqs anvil-tool-cargo-udeps-validate-prereqs + +[group("anvil-setup")] +anvil-semver-check-setup installer="install": (anvil-tool-cargo-semver-checks-install installer) + +[group("anvil-setup")] +anvil-semver-check-validate-prereqs: anvil-tool-cargo-semver-checks-validate-prereqs + +[group("anvil-setup")] +anvil-external-types-setup installer="install": anvil-toolchain-nightly-external-types-install (anvil-tool-cargo-check-external-types-install installer) + +[group("anvil-setup")] +anvil-external-types-validate-prereqs: anvil-toolchain-nightly-external-types-validate-prereqs anvil-tool-cargo-check-external-types-validate-prereqs + +[group("anvil-setup")] +anvil-aprz-setup installer="install": (anvil-tool-cargo-aprz-install installer) + +[group("anvil-setup")] +anvil-aprz-validate-prereqs: anvil-tool-cargo-aprz-validate-prereqs + +# --- pr-test members (shared with scheduled-test) --- + +[group("anvil-setup")] +anvil-llvm-cov-setup installer="install": (anvil-tool-cargo-llvm-cov-install installer) (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-llvm-cov-validate-prereqs: anvil-tool-cargo-llvm-cov-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-doc-test-validate-prereqs: anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-examples-validate-prereqs: anvil-tool-rustc-validate-prereqs + +# --- pr-runtime-analysis members --- + +[group("anvil-setup")] +anvil-miri-setup installer="install": anvil-component-nightly-miri-install anvil-component-nightly-rust-src-install (anvil-tool-cargo-nextest-install installer) + +[group("anvil-setup")] +anvil-miri-validate-prereqs: anvil-component-nightly-miri-validate-prereqs anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-nextest-validate-prereqs + +[group("anvil-setup")] +anvil-careful-setup installer="install": anvil-component-nightly-rust-src-install (anvil-tool-cargo-careful-install installer) + +[group("anvil-setup")] +anvil-careful-validate-prereqs: anvil-component-nightly-rust-src-validate-prereqs anvil-tool-cargo-careful-validate-prereqs + +# --- pr-mutants members --- + +[group("anvil-setup")] +anvil-mutants-diff-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-diff-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +# --- scheduled-exhaustive members (mutants-full reuses cargo-mutants; +# cargo-hack and bench are dedicated tools) --- + +[group("anvil-setup")] +anvil-mutants-full-setup installer="install": (anvil-tool-cargo-mutants-install installer) + +[group("anvil-setup")] +anvil-mutants-full-validate-prereqs: anvil-tool-cargo-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-cargo-hack-setup installer="install": (anvil-tool-cargo-hack-install installer) + +[group("anvil-setup")] +anvil-cargo-hack-validate-prereqs: anvil-tool-cargo-hack-validate-prereqs + +# bench uses cargo-built-ins (cargo bench --no-run + plain bench runs); +# no extra tool install needed beyond the rust toolchain. +[group("anvil-setup")] +anvil-bench-setup installer="install": anvil-tool-rustc-validate-prereqs + +[group("anvil-setup")] +anvil-bench-validate-prereqs: anvil-tool-rustc-validate-prereqs \ No newline at end of file diff --git a/justfiles/anvil/groups.just b/justfiles/anvil/groups.just new file mode 100644 index 00000000..7e12a684 --- /dev/null +++ b/justfiles/anvil/groups.just @@ -0,0 +1,206 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the catalog and rationale. + +# Each group is one cloud-workflow job. Within a group, checks run sequentially. + +# PR groups +# =========================================================================== + +[group("anvil")] +anvil-pr-fast: \ + anvil-fmt \ + anvil-clippy \ + anvil-cargo-sort \ + anvil-license-headers \ + anvil-ensure-no-cyclic-deps \ + anvil-ensure-no-default-features \ + anvil-doc-build \ + anvil-readme-check \ + anvil-spellcheck \ + anvil-pr-title \ + anvil-deny \ + anvil-audit \ + anvil-udeps \ + anvil-semver-check \ + anvil-external-types \ + anvil-aprz + +# pr-slow is the single PR-tier group for everything that takes more +# than ~30s per crate (tests, stricter runtimes, mutation testing). +# It's internally split into three sub-recipes so individual concerns +# can be invoked locally without dragging the others along: +# +# slow1: tests + coverage (replaces the former pr-test group) +# slow2: stricter-runtime correctness (miri, careful) +# slow3: mutation testing +# +# Cloud workflows run pr-slow as ONE job per OS leg -- the sub-recipes run +# sequentially within. This trades per-leg wall-clock for fewer +# orchestration jobs and a flatter PR check graph. Individual sub- +# recipes are runnable on their own locally: +# +# $ just anvil-pr-test # tests + coverage only +# $ just anvil-pr-runtime-analysis # miri + careful only +# $ just anvil-pr-mutants # mutants only +[group("anvil")] +anvil-pr-slow: anvil-pr-test anvil-pr-runtime-analysis anvil-pr-mutants + +[group("anvil")] +anvil-pr-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-pr-runtime-analysis: \ + anvil-miri \ + anvil-careful + +[group("anvil")] +anvil-pr-mutants: anvil-mutants-diff + +# Scheduled groups +# =========================================================================== + +[group("anvil")] +anvil-scheduled-test: \ + anvil-llvm-cov \ + anvil-doc-test \ + anvil-examples + +[group("anvil")] +anvil-scheduled-advisories: \ + anvil-deny \ + anvil-audit \ + anvil-aprz \ + anvil-clippy + +[group("anvil")] +anvil-scheduled-exhaustive: \ + anvil-mutants-full \ + anvil-cargo-hack \ + anvil-bench +# Group-level setup + validate-prereqs +# =========================================================================== +# +# Per-group recipes that fan out to the per-check setup/validate-prereqs +# from checks.just. Setup recipes accept `installer="install"|"binstall"`; +# validate-prereqs recipes take no parameters. + +[group("anvil-setup")] +anvil-pr-fast-setup installer="install": \ + (anvil-fmt-setup installer) \ + (anvil-clippy-setup installer) \ + (anvil-cargo-sort-setup installer) \ + (anvil-license-headers-setup installer) \ + (anvil-ensure-no-cyclic-deps-setup installer) \ + (anvil-ensure-no-default-features-setup installer) \ + (anvil-doc-build-setup installer) \ + (anvil-readme-check-setup installer) \ + (anvil-spellcheck-setup installer) \ + (anvil-pr-title-setup installer) \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-udeps-setup installer) \ + (anvil-semver-check-setup installer) \ + (anvil-external-types-setup installer) \ + (anvil-aprz-setup installer) + +[group("anvil-setup")] +anvil-pr-fast-validate-prereqs: \ + anvil-fmt-validate-prereqs \ + anvil-clippy-validate-prereqs \ + anvil-cargo-sort-validate-prereqs \ + anvil-license-headers-validate-prereqs \ + anvil-ensure-no-cyclic-deps-validate-prereqs \ + anvil-ensure-no-default-features-validate-prereqs \ + anvil-doc-build-validate-prereqs \ + anvil-readme-check-validate-prereqs \ + anvil-spellcheck-validate-prereqs \ + anvil-pr-title-validate-prereqs \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-udeps-validate-prereqs \ + anvil-semver-check-validate-prereqs \ + anvil-external-types-validate-prereqs \ + anvil-aprz-validate-prereqs + +[group("anvil-setup")] +anvil-pr-slow-setup installer="install": \ + (anvil-pr-test-setup installer) \ + (anvil-pr-runtime-analysis-setup installer) \ + (anvil-pr-mutants-setup installer) + +[group("anvil-setup")] +anvil-pr-slow-validate-prereqs: \ + anvil-pr-test-validate-prereqs \ + anvil-pr-runtime-analysis-validate-prereqs \ + anvil-pr-mutants-validate-prereqs + +[group("anvil-setup")] +anvil-pr-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-pr-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-pr-runtime-analysis-setup installer="install": \ + (anvil-miri-setup installer) \ + (anvil-careful-setup installer) + +[group("anvil-setup")] +anvil-pr-runtime-analysis-validate-prereqs: \ + anvil-miri-validate-prereqs \ + anvil-careful-validate-prereqs + +[group("anvil-setup")] +anvil-pr-mutants-setup installer="install": (anvil-mutants-diff-setup installer) + +[group("anvil-setup")] +anvil-pr-mutants-validate-prereqs: anvil-mutants-diff-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-test-setup installer="install": \ + (anvil-llvm-cov-setup installer) \ + (anvil-doc-test-setup installer) \ + (anvil-examples-setup installer) + +[group("anvil-setup")] +anvil-scheduled-test-validate-prereqs: \ + anvil-llvm-cov-validate-prereqs \ + anvil-doc-test-validate-prereqs \ + anvil-examples-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-advisories-setup installer="install": \ + (anvil-deny-setup installer) \ + (anvil-audit-setup installer) \ + (anvil-aprz-setup installer) \ + (anvil-clippy-setup installer) + +[group("anvil-setup")] +anvil-scheduled-advisories-validate-prereqs: \ + anvil-deny-validate-prereqs \ + anvil-audit-validate-prereqs \ + anvil-aprz-validate-prereqs \ + anvil-clippy-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-exhaustive-setup installer="install": \ + (anvil-mutants-full-setup installer) \ + (anvil-cargo-hack-setup installer) \ + (anvil-bench-setup installer) + +[group("anvil-setup")] +anvil-scheduled-exhaustive-validate-prereqs: \ + anvil-mutants-full-validate-prereqs \ + anvil-cargo-hack-validate-prereqs \ + anvil-bench-validate-prereqs \ No newline at end of file diff --git a/justfiles/anvil/mod.just b/justfiles/anvil/mod.just new file mode 100644 index 00000000..bd2ca810 --- /dev/null +++ b/justfiles/anvil/mod.just @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# Entry point for the anvil just recipe tree. The user's root Justfile +# imports this single file; everything else lives in sibling .just files +# pulled in here. + +# All multi-statement recipes in this tree use [script("pwsh")]. pwsh +# is preinstalled on Windows (10+), GH/ADO hosted Linux + Windows +# runners, and is installable on macOS/Linux via Homebrew / upstream +# installer. We chose pwsh over bash because: +# +# - just's shebang dispatch (#!/usr/bin/env bash) requires `cygpath` +# on Windows, which is only on PATH inside Git Bash. Plain +# PowerShell can't run shebang recipes. +# - just's [script("bash")] attribute passes Windows tempfile paths +# to bash unescaped, and bash interprets the backslashes as escape +# characters — every recipe fails with a mangled path. +# - [script("pwsh")] works from plain PowerShell with no PATH +# augmentation and no path translation. pwsh handles Windows +# paths natively. +# +# The existing `_anvil-require pwsh` recipe already established +# pwsh as a tools-floor requirement, so requiring it as the recipe +# interpreter is consistent. +# +# Single-command recipes (e.g. `cargo deny check`) are plain just +# recipes that inherit the adopter's default shell — they're +# shell-agnostic. +# +# IMPORTANT: [script(...)] requires `set unstable`. The adopter's +# root justfile must declare it (typically as a top-level line). +# anvil's mod.just does NOT redeclare it, to avoid conflicting +# with adopters who already have it. + +import 'checks.just' +import 'groups.just' +import 'tiers.just' +import 'tools.just' +import 'versions.just' + +# Friendly default: `just anvil` runs the PR tier. +alias anvil := anvil-pr diff --git a/justfiles/anvil/tiers.just b/justfiles/anvil/tiers.just new file mode 100644 index 00000000..7fcf378a --- /dev/null +++ b/justfiles/anvil/tiers.just @@ -0,0 +1,81 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/checks.md for the tier structure. + +# PR tier: every check that should run on every pull request, split +# into two groups so the fast checks aren't blocked behind the slow +# ones in cloud workflows. +[group("anvil")] +anvil-pr: \ + anvil-pr-fast \ + anvil-pr-slow + +# scheduled tier: full-workspace re-runs of things that can change +# without a commit (advisories, flakes) + the truly expensive +# exhaustive checks that don't fit in a PR budget. Runs on a schedule +# against `main`, not on PRs. +[group("anvil")] +anvil-scheduled: \ + anvil-scheduled-test \ + anvil-scheduled-advisories \ + anvil-scheduled-exhaustive + +# Full tier: PR + scheduled, end-to-end. Useful before tagging a release. +[group("anvil")] +anvil-full: \ + anvil-pr \ + anvil-scheduled + +# Tier-level + global setup + validate-prereqs +# =========================================================================== +# +# Per-tier recipes that fan out to per-group setup/validate-prereqs from +# groups.just. The global `anvil-setup` / `anvil-validate-prereqs` +# recipes are the catch-all entry points that install / verify everything. +# +# Cloud workflows typically invoke only the per-group setup it needs (e.g. the +# `anvil-pr-fast` composite action / step template runs +# `anvil-pr-fast-setup` rather than the global `anvil-setup`). +# Local users who want "install everything" run `just anvil-setup`. + +[group("anvil-setup")] +anvil-pr-setup installer="install": \ + (anvil-pr-fast-setup installer) \ + (anvil-pr-slow-setup installer) + +[group("anvil-setup")] +anvil-pr-validate-prereqs: \ + anvil-pr-fast-validate-prereqs \ + anvil-pr-slow-validate-prereqs + +[group("anvil-setup")] +anvil-scheduled-setup installer="install": \ + (anvil-scheduled-test-setup installer) \ + (anvil-scheduled-advisories-setup installer) \ + (anvil-scheduled-exhaustive-setup installer) + +[group("anvil-setup")] +anvil-scheduled-validate-prereqs: \ + anvil-scheduled-test-validate-prereqs \ + anvil-scheduled-advisories-validate-prereqs \ + anvil-scheduled-exhaustive-validate-prereqs + +[group("anvil-setup")] +anvil-full-setup installer="install": \ + (anvil-pr-setup installer) \ + (anvil-scheduled-setup installer) + +[group("anvil-setup")] +anvil-full-validate-prereqs: \ + anvil-pr-validate-prereqs \ + anvil-scheduled-validate-prereqs + +# Global aliases. `anvil-setup` (no suffix) installs everything the +# catalog knows about; `anvil-validate-prereqs` verifies every tool +# and component is present at or above its pinned version. +[group("anvil-setup")] +anvil-setup installer="install": (anvil-full-setup installer) + +[group("anvil-setup")] +anvil-validate-prereqs: anvil-full-validate-prereqs \ No newline at end of file diff --git a/justfiles/anvil/tools.just b/justfiles/anvil/tools.just new file mode 100644 index 00000000..dc2fa058 --- /dev/null +++ b/justfiles/anvil/tools.just @@ -0,0 +1,481 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# See ../../docs/design/local.md for the policy. + +# ============================================================================ +# System-level prerequisites (libclang, etc.) +# ============================================================================ + +# Public: probe for system-level prerequisites that catalog tools need +# to BUILD from source. The `binstall` install path downloads pre-built +# binaries and does not need these, so this check is primarily relevant +# for the source-build `install` path (used by local devs and the ADO +# backend) and is best-effort (skipped silently) for `binstall`. +# +# Scope policy: only system libs that an anvil catalog tool DIRECTLY +# requires. We do not try to be a general-purpose dev-env doctor. +# Current entries: +# - libclang: required by cargo-spellcheck (clang-sys / hunspell-rs) +# at build time. +# +# Detection uses presence-only probes (file existence in standard install +# dirs + `LIBCLANG_PATH` env var). No version checks -- system libs upgrade +# independently and any reasonably modern libclang works for clang-sys. +# +# On missing deps the recipe prints copy-paste install hints per OS / +# package manager and exits non-zero. No auto-install: admin / sudo and +# package-manager choice stay with the user. +[group("anvil-setup")] +[script("pwsh")] +anvil-system-deps-check: + $ErrorActionPreference = 'Stop' + $missing = @() + + # libclang: required to BUILD cargo-spellcheck from source. + $haveLibclang = $false + $libclangFiles = @('libclang.dll', 'libclang.so', 'libclang.so.1', 'libclang.dylib') + if ($env:LIBCLANG_PATH) { + foreach ($f in $libclangFiles) { + if (Test-Path (Join-Path $env:LIBCLANG_PATH $f)) { $haveLibclang = $true; break } + } + } + if (-not $haveLibclang) { + $probes = if ($IsWindows) { + @( + 'C:\Program Files\LLVM\bin\libclang.dll', + "$env:USERPROFILE\scoop\apps\llvm\current\bin\libclang.dll" + ) + } elseif ($IsMacOS) { + @( + '/usr/local/opt/llvm/lib/libclang.dylib', + '/opt/homebrew/opt/llvm/lib/libclang.dylib' + ) + } else { + @( + '/usr/lib/x86_64-linux-gnu/libclang.so.1', + '/usr/lib/aarch64-linux-gnu/libclang.so.1', + '/usr/lib64/libclang.so', + '/usr/lib64/libclang.so.1' + ) + } + foreach ($p in $probes) { + if (Get-Item -LiteralPath $p -ErrorAction SilentlyContinue) { $haveLibclang = $true; break } + } + # Linux distros often add a version suffix (libclang-19.so etc.); glob fallback. + if (-not $haveLibclang -and -not $IsWindows -and -not $IsMacOS) { + $glob = Get-ChildItem -Path '/usr/lib','/usr/lib64','/usr/lib/x86_64-linux-gnu','/usr/lib/aarch64-linux-gnu' -Filter 'libclang*.so*' -ErrorAction SilentlyContinue + if ($glob) { $haveLibclang = $true } + } + } + if (-not $haveLibclang) { + $missing += [pscustomobject]@{ + name = 'libclang' + why = 'cargo-spellcheck (clang-sys/hunspell-rs) needs libclang at build time' + hints = @( + 'Linux (Ubuntu/Debian): sudo apt-get install -y libclang-dev' + 'Linux (Azure Linux/RHEL): sudo tdnf install -y clang-devel' + 'macOS (homebrew): brew install llvm' + 'Windows (scoop): scoop install llvm # no admin' + 'Windows (winget, admin): winget install LLVM.LLVM' + ) + } + } + + if ($missing.Count -gt 0) { + Write-Host '' + foreach ($m in $missing) { + Write-Host "anvil: missing system dependency '$($m.name)'" -ForegroundColor Yellow + Write-Host " why: $($m.why)" + Write-Host ' install:' + foreach ($h in $m.hints) { Write-Host " $h" } + Write-Host '' + } + Write-Host "If the dep is installed but not on the standard search path, set LIBCLANG_PATH and re-run." -ForegroundColor Yellow + exit 1 + } + +# ============================================================================ +# rustc + pwsh validate-prereqs (no install -- system prereqs) +# ============================================================================ +# +# rustc is installed via rustup (https://rustup.rs); pwsh is installed +# via the platform's package manager. Both are presumed present on any +# machine running anvil; the validate recipes just surface a friendly +# error if not. + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-rustc-validate-prereqs: + if (-not (Get-Command rustc -ErrorAction SilentlyContinue)) { + Write-Error 'anvil: rustc not found. Install via rustup: https://rustup.rs' + exit 1 + } + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-pwsh-validate-prereqs: + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + $hint = if ($IsMacOS) { + 'brew install --cask powershell' + } elseif ($IsLinux) { + 'see https://github.com/PowerShell/PowerShell' + } else { + 'winget install --id Microsoft.PowerShell' + } + Write-Error "anvil: pwsh (PowerShell Core) not found. Install: $hint" + exit 1 + } + +# ============================================================================ +# Private helpers (install/check primitives) +# ============================================================================ + +# _install-tool: install a cargo subcommand at exactly the pinned version, +# or no-op if it is already installed at or above that version. The +# `installer` parameter selects between: +# - "install" (cargo install --locked, pure-source). Default. +# - "binstall" (cargo binstall --no-confirm --locked, with cargo install +# fallback if binstall fails). Bootstraps cargo-binstall +# itself if not on PATH. +[script("pwsh")] +_install-tool name version installer: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + $installer = '{{installer}}' + + if ($installer -ne 'install' -and $installer -ne 'binstall') { + Write-Error "_install-tool: unknown installer '$installer' (expected 'install' or 'binstall')" + exit 2 + } + + # Already at or above the pin: skip. We don't downgrade tools the + # user upgraded for their own reasons; the validate side uses + # `installed >= pin`, so newer is fine. The early-exit is also what + # makes the actions/cache restore actually useful -- without it, + # every post-restore run would try to re-install on top of the + # cached binaries and fail with "binary already exists in destination". + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if ($installed) { + try { + if (([version]$installed) -ge ([version]$version)) { + Write-Host "$name >= $version (already satisfied; installed=$installed)" + exit 0 + } + } catch { + # Fall through to reinstall when versions don't parse as [version]. + } + } + + Write-Host "Installing $name =$version (installer: $installer)" + if ($installer -eq 'binstall') { + if (-not (Get-Command cargo-binstall -ErrorAction SilentlyContinue)) { + Write-Host ' Bootstrapping cargo-binstall' + cargo install --locked cargo-binstall + if ($LASTEXITCODE -ne 0) { + Write-Error 'cargo-binstall bootstrap failed' + exit $LASTEXITCODE + } + } + cargo binstall --no-confirm --locked $name --version "=$version" + if ($LASTEXITCODE -eq 0) { exit 0 } + Write-Host ' binstall failed; falling back to cargo install' -ForegroundColor Yellow + } + cargo install --locked $name --version "=$version" + if ($LASTEXITCODE -ne 0) { + Write-Error "$name install FAILED" + exit $LASTEXITCODE + } + +# _check-tool: verify a cargo subcommand is installed at or above the +# pinned version. Errors with an install hint on missing or too-old. +[script("pwsh")] +_check-tool name version: + $ErrorActionPreference = 'Stop' + $name = '{{name}}' + $version = '{{version}}' + + $installed = $null + $pattern = '^' + [regex]::Escape($name) + ' v(\S+):' + foreach ($line in (cargo install --list 2>$null)) { + if ($line -match $pattern) { $installed = $Matches[1]; break } + } + if (-not $installed) { + Write-Error "anvil: required tool '$name' not found. Install: cargo install --locked --version =$version $name" + exit 1 + } + try { + $installedV = [version]$installed + $pinV = [version]$version + } catch { + Write-Error "anvil: cannot compare versions for '$name' (installed=$installed pin=$version). Reinstall: cargo install --locked --version =$version $name" + exit 1 + } + if ($installedV -lt $pinV) { + Write-Error "anvil: '$name' v$installed is older than the required minimum v$version. Upgrade: cargo install --locked --version =$version $name" + exit 1 + } + +# _install-toolchain: install a specific rustup toolchain (no components). +[script("pwsh")] +_install-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + Write-Host "rustup toolchain install $toolchain --profile minimal --no-self-update" + rustup toolchain install $toolchain --profile minimal --no-self-update + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-toolchain: verify a rustup toolchain is installed. +[script("pwsh")] +_check-toolchain toolchain: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + rustup which --toolchain $toolchain rustc 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + +# _install-component: add a component to a toolchain. The toolchain is +# either the literal "default" (current rustup default) or a specific +# pinned toolchain string (which must already be installed -- the +# per-component setup recipes ensure this via a dependency on +# anvil--install). +[script("pwsh")] +_install-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + if ($toolchain -eq 'default') { + Write-Host "rustup component add $component (default toolchain)" + rustup component add $component + } else { + Write-Host "rustup component add --toolchain $toolchain $component" + rustup component add --toolchain $toolchain $component + } + if ($LASTEXITCODE -ne 0) { + Write-Error "Failed to install component '$component' on toolchain '$toolchain'" + exit $LASTEXITCODE + } + +# _check-component: verify a component is installed on a toolchain. +[script("pwsh")] +_check-component toolchain component: + $ErrorActionPreference = 'Stop' + $toolchain = '{{toolchain}}' + $component = '{{component}}' + $output = if ($toolchain -eq 'default') { + rustup component list --installed 2>$null + } else { + rustup component list --installed --toolchain $toolchain 2>$null + } + if ($LASTEXITCODE -ne 0) { + Write-Error "anvil: toolchain '$toolchain' not installed. Run: rustup toolchain install $toolchain --profile minimal" + exit 1 + } + $found = $output | Where-Object { $_ -like "$component*" } + if (-not $found) { + $cmd = if ($toolchain -eq 'default') { "rustup component add $component" } else { "rustup component add --toolchain $toolchain $component" } + Write-Error "anvil: component '$component' not installed on toolchain '$toolchain'. Run: $cmd" + exit 1 + } + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +[group("anvil-setup")] +anvil-toolchain-nightly-install: (_install-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-validate-prereqs: (_check-toolchain rust_nightly) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-install: (_install-toolchain rust_nightly_external_types) + +[group("anvil-setup")] +anvil-toolchain-nightly-external-types-validate-prereqs: (_check-toolchain rust_nightly_external_types) + +# ============================================================================ +# Rustup components +# ============================================================================ +# +# Default-toolchain components are installed via `rustup component add` +# (no toolchain spec). Nightly components depend on the toolchain +# being installed first (via the relevant anvil--install +# recipe) and then add the component. + +[group("anvil-setup")] +anvil-component-default-clippy-install: (_install-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-clippy-validate-prereqs: (_check-component "default" "clippy") + +[group("anvil-setup")] +anvil-component-default-rustfmt-install: (_install-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-default-rustfmt-validate-prereqs: (_check-component "default" "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-rustfmt-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rustfmt") + +[group("anvil-setup")] +anvil-component-nightly-miri-install: anvil-toolchain-nightly-install (_install-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-miri-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "miri") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-install: anvil-toolchain-nightly-install (_install-component rust_nightly "rust-src") + +[group("anvil-setup")] +anvil-component-nightly-rust-src-validate-prereqs: anvil-toolchain-nightly-validate-prereqs (_check-component rust_nightly "rust-src") + +# ============================================================================ +# Cargo subcommands +# ============================================================================ +# +# One pair (install + validate-prereqs) per tool, alphabetical. +# Version pins live in versions.just (one cargo__version variable +# per tool). The install recipes accept an `installer="install"|"binstall"` +# parameter; the validate-prereqs recipes do not (they only read state). + +[group("anvil-setup")] +anvil-tool-cargo-aprz-install installer="install": (_install-tool "cargo-aprz" cargo_aprz_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-aprz-validate-prereqs: (_check-tool "cargo-aprz" cargo_aprz_version) + +[group("anvil-setup")] +anvil-tool-cargo-audit-install installer="install": (_install-tool "cargo-audit" cargo_audit_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-audit-validate-prereqs: (_check-tool "cargo-audit" cargo_audit_version) + +[group("anvil-setup")] +anvil-tool-cargo-careful-install installer="install": (_install-tool "cargo-careful" cargo_careful_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-careful-validate-prereqs: (_check-tool "cargo-careful" cargo_careful_version) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-install installer="install": (_install-tool "cargo-check-external-types" cargo_check_external_types_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-check-external-types-validate-prereqs: (_check-tool "cargo-check-external-types" cargo_check_external_types_version) + +[group("anvil-setup")] +anvil-tool-cargo-delta-install installer="install": (_install-tool "cargo-delta" cargo_delta_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-delta-validate-prereqs: (_check-tool "cargo-delta" cargo_delta_version) + +[group("anvil-setup")] +anvil-tool-cargo-deny-install installer="install": (_install-tool "cargo-deny" cargo_deny_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-deny-validate-prereqs: (_check-tool "cargo-deny" cargo_deny_version) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-install installer="install": (_install-tool "cargo-doc2readme" cargo_doc2readme_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-doc2readme-validate-prereqs: (_check-tool "cargo-doc2readme" cargo_doc2readme_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-install installer="install": (_install-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-cyclic-deps-validate-prereqs: (_check-tool "cargo-ensure-no-cyclic-deps" cargo_ensure_no_cyclic_deps_version) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-install installer="install": (_install-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-ensure-no-default-features-validate-prereqs: (_check-tool "cargo-ensure-no-default-features" cargo_ensure_no_default_features_version) + +[group("anvil-setup")] +anvil-tool-cargo-hack-install installer="install": (_install-tool "cargo-hack" cargo_hack_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-hack-validate-prereqs: (_check-tool "cargo-hack" cargo_hack_version) + +[group("anvil-setup")] +anvil-tool-cargo-heather-install installer="install": (_install-tool "cargo-heather" cargo_heather_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-heather-validate-prereqs: (_check-tool "cargo-heather" cargo_heather_version) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-install installer="install": (_install-tool "cargo-llvm-cov" cargo_llvm_cov_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-llvm-cov-validate-prereqs: (_check-tool "cargo-llvm-cov" cargo_llvm_cov_version) + +# cargo-mutants doesn't build on aarch64-pc-windows-msvc (upstream +# winapi crate incompat). The install recipe self-skips on that target +# so per-group setup recipes that depend on it (pr-mutants-setup, +# scheduled-exhaustive-setup) don't fail on that platform. The +# mutants-diff / mutants-full check recipes also self-skip on the same +# target, so the check is effectively a no-op end-to-end there. +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-install installer="install": + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-install: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _install-tool cargo-mutants {{cargo_mutants_version}} {{installer}} + exit $LASTEXITCODE + +[group("anvil-setup")] +[script("pwsh")] +anvil-tool-cargo-mutants-validate-prereqs: + if ($IsWindows -and ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64')) { + Write-Host 'anvil-tool-cargo-mutants-validate-prereqs: aarch64-pc-windows-msvc -- skipping (winapi build incompat)' + exit 0 + } + & just _check-tool cargo-mutants {{cargo_mutants_version}} + exit $LASTEXITCODE + +[group("anvil-setup")] +anvil-tool-cargo-nextest-install installer="install": (_install-tool "cargo-nextest" cargo_nextest_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-nextest-validate-prereqs: (_check-tool "cargo-nextest" cargo_nextest_version) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-install installer="install": (_install-tool "cargo-semver-checks" cargo_semver_checks_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-semver-checks-validate-prereqs: (_check-tool "cargo-semver-checks" cargo_semver_checks_version) + +[group("anvil-setup")] +anvil-tool-cargo-sort-install installer="install": (_install-tool "cargo-sort" cargo_sort_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-sort-validate-prereqs: (_check-tool "cargo-sort" cargo_sort_version) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-install installer="install": (_install-tool "cargo-spellcheck" cargo_spellcheck_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-spellcheck-validate-prereqs: (_check-tool "cargo-spellcheck" cargo_spellcheck_version) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-install installer="install": (_install-tool "cargo-udeps" cargo_udeps_version installer) + +[group("anvil-setup")] +anvil-tool-cargo-udeps-validate-prereqs: (_check-tool "cargo-udeps" cargo_udeps_version) diff --git a/justfiles/anvil/versions.just b/justfiles/anvil/versions.just new file mode 100644 index 00000000..730c3059 --- /dev/null +++ b/justfiles/anvil/versions.just @@ -0,0 +1,68 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# Owned by cargo-anvil; edit via `cargo anvil`. +# +# Pinned versions used by the anvil check tree. +# +# Everything anvil installs (cargo subcommands + rustup toolchains) +# is pinned here. The pinning policy: +# - On install (`-install` recipes): exactly this version (`=` for +# cargo subcommands, exact ref for rustup toolchains). Pulling +# "latest-matching" at install time is a cloud-workflow reproducibility risk -- +# an upstream release between yesterday's green build and today's +# PR can break things (cargo-spellcheck 0.15.7's em-dash regression +# is the canonical case). The `=` constraint locks the install to +# the version the catalog was validated against. +# - On validate-prereqs (`-validate-prereqs` recipes): the installed +# version must be `>= `. A user who has manually upgraded a +# tool for their own reasons (e.g. needing an unreleased bugfix) +# is not downgraded by setup. The validate gate uses +# `installed >= pin`, so newer is fine. +# +# To bump a pin, edit the version in place and re-run +# `cargo anvil`. The dirty-file flow preserves the edit on +# subsequent runs. To add a new tool, append a variable and the matching +# `-install`/`-validate-prereqs` pair in `tools.just`. To remove a tool, +# delete its variable and recipes (and any check that depends on them). + +# ============================================================================ +# Rustup toolchains +# ============================================================================ + +# General nightly used by udeps, miri, careful, and any future +# nightly-dependent check. Bumped on a regular cadence (monthly is a +# reasonable default) when an adopter has time to absorb formatting / +# lint / API-surface drift. Pin only -- do not use bare `nightly` here. +rust_nightly := "nightly-2026-02-10" + +# Pinned narrowly to the rustdoc JSON schema version that the currently +# selected cargo-check-external-types release accepts. cargo-check- +# external-types embeds a specific rustdoc-types crate version; if the +# nightly's emitted JSON format_version drifts past it, every run fails +# with "produces JSON format version X, but this tool requires +# format version Y" -- which is a tooling-incompat, not a real API +# violation. Bump this alongside any cargo-check-external-types upgrade. +rust_nightly_external_types := "nightly-2025-10-18" + +# ============================================================================ +# Cargo subcommands +# ============================================================================ + +cargo_aprz_version := "1.0.0" +cargo_audit_version := "0.22.2" +cargo_careful_version := "0.4.9" +cargo_check_external_types_version := "0.4.0" +cargo_delta_version := "0.3.1" +cargo_deny_version := "0.19.0" +cargo_doc2readme_version := "0.6.4" +cargo_ensure_no_cyclic_deps_version := "0.2.0" +cargo_ensure_no_default_features_version := "1.0.0" +cargo_hack_version := "0.6.41" +cargo_heather_version := "0.2.1" +cargo_llvm_cov_version := "0.8.4" +cargo_mutants_version := "26.1.2" +cargo_nextest_version := "0.9.122" +cargo_semver_checks_version := "0.46.0" +cargo_sort_version := "2.0.2" +cargo_spellcheck_version := "0.15.7" +cargo_udeps_version := "0.1.60" diff --git a/rustfmt.toml b/rustfmt.toml index 826ef5e4..53bb4a6d 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# >>> anvil-managed: anvil-rustfmt +edition = "2024" max_width = 140 +newline_style = "Unix" +use_field_init_shorthand = true +use_try_shorthand = true +# The following options require nightly rustfmt. anvil-fmt invokes +# `cargo +{{ rust_nightly }} fmt`; see justfiles/anvil/versions.just +# for the pin and docs/design/local.md#nightly-pinning for the policy. +unstable_features = true +# Format Rust code blocks inside `///` doc comments. Catches stale +# examples that drift from the prose. +format_code_in_doc_comments = true +# One use-statement per module (vs collapsed `a::{b, c, d}` form). +# Diffs touch only the lines that actually changed. +imports_granularity = "Module" +# Group imports: std, then external crates, then crate-internal. Matches +# the convention used across the surveyed Microsoft Rust repos. +group_imports = "StdExternalCrate" +# <<< anvil-managed: anvil-rustfmt diff --git a/spellcheck.toml b/spellcheck.toml index 9b55b1fa..03a7d8c2 100644 --- a/spellcheck.toml +++ b/spellcheck.toml @@ -1,39 +1,65 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -# Check spelling in code comments marked as dev/developer comments. -# Set to false to skip comments like `// TODO:` or `// FIXME:`. +# >>> anvil-managed: anvil-spellcheck +# Check spelling in code comments marked as dev/developer comments +# (e.g., `// TODO:`, `// FIXME:`). Set to false to skip them. dev_comments = false -# Whether to skip spell checking README files. -# Set to false to include README files in spell checking. +# Whether to skip spell checking README files. Set to false to include +# README files in spell checking. skip_readme = false [Hunspell] -# The language dictionary to use for spell checking. -# "en_US" uses the built-in English (US) dictionary. +# Language dictionary. "en_US" uses the built-in English (US) dictionary. lang = "en_US" -# Directories to search for custom dictionary files. -# Relative paths in extra_dictionaries are resolved relative to these directories. +# Directories searched for `extra_dictionaries` paths. The default +# repo-root entry lets adopters keep their custom dictionary next to +# the .spelling source. search_dirs = ["."] -# Additional custom dictionary files to load. -# Format: First line = word count, remaining lines = sorted words (one per line). -# Add project-specific terms, acronyms, and technical words here. -# -# This file is generated by the `just spellcheck` command. +# Additional dictionary files loaded after the language dictionary. +# Format: first line is the word count, remaining lines are sorted +# words (one per line). `target/spelling.dic` is generated by the +# `anvil-spellcheck` recipe from the repo's `.spelling` file. extra_dictionaries = ["target/spelling.dic"] -# Skip looking up words in OS-provided dictionaries. -# Set to true for consistent results across different systems. +# Don't consult OS-provided dictionaries. Keeps results consistent +# across Linux/macOS/Windows runners. skip_os_lookups = true -# Use cargo-spellcheck's built-in dictionaries. -# Set to true to ensure consistent behavior without relying on system dictionaries. +# Use cargo-spellcheck's built-in language dictionaries (independent of +# system hunspell installation). Required for the cross-platform +# reproducibility guarantee above. use_builtin = true +# Token-boundary characters. Override the upstream default to add +# typographic punctuation we use in prose (em-dash, en-dash, arrows, +# minus sign). Without these, cargo-spellcheck 0.15.7 tokenises text +# like `runtime — it` as three tokens including the em-dash itself, +# then fails its dictionary lookup and flags the em-dash as a +# "possible spelling mistake". Upstream default keeps figure-dash +# (U+2012) and the ASCII hyphen but omits the rest; this list is a +# superset, so the only behavioural change is that the added chars +# now act as token boundaries. +# +# Encoded as \uXXXX escapes for grep-ability and to keep the file +# 7-bit ASCII: +# ASCII punctuation (default): ",;:.!?#(){}[]|/_- +# Dashes & minus : \u2012 figure-dash (default) +# \u2013 en-dash (added) +# \u2014 em-dash (added) +# \u2015 horizontal-bar (added) +# \u2212 minus-sign (added) +# Arrows : \u2190 leftwards-arrow (added) +# \u2192 rightwards-arrow (added) +# ASCII punctuation (default): ' ` & @ +# Misc (default) : \u00A7 section, \u00B6 pilcrow, \u2026 ellipsis +tokenization_splitchars = "\",;:.!?#(){}[]|/_-\u2012\u2013\u2014\u2015\u2190\u2192\u2212'`&@\u00A7\u00B6\u2026" + [Hunspell.quirks] -# Allow checking concatenated words (e.g., "TcpStream" as "Tcp" + "Stream"). -# Useful for CamelCase identifiers common in Rust code. +# Treat CamelCase identifiers as concatenations of dictionary words +# (e.g., `TcpStream` = `Tcp` + `Stream`). Lowers false-positive rate +# substantially on Rust codebases. allow_concatenation = true +# <<< anvil-managed: anvil-spellcheck