From 3f80145434d069fcbbe9a344c093bc5d1cf59ff6 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:36:09 +0000 Subject: [PATCH 1/6] feat(rust-sdk/py): target= auto-installs the rustup target (add_target opt-out) --- .../hm-dsl-engine/harmont-py/harmont/_rust.py | 300 ++++++++++-------- .../harmont-py/tests/test_rust.py | 46 +++ .../harmont-py/tests/test_rust_parity.py | 1 + 3 files changed, 213 insertions(+), 134 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index f43f85c1..dd202126 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,22 @@ def build( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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 +208,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 +216,23 @@ def test( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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 +247,24 @@ 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: + 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 +279,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 +287,22 @@ def clippy( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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, ) @@ -309,26 +329,28 @@ 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: _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, ) @@ -410,6 +432,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 +440,22 @@ def build( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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 +471,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 +479,23 @@ def test( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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 +510,24 @@ 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: + 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 +542,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 +550,22 @@ def clippy( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + 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, ) @@ -564,26 +594,28 @@ 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: _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, ) 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..964e10fd 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -272,6 +272,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 +481,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 ( From 3eb254b5d23b6cf9b16c08bb433280c7fe3d38a3 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:41:16 +0000 Subject: [PATCH 2/6] test(rust-sdk/py): assert rustup prefix on doctest+target and nextest+target --- crates/hm-dsl-engine/harmont-py/tests/test_rust.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 964e10fd..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=".") From e15cb8814d546e7a1d6c6635d7f13f8d92db2386 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:43:22 +0000 Subject: [PATCH 3/6] feat(rust-sdk/ts): target auto-installs the rustup target (addTarget opt-out), at parity --- .../harmont-ts/src/toolchains/rust.ts | 92 ++++++++++--------- .../tests/toolchains/rust-parity.test.ts | 5 +- .../harmont-ts/tests/toolchains/rust.test.ts | 34 +++++++ 3 files changed, 88 insertions(+), 43 deletions(-) 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"]); From 8bc9582970e6c1c741962b907e8e53f4d1899107 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:45:39 +0000 Subject: [PATCH 4/6] fix(hm init): Rust template uses rust.project().ci() Replace the legacy toolchain() + explicit build/test/clippy/fmt tuple with the rust.project().ci() one-call DAG. Strengthen the test to assert the new API and reject the old one. --- crates/hm/src/commands/init_templates/rust.py | 21 ++++++++++--------- crates/hm/tests/cmd_init.rs | 10 +++++---- 2 files changed, 17 insertions(+), 14 deletions(-) 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}" ); } From 0767f0f76d056caa1a642cd825fb32474c11d2dd Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:49:35 +0000 Subject: [PATCH 5/6] docs(rust-sdk): docstrings on every action method (incl. target auto-install) --- .../hm-dsl-engine/harmont-py/harmont/_rust.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index dd202126..88d1fa60 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -177,6 +177,8 @@ 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, @@ -216,6 +218,9 @@ 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, @@ -252,6 +257,8 @@ def doctest( 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, @@ -287,6 +294,9 @@ 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, @@ -315,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( @@ -335,6 +347,9 @@ def doc( 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, @@ -356,6 +371,8 @@ def doc( ) 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", @@ -440,6 +457,8 @@ 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, @@ -479,6 +498,9 @@ 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, @@ -515,6 +537,7 @@ def doctest( flags: tuple[str, ...] = (), **kw: Any, ) -> Step: + """Run workspace doctests (``cargo test --doc``), reusing the shared warmup.""" cmd = _doctest_cmd( workspace=workspace, packages=packages, @@ -550,6 +573,8 @@ 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, @@ -578,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) @@ -600,6 +627,9 @@ def doc( 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, @@ -643,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) From 763cf8c2eb9a02772b28000e46d6e9870fc9e511 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Fri, 12 Jun 2026 16:51:10 +0000 Subject: [PATCH 6/6] docs(claude-md): require docs + hm init template updates with any SDK change --- CLAUDE.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) 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.