diff --git a/Cargo.lock b/Cargo.lock index 68513ce05..d734f58a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,7 +1422,7 @@ dependencies = [ ] [[package]] -name = "netsuke" +name = "netsuke-build" version = "0.1.0" dependencies = [ "anyhow", @@ -2633,7 +2633,7 @@ dependencies = [ "camino", "cap-std", "mockable", - "netsuke", + "netsuke-build", "proptest", "rstest", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index caa919c8f..2841b22ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "netsuke" +name = "netsuke-build" version = "0.1.0" edition = "2024" include = [ @@ -20,6 +20,13 @@ repository = "https://github.com/leynos/netsuke" keywords = ["build", "ninja", "jinja", "yaml", "automation"] categories = ["command-line-utilities", "development-tools::build-utils"] +[lib] +name = "netsuke" + +[[bin]] +name = "netsuke" +path = "src/main.rs" + [package.metadata.ortho_config] root_type = "netsuke::cli::CliConfig" locales = [ @@ -63,6 +70,38 @@ locales = [ [package.metadata.kani.flags] default-unwind = "6" +# `cargo binstall` derives release asset names from the Cargo package name, +# which is `netsuke-build`. Every release asset is named after the `netsuke` +# binary instead, so the defaults would never match and `cargo binstall +# netsuke-build` would fall back to a source build on the pinned nightly. +# +# Release assets are unarchived binaries (`pkg-fmt = "bin"`) whose names are +# built by `.github/workflows/release.yml` and +# `.github/actions/upload-release-assets`: the workflow artefact name, then +# `__`, then the staging directory from `.github/release-staging.toml` +# (`{bin_name}_{platform}_{arch}`), then `-`, then the staged file name. +# `tests/binstall_metadata_tests.rs` holds these overrides to that contract. +[package.metadata.binstall] +pkg-fmt = "bin" + +[package.metadata.binstall.overrides.x86_64-unknown-linux-gnu] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-linux-amd64__netsuke_linux_x86_64-netsuke" + +[package.metadata.binstall.overrides.aarch64-unknown-linux-gnu] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-linux-arm64__netsuke_linux_aarch64-netsuke" + +[package.metadata.binstall.overrides.x86_64-apple-darwin] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-macos-x86_64__netsuke_macos_x86_64-netsuke" + +[package.metadata.binstall.overrides.aarch64-apple-darwin] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-macos-arm64__netsuke_macos_aarch64-netsuke" + +[package.metadata.binstall.overrides.x86_64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-windows-amd64__netsuke_windows_x86_64-netsuke.exe" + +[package.metadata.binstall.overrides.aarch64-pc-windows-msvc] +pkg-url = "{ repo }/releases/download/v{ version }/netsuke-windows-arm64__netsuke_windows_aarch64-netsuke.exe" + [features] default = [] legacy-digests = ["sha1", "md5"] diff --git a/README.md b/README.md index 0f93ce656..0223109c0 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ requirement below. ```sh -cargo binstall netsuke +cargo binstall netsuke-build ``` Building from the registry instead runs outside a repository checkout, so @@ -60,7 +60,7 @@ supply both explicitly: ```sh rustup toolchain install nightly-2026-06-25 -RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke +RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke-build ``` Pre-built installers are available from the diff --git a/build.rs b/build.rs index 7b6a0a40a..7f063f9a8 100644 --- a/build.rs +++ b/build.rs @@ -145,8 +145,6 @@ fn emit_rerun_directives() { println!("cargo:rerun-if-changed=src/cli/parser.rs"); println!("cargo:rerun-if-changed=src/cli/parsing.rs"); println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); - println!("cargo:rerun-if-env-changed=CARGO_PKG_NAME"); - println!("cargo:rerun-if-env-changed=CARGO_BIN_NAME"); println!("cargo:rerun-if-env-changed=CARGO_PKG_DESCRIPTION"); println!("cargo:rerun-if-env-changed=CARGO_PKG_AUTHORS"); println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH"); @@ -167,7 +165,7 @@ fn emit_rerun_directives() { #[expect( clippy::disallowed_methods, - reason = "CARGO_BIN_NAME, CARGO_PKG_NAME, CARGO_PKG_VERSION and OUT_DIR are Cargo's own build-script inputs; they describe the crate being compiled and Cargo provides them only through the environment" + reason = "CARGO_PKG_VERSION and OUT_DIR are Cargo's own build-script inputs; they describe the crate being compiled and Cargo provides them only through the environment" )] fn generate_man_page(out_dir: &Path) -> Result<(), Box> { let cmd = cli::Cli::command(); @@ -175,26 +173,24 @@ fn generate_man_page(out_dir: &Path) -> Result<(), Box> { .get_bin_name() .unwrap_or_else(|| cmd.get_name()) .to_owned(); - let cargo_bin = env::var("CARGO_BIN_NAME") - .or_else(|_| env::var("CARGO_PKG_NAME")) - .unwrap_or_else(|_| name.clone()); - if name != cargo_bin { - return Err(format!( - "CLI name {name} differs from Cargo bin/package name {cargo_bin}; packaging expects {cargo_bin}.1" - ) - .into()); - } let version = env::var("CARGO_PKG_VERSION").map_err( |_| "CARGO_PKG_VERSION must be set by Cargo; cannot render manual page without it.", )?; let man = Man::new(cmd) .section("1") - .source(format!("{cargo_bin} {version}")) + .source(format!("{name} {version}")) .date(manual_date()); let mut buf = Vec::new(); man.render(&mut buf)?; - let page_name = format!("{cargo_bin}.1"); - write_man_page(&buf, out_dir, &page_name)?; + let page_name = format!("{name}.1"); + let destination = write_man_page(&buf, out_dir, &page_name)?; + // Publish the destination so the crate's tests can assert the manual page + // contract (name, location, and `.TH` source) without re-deriving where the + // build script chose to write it. + println!( + "cargo:rustc-env=NETSUKE_GENERATED_MAN_PAGE={}", + destination.display() + ); if let Some(extra_dir) = env::var_os("OUT_DIR") { let extra_dir_path = PathBuf::from(extra_dir); if let Err(err) = write_man_page(&buf, &extra_dir_path, &page_name) { diff --git a/docs/adr-006-adopt-polonius-nightly-toolchain.md b/docs/adr-006-adopt-polonius-nightly-toolchain.md index b8040c8a5..cb9a6a288 100644 --- a/docs/adr-006-adopt-polonius-nightly-toolchain.md +++ b/docs/adr-006-adopt-polonius-nightly-toolchain.md @@ -79,14 +79,14 @@ remains correct. - Publishing to crates.io remains possible, but the packaged source excludes `rust-toolchain.toml` and `.cargo/config.toml` (and Cargo would not apply - them to a registry build anyway), so a bare `cargo install netsuke` of a - Polonius-dependent release fails borrow checking on the user's default + them to a registry build anyway), so a bare `cargo install netsuke-build` of + a Polonius-dependent release fails borrow checking on the user's default toolchain. Registry installs must select the pinned nightly and pass the flag explicitly - (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke`); the - README and users' guide document this command and a contract test pins it. - Source installs from a checkout are unaffected because the pinned toolchain - and workspace configuration apply there. + (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke-build`); + the README and users' guide document this command and a contract test pins + it. Source installs from a checkout are unaffected because the pinned + toolchain and workspace configuration apply there. - Release packaging builds from the pinned nightly. Binary artefacts are unaffected: the borrow checker changes what compiles, not what is generated. - Dependabot-style toolchain drift is impossible; moving the pin is a diff --git a/docs/adr-007-publish-as-netsuke-build.md b/docs/adr-007-publish-as-netsuke-build.md new file mode 100644 index 000000000..c4292af31 --- /dev/null +++ b/docs/adr-007-publish-as-netsuke-build.md @@ -0,0 +1,100 @@ +# Architecture decision record (ADR): Publish the crates.io package as `netsuke-build` + +## Status + +Accepted. + +## Date + +2026-08-05. + +## Context and problem statement + +Netsuke ships a single application crate whose Cargo package, library target, +and binary target were all named `netsuke`. The `netsuke` name is already +taken on crates.io by an unrelated package, so the registry cannot accept a +release under it. + +Renaming the package is the only way to publish, but the name is load-bearing +in several places that have nothing to do with the registry: + +- the command users type, and the `Usage:` line clap renders for it; +- the manual page, which packaging installs as `netsuke.1` and users read with + `man netsuke`; +- the Debian, RPM, macOS, and Windows package names, and the release assets + they are built from; +- the library target that the integration tests, behavioural tests, and build + script all import as `netsuke`. + +Cargo lets the package name and the target names diverge, but nothing enforces +the divergence: the build script previously derived the manual page name from +`CARGO_BIN_NAME`/`CARGO_PKG_NAME` and rejected a mismatch with the +command-line interface (CLI) name, which would have renamed the manual page to +follow the package. + +## Decision + +Publish as `netsuke-build`, and keep every user-facing name as `netsuke`. + +- Set `package.name = "netsuke-build"` in `Cargo.toml`, with `[lib] name = + "netsuke"` and `[[bin]] name = "netsuke"`. +- Derive the manual page name and its `.TH` source from the CLI name that + `clap` reports, not from Cargo's package or binary environment variables. + `build.rs` no longer reads `CARGO_PKG_NAME` or `CARGO_BIN_NAME`, and no + longer fails the build when they differ from the CLI name; that check + enforced exactly the coupling this decision removes. +- Keep `.github/release-staging.toml`, the `linux-packages`, `windows-package`, + and `macos-package` steps, and the release help tooling driven by the + `bin-name` Cargo metadata field, which resolves to `netsuke`. +- Add `[package.metadata.binstall]` overrides so `cargo binstall + netsuke-build` resolves the release assets, which are named after the binary. + Without them `cargo binstall` would look for `netsuke-build`-prefixed assets, + fail to find any, and fall back to a source build that needs the pinned + nightly and the Polonius flag — the very fallback the documented command + exists to avoid. +- Update the crates.io installation guidance in the README, the users' guide, + and the quickstart to install `netsuke-build`. + +## Rationale + +- **The registry name is an implementation detail.** Users invoke `netsuke`, + read `man netsuke`, and install a `netsuke` operating-system package. Only + the two `cargo install` and `cargo binstall` commands mention the package + name, and both are documented and pinned by contract tests. +- **Renaming the targets would be far more invasive.** The library target name + is the crate path every test, the build script, and the + `[package.metadata.ortho_config]` `root_type` setting use; renaming it would + churn the whole tree to work around a registry collision. +- **`netsuke-build` reads as a description, not a substitute.** It names what + the package is — the Netsuke build system — so a reader who finds it on + crates.io is not left guessing whether it is the same project. + +## Consequences + +- The package name and target names diverge permanently. Anything deriving a + user-facing name from Cargo package metadata is a defect; derive from the + CLI name or from the `bin-name` metadata field instead. +- `tests/man_page_contract_tests.rs` pins the manual page's name, staging + location, and `.TH` source against the CLI name, and asserts the package + name never reaches the title. `tests/binstall_metadata_tests.rs` pins the + `binstall` overrides to `.github/release-staging.toml` and to the release + workflow's target matrix. +- The `binstall` overrides encode release asset names. Changing + `staging_dir_template`, `bin_name`, or the workflow artefact names without + updating the overrides breaks `cargo binstall`; the contract test fails + first for the parts it can derive. +- Documentation and contract tests refer to `netsuke-build` only for registry + installation. Everywhere else — prose, examples, help output, packaging — + the project remains Netsuke. +- Should the `netsuke` name become available on crates.io, this decision can be + reversed by changing `package.name` alone, because nothing else derives from + it. + +## References + +- [ADR-006](adr-006-adopt-polonius-nightly-toolchain.md): the pinned-nightly + policy that makes the `cargo binstall` path worth preserving. +- [Repository layout](repository-layout.md): the package-versus-target naming + rule. +- [Developer guide](developers-guide.md): the day-to-day naming guidance and + the contract tests that enforce it. diff --git a/docs/contents.md b/docs/contents.md index a89d31f58..f63c31465 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -43,6 +43,9 @@ operator, user, and contributor references are easier to find. `command_available`. - [adr-006-adopt-polonius-nightly-toolchain.md](adr-006-adopt-polonius-nightly-toolchain.md): Pinned-nightly Polonius borrow-checker adoption decision record. +- [adr-007-publish-as-netsuke-build.md](adr-007-publish-as-netsuke-build.md): + crates.io package rename decision record, and the package-versus-target + naming rule it establishes. ## User and operator guides diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ff9568a05..9809cf2fe 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -192,6 +192,44 @@ they are per-invocation arguments tagged `#[serde(skip)]` on would silently change the artefact destination — a footgun the design avoids by construction. +## Package and target naming + +The crates.io package is `netsuke-build`; the library target, the binary +target, and the command are all `netsuke`. The names diverge because `netsuke` +is taken on crates.io. [ADR-007](adr-007-publish-as-netsuke-build.md) records +the decision and [repository layout](repository-layout.md) states the rule. + +The practical consequence is that **no user-facing name may be derived from +Cargo package metadata**. Derive from the command-line interface (CLI) name, or +from the `bin-name` field that +`leynos/shared-actions/.github/actions/export-cargo-metadata` reads out of +`[[bin]]`: + +- `build.rs` names the manual page `.1` and stamps its `.TH` source + as ` `, taking the name from `Cli::command()`. It reads + neither `CARGO_PKG_NAME` nor `CARGO_BIN_NAME`. The build script publishes the + path it wrote through `cargo:rustc-env=NETSUKE_GENERATED_MAN_PAGE`, and + `tests/man_page_contract_tests.rs` asserts the file is `netsuke.1`, is staged + under `target/generated-man///`, and carries a title that + never mentions `netsuke-build`. +- Release packaging takes `bin-name` from the `metadata` job in + `.github/workflows/release.yml`, so `.github/release-staging.toml`, the + Debian and RPM payloads, the Windows Installer product, and the macOS + installer package all stay named `netsuke`. +- `[package.metadata.binstall]` in `Cargo.toml` overrides `cargo binstall`'s + default asset resolution, which would otherwise look for `netsuke-build` + assets and fall back to a source build on the pinned nightly. The overrides + spell out one unarchived (`pkg-fmt = "bin"`) asset per released target; + `tests/binstall_metadata_tests.rs` rebuilds the expected names from + `.github/release-staging.toml` and checks the target set against the release + workflow matrix. + +Only the two registry installation commands name `netsuke-build`, and +`tests/documentation_examples_tests.rs` pins both. When adding a release +target, a packaging format, or an artefact name, update the `binstall` +overrides and the artefact-name table in `tests/binstall_metadata_tests.rs` +alongside the workflow. + ## Toolchain and borrow checker Netsuke builds on the dated nightly toolchain pinned in `rust-toolchain.toml` @@ -1171,6 +1209,12 @@ stale `ninja_env/` paths. It also asserts that every catalogue named by the locale registry ships in the package, so adding a locale cannot silently omit its `messages.ftl` from a release. +`tests/man_page_contract_tests.rs` and `tests/binstall_metadata_tests.rs` guard +the package-versus-target naming split described in +[package and target naming](#package-and-target-naming). The first asserts the +manual page `build.rs` generates, the second holds the `cargo binstall` +overrides to the release staging configuration and workflow matrix. + ### Temporary executable test helpers The low-level executable-stub primitive is owned by diff --git a/docs/execplans/3-14-1-manifest-time-condition-semantics-for-actions-and-targets.md b/docs/execplans/3-14-1-manifest-time-condition-semantics-for-actions-and-targets.md index 3a7fb7c11..b94a3897d 100644 --- a/docs/execplans/3-14-1-manifest-time-condition-semantics-for-actions-and-targets.md +++ b/docs/execplans/3-14-1-manifest-time-condition-semantics-for-actions-and-targets.md @@ -496,7 +496,7 @@ exit status 0 make nixie exit status 0 -cargo test -p netsuke manifest::expand +cargo test -p netsuke-build manifest::expand 29 passed; 0 failed make check-fmt diff --git a/docs/execplans/3-14-3-lower-target-and-action-deps.md b/docs/execplans/3-14-3-lower-target-and-action-deps.md index b8ab20aee..e8d7d1eb5 100644 --- a/docs/execplans/3-14-3-lower-target-and-action-deps.md +++ b/docs/execplans/3-14-3-lower-target-and-action-deps.md @@ -698,7 +698,7 @@ Expected: the new parameterized cases pass; existing IR cases continue to pass. After Stage D (cycle detection): ```sh -cargo test -p netsuke ir::cycle \ +cargo test -p netsuke-build ir::cycle \ 2>&1 \ | tee /tmp/stage-d-netsuke-3-14-3-lower-target-and-action-deps.out ``` diff --git a/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md b/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md index 9ece96fa0..af8babf43 100644 --- a/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md +++ b/docs/execplans/3-4-5-extend-graph-subcommand-with-an-html-renderer.md @@ -560,7 +560,7 @@ will consume. No user-visible behaviour change. Pure scaffolding. 6. **Stage A acceptance**: - - `cargo test -p netsuke graph_view::tests` passes. + - `cargo test -p netsuke-build graph_view::tests` passes. - The proptest covers at least 256 cases with shrinking and reports no failures over 60 seconds. - No public behaviour change visible to existing tests. diff --git a/docs/polonius.md b/docs/polonius.md index 734a7daa5..16e3b11d6 100644 --- a/docs/polonius.md +++ b/docs/polonius.md @@ -122,10 +122,10 @@ flag or avoid compiling the crate: coverage inherits the flag from the job environment. - **Registry installs**: the crates.io package excludes `rust-toolchain.toml` and `.cargo/config.toml`, and registry builds run - outside the checkout, so `cargo install netsuke` must select the pinned + outside the checkout, so `cargo install netsuke-build` must select the pinned nightly and pass the flag explicitly - (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke`). The - README and users' guide document the command and + (`RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke-build`). + The README and users' guide document the command and `tests/documentation_examples_tests.rs` pins it. - **cargo-mutants** (scheduled, informational) runs through the shared `mutation-cargo.yml` workflow, which controls its own environment; if those diff --git a/docs/quickstart.md b/docs/quickstart.md index e9f7ec76d..856c429c0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -8,7 +8,7 @@ minutes. Before beginning, ensure the following are available: - **Netsuke** installed — install a prebuilt release binary with - `cargo binstall netsuke` where + `cargo binstall netsuke-build` where [`cargo binstall`](https://github.com/cargo-bins/cargo-binstall) is available, install from source inside a repository checkout with `cargo install --path .` (which puts `netsuke` on `PATH` for the commands diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 2f2c61884..4c90317f5 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -77,7 +77,7 @@ output and some leaf files so the long-lived structure remains visible. [translator guide](translators-guide.md). - `scripts/`: Shell and helper scripts used by quality gates, release help generation, packaging, and formal checks. -- `src/`: Main Netsuke Rust crate source code. +- `src/`: Main `netsuke-build` Rust package source code. - `src/cli/`: Command-line configuration, parsing, validation, and merge logic. - `src/ir/`: Intermediate representation generation, interpolation, graph, and cycle logic. @@ -122,6 +122,14 @@ Place new production Rust modules under the `src/` subtree that owns the feature boundary. Use `test_support/` for reusable integration-test helpers and keep one-off fixtures close to the tests that consume them. +The crates.io package is named `netsuke-build`, while its library and binary +targets remain named `netsuke`. Keep command-line help, manual pages, release +artefacts, and operating-system packages aligned with the `netsuke` target name +rather than the Cargo package name. +[ADR-007](adr-007-publish-as-netsuke-build.md) records the decision, and the +[developer guide](developers-guide.md) describes how the build script, release +packaging, and `cargo binstall` metadata honour it. + Place feature files in `tests/features/` unless the behaviour depends on Unix-specific platform contracts, in which case use `tests/features_unix/`. Place generated or approved snapshot files under the existing `src/snapshots/` diff --git a/docs/users-guide.md b/docs/users-guide.md index 80431ace0..6509c321c 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -26,7 +26,7 @@ requirement below. ```sh -cargo binstall netsuke +cargo binstall netsuke-build ``` Building from the registry instead runs outside a repository checkout, so @@ -37,7 +37,7 @@ supply both explicitly: ```sh rustup toolchain install nightly-2026-06-25 -RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke +RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke-build ``` Pre-built installers are available from the diff --git a/src/cli/parser.rs b/src/cli/parser.rs index fa7ecdc22..260eac883 100644 --- a/src/cli/parser.rs +++ b/src/cli/parser.rs @@ -80,7 +80,7 @@ pub(super) fn validation_message( /// A modern, friendly build system that uses YAML and Jinja, powered by Ninja. #[derive(Debug, Parser, Serialize, Deserialize)] -#[command(author, version, about, long_about = None)] +#[command(name = "netsuke", author, version, about, long_about = None)] pub struct Cli { /// Path to the Netsuke manifest file to use. #[arg( diff --git a/test_support/Cargo.lock b/test_support/Cargo.lock index accdc6798..235092783 100644 --- a/test_support/Cargo.lock +++ b/test_support/Cargo.lock @@ -1141,7 +1141,7 @@ dependencies = [ ] [[package]] -name = "netsuke" +name = "netsuke-build" version = "0.1.0" dependencies = [ "anyhow", @@ -2082,7 +2082,7 @@ dependencies = [ "camino", "cap-std", "mockable", - "netsuke", + "netsuke-build", "proptest", "rstest", "sha2", diff --git a/test_support/Cargo.toml b/test_support/Cargo.toml index 921deba48..6c29dce0b 100644 --- a/test_support/Cargo.toml +++ b/test_support/Cargo.toml @@ -17,7 +17,7 @@ sha2 = { version = "0.11", default-features = false, features = ["alloc"] } anyhow = "1" thiserror = "1" assert_cmd = "2.0.0" -netsuke = { path = ".." } +netsuke = { package = "netsuke-build", path = ".." } rstest = "0.26.1" [dev-dependencies] diff --git a/tests/binstall_metadata_tests.rs b/tests/binstall_metadata_tests.rs new file mode 100644 index 000000000..2d4b4f573 --- /dev/null +++ b/tests/binstall_metadata_tests.rs @@ -0,0 +1,173 @@ +//! Contract tests for the `cargo binstall` metadata in `Cargo.toml`. +//! +//! The README and the users' guide advertise `cargo binstall netsuke-build`. +//! `cargo binstall` derives its default asset names from the Cargo package +//! name, but every release asset is named after the `netsuke` binary, so the +//! package needs explicit `[package.metadata.binstall]` overrides. These tests +//! hold those overrides to `.github/release-staging.toml` and to the release +//! workflow's target matrix so a packaging change cannot silently strand the +//! documented command on a source build. + +use anyhow::{Context, Result, ensure}; +use serde::Deserialize; +use std::collections::BTreeMap; +use test_support::fs as test_fs; + +const CARGO_MANIFEST: &str = "Cargo.toml"; +const STAGING_CONFIG: &str = ".github/release-staging.toml"; +const RELEASE_WORKFLOW: &str = ".github/workflows/release.yml"; + +/// Workflow artefact names that prefix each uploaded release asset. +/// +/// `release.yml` builds these as `-`, which is +/// not derivable from `.github/release-staging.toml`. The staging-target keys +/// are checked against the workflow matrix below, so a matrix rename fails +/// here rather than at release time. +const WORKFLOW_ARTEFACT_NAMES: [(&str, &str); 6] = [ + ("linux-x86_64", "netsuke-linux-amd64"), + ("linux-aarch64", "netsuke-linux-arm64"), + ("windows-x86_64", "netsuke-windows-amd64"), + ("windows-aarch64", "netsuke-windows-arm64"), + ("macos-x86_64", "netsuke-macos-x86_64"), + ("macos-aarch64", "netsuke-macos-arm64"), +]; + +#[derive(Deserialize)] +struct StagingConfig { + common: StagingCommon, + targets: BTreeMap, +} + +#[derive(Deserialize)] +struct StagingCommon { + bin_name: String, + staging_dir_template: String, +} + +#[derive(Deserialize)] +struct StagingTarget { + platform: String, + arch: String, + target: String, + #[serde(default)] + bin_ext: String, +} + +#[derive(Deserialize)] +struct CargoManifest { + package: CargoPackage, +} + +#[derive(Deserialize)] +struct CargoPackage { + name: String, + metadata: CargoPackageMetadata, +} + +#[derive(Deserialize)] +struct CargoPackageMetadata { + binstall: BinstallMetadata, +} + +#[derive(Deserialize)] +struct BinstallMetadata { + #[serde(rename = "pkg-fmt")] + pkg_fmt: String, + #[serde(default)] + overrides: BTreeMap, +} + +#[derive(Deserialize)] +struct BinstallOverride { + #[serde(rename = "pkg-url")] + pkg_url: String, +} + +fn load(path: &str) -> Result { + let raw = test_fs::read_to_string(path).with_context(|| format!("read {path}"))?; + toml::from_str(&raw).with_context(|| format!("parse {path}")) +} + +/// Render the staging directory name for a target. +fn staging_dir(common: &StagingCommon, target: &StagingTarget) -> String { + common + .staging_dir_template + .replace("{bin_name}", &common.bin_name) + .replace("{platform}", &target.platform) + .replace("{arch}", &target.arch) +} + +/// Build the release asset name the upload action derives for a binary. +fn expected_asset_name(artefact: &str, common: &StagingCommon, target: &StagingTarget) -> String { + let staged = staging_dir(common, target); + let bin_name = &common.bin_name; + let bin_ext = &target.bin_ext; + format!("{artefact}__{staged}-{bin_name}{bin_ext}") +} + +#[test] +fn binstall_overrides_match_the_release_assets() -> Result<()> { + let manifest: CargoManifest = load(CARGO_MANIFEST)?; + let staging: StagingConfig = load(STAGING_CONFIG)?; + let workflow = test_fs::read_to_string(RELEASE_WORKFLOW) + .with_context(|| format!("read {RELEASE_WORKFLOW}"))?; + let binstall = &manifest.package.metadata.binstall; + + ensure!( + binstall.pkg_fmt == "bin", + "release assets are unarchived binaries; pkg-fmt should be `bin`, found `{}`", + binstall.pkg_fmt + ); + ensure!( + binstall.overrides.len() == WORKFLOW_ARTEFACT_NAMES.len(), + "every released target needs a binstall override; found {}", + binstall.overrides.len() + ); + + for (staging_key, artefact) in WORKFLOW_ARTEFACT_NAMES { + ensure!( + workflow.contains(&format!("target_key: {staging_key}")), + "{RELEASE_WORKFLOW} should build staging target {staging_key}" + ); + let target = staging + .targets + .get(staging_key) + .with_context(|| format!("{STAGING_CONFIG} should define target {staging_key}"))?; + let entry = binstall + .overrides + .get(&target.target) + .with_context(|| format!("binstall override missing for {}", target.target))?; + let expected = format!( + "{{ repo }}/releases/download/v{{ version }}/{asset}", + asset = expected_asset_name(artefact, &staging.common, target) + ); + ensure!( + entry.pkg_url == expected, + "binstall override for {triple} should resolve the released asset\n expected: {expected}\n found: {found}", + triple = target.target, + found = entry.pkg_url + ); + } + Ok(()) +} + +#[test] +fn binstall_overrides_never_reference_the_package_name() -> Result<()> { + let manifest: CargoManifest = load(CARGO_MANIFEST)?; + let package_name = &manifest.package.name; + ensure!( + package_name == "netsuke-build", + "Cargo package name drifted: {package_name}" + ); + for (triple, entry) in &manifest.package.metadata.binstall.overrides { + ensure!( + !entry.pkg_url.contains(package_name), + "binstall override for {triple} names the package rather than the binary" + ); + ensure!( + !entry.pkg_url.contains("{ name }"), + "binstall override for {triple} interpolates the package name" + ); + } + Ok(()) +} diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 513020500..ff854024d 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -178,11 +178,11 @@ fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { let mut registry_install_ids = Vec::new(); for example in load_documented_examples()? { for line in example.body.lines() { - if !line.contains("install netsuke") || line.contains("binstall") { + if !line.contains("install netsuke-build") || line.contains("binstall") { continue; } ensure!( - line.contains("cargo +nightly-2026-06-25 install netsuke"), + line.contains("cargo +nightly-2026-06-25 install netsuke-build"), "{id} must install with the pinned nightly toolchain: {line}", id = example.id ); @@ -203,7 +203,7 @@ fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { let quickstart = test_fs::read_to_string("docs/quickstart.md").context("read docs/quickstart.md")?; ensure!( - !quickstart.contains("cargo install netsuke"), + !quickstart.contains("cargo install netsuke-build"), "docs/quickstart.md must defer to the users' guide install command" ); Ok(()) @@ -213,7 +213,7 @@ fn registry_install_examples_pin_toolchain_and_polonius() -> Result<()> { fn assert_release_installation_contract() -> Result<()> { let readme_binstall = documented_example("readme-binstall-install")?; let guide_binstall = documented_example("guide-binstall-install")?; - let expected_binstall = "cargo binstall netsuke\n"; + let expected_binstall = "cargo binstall netsuke-build\n"; ensure!( readme_binstall.body == expected_binstall, "README binstall drifted" @@ -229,7 +229,7 @@ fn assert_release_installation_contract() -> Result<()> { // command must select the pinned nightly and the Polonius flag itself. let expected_release = concat!( "rustup toolchain install nightly-2026-06-25\n", - "RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke\n" + "RUSTFLAGS=-Zpolonius=next cargo +nightly-2026-06-25 install netsuke-build\n" ); ensure!(readme_release.body == expected_release, "README drifted"); ensure!(guide_release.body == expected_release, "user guide drifted"); diff --git a/tests/man_page_contract_tests.rs b/tests/man_page_contract_tests.rs new file mode 100644 index 000000000..f31c967b4 --- /dev/null +++ b/tests/man_page_contract_tests.rs @@ -0,0 +1,123 @@ +//! Contract tests for the manual page emitted by `build.rs`. +//! +//! The crates.io package is `netsuke-build`, while the command, the library, +//! and the binary are all `netsuke`. The build script must name and stamp the +//! manual page from the command-line interface (CLI) name alone, because +//! packaging, the Debian and RPM payloads, and `man netsuke` all expect +//! `netsuke.1`. These tests pin that decoupling so a manual page named after +//! the Cargo package cannot pass unnoticed. + +use anyhow::{Context, Result, ensure}; +use clap::CommandFactory; +use netsuke::cli::Cli; +use std::path::{Component, Path, PathBuf}; +use test_support::fs as test_fs; + +/// Cargo package name, deliberately distinct from the target names. +const PACKAGE_NAME: &str = env!("CARGO_PKG_NAME"); +/// Package version stamped into the manual page's source field. +const PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Manual page path published by `build.rs` via `cargo:rustc-env`. +const GENERATED_MAN_PAGE: &str = env!("NETSUKE_GENERATED_MAN_PAGE"); + +/// Resolve the generated manual page against the package root. +/// +/// `build.rs` emits a path relative to the package root; joining an absolute +/// path is a no-op, so this also tolerates an absolute emission. +fn generated_man_page() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join(GENERATED_MAN_PAGE) +} + +/// Return the command name clap renders the manual page for. +fn cli_name() -> String { + let command = Cli::command(); + command + .get_bin_name() + .unwrap_or_else(|| command.get_name()) + .to_owned() +} + +/// Return the `.TH` header line of a rendered manual page. +fn title_header(page: &str) -> Result { + page.lines() + .find(|line| line.starts_with(".TH ")) + .map(str::trim_end) + .map(ToOwned::to_owned) + .context("rendered manual page should carry a `.TH` header") +} + +#[test] +fn package_and_target_names_diverge() { + // Without this divergence the remaining assertions would hold trivially. + assert_eq!(PACKAGE_NAME, "netsuke-build", "Cargo package name drifted"); + assert_eq!(cli_name(), "netsuke", "CLI command name drifted"); +} + +#[test] +fn manual_page_is_named_for_the_command_not_the_package() -> Result<()> { + let path = generated_man_page(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .context("generated manual page should have a UTF-8 file name")?; + ensure!( + file_name == format!("{name}.1", name = cli_name()), + "manual page {file_name} should be named for the command" + ); + ensure!( + file_name == "netsuke.1", + "packaging and `man netsuke` expect netsuke.1, found {file_name}" + ); + ensure!( + file_name != format!("{PACKAGE_NAME}.1"), + "manual page must not be named after the Cargo package" + ); + Ok(()) +} + +#[test] +fn manual_page_is_staged_under_the_target_and_profile_directory() -> Result<()> { + let relative = Path::new(GENERATED_MAN_PAGE); + let components: Vec<_> = relative + .components() + .filter_map(|component| match component { + Component::Normal(part) => part.to_str(), + _ => None, + }) + .collect(); + // target/generated-man///netsuke.1 + ensure!( + components.len() >= 5, + "manual page path {GENERATED_MAN_PAGE} should carry target and profile directories" + ); + let prefix = components + .get(..2) + .context("manual page path should start with a staging directory")?; + ensure!( + prefix == ["target", "generated-man"], + "manual page should stage under target/generated-man, found {GENERATED_MAN_PAGE}" + ); + Ok(()) +} + +#[test] +fn manual_page_source_is_stamped_with_the_command_name() -> Result<()> { + let path = generated_man_page(); + let page = test_fs::read_to_string(&path) + .with_context(|| format!("read generated manual page {}", path.display()))?; + let header = title_header(&page)?; + let name = cli_name(); + ensure!( + header.starts_with(&format!(".TH {name} 1 ")), + "manual page title should announce the command and section 1: {header}" + ); + ensure!( + header.contains(&format!("\"{name} {PACKAGE_VERSION}\"")), + "manual page source should read `{name} {PACKAGE_VERSION}`: {header}" + ); + ensure!( + !header.contains(PACKAGE_NAME), + "manual page title must not surface the Cargo package name: {header}" + ); + Ok(()) +} diff --git a/tests/packaging_smoke_tests.rs b/tests/packaging_smoke_tests.rs index 9e9b7216c..bcded31b9 100644 --- a/tests/packaging_smoke_tests.rs +++ b/tests/packaging_smoke_tests.rs @@ -38,7 +38,13 @@ fn required_catalogue_paths() -> Vec { fn packaged_manifest_retains_build_script_sources() { let cargo_binary = env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); let publish_output = Command::new(&cargo_binary) - .args(["publish", "--dry-run", "--allow-dirty", "-p", "netsuke"]) + .args([ + "publish", + "--dry-run", + "--allow-dirty", + "-p", + "netsuke-build", + ]) .current_dir(env!("CARGO_MANIFEST_DIR")) .output() .unwrap_or_else(|error| panic!("run cargo publish --dry-run: {error}")); @@ -50,7 +56,7 @@ fn packaged_manifest_retains_build_script_sources() { ); let list_output = Command::new(cargo_binary) - .args(["package", "--list", "--allow-dirty", "-p", "netsuke"]) + .args(["package", "--list", "--allow-dirty", "-p", "netsuke-build"]) .current_dir(env!("CARGO_MANIFEST_DIR")) .output() .unwrap_or_else(|error| panic!("run cargo package --list: {error}"));