From aa5569ae39f92b508ceb4d374edd7a148f7e39d7 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 15:42:56 +0100 Subject: [PATCH 01/30] Document template agent workflow Add root guidance that identifies this repository as a Copier template and documents the pytest-copier test setup path. Record the living execution plan for the Rust template tooling import so future work can resume from repository evidence. --- AGENTS.md | 58 +++++++++ docs/execplans/rust-project-enhancements.md | 123 ++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 AGENTS.md create mode 100644 docs/execplans/rust-project-enhancements.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..354080e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# 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. + +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).out +``` + +Review the log before committing if the terminal output is truncated. diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md new file mode 100644 index 0000000..7d6b9c1 --- /dev/null +++ b/docs/execplans/rust-project-enhancements.md @@ -0,0 +1,123 @@ +# 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: IN PROGRESS + +## 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 instruction. + +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. +- [ ] Import generated-project Rust toolchain, linker, nextest, Whitaker, + cargo-binstall, metadata, and CI contracts. +- [ ] Replace imported GitHub Action version pins with current SHAs sourced + through Firecrawl-assisted lookup. +- [ ] Extend pytest-copier tests so rendered projects pass formatting, linting, + nextest, and generated Makefile checks through public targets. +- [ ] Run `coderabbit review --agent` after major milestones and resolve + concerns. +- [ ] Gate, commit, push, and create a 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. + +The branch currently points at the same commit as `main`, so the requested +implementation has not yet been committed. + +## 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 + +No final outcome yet. Completion requires current evidence for every requested +template feature, passing parent-template tests, `coderabbit review --agent` +with concerns resolved, committed changes, and a draft pull request. From 60063ab288d532e7b7ba42ee5891df77aa26081c Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 16:26:23 +0100 Subject: [PATCH 02/30] Import Rust template tooling Render generated Rust projects with local Cranelift codegen, Linux mold linker configuration, nextest tests, Whitaker linting, crates.io metadata prompts, and cargo-binstall metadata for app projects. Import the dear-diary CI shape with SHA-pinned actions, Whitaker caching, mold installation, and an llvm-cov lld fallback. Strengthen pytest-copier coverage so rendered app and library projects pass their public make all gate. --- copier.yaml | 36 +++++ docs/execplans/rust-project-enhancements.md | 38 +++-- template/.cargo/config.toml.jinja | 16 ++ template/.github/workflows/ci.yml | 42 +++++- ...ur == 'app' %}release.yml{% endif %}.jinja | 111 ++++++++++++++ ...vour == APP %}release.yml{% endif %}.jinja | 116 --------------- template/Cargo.toml.jinja | 15 ++ template/Makefile.jinja | 14 +- template/rust-toolchain.toml.jinja | 7 +- ...%}main.rs{% else %}lib.rs{% endif %}.jinja | 16 +- template/tests/stub.rs | 13 ++ tests/test_template.py | 138 +++++++++++++----- 12 files changed, 379 insertions(+), 183 deletions(-) create mode 100644 template/.cargo/config.toml.jinja create mode 100644 template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja delete mode 100644 template/.github/workflows/{% if flavour == APP %}release.yml{% endif %}.jinja create mode 100644 template/tests/stub.rs diff --git a/copier.yaml b/copier.yaml index a3ab7e3..1235a2a 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 @@ -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/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index 7d6b9c1..0513378 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -78,15 +78,30 @@ and record the resulting pins in the plan and pull request validation notes. patterns. - [x] 2026-05-23: Inspected `../agent-template-python` pytest-copier template testing approach. -- [ ] Import generated-project Rust toolchain, linker, nextest, Whitaker, - cargo-binstall, metadata, and CI contracts. -- [ ] Replace imported GitHub Action version pins with current SHAs sourced - through Firecrawl-assisted lookup. -- [ ] Extend pytest-copier tests so rendered projects pass formatting, linting, - nextest, and generated Makefile checks through public targets. -- [ ] Run `coderabbit review --agent` after major milestones and resolve - concerns. -- [ ] Gate, commit, push, and create a draft pull request. +- [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. +- [ ] Commit the tooling import milestone. +- [ ] Final validation, push, and create a draft pull request. ## Surprises & Discoveries @@ -100,6 +115,11 @@ file reads are used for this template repository. The branch currently points at the same commit as `main`, so the requested implementation has not yet been committed. +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 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..44d51ee 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -13,23 +13,55 @@ jobs: env: CARGO_TERM_COLOR: always BUILD_PROFILE: debug + WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - uses: actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231 - 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 + 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: 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: | + if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then + cargo install --locked \ + --git https://github.com/leynos/whitaker \ + --rev "${WHITAKER_INSTALLER_REV}" \ + whitaker-installer + fi + whitaker-installer --cranelift - name: Lint run: make lint - 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 with: output-path: lcov.info format: lcov @@ -37,7 +69,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..a14e3ba --- /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: windows + arch: aarch64 + target: aarch64-pc-windows-gnullvm + 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 + - 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, including mold linker flags from + # .cargo/config.toml, 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 + 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..dcfa4ff 100644 --- a/template/Cargo.toml.jinja +++ b/template/Cargo.toml.jinja @@ -2,9 +2,24 @@ 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' -%} +[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..9d5468d 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -5,6 +5,8 @@ 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 ?= @@ -14,9 +16,9 @@ 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) 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 @@ -27,10 +29,10 @@ clean: ## Remove build artifacts $(CARGO) clean test: ## Run tests with warnings treated as errors - RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) -ifneq ($(TEST_CMD),test) + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS) +{% 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 %} @@ -38,9 +40,7 @@ target/%/$(TARGET): ## Build binary in debug or release mode 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."; } + 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/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..7388885 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,17 @@ -{%- if flavour == 'app' -%} +{% if flavour == 'app' -%} //! `{{ project_name }}` application entry point. -// TODO: Remove this stub and implement actual application functionality. +// TODO: Remove this stub as soon as actual application functionality exists. /// Application entry point. -#[allow(clippy::print_stdout, reason = "CLI output is the intended behaviour")] +#[expect(clippy::print_stdout, reason = "CLI output is the intended behaviour")] 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/test_template.py b/tests/test_template.py index 944ef53..11f9882 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,10 +1,10 @@ from __future__ import annotations -from pathlib import Path from datetime import datetime, UTC +from pathlib import Path import pytest -from pytest_copier.plugin import CopierFixture +from pytest_copier.plugin import CopierFixture, CopierProject APP = "app" LIB = "lib" @@ -12,76 +12,100 @@ TEMPLATE_PATH = Path(__file__).parents[1] -def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: - """Template renders with default values.""" - project = copier.copy( +def render_project( + tmp_path: Path, + copier: CopierFixture, + *, + project_name: str, + package_name: str, + flavour: str = LIB, +) -> CopierProject: + """Render a generated Rust project with publishable metadata.""" + return copier.copy( tmp_path, - project_name="Example", - package_name="example", + 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_year=datetime.now(tz=UTC).year, - license_holder="Example Dev", - license_email="example@example.com", + license_holder=f"{project_name} Dev", + license_email=f"{package_name}@example.com", + flavour=flavour, + ) + + +def run_quality_gates(project: CopierProject) -> None: + """Run the generated project's public quality gate.""" + project.run("make all") + + +def read_generated_file(project: CopierProject, relative_path: str) -> str: + """Read a generated file as UTF-8.""" + return (project / relative_path).read_text(encoding="utf-8") + + +def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: + """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() assert (project / "src" / f"{LIB}.rs").exists() - project.run("cargo build") + run_quality_gates(project) 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 / ".github" / "workflows" / "release.yml").exists() + run_quality_gates(project) 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 not (project / ".github" / "workflows" / "release.yml").exists() + run_quality_gates(project) 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() 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("flavour", [LIB, APP]) @@ -89,14 +113,56 @@ 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", [LIB, APP]) +def test_generated_tooling_contracts( + tmp_path: Path, copier: CopierFixture, flavour: 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, + ) + + cargo_toml = read_generated_file(project, "Cargo.toml") + makefile = read_generated_file(project, "Makefile") + cargo_config = read_generated_file(project, ".cargo/config.toml") + ci_workflow = read_generated_file(project, ".github/workflows/ci.yml") + rust_toolchain = read_generated_file(project, "rust-toolchain.toml") + test_stub = read_generated_file(project, "tests/stub.rs") + + assert 'description = "ToolingExample package used by template tests."' in cargo_toml + assert 'repository = "https://github.com/example/tooling_example"' in cargo_toml + assert 'license = "ISC"' in cargo_toml + assert "$(CARGO) nextest run" in makefile + assert "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile + assert 'codegen-backend = "cranelift"' in cargo_config + assert "[target.x86_64-unknown-linux-gnu]" in cargo_config + assert 'link-arg=-fuse-ld=mold' in cargo_config + assert "rustc-codegen-cranelift-preview" in rust_toolchain + assert "llvm-tools-preview" in rust_toolchain + assert "Cache Whitaker installation" in ci_workflow + assert "fuse-ld=lld" in ci_workflow + assert "Delete this file as soon as the project has real" in test_stub + + if flavour == APP: + assert "[package.metadata.binstall]" in cargo_toml + release_workflow = read_generated_file(project, ".github/workflows/release.yml") + assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in release_workflow + assert "CROSS_REVISION: v0.2.5" in release_workflow + assert 'key: cross-${{ env.CROSS_REVISION }}' in release_workflow + assert "files: |" in release_workflow + else: + assert "[package.metadata.binstall]" not in cargo_toml From 5b743a97b30eceba44c97cf820ab8508ca8e4444 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 16:27:00 +0100 Subject: [PATCH 03/30] Record tooling import progress Update the living execution plan with the committed tooling milestone, CodeRabbit disposition, and final validation status before the branch is pushed for review. --- docs/execplans/rust-project-enhancements.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index 0513378..818b5d3 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -100,7 +100,8 @@ and record the resulting pins in the plan and pull request validation notes. - [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. -- [ ] Commit the tooling import milestone. +- [x] 2026-05-23: Committed the tooling import milestone as `60063ab + Import Rust template tooling`. - [ ] Final validation, push, and create a draft pull request. ## Surprises & Discoveries @@ -138,6 +139,9 @@ the parent repository. ## Outcomes & Retrospective -No final outcome yet. Completion requires current evidence for every requested -template feature, passing parent-template tests, `coderabbit review --agent` -with concerns resolved, committed changes, and a draft pull request. +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. Final completion still requires a clean post-commit validation, +push, and draft pull request. From 2993b19e427379b1800d6ccc5748b2ac934dc902 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 16:27:16 +0100 Subject: [PATCH 04/30] Record final template validation Capture the final post-milestone template test evidence in the living execution plan before pushing the branch for pull request review. --- docs/execplans/rust-project-enhancements.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index 818b5d3..175a507 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -102,7 +102,10 @@ and record the resulting pins in the plan and pull request validation notes. 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`. -- [ ] Final validation, push, and create a draft pull request. +- [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. +- [ ] Push and create a draft pull request. ## Surprises & Discoveries @@ -143,5 +146,4 @@ 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. Final completion still requires a clean post-commit validation, -push, and draft pull request. +all` gates. Final completion still requires a push and draft pull request. From 3b94065abc1ef6e8f584d9704410995759cd7584 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 16:28:48 +0100 Subject: [PATCH 05/30] Record draft PR handoff Update the living execution plan with the pushed branch and draft pull request URL so the repository captures the completed delivery state. --- docs/execplans/rust-project-enhancements.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index 175a507..590baf9 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -105,7 +105,8 @@ and record the resulting pins in the plan and pull request validation notes. - [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. -- [ ] Push and create a draft pull request. +- [x] 2026-05-23: Pushed `rust-project-enhancements` and created draft pull + request . ## Surprises & Discoveries @@ -146,4 +147,5 @@ 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. Final completion still requires a push and draft pull request. +all` gates. The branch was pushed and draft pull request + was opened for review. From ca44b421ba727d9795916d22e61b81a82be9deb9 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sat, 23 May 2026 16:34:22 +0100 Subject: [PATCH 06/30] Remove unsupported parent CI gates Drop the parent repository CI format and lint steps because the parent Makefile only exposes the supported template test target. Generated-project format and lint validation remains covered by pytest-copier rendering and make all. --- .github/workflows/ci.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab9a531..be7777b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,10 +18,6 @@ jobs: - 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 with: From a16f3e2971c762ced85fd8a22a4dec326d069334 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 03:05:18 +0100 Subject: [PATCH 07/30] Run parent template tests in CI Replace the shared coverage action in the parent repository workflow with the supported make test target. Remove the dependent CodeScene upload because the parent template test target does not produce lcov output. --- .github/workflows/ci.yml | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be7777b..86e5b93 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,18 +18,5 @@ jobs: - uses: actions/checkout@v5 - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 - - name: Test and Measure Coverage - uses: leynos/shared-actions/.github/actions/generate-coverage@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 - 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 - - with: - format: lcov - access-token: ${{ env.CS_ACCESS_TOKEN }} - installer-checksum: ${{ vars.CODESCENE_CLI_SHA256 }} + - name: Test + run: make test From 6bbe9437cac8ad8ca06901e3d15847534c319e69 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 03:26:36 +0100 Subject: [PATCH 08/30] Fix pytest-copier CI template fixture Limit pytest-copier's temporary template repository to the actual Copier inputs so CI does not copy the pull request checkout's .git directory. This avoids detached merge metadata making the fixture's synthetic git commit a no-op. --- tests/conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9692726 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,9 @@ +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"] From 6ccafb8ec6e0d4ca7befbfc12291db2d5e13dc55 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 03:39:06 +0100 Subject: [PATCH 09/30] Document pytest-copier fixture scope Add a module docstring to tests.conftest explaining why pytest-copier only copies the actual Copier template inputs and how tests consume that fixture through the normal copier fixture. --- tests/conftest.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9692726..455050c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,17 @@ from __future__ import annotations +"""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. +""" + import pytest From b57045a21caec2e02167da5a444f934945bae18a Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 03:42:26 +0100 Subject: [PATCH 10/30] Install CI template test dependencies Install mold, nextest, mbake, and Whitaker in the parent CI workflow so pytest-copier can run generated make all gates on GitHub runners. Also install nextest in rendered CI and fix the generated lib Makefile doctest recipe indentation. --- .github/workflows/ci.yml | 35 +++++++++++++++++++++++++++++++ template/.github/workflows/ci.yml | 2 ++ template/Makefile.jinja | 2 +- tests/test_template.py | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86e5b93..1f0bc6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,9 +14,44 @@ 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: 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: + 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 + if [ "${{ steps.cache-whitaker.outputs.cache-hit }}" != "true" ]; then + cargo install --locked \ + --git https://github.com/leynos/whitaker \ + --rev "${WHITAKER_INSTALLER_REV}" \ + whitaker-installer + fi + whitaker-installer --cranelift - name: Test run: make test diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index 44d51ee..c54a3a8 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -36,6 +36,8 @@ jobs: !**/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 diff --git a/template/Makefile.jinja b/template/Makefile.jinja index 9d5468d..18f07c5 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -30,7 +30,7 @@ clean: ## Remove build artifacts test: ## Run tests with warnings treated as errors RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS) -{% if flavour == 'lib' -%} +{% if flavour == 'lib' %} RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features {% endif %} diff --git a/tests/test_template.py b/tests/test_template.py index 11f9882..f2e5add 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -154,6 +154,7 @@ def test_generated_tooling_contracts( assert "rustc-codegen-cranelift-preview" in rust_toolchain assert "llvm-tools-preview" in rust_toolchain assert "Cache Whitaker installation" in ci_workflow + assert "cargo-nextest" in ci_workflow assert "fuse-ld=lld" in ci_workflow assert "Delete this file as soon as the project has real" in test_stub From 035d1cb81a1f8c0309ebc3b5ed7f6a25f5122524 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 03:57:41 +0100 Subject: [PATCH 11/30] Pin workflow actions and clarify template tests Pin parent workflow actions to full commit SHAs and use the requested shared-actions revision for the parent Rust setup action. Document the template test module and helper contracts, then add explicit failure messages to generated-project assertions. --- .github/workflows/ci.yml | 4 +- .github/workflows/delayed-pr-comment.yml | 2 +- tests/test_template.py | 175 +++++++++++++++++++---- 3 files changed, 148 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f0bc6b..be56746 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,9 @@ jobs: BUILD_PROFILE: debug WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - name: Setup Rust - uses: leynos/shared-actions/.github/actions/setup-rust@854baf3f4cb322d48ceececb22d4ea72fd4f84d0 + uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 - name: Install mold linker if: runner.os == 'Linux' run: | 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/tests/test_template.py b/tests/test_template.py index f2e5add..bb5b0cb 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,5 +1,18 @@ from __future__ import annotations +"""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 datetime import datetime, UTC from pathlib import Path @@ -20,7 +33,26 @@ def render_project( package_name: str, flavour: str = LIB, ) -> CopierProject: - """Render a generated Rust project with publishable metadata.""" + """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. + + Returns + ------- + CopierProject + Rendered project wrapper for file assertions and command execution. + """ return copier.copy( tmp_path, project_name=project_name, @@ -38,12 +70,36 @@ def render_project( def run_quality_gates(project: CopierProject) -> None: - """Run the generated project's public quality gate.""" + """Run the generated project's public quality gate. + + Parameters + ---------- + project : CopierProject + Rendered generated project whose public gate should be executed. + + Returns + ------- + None + The function returns nothing and raises if the quality gate fails. + """ project.run("make all") def read_generated_file(project: CopierProject, relative_path: str) -> str: - """Read a generated file as UTF-8.""" + """Read a generated file as UTF-8 text. + + Parameters + ---------- + project : CopierProject + Rendered generated project that contains the file. + relative_path : str + Path to read relative to the generated project root. + + Returns + ------- + str + UTF-8 decoded contents of the generated file. + """ return (project / relative_path).read_text(encoding="utf-8") @@ -52,8 +108,12 @@ def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: project = render_project( tmp_path, copier, project_name="Example", package_name="example" ) - assert (project / "Cargo.toml").exists() - assert (project / "src" / f"{LIB}.rs").exists() + assert (project / "Cargo.toml").exists(), ( + "expected Cargo.toml to exist in generated project" + ) + assert (project / "src" / f"{LIB}.rs").exists(), ( + f"expected src/{LIB}.rs to exist in generated project" + ) run_quality_gates(project) @@ -66,8 +126,12 @@ def test_template_renders_app_flavour(tmp_path: Path, copier: CopierFixture) -> package_name="app_example", flavour=APP, ) - assert (project / "src" / "main.rs").exists() - assert (project / ".github" / "workflows" / "release.yml").exists() + 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" + ) run_quality_gates(project) @@ -80,8 +144,12 @@ def test_template_renders_lib_flavour(tmp_path: Path, copier: CopierFixture) -> package_name="lib_example", flavour=LIB, ) - assert (project / "src" / "lib.rs").exists() - assert not (project / ".github" / "workflows" / "release.yml").exists() + 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" + ) run_quality_gates(project) @@ -93,7 +161,9 @@ def test_makefile_validates(tmp_path: Path, copier: CopierFixture) -> None: project_name="MakefileExample", package_name="makefile_example", ) - assert (project / "Makefile").exists() + assert (project / "Makefile").exists(), ( + "expected generated project Makefile to exist" + ) project.run("mbake validate Makefile") @@ -143,27 +213,72 @@ def test_generated_tooling_contracts( rust_toolchain = read_generated_file(project, "rust-toolchain.toml") test_stub = read_generated_file(project, "tests/stub.rs") - assert 'description = "ToolingExample package used by template tests."' in cargo_toml - assert 'repository = "https://github.com/example/tooling_example"' in cargo_toml - assert 'license = "ISC"' in cargo_toml - assert "$(CARGO) nextest run" in makefile - assert "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile - assert 'codegen-backend = "cranelift"' in cargo_config - assert "[target.x86_64-unknown-linux-gnu]" in cargo_config - assert 'link-arg=-fuse-ld=mold' in cargo_config - assert "rustc-codegen-cranelift-preview" in rust_toolchain - assert "llvm-tools-preview" in rust_toolchain - assert "Cache Whitaker installation" in ci_workflow - assert "cargo-nextest" in ci_workflow - assert "fuse-ld=lld" in ci_workflow - assert "Delete this file as soon as the project has real" in test_stub + assert 'description = "ToolingExample package used by template tests."' in cargo_toml, ( + "expected generated Cargo.toml to include package description" + ) + assert 'repository = "https://github.com/example/tooling_example"' in cargo_toml, ( + "expected generated Cargo.toml to include repository URL" + ) + assert 'license = "ISC"' in cargo_toml, ( + "expected generated Cargo.toml to include ISC licence" + ) + assert "$(CARGO) nextest run" in makefile, ( + "expected generated Makefile to run tests with cargo-nextest" + ) + assert "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile, ( + "expected generated Makefile lint target to run Whitaker" + ) + assert 'codegen-backend = "cranelift"' in cargo_config, ( + "expected generated cargo config to enable Cranelift" + ) + assert "[target.x86_64-unknown-linux-gnu]" 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" + ) + 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" + ) + 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 "fuse-ld=lld" in ci_workflow, ( + "expected generated CI workflow coverage to use lld linker flags" + ) + assert "Delete this file as soon as the project has real" in test_stub, ( + "expected generated test stub to explain when to delete it" + ) if flavour == APP: - assert "[package.metadata.binstall]" in cargo_toml + assert "[package.metadata.binstall]" in cargo_toml, ( + "expected app flavour Cargo.toml to include binstall metadata" + ) release_workflow = read_generated_file(project, ".github/workflows/release.yml") - assert "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in release_workflow - assert "CROSS_REVISION: v0.2.5" in release_workflow - assert 'key: cross-${{ env.CROSS_REVISION }}' in release_workflow - assert "files: |" in release_workflow + 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 '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" + ) else: - assert "[package.metadata.binstall]" not in cargo_toml + assert "[package.metadata.binstall]" not in cargo_toml, ( + "expected lib flavour Cargo.toml to omit binstall metadata" + ) From da72c32af7c3719cb343663d685a0615a157f3c9 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:11:26 +0100 Subject: [PATCH 12/30] Restore generated test fallback coverage Restore the generated Makefile fallback to `cargo test` when `cargo-nextest` is unavailable, and assert the fallback contract in the template tests. Parse generated `Cargo.toml` as TOML so metadata prompts and app binstall fields are covered by exact structured assertions. --- docs/execplans/rust-project-enhancements.md | 2 +- template/Makefile.jinja | 3 +- tests/test_template.py | 45 +++++++++++++++++---- 3 files changed, 41 insertions(+), 9 deletions(-) diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index 590baf9..e39c81d 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -28,7 +28,7 @@ real functionality replaces it. 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 instruction. +repository instructions. Use the current worktree and the sibling repositories as authoritative source inputs. The relevant imports are `../dear-diary/.cargo/config.toml`, diff --git a/template/Makefile.jinja b/template/Makefile.jinja index 18f07c5..e781a33 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -16,6 +16,7 @@ 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) MDLINT ?= markdownlint-cli2 NIXIE ?= nixie WHITAKER ?= $(or $(shell command -v whitaker 2>/dev/null),$(wildcard $(USER_WHITAKER)),whitaker) @@ -29,7 +30,7 @@ clean: ## Remove build artifacts $(CARGO) clean test: ## Run tests with warnings treated as errors - RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) nextest run $(TEST_FLAGS) $(BUILD_JOBS) + RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) $(TEST_CMD) $(TEST_FLAGS) $(BUILD_JOBS) {% if flavour == 'lib' %} RUSTFLAGS="$(RUST_FLAGS)" $(CARGO) test --doc --workspace --all-features {% endif %} diff --git a/tests/test_template.py b/tests/test_template.py index bb5b0cb..b514fd6 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -13,6 +13,7 @@ contract checks. """ +import tomllib from datetime import datetime, UTC from pathlib import Path @@ -207,23 +208,41 @@ def test_generated_tooling_contracts( ) cargo_toml = read_generated_file(project, "Cargo.toml") + cargo = tomllib.loads(cargo_toml) + package = cargo["package"] + metadata = package.get("metadata", {}) makefile = read_generated_file(project, "Makefile") cargo_config = read_generated_file(project, ".cargo/config.toml") ci_workflow = read_generated_file(project, ".github/workflows/ci.yml") rust_toolchain = read_generated_file(project, "rust-toolchain.toml") test_stub = read_generated_file(project, "tests/stub.rs") - assert 'description = "ToolingExample package used by template tests."' in cargo_toml, ( + assert package["description"] == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" ) - assert 'repository = "https://github.com/example/tooling_example"' in cargo_toml, ( + assert package["repository"] == "https://github.com/example/tooling_example", ( "expected generated Cargo.toml to include repository URL" ) - assert 'license = "ISC"' in cargo_toml, ( + assert package["homepage"] == "https://example.com/tooling_example", ( + "expected generated Cargo.toml to include homepage URL" + ) + assert package["keywords"] == ["rust", "template"], ( + "expected generated Cargo.toml to include package keywords" + ) + assert package["categories"] == ["development-tools"], ( + "expected generated Cargo.toml to include package categories" + ) + assert package["license"] == "ISC", ( "expected generated Cargo.toml to include ISC licence" ) - assert "$(CARGO) nextest run" in makefile, ( - "expected generated Makefile to run tests with cargo-nextest" + 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 "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile, ( "expected generated Makefile lint target to run Whitaker" @@ -261,9 +280,21 @@ def test_generated_tooling_contracts( ) if flavour == APP: - assert "[package.metadata.binstall]" in cargo_toml, ( + binstall = metadata.get("binstall") + assert binstall is not None, ( "expected app flavour Cargo.toml to include binstall metadata" ) + assert ( + binstall["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["pkg-fmt"] == "bin", ( + "expected app flavour binstall metadata to describe binary artifacts" + ) + assert binstall["disabled-strategies"] == ["quick-install", "compile"], ( + "expected app flavour binstall metadata to disable unsupported strategies" + ) release_workflow = read_generated_file(project, ".github/workflows/release.yml") assert ( "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" @@ -279,6 +310,6 @@ def test_generated_tooling_contracts( "expected app release workflow to upload release files" ) else: - assert "[package.metadata.binstall]" not in cargo_toml, ( + assert "binstall" not in metadata, ( "expected lib flavour Cargo.toml to omit binstall metadata" ) From 2ee870fe2820ea5c9297d8d07922b93dc9c29e48 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:12:52 +0100 Subject: [PATCH 13/30] Document binstall template placeholders Explain that the generated `pkg-url` combines Copier-rendered Jinja2 values with cargo-binstall placeholders expanded later during install. --- template/Cargo.toml.jinja | 3 +++ 1 file changed, 3 insertions(+) diff --git a/template/Cargo.toml.jinja b/template/Cargo.toml.jinja index dcfa4ff..2280795 100644 --- a/template/Cargo.toml.jinja +++ b/template/Cargo.toml.jinja @@ -13,6 +13,9 @@ categories = [{% for category in package_categories.split(',') %}"{{ category.st [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" From 23a38b3f6b1993b989f046cbf52bb5f69d814190 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:15:26 +0100 Subject: [PATCH 14/30] Clarify generated coverage linker policy Document that generated Linux development builds use mold while coverage uses lld for LLVM tooling compatibility. Add a generated Makefile coverage target that sets RUSTFLAGS, CFLAGS, and LDFLAGS to use lld, and assert those contracts in the template tests. --- template/.github/workflows/ci.yml | 3 +++ template/Makefile.jinja | 11 ++++++++++- template/README.md.jinja | 18 ++++++++++++++++++ tests/test_template.py | 31 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index c54a3a8..e87dd82 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: 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 @@ -64,6 +65,8 @@ jobs: 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 diff --git a/template/Makefile.jinja b/template/Makefile.jinja index e781a33..de00798 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -1,4 +1,4 @@ -.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 }} @@ -17,6 +17,8 @@ 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) @@ -38,6 +40,13 @@ test: ## Run tests with warnings treated as errors 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 + 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) 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/tests/test_template.py b/tests/test_template.py index b514fd6..f12788d 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -214,6 +214,7 @@ def test_generated_tooling_contracts( makefile = read_generated_file(project, "Makefile") cargo_config = read_generated_file(project, ".cargo/config.toml") ci_workflow = read_generated_file(project, ".github/workflows/ci.yml") + readme = read_generated_file(project, "README.md") rust_toolchain = read_generated_file(project, "rust-toolchain.toml") test_stub = read_generated_file(project, "tests/stub.rs") @@ -244,6 +245,18 @@ def test_generated_tooling_contracts( 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" ) @@ -272,9 +285,27 @@ def test_generated_tooling_contracts( 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 "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" + ) assert "Delete this file as soon as the project has real" in test_stub, ( "expected generated test stub to explain when to delete it" ) From 26485579fee4525821c4b58ff09f02ff6093ae26 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:16:48 +0100 Subject: [PATCH 15/30] Update ExecPlan branch status note Replace the stale note saying the branch matched main with a current statement that the branch changes have been committed and pushed. --- docs/execplans/rust-project-enhancements.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/execplans/rust-project-enhancements.md b/docs/execplans/rust-project-enhancements.md index e39c81d..f093066 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -117,8 +117,7 @@ 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. -The branch currently points at the same commit as `main`, so the requested -implementation has not yet been committed. +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 From ed8a0265c57b07bda3b91dd787511fe4a7c0e292 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:17:54 +0100 Subject: [PATCH 16/30] Fix AGENTS test log command example Replace the invalid `git branch --show` option in the documented tee pipeline with `git branch --show-current` so the example can run. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 354080e..a9567fc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ 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).out +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. From 2d2e8a87b1725685b4463dc4c1caeee92b4b448e Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:19:35 +0100 Subject: [PATCH 17/30] Remove unsupported Windows ARM release target Drop `aarch64-pc-windows-gnullvm` from the generated app release matrix because the cross supported-target table does not list it. Add a rendered workflow assertion so unsupported cross targets are not reintroduced silently. --- .../{% if flavour == 'app' %}release.yml{% endif %}.jinja | 4 ---- tests/test_template.py | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja index a14e3ba..391e9a5 100644 --- a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja @@ -29,10 +29,6 @@ jobs: arch: x86_64 target: x86_64-pc-windows-gnu ext: ".exe" - - os: windows - arch: aarch64 - target: aarch64-pc-windows-gnullvm - ext: ".exe" - os: macos arch: x86_64 target: x86_64-apple-darwin diff --git a/tests/test_template.py b/tests/test_template.py index f12788d..6ebe682 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -334,6 +334,9 @@ def test_generated_tooling_contracts( 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" ) From f4f7b43e66f3d50c72b835909f358c9510e952f8 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 04:20:46 +0100 Subject: [PATCH 18/30] Disable parent checkout credential persistence Set `persist-credentials: false` on the pinned parent CI checkout step so GitHub credentials are not left in the workspace after checkout. --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be56746..f168884 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,8 @@ jobs: WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + with: + persist-credentials: false - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 - name: Install mold linker From 499742822b62a9b4cee522bbdc0ec6e2bd796e36 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 16:10:05 +0100 Subject: [PATCH 19/30] Address template review check failures Make module docstrings detectable, expose the template test licence year as an explicit input, and remove trivial test helper wrappers. Strengthen the generated tooling contract test with generated command validation and add user and developer guides for the new template tooling. --- AGENTS.md | 3 + README.md | 10 +++ docs/developers-guide.md | 50 +++++++++++++++ docs/execplans/rust-project-enhancements.md | 2 +- docs/users-guide.md | 50 +++++++++++++++ tests/conftest.py | 4 +- tests/test_template.py | 71 +++++++-------------- 7 files changed, 138 insertions(+), 52 deletions(-) create mode 100644 docs/developers-guide.md create mode 100644 docs/users-guide.md diff --git a/AGENTS.md b/AGENTS.md index a9567fc..772d6c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,9 @@ 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 diff --git a/README.md b/README.md index 9088980..d832ce6 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 an 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/docs/developers-guide.md b/docs/developers-guide.md new file mode 100644 index 0000000..816d30f --- /dev/null +++ b/docs/developers-guide.md @@ -0,0 +1,50 @@ +# 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 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. +- 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 index f093066..16ec751 100644 --- a/docs/execplans/rust-project-enhancements.md +++ b/docs/execplans/rust-project-enhancements.md @@ -5,7 +5,7 @@ This ExecPlan (execution plan) is a living document. The sections `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. -Status: IN PROGRESS +Status: COMPLETE ## Purpose / Big Picture diff --git a/docs/users-guide.md b/docs/users-guide.md new file mode 100644 index 0000000..8340479 --- /dev/null +++ b/docs/users-guide.md @@ -0,0 +1,50 @@ +# 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`: + +- `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. +- `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/tests/conftest.py b/tests/conftest.py index 455050c..42dc01e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """Pytest fixtures for configuring pytest-copier template selection. This module narrows pytest-copier's temporary template repository to the real @@ -12,6 +10,8 @@ these template paths. """ +from __future__ import annotations + import pytest diff --git a/tests/test_template.py b/tests/test_template.py index 6ebe682..b006f0d 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -1,5 +1,3 @@ -from __future__ import annotations - """Template rendering tests for the Rust Copier project. This module verifies that the template renders useful Rust library and @@ -13,8 +11,9 @@ contract checks. """ +from __future__ import annotations + import tomllib -from datetime import datetime, UTC from pathlib import Path import pytest @@ -33,6 +32,7 @@ def render_project( project_name: str, package_name: str, flavour: str = LIB, + license_year: int = 2026, ) -> CopierProject: """Render a generated Rust project with publishable metadata. @@ -48,6 +48,8 @@ def render_project( Rust package name supplied to the Copier template. flavour : str, default=LIB Generated project flavour to render. + license_year : int, default=2026 + Licence year supplied to the Copier template. Returns ------- @@ -63,47 +65,13 @@ def render_project( homepage_url=f"https://example.com/{package_name}", package_keywords="rust,template", package_categories="development-tools", - license_year=datetime.now(tz=UTC).year, + license_year=license_year, license_holder=f"{project_name} Dev", license_email=f"{package_name}@example.com", flavour=flavour, ) -def run_quality_gates(project: CopierProject) -> None: - """Run the generated project's public quality gate. - - Parameters - ---------- - project : CopierProject - Rendered generated project whose public gate should be executed. - - Returns - ------- - None - The function returns nothing and raises if the quality gate fails. - """ - project.run("make all") - - -def read_generated_file(project: CopierProject, relative_path: str) -> str: - """Read a generated file as UTF-8 text. - - Parameters - ---------- - project : CopierProject - Rendered generated project that contains the file. - relative_path : str - Path to read relative to the generated project root. - - Returns - ------- - str - UTF-8 decoded contents of the generated file. - """ - return (project / relative_path).read_text(encoding="utf-8") - - def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: """Template renders with default values and passes public gates.""" project = render_project( @@ -115,7 +83,7 @@ def test_template_renders(tmp_path: Path, copier: CopierFixture) -> None: assert (project / "src" / f"{LIB}.rs").exists(), ( f"expected src/{LIB}.rs to exist in generated project" ) - run_quality_gates(project) + project.run("make all") def test_template_renders_app_flavour(tmp_path: Path, copier: CopierFixture) -> None: @@ -133,7 +101,7 @@ def test_template_renders_app_flavour(tmp_path: Path, copier: CopierFixture) -> assert (project / ".github" / "workflows" / "release.yml").exists(), ( "expected release workflow to exist for app flavour" ) - run_quality_gates(project) + project.run("make all") def test_template_renders_lib_flavour(tmp_path: Path, copier: CopierFixture) -> None: @@ -151,7 +119,7 @@ def test_template_renders_lib_flavour(tmp_path: Path, copier: CopierFixture) -> assert not (project / ".github" / "workflows" / "release.yml").exists(), ( "expected release workflow to be omitted for lib flavour" ) - run_quality_gates(project) + project.run("make all") def test_makefile_validates(tmp_path: Path, copier: CopierFixture) -> None: @@ -207,16 +175,19 @@ def test_generated_tooling_contracts( flavour=flavour, ) - cargo_toml = read_generated_file(project, "Cargo.toml") + project.run("mbake validate Makefile") + project.run("cargo metadata --format-version=1 --no-deps") + + cargo_toml = (project / "Cargo.toml").read_text(encoding="utf-8") cargo = tomllib.loads(cargo_toml) package = cargo["package"] metadata = package.get("metadata", {}) - makefile = read_generated_file(project, "Makefile") - cargo_config = read_generated_file(project, ".cargo/config.toml") - ci_workflow = read_generated_file(project, ".github/workflows/ci.yml") - readme = read_generated_file(project, "README.md") - rust_toolchain = read_generated_file(project, "rust-toolchain.toml") - test_stub = read_generated_file(project, "tests/stub.rs") + makefile = (project / "Makefile").read_text(encoding="utf-8") + cargo_config = (project / ".cargo/config.toml").read_text(encoding="utf-8") + ci_workflow = (project / ".github/workflows/ci.yml").read_text(encoding="utf-8") + readme = (project / "README.md").read_text(encoding="utf-8") + rust_toolchain = (project / "rust-toolchain.toml").read_text(encoding="utf-8") + test_stub = (project / "tests/stub.rs").read_text(encoding="utf-8") assert package["description"] == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" @@ -326,7 +297,9 @@ def test_generated_tooling_contracts( assert binstall["disabled-strategies"] == ["quick-install", "compile"], ( "expected app flavour binstall metadata to disable unsupported strategies" ) - release_workflow = read_generated_file(project, ".github/workflows/release.yml") + release_workflow = (project / ".github/workflows/release.yml").read_text( + encoding="utf-8" + ) assert ( "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" in release_workflow From 4369d14a6ee7c1fb9c7371ac9cf5eab63f48adee Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Sun, 24 May 2026 17:09:47 +0100 Subject: [PATCH 20/30] Harden generated template contracts Anonymize Copier licence defaults and disable credential persistence in generated checkout steps. Exercise rendered tooling contracts through `make all` and parse rendered GitHub Actions workflows so tests validate executable generated output, not only raw substrings. --- Makefile | 2 +- copier.yaml | 4 +- docs/developers-guide.md | 9 +- template/.github/workflows/ci.yml | 2 + ...ur == 'app' %}release.yml{% endif %}.jinja | 2 + tests/test_template.py | 100 +++++++++++++----- 6 files changed, 88 insertions(+), 31 deletions(-) diff --git a/Makefile b/Makefile index 14cf8cc..f6759a5 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 pytest tests/ help: ## Show available targets @grep -E '^[a-zA-Z_-]+:.*?##' $(MAKEFILE_LIST) | \ diff --git a/copier.yaml b/copier.yaml index 1235a2a..b3109b2 100644 --- a/copier.yaml +++ b/copier.yaml @@ -72,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' diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 816d30f..005649a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -12,10 +12,10 @@ Run the public parent gate: make test ``` -The target uses `uvx --with pytest-copier 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 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 @@ -28,6 +28,7 @@ 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. - 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. diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index e87dd82..64b333f 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -16,6 +16,8 @@ jobs: WHITAKER_INSTALLER_REV: f768c2e53c47df13658af1168a67851d388750bf steps: - uses: actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231 + with: + persist-credentials: false - name: Setup Rust uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 - name: Install mold linker diff --git a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja index 391e9a5..03fd9e8 100644 --- a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja @@ -43,6 +43,8 @@ jobs: ext: "" steps: - uses: actions/checkout@900f2210b1d28bbbd0bd22d17926b9e224e8f231 + with: + persist-credentials: false - uses: leynos/shared-actions/.github/actions/setup-rust@e4c6b0e200a057edf927c45c298e7ddf229b3934 with: toolchain: stable diff --git a/tests/test_template.py b/tests/test_template.py index b006f0d..c3b0f19 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -17,6 +17,7 @@ from pathlib import Path import pytest +import yaml from pytest_copier.plugin import CopierFixture, CopierProject APP = "app" @@ -32,7 +33,8 @@ def render_project( project_name: str, package_name: str, flavour: str = LIB, - license_year: int = 2026, + license_year: int | None = 2026, + dev_target: str = "x86_64-unknown-linux-gnu", ) -> CopierProject: """Render a generated Rust project with publishable metadata. @@ -48,28 +50,33 @@ def render_project( Rust package name supplied to the Copier template. flavour : str, default=LIB Generated project flavour to render. - license_year : int, default=2026 + 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. """ - return copier.copy( - tmp_path, - 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_year=license_year, - license_holder=f"{project_name} Dev", - license_email=f"{package_name}@example.com", - flavour=flavour, - ) + 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: @@ -162,9 +169,16 @@ def test_template_compiles( project.run("cargo check --all-targets --all-features") -@pytest.mark.parametrize("flavour", [LIB, APP]) +@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 + tmp_path: Path, copier: CopierFixture, flavour: str, dev_target: str ) -> None: """Generated projects include the requested Rust tooling contracts.""" project = render_project( @@ -173,8 +187,10 @@ def test_generated_tooling_contracts( 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") @@ -188,6 +204,19 @@ def test_generated_tooling_contracts( readme = (project / "README.md").read_text(encoding="utf-8") rust_toolchain = (project / "rust-toolchain.toml").read_text(encoding="utf-8") test_stub = (project / "tests/stub.rs").read_text(encoding="utf-8") + parsed_ci_workflow = yaml.safe_load(ci_workflow) + + checkout_steps = [ + step + for job in parsed_ci_workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + 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 package["description"] == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" @@ -234,12 +263,21 @@ def test_generated_tooling_contracts( assert 'codegen-backend = "cranelift"' in cargo_config, ( "expected generated cargo config to enable Cranelift" ) - assert "[target.x86_64-unknown-linux-gnu]" 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" - ) + 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" ) @@ -300,6 +338,20 @@ def test_generated_tooling_contracts( release_workflow = (project / ".github/workflows/release.yml").read_text( encoding="utf-8" ) + parsed_release_workflow = yaml.safe_load(release_workflow) + release_checkout_steps = [ + step + for job in parsed_release_workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + 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 From 54b91a2d49b8f9b66f87ffe1bb26c30129ec37d6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 24 May 2026 19:43:16 +0200 Subject: [PATCH 21/30] Test generated Whitaker fallback resolution Assert that the generated lint target resolves Whitaker from PATH and from the documented user-local installation path. This keeps the review fix focused on the remaining behavioural coverage gap without changing generated project behaviour. --- tests/test_template.py | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/test_template.py b/tests/test_template.py index c3b0f19..b53fbcc 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -13,6 +13,8 @@ from __future__ import annotations +import os +import subprocess import tomllib from pathlib import Path @@ -154,6 +156,62 @@ def test_clippy_runs(tmp_path: Path, copier: CopierFixture) -> None: project.run("make lint") +@pytest.mark.parametrize( + ("path_has_whitaker", "expected_location"), + [ + (True, "path"), + (False, "home"), + ], +) +def test_makefile_resolves_whitaker_fallback( + tmp_path: Path, + copier: CopierFixture, + path_has_whitaker: bool, + expected_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" + path_bin.mkdir(parents=True) + user_bin.mkdir(parents=True) + + expected_whitaker = ( + path_bin / "whitaker" + if path_has_whitaker + else user_bin / "whitaker" + ) + expected_whitaker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + expected_whitaker.chmod(0o755) + + result = subprocess.run( + ["make", "--dry-run", "lint"], + cwd=project.path, + env={ + **os.environ, + "HOME": str(home), + "PATH": ( + f"{path_bin}:/usr/bin:/bin" + if path_has_whitaker + else "/usr/bin:/bin" + ), + }, + check=True, + capture_output=True, + text=True, + ) + + assert str(expected_whitaker) in result.stdout, ( + f"expected generated lint target to use {expected_location} Whitaker" + ) + + @pytest.mark.parametrize("flavour", [LIB, APP]) def test_template_compiles( tmp_path: Path, copier: CopierFixture, flavour: str From 08d47ad74ba214543459863484fd6761bd091616 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 24 May 2026 20:08:32 +0200 Subject: [PATCH 22/30] Tighten generated tooling contract tests Resolve the generated Makefile dry-run through an absolute make path, construct synthetic PATH values portably, and split the large tooling contract test into focused assertion helpers. Also fix the README article before lld-backed. --- README.md | 2 +- tests/test_template.py | 183 ++++++++++++++++++++++++++--------------- 2 files changed, 116 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index d832ce6..7d563df 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The template requires **Copier 9.0** or later to avoid incompatibilities. 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 an lld-backed coverage target. + 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 diff --git a/tests/test_template.py b/tests/test_template.py index b53fbcc..9a30265 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import shutil import subprocess import tomllib from pathlib import Path @@ -189,17 +190,19 @@ def test_makefile_resolves_whitaker_fallback( ) expected_whitaker.write_text("#!/bin/sh\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", "--dry-run", "lint"], + [make, "--dry-run", "lint"], cwd=project.path, env={ **os.environ, "HOME": str(home), - "PATH": ( - f"{path_bin}:/usr/bin:/bin" + "PATH": os.pathsep.join( + [str(path_bin), "/usr/bin", "/bin"] if path_has_whitaker - else "/usr/bin:/bin" + else ["/usr/bin", "/bin"] ), }, check=True, @@ -264,18 +267,29 @@ def test_generated_tooling_contracts( test_stub = (project / "tests/stub.rs").read_text(encoding="utf-8") parsed_ci_workflow = yaml.safe_load(ci_workflow) - checkout_steps = [ - step - for job in parsed_ci_workflow["jobs"].values() - for step in job["steps"] - if step.get("uses", "").startswith("actions/checkout@") - ] - 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_cargo_contracts(package, metadata, flavour) + assert_makefile_contracts(makefile) + assert_cargo_config_contracts(cargo_config, dev_target) + assert_toolchain_contracts(rust_toolchain) + assert_ci_workflow_contracts(parsed_ci_workflow, ci_workflow) + assert_readme_contracts(readme) + assert_test_stub_contracts(test_stub) + + if flavour == APP: + release_workflow = (project / ".github/workflows/release.yml").read_text( + encoding="utf-8" + ) + assert_release_workflow_contracts(release_workflow) + else: + assert "binstall" not in metadata, ( + "expected lib flavour Cargo.toml to omit binstall metadata" + ) + +def assert_cargo_contracts( + package: dict[str, object], metadata: dict[str, object], flavour: str +) -> None: + """Assert generated Cargo package metadata contracts.""" assert package["description"] == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" ) @@ -294,6 +308,27 @@ def test_generated_tooling_contracts( assert package["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["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["pkg-fmt"] == "bin", ( + "expected app flavour binstall metadata to describe binary artifacts" + ) + assert binstall["disabled-strategies"] == ["quick-install", "compile"], ( + "expected app flavour binstall metadata to disable unsupported strategies" + ) + + +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" ) @@ -318,6 +353,10 @@ def test_generated_tooling_contracts( assert "$(WHITAKER) --all -- $(CARGO_FLAGS)" in makefile, ( "expected generated Makefile lint target to run Whitaker" ) + + +def assert_cargo_config_contracts(cargo_config: str, dev_target: str) -> None: + """Assert generated Cargo config linker contracts.""" assert 'codegen-backend = "cranelift"' in cargo_config, ( "expected generated cargo config to enable Cranelift" ) @@ -336,12 +375,34 @@ def test_generated_tooling_contracts( assert 'link-arg=-fuse-ld=mold' not in cargo_config, ( "expected generated cargo config to avoid mold for non-Linux targets" ) + + +def assert_toolchain_contracts(rust_toolchain: str) -> None: + """Assert generated Rust toolchain component contracts.""" 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, object], ci_workflow: str +) -> None: + """Assert generated CI workflow contracts.""" + checkout_steps = [ + step + for job in parsed_ci_workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + 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" ) @@ -367,66 +428,52 @@ def test_generated_tooling_contracts( assert "LDFLAGS: -fuse-ld=lld" in ci_workflow, ( "expected generated CI workflow coverage to set LDFLAGS for lld" ) + + +def assert_readme_contracts(readme: str) -> None: + """Assert generated README tooling notes.""" 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" ) + + +def assert_test_stub_contracts(test_stub: str) -> None: + """Assert generated test stub guidance.""" assert "Delete this file as soon as the project has real" in test_stub, ( "expected generated test stub to explain when to delete it" ) - if flavour == APP: - binstall = metadata.get("binstall") - assert binstall is not None, ( - "expected app flavour Cargo.toml to include binstall metadata" - ) - assert ( - binstall["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["pkg-fmt"] == "bin", ( - "expected app flavour binstall metadata to describe binary artifacts" - ) - assert binstall["disabled-strategies"] == ["quick-install", "compile"], ( - "expected app flavour binstall metadata to disable unsupported strategies" - ) - release_workflow = (project / ".github/workflows/release.yml").read_text( - encoding="utf-8" - ) - parsed_release_workflow = yaml.safe_load(release_workflow) - release_checkout_steps = [ - step - for job in parsed_release_workflow["jobs"].values() - for step in job["steps"] - if step.get("uses", "").startswith("actions/checkout@") - ] - 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" - ) - else: - assert "binstall" not in metadata, ( - "expected lib flavour Cargo.toml to omit binstall metadata" - ) + +def assert_release_workflow_contracts(release_workflow: str) -> None: + """Assert generated release workflow contracts.""" + parsed_release_workflow = yaml.safe_load(release_workflow) + release_checkout_steps = [ + step + for job in parsed_release_workflow["jobs"].values() + for step in job["steps"] + if step.get("uses", "").startswith("actions/checkout@") + ] + 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" + ) From 85f4c1d6becd11878c85bb3c15545025acadd164 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 00:55:37 +0200 Subject: [PATCH 23/30] Clarify Copier prompt meanings in user guide --- docs/users-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/users-guide.md b/docs/users-guide.md index 8340479..5ce8745 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -9,6 +9,8 @@ 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. @@ -16,6 +18,7 @@ metadata used in the generated `Cargo.toml`: - `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`. From a4369913fd5952bf932276c5e41b4c85eae6539a Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 00:59:27 +0200 Subject: [PATCH 24/30] Harden generated tooling verification Add explicit parsing context for generated files, exercise Whitaker fallback behaviour through real Makefile execution, and snapshot generated structured tooling files. Log Whitaker and coverage linker diagnostics in generated workflows and Makefiles, and document the new snapshot test dependency. --- Makefile | 2 +- docs/developers-guide.md | 1 + requirements.txt | 1 + template/.github/workflows/ci.yml | 10 + template/Makefile.jinja | 2 + tests/__snapshots__/test_template.ambr | 405 +++++++++++++++++++++++++ tests/test_template.py | 218 +++++++++---- 7 files changed, 583 insertions(+), 56 deletions(-) create mode 100644 tests/__snapshots__/test_template.ambr diff --git a/Makefile b/Makefile index f6759a5..e530c77 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ .PHONY: test test: ## Run template tests - uvx --with pytest-copier --with pyyaml 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/docs/developers-guide.md b/docs/developers-guide.md index 005649a..2563e87 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -29,6 +29,7 @@ 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. 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/.github/workflows/ci.yml b/template/.github/workflows/ci.yml index 64b333f..b9c136f 100644 --- a/template/.github/workflows/ci.yml +++ b/template/.github/workflows/ci.yml @@ -53,15 +53,25 @@ jobs: 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@e4c6b0e200a057edf927c45c298e7ddf229b3934 env: diff --git a/template/Makefile.jinja b/template/Makefile.jinja index de00798..a7ab038 100644 --- a/template/Makefile.jinja +++ b/template/Makefile.jinja @@ -41,6 +41,7 @@ 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)" \ @@ -50,6 +51,7 @@ coverage: ## Generate lcov coverage with lld for llvm-tools compatibility 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 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/test_template.py b/tests/test_template.py index 9a30265..236929c 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -18,10 +18,12 @@ import subprocess import tomllib from pathlib import Path +from typing import Any import pytest import yaml from pytest_copier.plugin import CopierFixture, CopierProject +from syrupy.assertion import SnapshotAssertion APP = "app" LIB = "lib" @@ -157,18 +159,11 @@ def test_clippy_runs(tmp_path: Path, copier: CopierFixture) -> None: project.run("make lint") -@pytest.mark.parametrize( - ("path_has_whitaker", "expected_location"), - [ - (True, "path"), - (False, "home"), - ], -) +@pytest.mark.parametrize("whitaker_location", ["path", "home", "missing"]) def test_makefile_resolves_whitaker_fallback( tmp_path: Path, copier: CopierFixture, - path_has_whitaker: bool, - expected_location: str, + whitaker_location: str, ) -> None: """Generated lint target resolves Whitaker from PATH or user install.""" project = render_project( @@ -180,39 +175,54 @@ def test_makefile_resolves_whitaker_fallback( 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) - - expected_whitaker = ( - path_bin / "whitaker" - if path_has_whitaker - else user_bin / "whitaker" - ) - expected_whitaker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - expected_whitaker.chmod(0o755) + 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, "--dry-run", "lint"], + [make, "lint"], cwd=project.path, env={ **os.environ, "HOME": str(home), "PATH": os.pathsep.join( [str(path_bin), "/usr/bin", "/bin"] - if path_has_whitaker + if whitaker_location == "path" else ["/usr/bin", "/bin"] ), + "CARGO": str(cargo), }, - check=True, capture_output=True, text=True, ) - assert str(expected_whitaker) in result.stdout, ( - f"expected generated lint target to use {expected_location} Whitaker" - ) + 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]) @@ -255,17 +265,16 @@ def test_generated_tooling_contracts( project.run("mbake validate Makefile") project.run("cargo metadata --format-version=1 --no-deps") - cargo_toml = (project / "Cargo.toml").read_text(encoding="utf-8") - cargo = tomllib.loads(cargo_toml) - package = cargo["package"] - metadata = package.get("metadata", {}) - makefile = (project / "Makefile").read_text(encoding="utf-8") - cargo_config = (project / ".cargo/config.toml").read_text(encoding="utf-8") - ci_workflow = (project / ".github/workflows/ci.yml").read_text(encoding="utf-8") - readme = (project / "README.md").read_text(encoding="utf-8") - rust_toolchain = (project / "rust-toolchain.toml").read_text(encoding="utf-8") - test_stub = (project / "tests/stub.rs").read_text(encoding="utf-8") - parsed_ci_workflow = yaml.safe_load(ci_workflow) + 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") assert_cargo_contracts(package, metadata, flavour) assert_makefile_contracts(makefile) @@ -276,9 +285,7 @@ def test_generated_tooling_contracts( assert_test_stub_contracts(test_stub) if flavour == APP: - release_workflow = (project / ".github/workflows/release.yml").read_text( - encoding="utf-8" - ) + release_workflow = read_generated_text(project / ".github/workflows/release.yml") assert_release_workflow_contracts(release_workflow) else: assert "binstall" not in metadata, ( @@ -286,26 +293,103 @@ def test_generated_tooling_contracts( ) +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 + + +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_cargo_contracts( - package: dict[str, object], metadata: dict[str, object], flavour: str + package: dict[str, Any], metadata: dict[str, Any], flavour: str ) -> None: """Assert generated Cargo package metadata contracts.""" - assert package["description"] == "ToolingExample package used by template tests.", ( + assert package.get("description") == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" ) - assert package["repository"] == "https://github.com/example/tooling_example", ( + assert package.get("repository") == "https://github.com/example/tooling_example", ( "expected generated Cargo.toml to include repository URL" ) - assert package["homepage"] == "https://example.com/tooling_example", ( + assert package.get("homepage") == "https://example.com/tooling_example", ( "expected generated Cargo.toml to include homepage URL" ) - assert package["keywords"] == ["rust", "template"], ( + assert package.get("keywords") == ["rust", "template"], ( "expected generated Cargo.toml to include package keywords" ) - assert package["categories"] == ["development-tools"], ( + assert package.get("categories") == ["development-tools"], ( "expected generated Cargo.toml to include package categories" ) - assert package["license"] == "ISC", ( + assert package.get("license") == "ISC", ( "expected generated Cargo.toml to include ISC licence" ) @@ -315,14 +399,14 @@ def assert_cargo_contracts( "expected app flavour Cargo.toml to include binstall metadata" ) assert ( - binstall["pkg-url"] + 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["pkg-fmt"] == "bin", ( + assert binstall.get("pkg-fmt") == "bin", ( "expected app flavour binstall metadata to describe binary artifacts" ) - assert binstall["disabled-strategies"] == ["quick-install", "compile"], ( + assert binstall.get("disabled-strategies") == ["quick-install", "compile"], ( "expected app flavour binstall metadata to disable unsupported strategies" ) @@ -353,6 +437,12 @@ def assert_makefile_contracts(makefile: str) -> None: 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) -> None: @@ -388,14 +478,17 @@ def assert_toolchain_contracts(rust_toolchain: str) -> None: def assert_ci_workflow_contracts( - parsed_ci_workflow: dict[str, object], ci_workflow: str + parsed_ci_workflow: dict[str, Any], ci_workflow: str ) -> None: """Assert generated CI workflow contracts.""" + jobs = require_mapping(parsed_ci_workflow, "jobs", "CI workflow") checkout_steps = [ step - for job in parsed_ci_workflow["jobs"].values() - for step in job["steps"] - if step.get("uses", "").startswith("actions/checkout@") + 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@") ] assert checkout_steps, "expected generated CI workflow to check out sources" assert all( @@ -428,6 +521,18 @@ def assert_ci_workflow_contracts( 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" + ) def assert_readme_contracts(readme: str) -> None: @@ -449,12 +554,15 @@ def assert_test_stub_contracts(test_stub: str) -> None: def assert_release_workflow_contracts(release_workflow: str) -> None: """Assert generated release workflow contracts.""" - parsed_release_workflow = yaml.safe_load(release_workflow) + parsed_release_workflow = parse_yaml_mapping(release_workflow, "release workflow") + jobs = require_mapping(parsed_release_workflow, "jobs", "release workflow") release_checkout_steps = [ step - for job in parsed_release_workflow["jobs"].values() - for step in job["steps"] - if step.get("uses", "").startswith("actions/checkout@") + 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@") ] assert release_checkout_steps, "expected app release workflow to check out sources" assert all( From 9985d2897d82fbd7e76159c72931aea8db956c69 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:01:19 +0200 Subject: [PATCH 25/30] Document release workflow toolchain overrides Explain why the generated release workflow clears repository Rust flags before installing cross and why release builds explicitly use the stable toolchain. --- .../{% if flavour == 'app' %}release.yml{% endif %}.jinja | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja index 03fd9e8..a8626d0 100644 --- a/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja +++ b/template/.github/workflows/{% if flavour == 'app' %}release.yml{% endif %}.jinja @@ -56,8 +56,8 @@ jobs: key: cross-${{ env.CROSS_REVISION }} - name: Install cross env: - # Clear repository build flags, including mold linker flags from - # .cargo/config.toml, before installing cross from CROSS_REVISION. + # 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 @@ -74,6 +74,8 @@ jobs: 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: | From 73887452b57cccefaf4148b5209a3646ae3bb8b8 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:11:34 +0200 Subject: [PATCH 26/30] Clarify snapshot and lint test diagnostics Format the syrupy dependency name in the developer guide, make the lint subprocess non-raising behaviour explicit, and add a diagnostic message to the generated structured file snapshot assertion. --- docs/developers-guide.md | 2 +- tests/test_template.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2563e87..e9992f6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -29,7 +29,7 @@ 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. +- `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. diff --git a/tests/test_template.py b/tests/test_template.py index 236929c..2bdef49 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -209,6 +209,7 @@ def test_makefile_resolves_whitaker_fallback( ), "CARGO": str(cargo), }, + check=False, capture_output=True, text=True, ) @@ -320,7 +321,10 @@ def test_generated_structured_file_snapshots( "makefile": makefile, "ci_workflow": ci_workflow, "release_workflow": release_workflow, - } == snapshot + } == snapshot, ( + "Snapshot mismatch for template outputs " + "(cargo_config, makefile, ci_workflow, release_workflow)" + ) def read_generated_text(path: Path) -> str: From 74e42d8a0bd6ab45a0664ef1455e57c1f367634c Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:15:33 +0200 Subject: [PATCH 27/30] Tighten generated tooling review fixes Link the app stub lint expectation to the tracked tooling plan, add root CI Whitaker diagnostics, and consolidate generated tooling contract assertions into one validator. --- .github/workflows/ci.yml | 3 + ...%}main.rs{% else %}lib.rs{% endif %}.jinja | 8 +- tests/test_template.py | 80 +++++++++---------- 3 files changed, 49 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f168884..fb3a7c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,12 +48,15 @@ jobs: - 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/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 7388885..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,9 +1,13 @@ {% if flavour == 'app' -%} //! `{{ project_name }}` application entry point. -// TODO: Remove this stub as soon as actual application functionality exists. +// TODO: Remove when replacing app scaffolding +// (docs/execplans/rust-project-enhancements.md). /// Application entry point. -#[expect(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 }}!"); } diff --git a/tests/test_template.py b/tests/test_template.py index 2bdef49..0c613df 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -277,21 +277,25 @@ def test_generated_tooling_contracts( test_stub = read_generated_text(project / "tests/stub.rs") parsed_ci_workflow = parse_yaml_mapping(ci_workflow, "CI workflow") - assert_cargo_contracts(package, metadata, flavour) - assert_makefile_contracts(makefile) - assert_cargo_config_contracts(cargo_config, dev_target) - assert_toolchain_contracts(rust_toolchain) - assert_ci_workflow_contracts(parsed_ci_workflow, ci_workflow) - assert_readme_contracts(readme) - assert_test_stub_contracts(test_stub) - - if flavour == APP: - release_workflow = read_generated_text(project / ".github/workflows/release.yml") - assert_release_workflow_contracts(release_workflow) - else: - assert "binstall" not in metadata, ( - "expected lib flavour Cargo.toml to omit binstall metadata" - ) + 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( @@ -374,10 +378,22 @@ def require_optional_mapping( return value -def assert_cargo_contracts( - package: dict[str, Any], metadata: dict[str, Any], flavour: str +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 Cargo package metadata contracts.""" + """Assert generated tooling contracts from a single validator.""" assert package.get("description") == "ToolingExample package used by template tests.", ( "expected generated Cargo.toml to include package description" ) @@ -413,10 +429,11 @@ def assert_cargo_contracts( 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" ) @@ -448,9 +465,6 @@ def assert_makefile_contracts(makefile: str) -> None: "expected generated Makefile coverage target to log linker flags" ) - -def assert_cargo_config_contracts(cargo_config: str, dev_target: str) -> None: - """Assert generated Cargo config linker contracts.""" assert 'codegen-backend = "cranelift"' in cargo_config, ( "expected generated cargo config to enable Cranelift" ) @@ -470,9 +484,6 @@ def assert_cargo_config_contracts(cargo_config: str, dev_target: str) -> None: "expected generated cargo config to avoid mold for non-Linux targets" ) - -def assert_toolchain_contracts(rust_toolchain: str) -> None: - """Assert generated Rust toolchain component contracts.""" assert "rustc-codegen-cranelift-preview" in rust_toolchain, ( "expected generated rust-toolchain to include Cranelift component" ) @@ -480,11 +491,6 @@ def assert_toolchain_contracts(rust_toolchain: str) -> None: "expected generated rust-toolchain to include llvm tools component" ) - -def assert_ci_workflow_contracts( - parsed_ci_workflow: dict[str, Any], ci_workflow: str -) -> None: - """Assert generated CI workflow contracts.""" jobs = require_mapping(parsed_ci_workflow, "jobs", "CI workflow") checkout_steps = [ step @@ -538,9 +544,6 @@ def assert_ci_workflow_contracts( "expected generated CI workflow to log coverage linker configuration" ) - -def assert_readme_contracts(readme: str) -> None: - """Assert generated README tooling notes.""" assert "Development builds use `mold` on Linux" in readme, ( "expected generated README to document mold for development builds" ) @@ -548,16 +551,13 @@ def assert_readme_contracts(readme: str) -> None: "expected generated README to document lld for coverage" ) - -def assert_test_stub_contracts(test_stub: str) -> None: - """Assert generated test stub guidance.""" assert "Delete this file as soon as the project has real" in test_stub, ( "expected generated test stub to explain when to delete it" ) + if release_workflow is None: + return -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 = [ From 3e5ccf010a5d05cac3a99723140fd5800241cd08 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:22:18 +0200 Subject: [PATCH 28/30] Format developer tooling names Wrap PyYAML and Whitaker in inline code formatting so the tooling list is consistent with the surrounding entries. --- docs/developers-guide.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e9992f6..e7824fd 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -28,13 +28,13 @@ 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. +- `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. +- `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 From e5722cdc796093a338a3e8624041b01c90c32cca Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:23:28 +0200 Subject: [PATCH 29/30] Share checkout step extraction in tests Extract the GitHub Actions checkout step selection into a helper and reuse it for generated CI and release workflow contract checks. --- tests/test_template.py | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/test_template.py b/tests/test_template.py index 0c613df..ecf2187 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -492,14 +492,7 @@ def assert_generated_tooling_contracts( ) jobs = require_mapping(parsed_ci_workflow, "jobs", "CI workflow") - checkout_steps = [ - 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@") - ] + 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 @@ -560,14 +553,7 @@ def assert_generated_tooling_contracts( parsed_release_workflow = parse_yaml_mapping(release_workflow, "release workflow") jobs = require_mapping(parsed_release_workflow, "jobs", "release workflow") - release_checkout_steps = [ - 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@") - ] + 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 @@ -589,3 +575,15 @@ def assert_generated_tooling_contracts( 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@") + ] From f788402f9a9ca9669bc2dc5c079b52188e5acc93 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 25 May 2026 01:50:53 +0200 Subject: [PATCH 30/30] Split generated tooling contract assertions Move the dense generated tooling validator into focused private assertion helpers while preserving the existing checks and failure messages. --- tests/test_template.py | 44 +++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/test_template.py b/tests/test_template.py index ecf2187..b50cc7d 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -394,6 +394,24 @@ def assert_generated_tooling_contracts( 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" ) @@ -434,6 +452,9 @@ def assert_generated_tooling_contracts( "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" ) @@ -465,6 +486,11 @@ def assert_generated_tooling_contracts( "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" ) @@ -491,6 +517,13 @@ def assert_generated_tooling_contracts( "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" @@ -537,20 +570,13 @@ def assert_generated_tooling_contracts( "expected generated CI workflow to log coverage linker configuration" ) - 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" - ) - assert "Delete this file as soon as the project has real" in test_stub, ( "expected generated test stub to explain when to delete it" ) - if release_workflow is None: - return +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)