diff --git a/CLAUDE.md b/CLAUDE.md index e901ac76..d4eb60e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,3 +18,29 @@ Both DSLs live inside `crates/hm-dsl-engine/` so they ship with the crate: - `crates/hm-dsl-engine/harmont-py/` — the `harmont` Python package (pipeline DSL). - `crates/hm-dsl-engine/harmont-ts/` — the `harmont` TypeScript package (pipeline DSL). + +## Keep the SDK, `hm init` templates, and docs in sync + +The toolchain helpers in `crates/hm-dsl-engine/` (e.g. +`harmont-py/harmont/_rust.py`, `harmont-ts/src/toolchains/rust.ts`) are the +**public authoring SDK**. They have two downstream surfaces that drift silently +unless you update them in the same change. **A toolchain change is not done until +all three agree:** + +1. **`hm init` templates** — `crates/hm/src/commands/init_templates/.py`, + embedded into the binary via `include_str!` in `crates/hm/src/commands/init.rs`. + When you change a toolchain's recommended entrypoint (e.g. Rust → + `rust.project().ci()`), update the matching template so scaffolded projects use + the current API. Roundtrip tests: `crates/hm/tests/cmd_init.rs`. + +2. **Pipeline-SDK reference docs** — + `docs-site/content/docs/pipeline-sdk/reference/toolchains/.mdx` are + **auto-generated from the Python docstrings** in `harmont-py` (griffe → + `docs-site/scripts/extract-dsl-api.py` → `generate-dsl-docs.ts`); they carry a + "do not edit" header. So: (a) write/refresh the docstring on any method you add + or change, then (b) regenerate from the simci repo root with `make docs-generate` + (DSL-only: rebuild `docs-site/dsl-api.json` from `harmont-py`, then + `cd docs-site && npx tsx scripts/generate-dsl-docs.ts && npx tsx scripts/check-dsl-pages.ts`), + and (c) commit the regenerated `*.mdx` in the **simci (parent) repo** alongside + the gitlink bump. `check-dsl-pages.ts` guards that the committed pages match the + docstrings. diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index f43f85c1..88d1fa60 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -95,6 +95,18 @@ def _doc_env(kw: dict[str, Any], *, deny_warnings: bool) -> None: kw["env"] = merged +def _with_target_add(cargo: str, *, target: str | None, add_target: bool) -> str: + """Prepend ``rustup target add `` when cross-compiling. + + The rustup target must be installed before cargo can build for it. Steps + fork from the warmup snapshot (host target only), so the install lives in + the compiling leaf. Idempotent; ``add_target=False`` skips it (e.g. when the + runner image already has the target).""" + if target is not None and add_target: + return f"rustup target add {shlex.quote(target)} && {cargo}" + return cargo + + def _hack_cmd( *, subcommand: str = "check", @@ -157,6 +169,7 @@ def build( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = False, release: bool = False, profile: str | None = None, @@ -164,21 +177,24 @@ def build( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Compile the crate/workspace (``cargo build``). ``target=`` cross-compiles + and auto-runs ``rustup target add`` first (``add_target=False`` to skip).""" + cmd = _build_cmd( + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + release=release, + profile=profile, + locked=locked, + flags=flags, + ) return self._emit( - _build_cmd( - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - release=release, - profile=profile, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: build", **kw, ) @@ -194,6 +210,7 @@ def test( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = False, release: bool = False, profile: str | None = None, @@ -201,22 +218,26 @@ def test( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Run tests — ``cargo test``, or ``cargo nextest run`` when ``nextest=True`` + (nextest skips doctests; use ``doctest()``). ``target=`` auto-installs the + rustup target.""" + cmd = _test_cmd( + nextest=nextest, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + release=release, + profile=profile, + locked=locked, + flags=flags, + ) return self._emit( - _test_cmd( - nextest=nextest, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - release=release, - profile=profile, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: test", **kw, ) @@ -231,22 +252,26 @@ def doctest( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, locked: bool = True, flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Run documentation tests (``cargo test --doc``). Pair with + ``test(nextest=True)``, which does not run them.""" + cmd = _doctest_cmd( + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ) return self._emit( - _doctest_cmd( - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: doctest", **kw, ) @@ -261,6 +286,7 @@ def clippy( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = True, locked: bool = True, deny_warnings: bool = True, @@ -268,21 +294,25 @@ def clippy( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Lint with Clippy (``cargo clippy``). ``deny_warnings=True`` (default) + appends ``-- -D warnings``; ``extra_lints=(...)`` adds more. ``target=`` + auto-installs the rustup target. Defaults ``all_targets=True``.""" + cmd = _clippy_cmd( + deny_warnings=deny_warnings, + extra_lints=extra_lints, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + locked=locked, + flags=flags, + ) return self._emit( - _clippy_cmd( - deny_warnings=deny_warnings, - extra_lints=extra_lints, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: clippy", **kw, ) @@ -295,6 +325,8 @@ def fmt( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Check formatting (``cargo fmt --all --check``). Set ``all=False`` or + ``check=False`` to narrow.""" return self._emit(_fmt_cmd(all=all, check=check, flags=flags), ":rust: fmt", **kw) def doc( @@ -309,31 +341,38 @@ def doc( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, locked: bool = True, deny_warnings: bool = True, flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Build API docs (``cargo doc``). ``deny_warnings=True`` (default) sets + ``RUSTDOCFLAGS=-D warnings``; ``document_private_items=True`` includes + private items. ``target=`` auto-installs the rustup target.""" _doc_env(kw, deny_warnings=deny_warnings) + cmd = _doc_cmd( + no_deps=no_deps, + document_private_items=document_private_items, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ) return self._emit( - _doc_cmd( - no_deps=no_deps, - document_private_items=document_private_items, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: doc", **kw, ) def warmup(self, **kw: Any) -> Step: + """Pre-build dependencies (``cargo build --workspace --tests --locked``) so + later steps reuse the compile. Used internally by ``hm.rust.project()``.""" return self._emit( "cargo build --workspace --tests --locked", ":rust: warmup", @@ -410,6 +449,7 @@ def build( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = False, release: bool = False, profile: str | None = None, @@ -417,21 +457,24 @@ def build( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Compile the workspace (``cargo build --workspace``), reusing the shared + warmup. ``target=`` cross-compiles and auto-installs the rustup target.""" + cmd = _build_cmd( + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + release=release, + profile=profile, + locked=locked, + flags=flags, + ) return self._emit( - _build_cmd( - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - release=release, - profile=profile, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: build", **kw, ) @@ -447,6 +490,7 @@ def test( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = False, release: bool = False, profile: str | None = None, @@ -454,22 +498,26 @@ def test( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Run workspace tests (``cargo test`` / ``cargo nextest run``), reusing the + shared warmup. See ``doctest()`` for doctests under nextest; ``target=`` + auto-installs the rustup target.""" + cmd = _test_cmd( + nextest=nextest, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + release=release, + profile=profile, + locked=locked, + flags=flags, + ) return self._emit( - _test_cmd( - nextest=nextest, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - release=release, - profile=profile, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: test", **kw, ) @@ -484,22 +532,25 @@ def doctest( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, locked: bool = True, flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Run workspace doctests (``cargo test --doc``), reusing the shared warmup.""" + cmd = _doctest_cmd( + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ) return self._emit( - _doctest_cmd( - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: doctest", **kw, ) @@ -514,6 +565,7 @@ def clippy( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, all_targets: bool = True, locked: bool = True, deny_warnings: bool = True, @@ -521,21 +573,24 @@ def clippy( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Lint the workspace with Clippy (``-- -D warnings`` by default), reusing + the shared warmup. ``target=`` auto-installs the rustup target.""" + cmd = _clippy_cmd( + deny_warnings=deny_warnings, + extra_lints=extra_lints, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + all_targets=all_targets, + locked=locked, + flags=flags, + ) return self._emit( - _clippy_cmd( - deny_warnings=deny_warnings, - extra_lints=extra_lints, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - all_targets=all_targets, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: clippy", **kw, ) @@ -548,6 +603,8 @@ def fmt( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Check formatting (``cargo fmt --all --check``). Chains off the toolchain + install so it runs in parallel with the warmup.""" # fmt has no warmup dependency; chain off the install step (like the # toolchain) so it can run without waiting on the build warmup. return self.toolchain.fmt(all=all, check=check, flags=flags, **kw) @@ -564,26 +621,31 @@ def doc( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + add_target: bool = True, locked: bool = True, deny_warnings: bool = True, flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Build workspace docs (``cargo doc``) with ``RUSTDOCFLAGS=-D warnings`` by + default, reusing the shared warmup. ``target=`` auto-installs the rustup + target.""" _doc_env(kw, deny_warnings=deny_warnings) + cmd = _doc_cmd( + no_deps=no_deps, + document_private_items=document_private_items, + workspace=workspace, + packages=packages, + exclude=exclude, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ) return self._emit( - _doc_cmd( - no_deps=no_deps, - document_private_items=document_private_items, - workspace=workspace, - packages=packages, - exclude=exclude, - all_features=all_features, - no_default_features=no_default_features, - features=features, - target=target, - locked=locked, - flags=flags, - ), + _with_target_add(cmd, target=target, add_target=add_target), ":rust: doc", **kw, ) @@ -611,6 +673,8 @@ def ci(self, *, nextest: bool = False, doc: bool = False) -> tuple[Step, ...]: return tuple(steps) def feature_powerset(self, **kw: Any) -> Step: + """Run a cargo-hack feature sweep — delegates to + ``RustToolchain.feature_powerset``.""" return self.toolchain.feature_powerset(**kw) diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py index 221730bd..e0673f0a 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -234,6 +234,12 @@ def test_doctest_target(self): tc = hm.rust.toolchain(path=".") s = tc.doctest(target="wasm32-unknown-unknown") assert s.cmd.endswith("cargo test --target wasm32-unknown-unknown --locked --doc") + assert "rustup target add wasm32-unknown-unknown && cargo test" in s.cmd + + def test_test_nextest_target_auto_installs(self): + tc = hm.rust.toolchain(path=".") + s = tc.test(nextest=True, target="wasm32-unknown-unknown") + assert "rustup target add wasm32-unknown-unknown && cargo nextest run" in s.cmd def test_clippy_extra_lints_without_deny(self): tc = hm.rust.toolchain(path=".") @@ -272,6 +278,44 @@ def test_feature_powerset_label(self): tc = hm.rust.toolchain(path=".") assert tc.feature_powerset().label == ":rust: feature-powerset" + def test_build_target_auto_installs(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(target="wasm32-unknown-unknown") + assert s.cmd.startswith( + ". $HOME/.cargo/env && cd . && rustup target add wasm32-unknown-unknown && cargo build" + ) + assert "--target wasm32-unknown-unknown" in s.cmd + + def test_build_target_add_opt_out(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(target="wasm32-unknown-unknown", add_target=False) + assert "rustup target add" not in s.cmd + assert "--target wasm32-unknown-unknown" in s.cmd + + def test_clippy_target_auto_installs(self): + tc = hm.rust.toolchain(path=".") + s = tc.clippy(target="wasm32-unknown-unknown") + assert "rustup target add wasm32-unknown-unknown && cargo clippy" in s.cmd + + def test_test_target_auto_installs(self): + tc = hm.rust.toolchain(path=".") + s = tc.test(target="wasm32-unknown-unknown") + assert "rustup target add wasm32-unknown-unknown && cargo test" in s.cmd + + def test_doc_target_auto_installs(self): + tc = hm.rust.toolchain(path=".") + s = tc.doc(target="wasm32-unknown-unknown") + assert "rustup target add wasm32-unknown-unknown && cargo doc" in s.cmd + + def test_no_target_no_rustup_add(self): + tc = hm.rust.toolchain(path=".") + assert "rustup target add" not in tc.build().cmd + + def test_target_value_quoted_in_rustup_add(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(target="x; rm -rf /") + assert "rustup target add 'x; rm -rf /' &&" in s.cmd + # --- RustProject (hm.rust.project) --- @@ -443,6 +487,14 @@ def test_feature_powerset_delegates(self): s = proj.feature_powerset(subcommand="clippy") assert "cargo hack clippy --feature-powerset --depth 2 --no-dev-deps" in s.cmd + def test_project_build_target_auto_installs(self): + proj = hm.rust.project(path=".") + s = proj.build(target="wasm32-unknown-unknown") + assert ( + "rustup target add wasm32-unknown-unknown && " + "cargo build --workspace --target wasm32-unknown-unknown --locked" + ) in s.cmd + def test_no_shell_injection_via_packages(): tc = hm.rust.toolchain(path=".") diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py b/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py index 81597cc0..e08018d2 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py @@ -24,6 +24,7 @@ def test_golden_commands(): "cargo doc --no-deps --document-private-items --workspace --locked" ) assert _tail(p.build(packages=("core",), target="wasm32-unknown-unknown").cmd) == ( + "rustup target add wasm32-unknown-unknown && " "cargo build -p core --target wasm32-unknown-unknown --locked" ) assert ( diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts index a62e169c..5012718e 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts @@ -127,6 +127,12 @@ function denyDocEnv(step: ActionOptions, deny: boolean): ActionOptions { return { ...step, env: { RUSTDOCFLAGS: "-D warnings", ...(step.env ?? {}) } }; } +function withTargetAdd(cmd: string, target: string | undefined, addTarget: boolean): string { + return target !== undefined && addTarget + ? `rustup target add ${shQuote(target)} && ${cmd}` + : cmd; +} + // --- classes --- export class RustToolchain { @@ -149,35 +155,38 @@ export class RustToolchain { ); } - build(opts?: CargoActionOptions): Step { - const { cargo, step } = splitCargo(opts); - return this._cargo(buildCmd(cargo), ":rust: build", step); + build(opts?: CargoActionOptions & { addTarget?: boolean }): Step { + const { addTarget, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo(rest); + const cmd = withTargetAdd(buildCmd(cargo), cargo.target, addTarget ?? true); + return this._cargo(cmd, ":rust: build", step); } - test(opts?: CargoActionOptions & { nextest?: boolean }): Step { - const { nextest, ...rest } = opts ?? {}; + test(opts?: CargoActionOptions & { nextest?: boolean; addTarget?: boolean }): Step { + const { nextest, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo(rest); - return this._cargo(testCmd(cargo, nextest ?? false), ":rust: test", step); + const cmd = withTargetAdd(testCmd(cargo, nextest ?? false), cargo.target, addTarget ?? true); + return this._cargo(cmd, ":rust: test", step); } - doctest(opts?: CargoActionOptions): Step { - const { cargo, step } = splitCargo(opts); - return this._cargo(doctestCmd(cargo), ":rust: doctest", step); + doctest(opts?: CargoActionOptions & { addTarget?: boolean }): Step { + const { addTarget, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo(rest); + const cmd = withTargetAdd(doctestCmd(cargo), cargo.target, addTarget ?? true); + return this._cargo(cmd, ":rust: doctest", step); } clippy( opts?: CargoActionOptions & { denyWarnings?: boolean; extraLints?: readonly string[]; + addTarget?: boolean; }, ): Step { - const { denyWarnings, extraLints, ...rest } = opts ?? {}; + const { denyWarnings, extraLints, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo({ allTargets: true, ...rest }); - return this._cargo( - clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), - ":rust: clippy", - step, - ); + const cmd = withTargetAdd(clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), cargo.target, addTarget ?? true); + return this._cargo(cmd, ":rust: clippy", step); } fmt( @@ -200,15 +209,13 @@ export class RustToolchain { noDeps?: boolean; documentPrivateItems?: boolean; denyWarnings?: boolean; + addTarget?: boolean; }, ): Step { - const { noDeps, documentPrivateItems, denyWarnings, ...rest } = opts ?? {}; + const { noDeps, documentPrivateItems, denyWarnings, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo(rest); - return this._cargo( - docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), - ":rust: doc", - denyDocEnv(step, denyWarnings ?? true), - ); + const cmd = withTargetAdd(docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), cargo.target, addTarget ?? true); + return this._cargo(cmd, ":rust: doc", denyDocEnv(step, denyWarnings ?? true)); } warmup(opts?: ActionOptions): Step { @@ -260,35 +267,38 @@ export class RustProject { ); } - build(opts?: CargoActionOptions): Step { - const { cargo, step } = splitCargo({ workspace: true, ...opts }); - return this._emit(buildCmd(cargo), ":rust: build", step); + build(opts?: CargoActionOptions & { addTarget?: boolean }): Step { + const { addTarget, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ workspace: true, ...rest }); + const cmd = withTargetAdd(buildCmd(cargo), cargo.target, addTarget ?? true); + return this._emit(cmd, ":rust: build", step); } - test(opts?: CargoActionOptions & { nextest?: boolean }): Step { - const { nextest, ...rest } = opts ?? {}; + test(opts?: CargoActionOptions & { nextest?: boolean; addTarget?: boolean }): Step { + const { nextest, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo({ workspace: true, ...rest }); - return this._emit(testCmd(cargo, nextest ?? false), ":rust: test", step); + const cmd = withTargetAdd(testCmd(cargo, nextest ?? false), cargo.target, addTarget ?? true); + return this._emit(cmd, ":rust: test", step); } - doctest(opts?: CargoActionOptions): Step { - const { cargo, step } = splitCargo({ workspace: true, ...opts }); - return this._emit(doctestCmd(cargo), ":rust: doctest", step); + doctest(opts?: CargoActionOptions & { addTarget?: boolean }): Step { + const { addTarget, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ workspace: true, ...rest }); + const cmd = withTargetAdd(doctestCmd(cargo), cargo.target, addTarget ?? true); + return this._emit(cmd, ":rust: doctest", step); } clippy( opts?: CargoActionOptions & { denyWarnings?: boolean; extraLints?: readonly string[]; + addTarget?: boolean; }, ): Step { - const { denyWarnings, extraLints, ...rest } = opts ?? {}; + const { denyWarnings, extraLints, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo({ workspace: true, allTargets: true, ...rest }); - return this._emit( - clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), - ":rust: clippy", - step, - ); + const cmd = withTargetAdd(clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), cargo.target, addTarget ?? true); + return this._emit(cmd, ":rust: clippy", step); } fmt( @@ -307,15 +317,13 @@ export class RustProject { noDeps?: boolean; documentPrivateItems?: boolean; denyWarnings?: boolean; + addTarget?: boolean; }, ): Step { - const { noDeps, documentPrivateItems, denyWarnings, ...rest } = opts ?? {}; + const { noDeps, documentPrivateItems, denyWarnings, addTarget, ...rest } = opts ?? {}; const { cargo, step } = splitCargo({ workspace: true, ...rest }); - return this._emit( - docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), - ":rust: doc", - denyDocEnv(step, denyWarnings ?? true), - ); + const cmd = withTargetAdd(docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), cargo.target, addTarget ?? true); + return this._emit(cmd, ":rust: doc", denyDocEnv(step, denyWarnings ?? true)); } featurePowerset(opts?: FeaturePowersetOptions): Step { diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts index c7456da9..a6893842 100644 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts +++ b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts @@ -19,7 +19,10 @@ describe("rust parity golden strings", () => { ); expect( tail(p.build({ packages: ["core"], target: "wasm32-unknown-unknown" })._cmd!), - ).toBe("cargo build -p core --target wasm32-unknown-unknown --locked"); + ).toBe( + "rustup target add wasm32-unknown-unknown && " + + "cargo build -p core --target wasm32-unknown-unknown --locked", + ); expect( tail(p.featurePowerset({ subcommand: "check", skip: ["a b", "c"] })._cmd!), ).toBe("cargo hack check --feature-powerset --depth 2 --no-dev-deps --skip 'a b',c"); diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust.test.ts index 03c5c675..508b8840 100644 --- a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust.test.ts +++ b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust.test.ts @@ -176,6 +176,34 @@ describe("rust.toolchain", () => { expect(s._cmd).toContain("cargo hack check --feature-powerset --depth 2 --no-dev-deps"); expect(s._parent!._cmd).toContain("cargo install cargo-hack --locked"); }); + + it("build target auto-installs the rustup target", () => { + const s = rust.toolchain().build({ target: "wasm32-unknown-unknown" }); + expect(s._cmd).toContain("rustup target add wasm32-unknown-unknown && cargo build"); + expect(s._cmd).toContain("--target wasm32-unknown-unknown"); + }); + + it("addTarget:false opts out of the rustup install", () => { + const s = rust.toolchain().build({ target: "wasm32-unknown-unknown", addTarget: false }); + expect(s._cmd).not.toContain("rustup target add"); + expect(s._cmd).toContain("--target wasm32-unknown-unknown"); + }); + + it("clippy target auto-installs", () => { + expect(rust.toolchain().clippy({ target: "wasm32-unknown-unknown" })._cmd).toContain( + "rustup target add wasm32-unknown-unknown && cargo clippy", + ); + }); + + it("test nextest+target auto-installs", () => { + expect(rust.toolchain().test({ target: "wasm32-unknown-unknown", nextest: true })._cmd).toContain( + "rustup target add wasm32-unknown-unknown && cargo nextest run", + ); + }); + + it("no target means no rustup add", () => { + expect(rust.toolchain().build()._cmd).not.toContain("rustup target add"); + }); }); describe("rust.project", () => { @@ -302,6 +330,12 @@ describe("rust.project", () => { expect(p.doc()._cmd).toContain("cargo doc --no-deps --workspace --locked"); }); + it("project build target auto-installs", () => { + expect(rust.project({ path: "." }).build({ target: "wasm32-unknown-unknown" })._cmd).toContain( + "rustup target add wasm32-unknown-unknown && cargo build --workspace --target wasm32-unknown-unknown --locked", + ); + }); + it("ci returns standard DAG", () => { const p = rust.project({ path: "." }); expect(p.ci().map((s) => s._label)).toEqual([":rust: test", ":rust: clippy", ":rust: fmt"]); diff --git a/crates/hm/src/commands/init_templates/rust.py b/crates/hm/src/commands/init_templates/rust.py index bcb64f56..707d8fda 100644 --- a/crates/hm/src/commands/init_templates/rust.py +++ b/crates/hm/src/commands/init_templates/rust.py @@ -1,13 +1,16 @@ """Rust CI pipeline.""" + from __future__ import annotations import harmont as hm -from harmont._rust import RustToolchain +from harmont._rust import RustProject @hm.target() -def project() -> RustToolchain: - return hm.rust.toolchain(path=".") +def project() -> RustProject: + # project() warms a shared dependency cache (keyed on Cargo.lock + sources) + # so test/clippy/fmt reuse one compile. + return hm.rust.project(path=".") @hm.pipeline( @@ -15,10 +18,8 @@ def project() -> RustToolchain: env={"CI": "true"}, triggers=[hm.push(branch="main")], ) -def ci(project: hm.Target[RustToolchain]) -> tuple[hm.Step, ...]: - return ( - project.build(), - project.test(), - project.clippy(), - project.fmt(), - ) +def ci(project: hm.Target[RustProject]) -> tuple[hm.Step, ...]: + # ci() is the zero-config DAG: test + clippy + fmt sharing one warmup. + # To cross-compile, add e.g. project.build(target="wasm32-unknown-unknown") — + # the rustup target is installed automatically. + return project.ci() diff --git a/crates/hm/tests/cmd_init.rs b/crates/hm/tests/cmd_init.rs index eb8b704a..a98d366c 100644 --- a/crates/hm/tests/cmd_init.rs +++ b/crates/hm/tests/cmd_init.rs @@ -24,13 +24,15 @@ fn init_rust_creates_pipeline_py() { assert!(pipeline.exists(), "expected {}", pipeline.display()); let content = std::fs::read_to_string(&pipeline).unwrap(); + assert!(content.contains("@hm.pipeline"), "expected pipeline decorator"); assert!( - content.contains("hm.rust"), - "expected rust toolchain import" + content.contains("hm.rust.project("), + "expected rust.project() entrypoint, got:\n{content}" ); + assert!(content.contains(".ci()"), "expected the one-call .ci() DAG, got:\n{content}"); assert!( - content.contains("@hm.pipeline"), - "expected pipeline decorator" + !content.contains("rust.toolchain("), + "template should not use the legacy toolchain() API, got:\n{content}" ); }