diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9a531..fb3a7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,26 +14,49 @@ jobs: env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug + WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - - uses: actions/checkout@v5 - - name: Setup Rust - uses: leynos/shared-actions/.github/actions/setup-rust@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 - - name: Format - run: make check-fmt - - name: Lint - run: make lint - - name: Test and Measure Coverage - uses: leynos/shared-actions/.github/actions/generate-coverage@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd with: - output-path: lcov.info - format: lcov - - name: Upload coverage data to CodeScene - env: - CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }} - if: ${{ env.CS_ACCESS_TOKEN }} - uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 - + persist-credentials: false + - name: Setup Rust + uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 + - name: Install mold linker + if: runner.os == 'Linux' + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + sudo apt-get update \ + && sudo apt-get install --yes --no-install-recommends clang lld mold + - name: Setup uv + uses: astral-sh/setup-uv@12d13f90bc3a5a1971bebad4beb09a4dfa962e91 + - name: Install template test tools + run: | + set -euo pipefail + cargo binstall --no-confirm cargo-nextest + uv tool install mbake + - name: Cache Whitaker installation + id: cache-whitaker + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae with: - format: lcov - access-token: ${{ env.CS_ACCESS_TOKEN }} - installer-checksum: ${{ vars.CODESCENE_CLI_SHA256 }} + path: | + ~/.cargo/bin/whitaker-installer + ~/.local/bin/whitaker + ~/.dylint_drivers + ~/.local/share/whitaker + key: whitaker-v2-${{ runner.os }}-${{ env.WHITAKER_INSTALLER_REV }} + - name: Install Whitaker + run: | + set -euo pipefail + echo "Whitaker cache hit: ${{ steps.cache-whitaker.outputs.cache-hit }}" + if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then + echo "Installing Whitaker installer at ${WHITAKER_INSTALLER_REV}" + cargo install --locked \ + --git https://github.com/leynos/whitaker \ + --rev "${WHITAKER_INSTALLER_REV}" \ + whitaker-installer + fi + whitaker-installer --cranelift + echo "Whitaker binary: $(command -v whitaker || true)" + - name: Test + run: make test diff --git a/.github/workflows/delayed-pr-comment.yml b/.github/workflows/delayed-pr-comment.yml index 2ad6131..e21b74c 100644 --- a/.github/workflows/delayed-pr-comment.yml +++ b/.github/workflows/delayed-pr-comment.yml @@ -25,7 +25,7 @@ jobs: run: sleep ${{ steps.calc.outputs.secs }} shell: bash - name: Comment PR - uses: thollander/actions-comment-pull-request@v3 + uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b with: pr-number: ${{ github.event.inputs.pr_number }} message: ${{ github.event.inputs.message }} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..772d6c0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,61 @@ +# Agent Instructions + +## Repository Purpose + +This repository is a Copier template for generating Rust projects. It is not +itself the generated Rust project. Files under `template/` are copied or +rendered into downstream projects, and files under `tests/` validate the +template output through `pytest-copier`. + +When changing generated Rust project behaviour, update the template files and +the parent repository tests that prove the rendered output. Prefer assertions +against the generated project's public commands, such as `make all`, over +testing private implementation details in the parent repository. + +## Copier Test Dependencies + +Template tests use `pytest` and `pytest-copier`. Install or run them through +`uv` so the parent repository does not need a manually managed virtual +environment. + +See `docs/users-guide.md` for generated-project features and +`docs/developers-guide.md` for parent-template tooling requirements. + +The repository Makefile exposes the expected entrypoint: + +```sh +make test +``` + +That target currently runs: + +```sh +uvx --with pytest-copier pytest tests/ +``` + +If tests import additional pytest plugins or assertion helpers, add them to +the `uvx --with ...` invocation or to the documented dependency list before +using them in tests. Do not rely on packages installed in an ambient shell +environment. + +When debugging generated projects manually, render with Copier into a temporary +directory, then run the generated project's public gates from that rendered +directory. Keep build output in the rendered project or Cargo's default shared +cache; do not configure an isolated Cargo cache. + +## Validation + +Run parent-template tests after changing `copier.yaml`, `template/`, or +`tests/`: + +```sh +make test +``` + +For long test runs, capture output with `tee` into `/tmp`, for example: + +```sh +make test 2>&1 | tee /tmp/test-agent-template-rust-$(git branch --show-current).out +``` + +Review the log before committing if the terminal output is truncated. diff --git a/Makefile b/Makefile index 14cf8cc..e530c77 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test test: ## Run template tests - uvx --with pytest-copier pytest tests/ + uvx --with pytest-copier --with pyyaml --with syrupy pytest tests/ help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ diff --git a/README.md b/README.md index 9088980..7d563df 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ The template requires **Copier 9.0** or later to avoid incompatibilities. enabled【F:template/Cargo.toml†L1-L9】. - **Pinned toolchain** file specifying a configurable nightly release 【F:template/rust-toolchain.toml.jinja†L1-L3】. +- **Project metadata prompts** for repository URL, homepage, crates.io keywords, + crates.io categories, nightly date, and optional Linux development target. +- **Fast generated tooling** including Cranelift debug code generation, Linux + mold linking for development builds, cargo-nextest tests with a cargo-test + fallback, Whitaker linting, and a lld-backed coverage target. - **Starter code** providing either a binary entry point or a library function depending on flavour【F:template/src/{% if flavour == APP %}main.rs{% else %}lib.rs{% endif %}.jinja†L1-L10】. - **GitHub CI workflow** that formats, lints, tests, and uploads @@ -41,3 +46,8 @@ linters run from the very first commit. Install the test requirements and run `pytest` to ensure the template renders correctly using `pytest-copier`. Additional details are in [`docs/testing.md`](docs/testing.md). + +User-facing generated-project behaviour is documented in +[`docs/users-guide.md`](docs/users-guide.md). Parent-template development +requirements are documented in +[`docs/developers-guide.md`](docs/developers-guide.md). diff --git a/copier.yaml b/copier.yaml index a3ab7e3..b3109b2 100644 --- a/copier.yaml +++ b/copier.yaml @@ -30,6 +30,36 @@ package_name: help: 'Crate name for Cargo.toml (lowercase, underscores)' placeholder: 'e.g. my_awesome_tool' +package_description: + type: str + default: 'A Rust project generated from agent-template-rust.' + help: 'Crates.io package description' + placeholder: 'e.g. Command-line tools for analysing project metadata' + +repository_url: + type: str + default: 'https://github.com/example/{{ package_name }}' + help: 'Repository URL for Cargo.toml metadata' + placeholder: 'e.g. https://github.com/example/my_awesome_tool' + +homepage_url: + type: str + default: '{{ repository_url }}' + help: 'Homepage URL for Cargo.toml metadata' + placeholder: 'e.g. https://example.com/my-awesome-tool' + +package_keywords: + type: str + default: 'rust,template' + help: 'Comma-separated crates.io keywords, up to five' + placeholder: 'e.g. cli,automation,tooling' + +package_categories: + type: str + default: 'development-tools' + help: 'Comma-separated crates.io categories' + placeholder: 'e.g. command-line-utilities,development-tools' + _flavour_choices: &flavour_choices - lib - app @@ -42,13 +72,13 @@ flavour: license_holder: type: str - default: 'Payton McIntosh' + default: 'Example Maintainer' help: 'Your name (for the ISC licence)' placeholder: 'e.g. Jane Smith' license_email: type: str - default: 'pmcintosh@df12.net' + default: 'maintainer@example.com' help: 'Your email (for the ISC licence)' placeholder: 'e.g. jane@example.com' @@ -62,3 +92,9 @@ rust_nightly_date: default: '2025-06-10' help: 'Rust nightly toolchain date (YYYY-MM-DD)' placeholder: 'e.g. 2025-06-10' + +dev_target: + type: str + default: 'x86_64-unknown-linux-gnu' + help: 'Optional Linux target triple for mold linker config' + placeholder: 'e.g. x86_64-unknown-linux-gnu' diff --git a/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 0000000..e7824fd --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,52 @@ +# Developers Guide + +This guide documents the tooling needed to work on the template itself. It is +separate from the generated-project guide because this parent repository runs +pytest-copier tests that render temporary Rust projects. + +## Parent Template Tests + +Run the public parent gate: + +```sh +make test +``` + +The target uses `uvx --with pytest-copier --with pyyaml pytest tests/`, so +Python test dependencies must be added to that invocation before tests import +them. Keep long runs logged through `tee` into `/tmp`, following the example +in `AGENTS.md`. + +The tests render both library and application projects, run generated public +gates such as `make all`, validate generated Makefiles with `mbake`, and parse +generated `Cargo.toml` files as TOML. + +## Required Tooling + +The parent tests expect these tools to be available when validating generated +projects: + +- `uv` for isolated Python test dependency execution. +- `pytest-copier` for rendering Copier templates in tests. +- `PyYAML` for parsing rendered GitHub Actions workflows in tests. +- `syrupy` for generated structured file snapshots in tests. +- Rust and Cargo through `rustup`. +- cargo-nextest for generated fast test execution in CI, while generated + Makefiles still fall back to `cargo test` for contributors. +- `mbake` for generated Makefile validation. +- `Whitaker` for generated lint gates. +- `clang`, `lld`, and `mold` for generated linker and coverage behaviour. + +The generated project itself uses Cranelift for debug code generation, Linux +`mold` linking for development builds, and `lld` for coverage because coverage +is driven by LLVM tooling. + +## Design Notes + +Keep generated-project behaviour in `template/` and prove it from parent tests +under `tests/`. Prefer assertions that render a real project and run public +generated commands over checks that only inspect template source text. + +GitHub Actions in both parent and generated workflows are SHA-pinned. When an +action revision changes, update the rendered workflow assertions that lock the +pin. diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md new file mode 100644 index 0000000..16ec751 --- /dev/null +++ b/docs/execplans/rust-project-enhancements.md @@ -0,0 +1,150 @@ +# Import Rust Template Tooling + +This ExecPlan (execution plan) is a living document. The sections +`Constraints`, `Tolerances`, `Risks`, `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work +proceeds. + +Status: COMPLETE + +## Purpose / Big Picture + +The Rust Copier template should render projects that are ready for fast local +iteration and strict continuous integration. A generated project should use +Cranelift for local debug code generation, mold for local Linux linking, LLVM +linking when coverage tools need it in continuous integration, cargo-nextest +for tests, cargo-binstall metadata for binary installation, required crates.io +metadata prompts, and Whitaker linting with the same cache pattern used by +`../dear-diary`. + +The observable success condition is that running this repository's template +tests renders both library and application projects, then the generated +projects pass their public gates through `make all`. The generated project +must include a disposable stub Rust test so nextest has at least one test until +real functionality replaces it. + +## Constraints + +Changes must obey the prompt and the scoped `template/AGENTS.md` guidance for +all files under `template/`. The branch is `rust-project-enhancements`, and the +plan file is `docs/execplans/rust-project-enhancements.md` as required by the +repository instructions. + +Use the current worktree and the sibling repositories as authoritative source +inputs. The relevant imports are `../dear-diary/.cargo/config.toml`, +`../dear-diary/Makefile`, `../dear-diary/.github/workflows/ci.yml`, +`../dear-diary/Cargo.toml`, and the pytest-copier approach in +`../agent-template-python/tests/test_template.py`. + +Prefer Makefile targets over direct commands. Run long gates with `tee` into +`/tmp` logs. Do not run format, lint, or test commands in parallel. Commit +after each completed and gated change. Use `coderabbit review --agent` after +major milestones and clear concerns before moving on. + +GitHub Actions versions imported from `../dear-diary` must be replaced by +current SHAs sourced through Firecrawl-assisted lookup before completion. + +## Tolerances + +Stop and ask for direction if the generated template cannot pass `make all` +without removing one of the requested tools. Stop if `coderabbit review +--agent` reports a concern that conflicts with the user's explicit +requirements. Stop if any gate requires more than 1200 seconds as a single +command and cannot be split into smaller public targets. Stop if disk space or +`/tmp` fills up. + +## Risks + +Some generated project gates may download Rust components or install tools, +which can be slow and may contend for the shared Cargo cache. The mitigation is +to run gates sequentially and let Cargo's package-cache lock serialize access. + +The template currently uses strict Clippy and rustdoc lints. Stub modules and +tests must be documented carefully so they satisfy lints while clearly telling +project owners to delete them once real code exists. + +Pinned GitHub Action SHAs drift over time. The mitigation is to resolve the +current default-branch commit for each action repository during implementation +and record the resulting pins in the plan and pull request validation notes. + +## Progress + +- [x] 2026-05-23: Confirmed branch `rust-project-enhancements` and clean + worktree. +- [x] 2026-05-23: Loaded `leta`, `execplans`, `grepai`, `firecrawl-mcp`, and + `pr-creation` skills because the task requires Rust template work, a living + plan, semantic search, Firecrawl-sourced action pins, and a final PR. +- [x] 2026-05-23: Inspected `../dear-diary` build, linker, CI, and metadata + patterns. +- [x] 2026-05-23: Inspected `../agent-template-python` pytest-copier template + testing approach. +- [x] 2026-05-23: Imported generated-project Rust toolchain, linker, nextest, + Whitaker, cargo-binstall, metadata, and CI contracts into the template. +- [x] 2026-05-23: Resolved action repositories with Firecrawl search results + and pinned current default-branch SHAs via `git ls-remote` for + `actions/checkout`, `actions/cache`, `astral-sh/setup-uv`, + `DavidAnson/markdownlint-cli2-action`, `leynos/shared-actions`, + `actions/upload-artifact`, `actions/download-artifact`, and + `softprops/action-gh-release`. +- [x] 2026-05-23: Extended pytest-copier tests so rendered library and + application projects pass generated `make all` gates and assert the requested + tooling contracts. +- [x] 2026-05-23: Ran `make test 2>&1 | tee + /tmp/test-agent-template-rust-rust-project-enhancements-tooling-import.out`; + result was 9 passed in 17.52 seconds. +- [x] 2026-05-23: Ran repeated `coderabbit review --agent` passes for the + tooling import milestone and resolved actionable release workflow, cache, + target matrix, timeout, stub, and linker configuration findings. Skipped the + final action-version-tag recommendation because it directly contradicted the + user requirement to replace action versions with SHAs sourced via Firecrawl. +- [x] 2026-05-23: Re-ran `make test 2>&1 | tee + /tmp/test-agent-template-rust-rust-project-enhancements-tooling-import.out`; + result was 9 passed in 19.06 seconds after CodeRabbit fixes. +- [x] 2026-05-23: Committed the tooling import milestone as `60063ab + Import Rust template tooling`. +- [x] 2026-05-23: Ran final post-milestone `make test 2>&1 | tee + /tmp/test-agent-template-rust-rust-project-enhancements-final.out`; result + was 9 passed in 18.54 seconds. +- [x] 2026-05-23: Pushed `rust-project-enhancements` and created draft pull + request . + +## Surprises & Discoveries + +There is no root `AGENTS.md` file in this repository. The in-scope project +guidance for generated template files is `template/AGENTS.md`. + +GrepAI's `Projects` workspace is available, but `agent-template-rust` is not +indexed there. GrepAI can still help with indexed sibling projects, and exact +file reads are used for this template repository. + +Branch changes have been committed and pushed as recorded below. + +CodeRabbit recommended changing GitHub Action pins back to version tags such +as `actions/checkout@v6.0.2`. That is intentionally not applied because the +objective explicitly asks for action versions to be replaced by SHAs sourced +using Firecrawl once the latest version is in place. + +## Decision Log + +Use `cargo nextest run` as the generated project's test target and keep +coverage delegated to the shared `generate-coverage` action in CI. This matches +the requested nextest adoption while retaining the dear-diary coverage action +shape that can fall back away from mold when llvm-cov requires LLVM linking. + +Use a generated `.cargo/config.toml` for local debug Cranelift and Linux mold +linking. In CI, set `CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=clang` and +`RUSTFLAGS=-C link-arg=-fuse-ld=lld` only for the coverage step so llvm-cov is +not coupled to mold. + +Validate generated projects through pytest-copier by invoking the generated +public `make all` target rather than stitching together private commands from +the parent repository. + +## Outcomes & Retrospective + +The template now renders projects with Cranelift debug codegen, Linux mold +linker configuration, nextest testing, required crates.io metadata prompts, +cargo-binstall metadata for app projects, Whitaker linting with CI caching, +SHA-pinned CI actions, and pytest-copier coverage that runs generated `make +all` gates. The branch was pushed and draft pull request + was opened for review. diff --git a/docs/users-guide.md b/docs/users-guide.md new file mode 100644 index 0000000..5ce8745 --- /dev/null +++ b/docs/users-guide.md @@ -0,0 +1,53 @@ +# User Guide + +This repository is a Copier template for creating Rust projects. The generated +project is intended to be usable immediately after rendering. + +## Copier Prompts + +The template asks for normal project identity values such as project name, +package name, licence holder, and contact email. It also asks for package +metadata used in the generated `Cargo.toml`: + +- `flavour` selects `lib` or `app` and determines the generated structure and + release metadata. +- `package_description` becomes `[package].description`. +- `repository_url` becomes `[package].repository` and is used by generated + app projects for cargo-binstall release URLs. +- `homepage_url` becomes `[package].homepage`. +- `package_keywords` becomes `[package].keywords`. +- `package_categories` becomes `[package].categories`. +- `rust_nightly_date` selects the pinned nightly toolchain date. +- `license_year` sets the copyright year in `LICENSE`. +- `dev_target` selects the target-specific Linux linker block generated in + `.cargo/config.toml`. + +## Generated Tooling + +Generated projects use Rust 2024, a pinned nightly toolchain, strict lint +settings, and documented starter code. Library projects render `src/lib.rs`. +Application projects render `src/main.rs`, release automation, and +`[package.metadata.binstall]` metadata for binary installation. + +Development builds use Cranelift for debug code generation. On Linux targets, +`.cargo/config.toml` configures clang to link with `mold` so local debug builds +link quickly. Coverage generation uses `lld` instead because LLVM coverage +tools expect LLVM-compatible linker behaviour. + +## Makefile Targets + +The generated `Makefile` exposes these public targets: + +- `make all` runs formatting checks, linting, and tests. +- `make check-fmt` verifies Rust formatting. +- `make lint` runs rustdoc, Clippy, and Whitaker with warnings denied. +- `make test` runs `cargo nextest run` when cargo-nextest is installed and + falls back to `cargo test` otherwise. Library projects also run doctests. +- `make build` builds the debug target. +- `make release` builds the release target. +- `make coverage` writes `lcov.info` using `cargo llvm-cov` and `lld`. +- `make markdownlint` checks Markdown files. +- `make nixie` validates Mermaid diagrams. + +Install `clang`, `lld`, and `mold` before running the full generated workflow +locally on Linux. diff --git a/requirements.txt b/requirements.txt index 552a396..c687665 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ pytest>=8.0 pytest-copier>=0.4.1 +syrupy>=4.0 diff --git a/template/.cargo/config.toml.jinja b/template/.cargo/config.toml.jinja new file mode 100644 index 0000000..b9d65a4 --- /dev/null +++ b/template/.cargo/config.toml.jinja @@ -0,0 +1,16 @@ +[unstable] +codegen-backend = true + +[profile.dev] +codegen-backend = "cranelift" + +{% if dev_target and 'linux' in dev_target -%} +[target.{{ dev_target }}] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] +{% elif dev_target -%} +# mold is Linux-only. For faster linking on {{ dev_target }}, add a +# platform-specific block here. Common options include lld via clang on macOS +# or BSD targets, and lld-link for Windows MSVC targets on recent Rust +# toolchains. +{% endif -%} diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index 552feed..b9c136f 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -13,23 +13,72 @@ jobs: env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug + WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231 + with: + persist-credentials: false - name: Setup Rust - uses: leynos/shared-actions/.github/actions/setup-rust@f9f1c863c8a5bef64aa6779caa746e1a4a6c1ad4 + uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 + - name: Install mold linker + if: runner.os == 'Linux' + run: | + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + # mold accelerates dev builds; coverage uses lld for llvm-tools compatibility. + sudo apt-get update \ + && sudo apt-get install --yes --no-install-recommends clang lld mold - name: Format run: make check-fmt - name: Markdown lint - uses: DavidAnson/markdownlint-cli2-action@992badcdf24e3b8eb7e87ff9287fe931bcb00c6e + uses: DavidAnson/markdownlint-cli2-action@2df9e28eb87988518ef3880c34edad45d65b1668 with: globs: | **/*.md !**/target/** !**/dist/** + - name: Setup uv + uses: astral-sh/setup-uv@12d13f90bc3a5a1971bebad4beb09a4dfa962e91 + - name: Install test runner + run: cargo binstall --no-confirm cargo-nextest + - name: Cache Whitaker installation + id: cache-whitaker + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + with: + path: | + ~/.cargo/bin/whitaker-installer + ~/.local/bin/whitaker + ~/.dylint_drivers + ~/.local/share/whitaker + key: whitaker-v2-${{ runner.os }}-${{ env.WHITAKER_INSTALLER_REV }} + - name: Install Whitaker + run: | + set -euo pipefail + echo "Whitaker cache hit: ${{ steps.cache-whitaker.outputs.cache-hit }}" + if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then + echo "Installing Whitaker installer at ${WHITAKER_INSTALLER_REV}" + cargo install --locked \ + --git https://github.com/leynos/whitaker \ + --rev "${WHITAKER_INSTALLER_REV}" \ + whitaker-installer + fi + whitaker-installer --cranelift + echo "Whitaker binary: $(command -v whitaker || true)" - name: Lint run: make lint + - name: Log coverage linker configuration + run: | + echo "Coverage linker: clang" + echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage CFLAGS: -fuse-ld=lld" + echo "Coverage LDFLAGS: -fuse-ld=lld" - name: Test and Measure Coverage - uses: leynos/shared-actions/.github/actions/generate-coverage@f9f1c863c8a5bef64aa6779caa746e1a4a6c1ad4 + uses: leynos/shared-actions/.github/actions/generate-coverage@e4c6b0e200a057edf927c45c298e7ddf229b3934 + env: + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER: clang + RUSTFLAGS: -C link-arg=-fuse-ld=lld + CFLAGS: -fuse-ld=lld + LDFLAGS: -fuse-ld=lld with: output-path: lcov.info format: lcov @@ -37,7 +86,7 @@ jobs: env: CS_ACCESS_TOKEN: ${{ secrets.CS_ACCESS_TOKEN }} if: ${{ env.CS_ACCESS_TOKEN }} - uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@f9f1c863c8a5bef64aa6779caa746e1a4a6c1ad4 + uses: leynos/shared-actions/.github/actions/upload-codescene-coverage@e4c6b0e200a057edf927c45c298e7ddf229b3934 with: format: lcov access-token: ${{ env.CS_ACCESS_TOKEN }} diff --git a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja new file mode 100644 index 0000000..a8626d0 --- /dev/null +++ b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja @@ -0,0 +1,111 @@ +{% raw %} +name: Release Binary + +on: + push: + tags: + - 'v*.*.*' + +env: + BIN_NAME: {% endraw %}{{ package_name }}{% raw %} + CROSS_REVISION: v0.2.5 + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + matrix: + include: + - os: linux + arch: x86_64 + target: x86_64-unknown-linux-gnu + ext: "" + - os: linux + arch: aarch64 + target: aarch64-unknown-linux-gnu + ext: "" + - os: windows + arch: x86_64 + target: x86_64-pc-windows-gnu + ext: ".exe" + - os: macos + arch: x86_64 + target: x86_64-apple-darwin + ext: "" + - os: macos + arch: aarch64 + target: aarch64-apple-darwin + ext: "" + - os: freebsd + arch: x86_64 + target: x86_64-unknown-freebsd + ext: "" + steps: + - uses: actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231 + with: + persist-credentials: false + - uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 + with: + toolchain: stable + - name: Cache cross binary + id: cache-cross + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + with: + path: ~/.cargo/bin/cross + key: cross-${{ env.CROSS_REVISION }} + - name: Install cross + env: + # Clear repository build flags from .cargo/config.toml (mold linker + # and Cranelift codegen-backend) before installing cross from CROSS_REVISION. + RUSTFLAGS: "" + run: | + if [ -x "$HOME/.cargo/bin/cross" ]; then + exit 0 + fi + cargo install cross --git https://github.com/cross-rs/cross --tag "${CROSS_REVISION}" + - name: Cache cargo registry + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-${{ matrix.target }}- + - name: Build release binary + # Use +stable to override rust-toolchain.toml (which specifies nightly + # with Cranelift for development) and ensure release builds use stable. + run: cross +stable build --release --target ${{ matrix.target }} + - name: Prepare artifact + run: | + mkdir -p artifacts/${{ matrix.os }}-${{ matrix.arch }} + cp target/${{ matrix.target }}/release/${{ env.BIN_NAME }}${{ matrix.ext }} \ + artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }} + cd artifacts/${{ matrix.os }}-${{ matrix.arch }} + sha256sum ${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }} > \ + ${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }}.sha256 + - name: Upload release artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: ${{ env.BIN_NAME }}-${{ matrix.os }}-${{ matrix.arch }} + path: artifacts/${{ matrix.os }}-${{ matrix.arch }} + + release: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write + steps: + - uses: actions/download-artifact@484a0b528fb4d7bd804637ccb632e47a0e638317 + with: + path: artifacts + - uses: softprops/action-gh-release@403a5240f3837fa857f642062e05aad6bb3391ca + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + generate_release_notes: true + files: | + artifacts/*/* +{% endraw %} diff --git a/template/.github/workflows/{% if flavour == APP %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == APP %}release.yml{% endif %}.jinja deleted file mode 100644 index ed1025a..0000000 --- a/template/.github/workflows/{% if flavour == APP %}release.yml{% endif %}.jinja +++ /dev/null @@ -1,116 +0,0 @@ -{% raw %} -name: Release Binary - -on: - push: - tags: - - 'v*.*.*' - -env: - REPO_NAME: ${{ github.event.repository.name }} - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - os: linux - arch: x86_64 - target: x86_64-unknown-linux-gnu - ext: "" - - os: linux - arch: aarch64 - target: aarch64-unknown-linux-gnu - ext: "" - - os: windows - arch: x86_64 - target: x86_64-pc-windows-msvc - ext: ".exe" - - os: windows - arch: aarch64 - target: aarch64-pc-windows-msvc - ext: ".exe" - - os: macos - arch: x86_64 - target: x86_64-apple-darwin - ext: "" - - os: macos - arch: aarch64 - target: aarch64-apple-darwin - ext: "" - - os: freebsd - arch: x86_64 - target: x86_64-unknown-freebsd - ext: "" - - os: freebsd - arch: aarch64 - target: aarch64-unknown-freebsd - ext: "" - - os: openbsd - arch: x86_64 - target: x86_64-unknown-openbsd - ext: "" - - os: openbsd - arch: aarch64 - target: aarch64-unknown-openbsd - ext: "" - steps: - - uses: actions/checkout@v4 - - uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 - with: - toolchain: stable - profile: minimal - override: true - - name: Cache cross binary - uses: actions/cache@v4 - with: - path: ~/.cargo/bin/cross - key: cross-${{ runner.os }} - - name: Install cross - run: cargo install cross --git https://github.com/cross-rs/cross - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- - - name: Build release binary - run: cross +stable build --release --target ${{ matrix.target }} - - name: Prepare artifact - run: | - mkdir -p artifacts/${{ matrix.os }}-${{ matrix.arch }} - cp target/${{ matrix.target }}/release/${{ env.REPO_NAME }}${{ matrix.ext }} \ - artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} - sha256sum artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }} > \ - artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.ext }}.sha256 - - name: Upload release artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ env.REPO_NAME }}-${{ matrix.os }}-${{ matrix.arch }} - path: artifacts/${{ matrix.os }}-${{ matrix.arch }} - - release: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: softprops/action-gh-release@v1 - with: - generate_release_notes: true - - uses: actions/download-artifact@v4 - with: - path: artifacts - - run: | - for dir in artifacts/${{ env.REPO_NAME }}-*; do - for file in "$dir"/*; do - gh release upload "${{ github.ref_name }}" "$file" - done - done - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -{% endraw %} diff --git a/template/Cargo.toml.jinja b/template/Cargo.toml.jinja index e77d878..2280795 100644 --- a/template/Cargo.toml.jinja +++ b/template/Cargo.toml.jinja @@ -2,9 +2,27 @@ name = "{{ package_name }}" version = "0.1.0" edition = "2024" +description = "{{ package_description }}" +license = "ISC" +repository = "{{ repository_url }}" +homepage = "{{ homepage_url }}" +readme = "README.md" +keywords = [{% for keyword in package_keywords.split(',') %}"{{ keyword.strip() }}"{% if not loop.last %}, {% endif %}{% endfor %}] +categories = [{% for category in package_categories.split(',') %}"{{ category.strip() }}"{% if not loop.last %}, {% endif %}{% endfor %}] [dependencies] +{% if flavour == 'app' -%} +# The package.metadata.binstall pkg-url intentionally mixes Jinja2 values +# rendered at project generation with single-brace cargo-binstall variables +# substituted at install time. +[package.metadata.binstall] +pkg-url = "{{ repository_url }}/releases/download/v{ version }/{{ package_name }}-{ target }{ binary-ext }" +pkg-fmt = "bin" +disabled-strategies = ["quick-install", "compile"] + +{% endif -%} + [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/template/Makefile.jinja b/template/Makefile.jinja index d2301c9..a7ab038 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,10 +1,12 @@ -.PHONY: help all clean test build release lint fmt check-fmt markdownlint nixie +.PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint nixie {% if flavour == 'app' %} TARGET ?= {{ package_name }} {% else %} TARGET ?= lib{{ package_name }}.rlib {% endif %} +USER_WHITAKER := $(HOME)/.local/bin/whitaker +USER_BIN_PATH := $(HOME)/.cargo/bin:$(HOME)/.local/bin:$(HOME)/.bun/bin CARGO ?= cargo BUILD_JOBS ?= RUST_FLAGS ?= @@ -15,8 +17,11 @@ CARGO_FLAGS ?= --all-targets --all-features CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS) TEST_FLAGS ?= $(CARGO_FLAGS) TEST_CMD := $(if $(shell $(CARGO) nextest --version 2>/dev/null),nextest run,test) +COVERAGE_LINKER_FLAGS ?= -fuse-ld=lld +COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) MDLINT ?= markdownlint-cli2 NIXIE ?= nixie +WHITAKER ?= $(or $(shell command -v whitaker 2>/dev/null),$(wildcard $(USER_WHITAKER)),whitaker) build: target/debug/$(TARGET) ## Build debug binary release: target/release/$(TARGET) ## Build release binary @@ -28,19 +33,26 @@ clean: ## Remove build artifacts test: ## Run tests with warnings treated as errors RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) -ifneq ($(TEST_CMD),test) +{% if flavour == 'lib' %} RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features -endif +{% endif %} target/%/$(TARGET): ## Build binary in debug or release mode $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release){% if flavour == 'app' %} --bin $(TARGET){% endif %} +coverage: ## Generate lcov coverage with lld for llvm-tools compatibility + @echo "coverage linker flags: $(COVERAGE_LINKER_FLAGS)" + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=clang \ + RUSTFLAGS="$(COVERAGE_RUST_FLAGS)" \ + CFLAGS="$(COVERAGE_LINKER_FLAGS)" \ + LDFLAGS="$(COVERAGE_LINKER_FLAGS)" \ + $(CARGO) llvm-cov --lcov --output-path lcov.info $(TEST_FLAGS) + lint: ## Run Clippy with warnings denied RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps $(CARGO) clippy $(CLIPPY_FLAGS) - @command -v whitaker >/dev/null 2>&1 && \ - RUSTFLAGS="$(RUST_FLAGS)" whitaker --all -- $(CARGO_FLAGS) || \ - { echo "whitaker not found on PATH; skipping whitaker lint. Install whitaker to run this check."; } + @echo "Whitaker binary: $(WHITAKER)" + PATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) typecheck: ## Type-check without building RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) diff --git a/template/README.md.jinja b/template/README.md.jinja index 3a59768..9cb13cc 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -1,3 +1,21 @@ # {{ project_name }} This is a generated project using [Copier](https://copier.readthedocs.io/). + +## Build Linkers + +Development builds use `mold` on Linux through `.cargo/config.toml` so local +debug builds link quickly. Coverage generation uses `lld` instead because the +LLVM coverage tools expect LLVM-compatible linker behaviour. + +Install both linkers before running the full workflow locally: + +```sh +sudo apt-get install clang lld mold +``` + +Run coverage with: + +```sh +make coverage +``` diff --git a/template/rust-toolchain.toml.jinja b/template/rust-toolchain.toml.jinja index a47e5cb..67d29a9 100644 --- a/template/rust-toolchain.toml.jinja +++ b/template/rust-toolchain.toml.jinja @@ -1,3 +1,8 @@ [toolchain] channel = "nightly-{{ rust_nightly_date }}" -components = ["rustfmt", "clippy"] +components = [ + "clippy", + "llvm-tools-preview", + "rustc-codegen-cranelift-preview", + "rustfmt", +] diff --git a/template/src/{% if flavour == 'app' %}main.rs{% else %}lib.rs{% endif %}.jinja b/template/src/{% if flavour == 'app' %}main.rs{% else %}lib.rs{% endif %}.jinja index 8262c9c..e734ff7 100644 --- a/template/src/{% if flavour == 'app' %}main.rs{% else %}lib.rs{% endif %}.jinja +++ b/template/src/{% if flavour == 'app' %}main.rs{% else %}lib.rs{% endif %}.jinja @@ -1,19 +1,21 @@ -{%- if flavour == 'app' -%} +{% if flavour == 'app' -%} //! `{{ project_name }}` application entry point. -// TODO: Remove this stub and implement actual application functionality. +// TODO: Remove when replacing app scaffolding +// (docs/execplans/rust-project-enhancements.md). /// Application entry point. -#[allow(clippy::print_stdout, reason = "CLI output is the intended behaviour")] +#[expect( + clippy::print_stdout, + reason = "temporary app stub tracked in docs/execplans/rust-project-enhancements.md" +)] fn main() { println!("Hello from {{ project_name }}!"); } -{%- else -%} +{% else -%} //! `{{ project_name }}` library. -// TODO: Remove this stub and implement actual library functionality. +// TODO: Remove this stub as soon as actual library functionality exists. /// Returns a greeting for the library. #[must_use] -pub const fn greet() -> &'static str { - "Hello from {{ project_name }}!" -} -{%- endif -%} +pub const fn greet() -> &'static str { "Hello from {{ project_name }}!" } +{% endif -%} diff --git a/template/tests/stub.rs b/template/tests/stub.rs new file mode 100644 index 0000000..28c8a7f --- /dev/null +++ b/template/tests/stub.rs @@ -0,0 +1,13 @@ +//! Disposable generated-template test stub. +//! +//! This test exists only so `cargo nextest run` has at least one test in a +//! freshly generated project. Delete this file as soon as the project has real +//! functionality and real tests. Do not keep this stub as permanent coverage. + +#[test] +fn replace_this_stub_when_real_tests_exist() { + assert!( + std::env::var_os("CARGO_MANIFEST_DIR").is_some(), + "CARGO_MANIFEST_DIR should be set by Cargo when running tests" + ); +} diff --git a/tests/__snapshots__/test_template.ambr b/tests/__snapshots__/test_template.ambr new file mode 100644 index 0000000..ceb8699 --- /dev/null +++ b/tests/__snapshots__/test_template.ambr @@ -0,0 +1,405 @@ +# serializer version: 1 +# name: test_generated_structured_file_snapshots + dict({ + 'cargo_config': ''' + [unstable] + codegen-backend = true + + [profile.dev] + codegen-backend = "cranelift" + + [target.x86_64-unknown-linux-gnu] + linker = "clang" + rustflags = ["-C", "link-arg=-fuse-ld=mold"] + + ''', + 'ci_workflow': dict({ + 'jobs': dict({ + 'build-test': dict({ + 'env': dict({ + 'BUILD_PROFILE': 'debug', + 'CARGO_TERM_COLOR': 'always', + 'WHITAKER_INSTALLER_REV': 'f768c2e53c47df13658af1168a67851d388750bf', + }), + 'permissions': dict({ + 'contents': 'read', + }), + 'runs-on': 'ubuntu-latest', + 'steps': list([ + dict({ + 'uses': 'actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231', + 'with': dict({ + 'persist-credentials': False, + }), + }), + dict({ + 'name': 'Setup Rust', + 'uses': 'leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934', + }), + dict({ + 'if': "runner.os == 'Linux'", + 'name': 'Install mold linker', + 'run': ''' + set -euo pipefail + export DEBIAN_FRONTEND=noninteractive + # mold accelerates dev builds; coverage uses lld for llvm-tools compatibility. + sudo apt-get update \ + && sudo apt-get install --yes --no-install-recommends clang lld mold + + ''', + }), + dict({ + 'name': 'Format', + 'run': 'make check-fmt', + }), + dict({ + 'name': 'Markdown lint', + 'uses': 'DavidAnson/markdownlint-cli2-action@2df9e28eb87988518ef3880c34edad45d65b1668', + 'with': dict({ + 'globs': ''' + **/*.md + !**/target/** + !**/dist/** + + ''', + }), + }), + dict({ + 'name': 'Setup uv', + 'uses': 'astral-sh/setup-uv@12d13f90bc3a5a1971bebad4beb09a4dfa962e91', + }), + dict({ + 'name': 'Install test runner', + 'run': 'cargo binstall --no-confirm cargo-nextest', + }), + dict({ + 'id': 'cache-whitaker', + 'name': 'Cache Whitaker installation', + 'uses': 'actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae', + 'with': dict({ + 'key': 'whitaker-v2-${{ runner.os }}-${{ env.WHITAKER_INSTALLER_REV }}', + 'path': ''' + ~/.cargo/bin/whitaker-installer + ~/.local/bin/whitaker + ~/.dylint_drivers + ~/.local/share/whitaker + + ''', + }), + }), + dict({ + 'name': 'Install Whitaker', + 'run': ''' + set -euo pipefail + echo "Whitaker cache hit: ${{ steps.cache-whitaker.outputs.cache-hit }}" + if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then + echo "Installing Whitaker installer at ${WHITAKER_INSTALLER_REV}" + cargo install --locked \ + --git https://github.com/leynos/whitaker \ + --rev "${WHITAKER_INSTALLER_REV}" \ + whitaker-installer + fi + whitaker-installer --cranelift + echo "Whitaker binary: $(command -v whitaker || true)" + + ''', + }), + dict({ + 'name': 'Lint', + 'run': 'make lint', + }), + dict({ + 'name': 'Log coverage linker configuration', + 'run': ''' + echo "Coverage linker: clang" + echo "Coverage RUSTFLAGS: -C link-arg=-fuse-ld=lld" + echo "Coverage CFLAGS: -fuse-ld=lld" + echo "Coverage LDFLAGS: -fuse-ld=lld" + + ''', + }), + dict({ + 'env': dict({ + 'CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER': 'clang', + 'CFLAGS': '-fuse-ld=lld', + 'LDFLAGS': '-fuse-ld=lld', + 'RUSTFLAGS': '-C link-arg=-fuse-ld=lld', + }), + 'name': 'Test and Measure Coverage', + 'uses': 'leynos/shared-actions/.github/actions/generate-coverage@e4c6b0e200a057edf927c45c298e7ddf229b3934', + 'with': dict({ + 'format': 'lcov', + 'output-path': 'lcov.info', + }), + }), + dict({ + 'env': dict({ + 'CS_ACCESS_TOKEN': '${{ secrets.CS_ACCESS_TOKEN }}', + }), + 'if': '${{ env.CS_ACCESS_TOKEN }}', + 'name': 'Upload coverage data to CodeScene', + 'uses': 'leynos/shared-actions/.github/actions/upload-codescene-coverage@e4c6b0e200a057edf927c45c298e7ddf229b3934', + 'with': dict({ + 'access-token': '${{ env.CS_ACCESS_TOKEN }}', + 'format': 'lcov', + 'installer-checksum': '${{ vars.CODESCENE_CLI_SHA256 }}', + }), + }), + ]), + }), + }), + 'name': 'CI', + 'on': dict({ + 'pull_request': dict({ + 'types': list([ + 'opened', + 'synchronize', + 'reopened', + ]), + }), + 'workflow_dispatch': None, + }), + }), + 'makefile': ''' + .PHONY: help all clean test build release coverage lint fmt check-fmt markdownlint nixie + + + TARGET ?= snapshot_example + + USER_WHITAKER := $(HOME)/.local/bin/whitaker + USER_BIN_PATH := $(HOME)/.cargo/bin:$(HOME)/.local/bin:$(HOME)/.bun/bin + CARGO ?= cargo + BUILD_JOBS ?= + RUST_FLAGS ?= + RUST_FLAGS := -D warnings $(RUST_FLAGS) + RUSTDOC_FLAGS ?= + RUSTDOC_FLAGS := -D warnings $(RUSTDOC_FLAGS) + CARGO_FLAGS ?= --all-targets --all-features + CLIPPY_FLAGS ?= $(CARGO_FLAGS) -- $(RUST_FLAGS) + TEST_FLAGS ?= $(CARGO_FLAGS) + TEST_CMD := $(if $(shell $(CARGO) nextest --version 2>/dev/null),nextest run,test) + COVERAGE_LINKER_FLAGS ?= -fuse-ld=lld + COVERAGE_RUST_FLAGS ?= $(RUST_FLAGS) -C link-arg=$(COVERAGE_LINKER_FLAGS) + MDLINT ?= markdownlint-cli2 + NIXIE ?= nixie + WHITAKER ?= $(or $(shell command -v whitaker 2>/dev/null),$(wildcard $(USER_WHITAKER)),whitaker) + + build: target/debug/$(TARGET) ## Build debug binary + release: target/release/$(TARGET) ## Build release binary + + all: check-fmt lint test ## Perform a comprehensive check of code + + clean: ## Remove build artifacts + $(CARGO) clean + + test: ## Run tests with warnings treated as errors + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) + + + target/%/$(TARGET): ## Build binary in debug or release mode + $(CARGO) build $(BUILD_JOBS) $(if $(findstring release,$(@)),--release) --bin $(TARGET) + + coverage: ## Generate lcov coverage with lld for llvm-tools compatibility + @echo "coverage linker flags: $(COVERAGE_LINKER_FLAGS)" + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=clang \ + RUSTFLAGS="$(COVERAGE_RUST_FLAGS)" \ + CFLAGS="$(COVERAGE_LINKER_FLAGS)" \ + LDFLAGS="$(COVERAGE_LINKER_FLAGS)" \ + $(CARGO) llvm-cov --lcov --output-path lcov.info $(TEST_FLAGS) + + lint: ## Run Clippy with warnings denied + RUSTDOCFLAGS="$(RUSTDOC_FLAGS)" $(CARGO) doc --no-deps + $(CARGO) clippy $(CLIPPY_FLAGS) + @echo "Whitaker binary: $(WHITAKER)" + PATH="$(USER_BIN_PATH):$(PATH)" RUSTFLAGS="$(RUST_FLAGS)" $(WHITAKER) --all -- $(CARGO_FLAGS) + + typecheck: ## Type-check without building + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) check $(CARGO_FLAGS) + + fmt: ## Format Rust and Markdown sources + $(CARGO) +nightly fmt --all + mdformat-all + + check-fmt: ## Verify formatting + $(CARGO) fmt --all -- --check + + markdownlint: ## Lint Markdown files + $(MDLINT) '**/*.md' + + nixie: ## Validate Mermaid diagrams + $(NIXIE) --no-sandbox + + help: ## Show available targets + @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS=":"; printf "Available targets:\n"} {printf " %-20s %s\n", $$1, $$2}' + + ''', + 'release_workflow': dict({ + 'env': dict({ + 'BIN_NAME': 'snapshot_example', + 'CROSS_REVISION': 'v0.2.5', + }), + 'jobs': dict({ + 'build': dict({ + 'runs-on': 'ubuntu-latest', + 'steps': list([ + dict({ + 'uses': 'actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231', + 'with': dict({ + 'persist-credentials': False, + }), + }), + dict({ + 'uses': 'leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934', + 'with': dict({ + 'toolchain': 'stable', + }), + }), + dict({ + 'id': 'cache-cross', + 'name': 'Cache cross binary', + 'uses': 'actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae', + 'with': dict({ + 'key': 'cross-${{ env.CROSS_REVISION }}', + 'path': '~/.cargo/bin/cross', + }), + }), + dict({ + 'env': dict({ + 'RUSTFLAGS': '', + }), + 'name': 'Install cross', + 'run': ''' + if [ -x "$HOME/.cargo/bin/cross" ]; then + exit 0 + fi + cargo install cross --git https://github.com/cross-rs/cross --tag "${CROSS_REVISION}" + + ''', + }), + dict({ + 'name': 'Cache cargo registry', + 'uses': 'actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae', + 'with': dict({ + 'key': "${{ runner.os }}-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}", + 'path': ''' + ~/.cargo/registry + ~/.cargo/git + + ''', + 'restore-keys': ''' + ${{ runner.os }}-cargo-${{ matrix.target }}- + + ''', + }), + }), + dict({ + 'name': 'Build release binary', + 'run': 'cross +stable build --release --target ${{ matrix.target }}', + }), + dict({ + 'name': 'Prepare artifact', + 'run': ''' + mkdir -p artifacts/${{ matrix.os }}-${{ matrix.arch }} + cp target/${{ matrix.target }}/release/${{ env.BIN_NAME }}${{ matrix.ext }} \ + artifacts/${{ matrix.os }}-${{ matrix.arch }}/${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }} + cd artifacts/${{ matrix.os }}-${{ matrix.arch }} + sha256sum ${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }} > \ + ${{ env.BIN_NAME }}-${{ matrix.target }}${{ matrix.ext }}.sha256 + + ''', + }), + dict({ + 'name': 'Upload release artifact', + 'uses': 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a', + 'with': dict({ + 'name': '${{ env.BIN_NAME }}-${{ matrix.os }}-${{ matrix.arch }}', + 'path': 'artifacts/${{ matrix.os }}-${{ matrix.arch }}', + }), + }), + ]), + 'strategy': dict({ + 'matrix': dict({ + 'include': list([ + dict({ + 'arch': 'x86_64', + 'ext': '', + 'os': 'linux', + 'target': 'x86_64-unknown-linux-gnu', + }), + dict({ + 'arch': 'aarch64', + 'ext': '', + 'os': 'linux', + 'target': 'aarch64-unknown-linux-gnu', + }), + dict({ + 'arch': 'x86_64', + 'ext': '.exe', + 'os': 'windows', + 'target': 'x86_64-pc-windows-gnu', + }), + dict({ + 'arch': 'x86_64', + 'ext': '', + 'os': 'macos', + 'target': 'x86_64-apple-darwin', + }), + dict({ + 'arch': 'aarch64', + 'ext': '', + 'os': 'macos', + 'target': 'aarch64-apple-darwin', + }), + dict({ + 'arch': 'x86_64', + 'ext': '', + 'os': 'freebsd', + 'target': 'x86_64-unknown-freebsd', + }), + ]), + }), + }), + 'timeout-minutes': 60, + }), + 'release': dict({ + 'needs': 'build', + 'permissions': dict({ + 'contents': 'write', + }), + 'runs-on': 'ubuntu-latest', + 'steps': list([ + dict({ + 'uses': 'actions/download-artifact@484a0b528fb4d7bd804637ccb632e47a0e638317', + 'with': dict({ + 'path': 'artifacts', + }), + }), + dict({ + 'env': dict({ + 'GITHUB_TOKEN': '${{ secrets.GITHUB_TOKEN }}', + }), + 'uses': 'softprops/action-gh-release@403a5240f3837fa857f642062e05aad6bb3391ca', + 'with': dict({ + 'files': ''' + artifacts/*/* + + ''', + 'generate_release_notes': True, + }), + }), + ]), + 'timeout-minutes': 30, + }), + }), + 'name': 'Release Binary', + True: dict({ + 'push': dict({ + 'tags': list([ + 'v*.*.*', + ]), + }), + }), + }), + }) +# --- diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..42dc01e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,21 @@ +"""Pytest fixtures for configuring pytest-copier template selection. + +This module narrows pytest-copier's temporary template repository to the real +Copier inputs used by this project. Tests can use the normal `copier` fixture; +pytest-copier reads `copier_template_paths` during setup and copies only +`copier.yaml` and `template/` before rendering generated projects. + +Example: + A test that accepts `copier` and calls `copier.copy(...)` automatically uses + these template paths. +""" + +from __future__ import annotations + +import pytest + + +@pytest.fixture(scope="session") +def copier_template_paths() -> list[str]: + """Copy only template inputs into pytest-copier's temporary Git repo.""" + return ["copier.yaml", "template"] diff --git a/tests/test_template.py b/tests/test_template.py index 944ef53..b50cc7d 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,10 +1,29 @@ +"""Template rendering tests for the Rust Copier project. + +This module verifies that the template renders useful Rust library and +application projects, that generated public quality gates work, and that key +tooling contracts are present in the generated files. The tests expect the +pytest-copier ``copier`` fixture configured by ``tests.conftest`` and can be +run with ``make test`` from the parent template repository. + +Example: + Run ``make test`` to render the template and execute all generated-project + contract checks. +""" + from __future__ import annotations +import os +import shutil +import subprocess +import tomllib from pathlib import Path -from datetime import datetime, UTC +from typing import Any import pytest -from pytest_copier.plugin import CopierFixture +import yaml +from pytest_copier.plugin import CopierFixture, CopierProject +from syrupy.assertion import SnapshotAssertion APP = "app" LIB = "lib" @@ -12,76 +31,199 @@ TEMPLATE_PATH = Path(__file__).parents[1] +def render_project( + tmp_path: Path, + copier: CopierFixture, + *, + project_name: str, + package_name: str, + flavour: str = LIB, + license_year: int | None = 2026, + dev_target: str = "x86_64-unknown-linux-gnu", +) -> CopierProject: + """Render a generated Rust project with publishable metadata. + + Parameters + ---------- + tmp_path : Path + Temporary directory used as the generated project destination. + copier : CopierFixture + pytest-copier fixture bound to this template repository. + project_name : str + Human-readable project name supplied to the Copier template. + package_name : str + Rust package name supplied to the Copier template. + flavour : str, default=LIB + Generated project flavour to render. + license_year : int | None, default=2026 + Licence year supplied to the Copier template. + dev_target : str, default="x86_64-unknown-linux-gnu" + Development target supplied to the Copier template. + + Returns + ------- + CopierProject + Rendered project wrapper for file assertions and command execution. + """ + answers: dict[str, str | int] = { + "project_name": project_name, + "package_name": package_name, + "package_description": f"{project_name} package used by template tests.", + "repository_url": f"https://github.com/example/{package_name}", + "homepage_url": f"https://example.com/{package_name}", + "package_keywords": "rust,template", + "package_categories": "development-tools", + "license_holder": f"{project_name} Dev", + "license_email": f"{package_name}@example.com", + "flavour": flavour, + "dev_target": dev_target, + } + if license_year is not None: + answers["license_year"] = license_year + + return copier.copy(tmp_path, **answers) + + def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: - """Template renders with default values.""" - project = copier.copy( - tmp_path, - project_name="Example", - package_name="example", - license_year=datetime.now(tz=UTC).year, - license_holder="Example Dev", - license_email="example@example.com", + """Template renders with default values and passes public gates.""" + project = render_project( + tmp_path, copier, project_name="Example", package_name="example" + ) + assert (project / "Cargo.toml").exists(), ( + "expected Cargo.toml to exist in generated project" ) - assert (project / "Cargo.toml").exists() - assert (project / "src" / f"{LIB}.rs").exists() - project.run("cargo build") + assert (project / "src" / f"{LIB}.rs").exists(), ( + f"expected src/{LIB}.rs to exist in generated project" + ) + project.run("make all") def test_template_renders_app_flavour(tmp_path: Path, copier: CopierFixture) -> None: - """Template renders app flavour correctly.""" - project = copier.copy( + """Template renders app flavour correctly and passes public gates.""" + project = render_project( tmp_path, + copier, project_name="AppExample", package_name="app_example", - license_year=datetime.now(tz=UTC).year, - license_holder="App Dev", - license_email="app@example.com", flavour=APP, ) - assert (project / "src" / "main.rs").exists() - project.run("cargo build") + assert (project / "src" / "main.rs").exists(), ( + "expected src/main.rs to exist for app flavour" + ) + assert (project / ".github" / "workflows" / "release.yml").exists(), ( + "expected release workflow to exist for app flavour" + ) + project.run("make all") def test_template_renders_lib_flavour(tmp_path: Path, copier: CopierFixture) -> None: - """Template renders lib flavour correctly.""" - project = copier.copy( + """Template renders lib flavour correctly and passes public gates.""" + project = render_project( tmp_path, + copier, project_name="LibExample", package_name="lib_example", - license_year=datetime.now(tz=UTC).year, - license_holder="Lib Dev", - license_email="lib@example.com", flavour=LIB, ) - assert (project / "src" / "lib.rs").exists() - project.run("cargo build") + assert (project / "src" / "lib.rs").exists(), ( + "expected src/lib.rs to exist for lib flavour" + ) + assert not (project / ".github" / "workflows" / "release.yml").exists(), ( + "expected release workflow to be omitted for lib flavour" + ) + project.run("make all") def test_makefile_validates(tmp_path: Path, copier: CopierFixture) -> None: """Generated Makefile validates with mbake.""" - project = copier.copy( + project = render_project( tmp_path, + copier, project_name="MakefileExample", package_name="makefile_example", - license_year=datetime.now(tz=UTC).year, - license_holder="Makefile Dev", - license_email="makefile@example.com", ) - assert (project / "Makefile").exists() + assert (project / "Makefile").exists(), ( + "expected generated project Makefile to exist" + ) project.run("mbake validate Makefile") def test_clippy_runs(tmp_path: Path, copier: CopierFixture) -> None: - """Generated project passes Clippy.""" - project = copier.copy( + """Generated project passes its full lint target.""" + project = render_project( tmp_path, + copier, project_name="ClippyExample", package_name="clippy_example", - license_year=datetime.now(tz=UTC).year, - license_holder="Clippy Dev", - license_email="clippy@example.com", ) - project.run("cargo clippy --all-targets --all-features -- -D warnings") + project.run("make lint") + + +@pytest.mark.parametrize("whitaker_location", ["path", "home", "missing"]) +def test_makefile_resolves_whitaker_fallback( + tmp_path: Path, + copier: CopierFixture, + whitaker_location: str, +) -> None: + """Generated lint target resolves Whitaker from PATH or user install.""" + project = render_project( + tmp_path, + copier, + project_name="WhitakerExample", + package_name="whitaker_example", + ) + home = tmp_path / "home" + path_bin = tmp_path / "path-bin" + user_bin = home / ".local" / "bin" + cargo = tmp_path / "cargo" + marker = tmp_path / "whitaker-ran" + path_bin.mkdir(parents=True) + user_bin.mkdir(parents=True) + cargo.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + cargo.chmod(0o755) + + expected_whitaker = None + if whitaker_location != "missing": + expected_whitaker = ( + path_bin / "whitaker" + if whitaker_location == "path" + else user_bin / "whitaker" + ) + expected_whitaker.write_text( + f"#!/bin/sh\ntouch {marker}\nexit 0\n", encoding="utf-8" + ) + expected_whitaker.chmod(0o755) + make = shutil.which("make") + assert make is not None, "expected make to be available for generated tests" + + result = subprocess.run( + [make, "lint"], + cwd=project.path, + env={ + **os.environ, + "HOME": str(home), + "PATH": os.pathsep.join( + [str(path_bin), "/usr/bin", "/bin"] + if whitaker_location == "path" + else ["/usr/bin", "/bin"] + ), + "CARGO": str(cargo), + }, + check=False, + capture_output=True, + text=True, + ) + + if expected_whitaker is None: + assert result.returncode != 0, "expected lint to fail without Whitaker" + assert "whitaker" in result.stderr.lower(), ( + "expected missing Whitaker failure to identify the missing tool" + ) + else: + assert result.returncode == 0, result.stderr + assert marker.exists(), ( + f"expected generated lint target to execute {whitaker_location} Whitaker" + ) @pytest.mark.parametrize("flavour", [LIB, APP]) @@ -89,14 +231,385 @@ def test_template_compiles( tmp_path: Path, copier: CopierFixture, flavour: str ) -> None: """Generated project compiles with cargo check.""" - project = copier.copy( + project = render_project( tmp_path, + copier, project_name="CompileExample", package_name="compile_example", - license_year=datetime.now(tz=UTC).year, - license_holder="Compile Dev", - license_email="compile@example.com", flavour=flavour, ) project.run("cargo check --all-targets --all-features") + +@pytest.mark.parametrize( + ("flavour", "dev_target"), + [ + (LIB, "x86_64-unknown-linux-gnu"), + (APP, "x86_64-unknown-linux-gnu"), + (LIB, "aarch64-apple-darwin"), + ], +) +def test_generated_tooling_contracts( + tmp_path: Path, copier: CopierFixture, flavour: str, dev_target: str +) -> None: + """Generated projects include the requested Rust tooling contracts.""" + project = render_project( + tmp_path, + copier, + project_name="ToolingExample", + package_name="tooling_example", + flavour=flavour, + dev_target=dev_target, + ) + + project.run("make all") + project.run("mbake validate Makefile") + project.run("cargo metadata --format-version=1 --no-deps") + + cargo = parse_toml_file(project / "Cargo.toml") + package = require_mapping(cargo, "package", "Cargo.toml") + metadata = require_optional_mapping(package, "metadata", "Cargo.toml package") + makefile = read_generated_text(project / "Makefile") + cargo_config = read_generated_text(project / ".cargo/config.toml") + ci_workflow = read_generated_text(project / ".github/workflows/ci.yml") + readme = read_generated_text(project / "README.md") + rust_toolchain = read_generated_text(project / "rust-toolchain.toml") + test_stub = read_generated_text(project / "tests/stub.rs") + parsed_ci_workflow = parse_yaml_mapping(ci_workflow, "CI workflow") + + release_workflow = ( + read_generated_text(project / ".github/workflows/release.yml") + if flavour == APP + else None + ) + assert_generated_tooling_contracts( + package=package, + metadata=metadata, + flavour=flavour, + makefile=makefile, + cargo_config=cargo_config, + dev_target=dev_target, + rust_toolchain=rust_toolchain, + parsed_ci_workflow=parsed_ci_workflow, + ci_workflow=ci_workflow, + readme=readme, + test_stub=test_stub, + release_workflow=release_workflow, + ) + + +def test_generated_structured_file_snapshots( + tmp_path: Path, copier: CopierFixture, snapshot: SnapshotAssertion +) -> None: + """Generated structured tooling files match reviewed snapshots.""" + project = render_project( + tmp_path, + copier, + project_name="SnapshotExample", + package_name="snapshot_example", + flavour=APP, + ) + + cargo_config = read_generated_text(project / ".cargo/config.toml") + makefile = read_generated_text(project / "Makefile") + ci_workflow = parse_yaml_mapping( + read_generated_text(project / ".github/workflows/ci.yml"), "CI workflow" + ) + release_workflow = parse_yaml_mapping( + read_generated_text(project / ".github/workflows/release.yml"), + "release workflow", + ) + + assert { + "cargo_config": cargo_config, + "makefile": makefile, + "ci_workflow": ci_workflow, + "release_workflow": release_workflow, + } == snapshot, ( + "Snapshot mismatch for template outputs " + "(cargo_config, makefile, ci_workflow, release_workflow)" + ) + + +def read_generated_text(path: Path) -> str: + """Read a generated file with assertion-focused error context.""" + try: + return path.read_text(encoding="utf-8") + except OSError as error: + pytest.fail(f"could not read generated file {path}: {error}") + + +def parse_toml_file(path: Path) -> dict[str, Any]: + """Parse generated TOML with assertion-focused error context.""" + text = read_generated_text(path) + try: + parsed = tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + pytest.fail(f"could not parse generated TOML {path}: {error}") + return parsed + + +def parse_yaml_mapping(text: str, label: str) -> dict[str, Any]: + """Parse generated YAML as a mapping with clear failure context.""" + try: + parsed = yaml.safe_load(text) + except yaml.YAMLError as error: + pytest.fail(f"could not parse generated {label}: {error}") + if not isinstance(parsed, dict): + pytest.fail(f"expected generated {label} to parse as a mapping") + return parsed + + +def require_mapping(mapping: dict[str, Any], key: str, label: str) -> dict[str, Any]: + """Return a nested mapping or fail with the missing schema path.""" + value = mapping.get(key) + if not isinstance(value, dict): + pytest.fail(f"expected {label} to include mapping key {key!r}") + return value + + +def require_optional_mapping( + mapping: dict[str, Any], key: str, label: str +) -> dict[str, Any]: + """Return an optional nested mapping or an empty mapping.""" + value = mapping.get(key, {}) + if not isinstance(value, dict): + pytest.fail(f"expected {label} key {key!r} to be a mapping when present") + return value + + +def assert_generated_tooling_contracts( + *, + package: dict[str, Any], + metadata: dict[str, Any], + flavour: str, + makefile: str, + cargo_config: str, + dev_target: str, + rust_toolchain: str, + parsed_ci_workflow: dict[str, Any], + ci_workflow: str, + readme: str, + test_stub: str, + release_workflow: str | None, +) -> None: + """Assert generated tooling contracts from a single validator.""" + _assert_cargo_package_contracts(package, metadata, flavour) + _assert_makefile_contracts(makefile) + _assert_cargo_config_contracts(cargo_config, dev_target, rust_toolchain) + _assert_ci_workflow_contracts(parsed_ci_workflow, ci_workflow, test_stub) + assert "Development builds use `mold` on Linux" in readme, ( + "expected generated README to document mold for development builds" + ) + assert "Coverage generation uses `lld`" in readme, ( + "expected generated README to document lld for coverage" + ) + if release_workflow is not None: + _assert_release_workflow_contracts(release_workflow) + + +def _assert_cargo_package_contracts( + package: dict[str, Any], metadata: dict[str, Any], flavour: str +) -> None: + """Assert generated Cargo package metadata contracts.""" + assert package.get("description") == "ToolingExample package used by template tests.", ( + "expected generated Cargo.toml to include package description" + ) + assert package.get("repository") == "https://github.com/example/tooling_example", ( + "expected generated Cargo.toml to include repository URL" + ) + assert package.get("homepage") == "https://example.com/tooling_example", ( + "expected generated Cargo.toml to include homepage URL" + ) + assert package.get("keywords") == ["rust", "template"], ( + "expected generated Cargo.toml to include package keywords" + ) + assert package.get("categories") == ["development-tools"], ( + "expected generated Cargo.toml to include package categories" + ) + assert package.get("license") == "ISC", ( + "expected generated Cargo.toml to include ISC licence" + ) + + if flavour == APP: + binstall = metadata.get("binstall") + assert isinstance(binstall, dict), ( + "expected app flavour Cargo.toml to include binstall metadata" + ) + assert ( + binstall.get("pkg-url") + == "https://github.com/example/tooling_example/releases/download/" + "v{ version }/tooling_example-{ target }{ binary-ext }" + ), "expected app flavour binstall metadata to include package URL" + assert binstall.get("pkg-fmt") == "bin", ( + "expected app flavour binstall metadata to describe binary artifacts" + ) + assert binstall.get("disabled-strategies") == ["quick-install", "compile"], ( + "expected app flavour binstall metadata to disable unsupported strategies" + ) + else: + assert "binstall" not in metadata, ( + "expected lib flavour Cargo.toml to omit binstall metadata" + ) + + +def _assert_makefile_contracts(makefile: str) -> None: + """Assert generated Makefile tooling contracts.""" + assert "TEST_CMD :=" in makefile, ( + "expected generated Makefile to define a test command fallback" + ) + assert "nextest run,test" in makefile, ( + "expected generated Makefile to fall back to cargo test without cargo-nextest" + ) + assert "$(CARGO) $(TEST_CMD)" in makefile, ( + "expected generated Makefile test target to use the selected test command" + ) + assert "coverage: ## Generate lcov coverage with lld" in makefile, ( + "expected generated Makefile to include an lld-backed coverage target" + ) + assert "COVERAGE_LINKER_FLAGS ?= -fuse-ld=lld" in makefile, ( + "expected generated Makefile coverage target to select lld" + ) + assert 'CFLAGS="$(COVERAGE_LINKER_FLAGS)"' in makefile, ( + "expected generated Makefile coverage target to set CFLAGS" + ) + assert 'LDFLAGS="$(COVERAGE_LINKER_FLAGS)"' in makefile, ( + "expected generated Makefile coverage target to set LDFLAGS" + ) + assert "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile, ( + "expected generated Makefile lint target to run Whitaker" + ) + assert 'echo "Whitaker binary: $(WHITAKER)"' in makefile, ( + "expected generated Makefile lint target to log Whitaker resolution" + ) + assert 'echo "coverage linker flags: $(COVERAGE_LINKER_FLAGS)"' in makefile, ( + "expected generated Makefile coverage target to log linker flags" + ) + + +def _assert_cargo_config_contracts( + cargo_config: str, dev_target: str, rust_toolchain: str +) -> None: + """Assert generated Cargo config and toolchain contracts.""" + assert 'codegen-backend = "cranelift"' in cargo_config, ( + "expected generated cargo config to enable Cranelift" + ) + if "linux" in dev_target: + assert f"[target.{dev_target}]" in cargo_config, ( + "expected generated cargo config to include Linux target settings" + ) + assert 'link-arg=-fuse-ld=mold' in cargo_config, ( + "expected generated cargo config to use mold linker for Linux" + ) + else: + assert f"[target.{dev_target}]" not in cargo_config, ( + "expected generated cargo config to avoid mold target blocks " + "for non-Linux targets" + ) + assert 'link-arg=-fuse-ld=mold' not in cargo_config, ( + "expected generated cargo config to avoid mold for non-Linux targets" + ) + + assert "rustc-codegen-cranelift-preview" in rust_toolchain, ( + "expected generated rust-toolchain to include Cranelift component" + ) + assert "llvm-tools-preview" in rust_toolchain, ( + "expected generated rust-toolchain to include llvm tools component" + ) + + +def _assert_ci_workflow_contracts( + parsed_ci_workflow: dict[str, Any], + ci_workflow: str, + test_stub: str, +) -> None: + """Assert generated CI workflow and adjacent documentation contracts.""" + jobs = require_mapping(parsed_ci_workflow, "jobs", "CI workflow") + checkout_steps = extract_checkout_steps(jobs) + assert checkout_steps, "expected generated CI workflow to check out sources" + assert all( + step.get("with", {}).get("persist-credentials") is False + for step in checkout_steps + ), "expected generated CI checkout steps to disable credential persistence" + + assert "Cache Whitaker installation" in ci_workflow, ( + "expected generated CI workflow to cache Whitaker installation" + ) + assert ( + "leynos/shared-actions/.github/actions/setup-rust" + "@e4c6b0e200a057edf927c45c298e7ddf229b3934" in ci_workflow + ), "expected generated CI workflow to use the pinned shared setup-rust action" + assert "cargo-nextest" in ci_workflow, ( + "expected generated CI workflow to install cargo-nextest" + ) + assert "coverage uses lld for llvm-tools compatibility" in ci_workflow, ( + "expected generated CI workflow to document mold and lld roles" + ) + assert "clang lld mold" in ci_workflow, ( + "expected generated CI workflow to install clang, lld, and mold" + ) + assert "fuse-ld=lld" in ci_workflow, ( + "expected generated CI workflow coverage to use lld linker flags" + ) + assert "CFLAGS: -fuse-ld=lld" in ci_workflow, ( + "expected generated CI workflow coverage to set CFLAGS for lld" + ) + assert "LDFLAGS: -fuse-ld=lld" in ci_workflow, ( + "expected generated CI workflow coverage to set LDFLAGS for lld" + ) + assert "Whitaker cache hit:" in ci_workflow, ( + "expected generated CI workflow to log Whitaker cache status" + ) + assert "Installing Whitaker installer at" in ci_workflow, ( + "expected generated CI workflow to log Whitaker installation" + ) + assert "Whitaker binary:" in ci_workflow, ( + "expected generated CI workflow to log Whitaker binary resolution" + ) + assert "Log coverage linker configuration" in ci_workflow, ( + "expected generated CI workflow to log coverage linker configuration" + ) + + assert "Delete this file as soon as the project has real" in test_stub, ( + "expected generated test stub to explain when to delete it" + ) + + +def _assert_release_workflow_contracts(release_workflow: str) -> None: + """Assert generated release workflow contracts.""" + parsed_release_workflow = parse_yaml_mapping(release_workflow, "release workflow") + jobs = require_mapping(parsed_release_workflow, "jobs", "release workflow") + release_checkout_steps = extract_checkout_steps(jobs) + assert release_checkout_steps, "expected app release workflow to check out sources" + assert all( + step.get("with", {}).get("persist-credentials") is False + for step in release_checkout_steps + ), "expected release workflow checkout steps to disable credentials" + assert ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + in release_workflow + ), "expected app release workflow to use pinned upload-artifact action" + assert "CROSS_REVISION: v0.2.5" in release_workflow, ( + "expected app release workflow to pin cross revision" + ) + assert "aarch64-pc-windows-gnullvm" not in release_workflow, ( + "expected app release workflow to omit unsupported cross targets" + ) + assert 'key: cross-${{ env.CROSS_REVISION }}' in release_workflow, ( + "expected app release workflow to cache cross by revision" + ) + assert "files: |" in release_workflow, ( + "expected app release workflow to upload release files" + ) + + +def extract_checkout_steps(jobs: dict[str, Any]) -> list[dict[str, Any]]: + """Return checkout steps from a parsed GitHub Actions jobs mapping.""" + return [ + step + for job in jobs.values() + if isinstance(job, dict) + for step in job.get("steps", []) + if isinstance(step, dict) + and step.get("uses", "").startswith("actions/checkout@") + ]