From 5b7aa5c86a0d4ef7ebae746fd3763a13e593f7c5 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:17:12 +0000 Subject: [PATCH 01/15] feat(rust-sdk/py): add shared cargo argument assembler --- .../harmont-py/harmont/_cargo.py | 98 ++++++++++++++++ .../harmont-py/tests/test_cargo.py | 105 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 crates/hm-dsl-engine/harmont-py/harmont/_cargo.py create mode 100644 crates/hm-dsl-engine/harmont-py/tests/test_cargo.py diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py b/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py new file mode 100644 index 00000000..d63e0742 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py @@ -0,0 +1,98 @@ +"""Shared cargo-argument assembly for the Rust toolchain helper. + +Turns structured options (features, packages, target, …) into a +canonically-ordered, shell-safe cargo argument string. Every user-supplied +*value* (package/exclude names, the joined feature list, target triple, +profile name) is shell-quoted; the raw escape-hatch ``flags`` pass through +verbatim because they are meant to be literal cargo arguments. +""" + +from __future__ import annotations + +import shlex +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CargoOpts: + """Structured cargo invocation options. See the SDK design reference for + the canonical token order each field lowers to.""" + + workspace: bool = False + packages: tuple[str, ...] = () + exclude: tuple[str, ...] = () + all_features: bool = False + no_default_features: bool = False + features: tuple[str, ...] = () + target: str | None = None + all_targets: bool = False + release: bool = False + profile: str | None = None + locked: bool = True + flags: tuple[str, ...] = () + + +def _validate(opts: CargoOpts) -> None: + if opts.all_features and (opts.features or opts.no_default_features): + msg = ( + "hm.rust: --all-features conflicts with features=/no_default_features=\n" + f" observed: all_features=True, features={list(opts.features)!r}, " + f"no_default_features={opts.no_default_features!r}\n" + " → pass all_features=True alone, or list explicit features= " + "without all_features" + ) + raise ValueError(msg) + if opts.release and opts.profile is not None: + msg = ( + "hm.rust: release=True conflicts with profile=\n" + f" observed: release=True, profile={opts.profile!r}\n" + ' → use profile="release" (identical effect) or drop one' + ) + raise ValueError(msg) + + +def cargo_flags(opts: CargoOpts) -> str: + """Assemble the cargo argument middle (after the subcommand, before any + ``--`` passthrough). Returns a leading-space string, or ``""`` when empty. + """ + _validate(opts) + toks: list[str] = [] + + # scope + if opts.packages: + toks += [f"-p {shlex.quote(p)}" for p in opts.packages] + toks += [f"--exclude {shlex.quote(e)}" for e in opts.exclude] + elif opts.workspace: + toks.append("--workspace") + + # target selection + if opts.all_targets: + toks.append("--all-targets") + + # features + if opts.all_features: + toks.append("--all-features") + else: + if opts.no_default_features: + toks.append("--no-default-features") + if opts.features: + toks.append("--features " + shlex.quote(",".join(opts.features))) + + # target triple + if opts.target is not None: + toks.append(f"--target {shlex.quote(opts.target)}") + + # profile / release + if opts.profile is not None: + toks.append(f"--profile {shlex.quote(opts.profile)}") + elif opts.release: + toks.append("--release") + + # lockfile + if opts.locked: + toks.append("--locked") + + # escape hatch — verbatim + toks += list(opts.flags) + + return (" " + " ".join(toks)) if toks else "" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py b/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py new file mode 100644 index 00000000..b5b2194e --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py @@ -0,0 +1,105 @@ +"""Cargo argument assembler tests.""" + +from __future__ import annotations + +import pytest + +from harmont._cargo import CargoOpts, cargo_flags + + +def test_empty_opts_emit_only_locked(): + assert cargo_flags(CargoOpts()) == " --locked" + + +def test_locked_can_be_disabled(): + assert cargo_flags(CargoOpts(locked=False)) == "" + + +def test_workspace_scope(): + assert cargo_flags(CargoOpts(workspace=True)) == " --workspace --locked" + + +def test_packages_take_precedence_over_workspace(): + out = cargo_flags(CargoOpts(workspace=True, packages=("a", "b"))) + assert out == " -p a -p b --locked" + + +def test_exclude_only_with_packages(): + out = cargo_flags(CargoOpts(packages=("a",), exclude=("b", "c"))) + assert out == " -p a --exclude b --exclude c --locked" + + +def test_all_features(): + assert cargo_flags(CargoOpts(all_features=True)) == " --all-features --locked" + + +def test_features_joined_comma(): + out = cargo_flags(CargoOpts(features=("x", "y"))) + assert out == " --features x,y --locked" + + +def test_no_default_features_with_features(): + out = cargo_flags(CargoOpts(no_default_features=True, features=("x",))) + assert out == " --no-default-features --features x --locked" + + +def test_target_and_release(): + out = cargo_flags(CargoOpts(target="wasm32-unknown-unknown", release=True)) + assert out == " --target wasm32-unknown-unknown --release --locked" + + +def test_profile_overrides_release_token_position(): + out = cargo_flags(CargoOpts(profile="ci")) + assert out == " --profile ci --locked" + + +def test_all_targets(): + out = cargo_flags(CargoOpts(workspace=True, all_targets=True)) + assert out == " --workspace --all-targets --locked" + + +def test_flags_appended_verbatim_after_locked(): + out = cargo_flags(CargoOpts(workspace=True, flags=("--no-fail-fast",))) + assert out == " --workspace --locked --no-fail-fast" + + +def test_full_token_order(): + out = cargo_flags( + CargoOpts( + packages=("core",), + all_targets=True, + no_default_features=True, + features=("a", "b"), + target="x86_64-unknown-linux-gnu", + profile="ci", + flags=("--keep-going",), + ) + ) + assert out == ( + " -p core --all-targets --no-default-features --features a,b" + " --target x86_64-unknown-linux-gnu --profile ci --locked --keep-going" + ) + + +def test_package_value_is_shell_quoted(): + out = cargo_flags(CargoOpts(packages=("evil; rm -rf /",))) + assert out == " -p 'evil; rm -rf /' --locked" + + +def test_simple_identifier_not_quoted(): + assert cargo_flags(CargoOpts(packages=("harmont-core",))) == " -p harmont-core --locked" + + +def test_flags_are_not_quoted(): + out = cargo_flags(CargoOpts(locked=False, flags=("--features=a b",))) + assert out == " --features=a b" + + +def test_all_features_conflict_raises(): + with pytest.raises(ValueError, match="all-features"): + cargo_flags(CargoOpts(all_features=True, features=("x",))) + + +def test_release_profile_conflict_raises(): + with pytest.raises(ValueError, match="profile"): + cargo_flags(CargoOpts(release=True, profile="ci")) From e8f363f29b8645752c58d15f0538df4aad654e6b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:20:46 +0000 Subject: [PATCH 02/15] fix(rust-sdk/py): exclude= pairs with workspace, not packages (cargo semantics) --- .../harmont-py/harmont/_cargo.py | 19 ++++++++++++++++++- .../harmont-py/tests/test_cargo.py | 16 +++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py b/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py index d63e0742..2b1dd6c6 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_cargo.py @@ -49,6 +49,23 @@ def _validate(opts: CargoOpts) -> None: ' → use profile="release" (identical effect) or drop one' ) raise ValueError(msg) + if opts.exclude: + if opts.packages: + msg = ( + "hm.rust: exclude= cannot combine with packages=\n" + f" observed: packages={list(opts.packages)!r}, " + f"exclude={list(opts.exclude)!r}\n" + " → --exclude pairs with --workspace; packages= already selects " + "explicitly, so drop one" + ) + raise ValueError(msg) + if not opts.workspace: + msg = ( + "hm.rust: exclude= requires workspace=True\n" + f" observed: exclude={list(opts.exclude)!r} without workspace=True\n" + " → cargo --exclude only applies to --workspace; pass workspace=True" + ) + raise ValueError(msg) def cargo_flags(opts: CargoOpts) -> str: @@ -61,9 +78,9 @@ def cargo_flags(opts: CargoOpts) -> str: # scope if opts.packages: toks += [f"-p {shlex.quote(p)}" for p in opts.packages] - toks += [f"--exclude {shlex.quote(e)}" for e in opts.exclude] elif opts.workspace: toks.append("--workspace") + toks += [f"--exclude {shlex.quote(e)}" for e in opts.exclude] # target selection if opts.all_targets: diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py b/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py index b5b2194e..d2deadc1 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_cargo.py @@ -24,9 +24,19 @@ def test_packages_take_precedence_over_workspace(): assert out == " -p a -p b --locked" -def test_exclude_only_with_packages(): - out = cargo_flags(CargoOpts(packages=("a",), exclude=("b", "c"))) - assert out == " -p a --exclude b --exclude c --locked" +def test_exclude_pairs_with_workspace(): + out = cargo_flags(CargoOpts(workspace=True, exclude=("b", "c"))) + assert out == " --workspace --exclude b --exclude c --locked" + + +def test_exclude_without_workspace_raises(): + with pytest.raises(ValueError, match="workspace"): + cargo_flags(CargoOpts(exclude=("b",))) + + +def test_exclude_with_packages_raises(): + with pytest.raises(ValueError, match="exclude"): + cargo_flags(CargoOpts(packages=("a",), exclude=("b",))) def test_all_features(): From cdfbc9d80f369c98639c141ee873676b7151f810 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:24:26 +0000 Subject: [PATCH 03/15] feat(rust-sdk/py): full cargo options + nextest/doctest on RustToolchain --- .../hm-dsl-engine/harmont-py/harmont/_rust.py | 246 ++++++++++++++++-- .../harmont-py/tests/test_rust.py | 67 +++++ 2 files changed, 298 insertions(+), 15 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index bf6cfb24..389f67da 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any +from ._cargo import CargoOpts, cargo_flags from ._toolchain import make_install_chain from .cache import CacheForever, CacheOnChange @@ -40,12 +41,70 @@ def _rustup_cmd(version: str, components: tuple[str, ...]) -> str: ) +def _build_cmd(**o: Any) -> str: + return f"cargo build{cargo_flags(CargoOpts(**o))}" + + +def _test_cmd(*, nextest: bool = False, **o: Any) -> str: + runner = "cargo nextest run" if nextest else "cargo test" + return f"{runner}{cargo_flags(CargoOpts(**o))}" + + +def _doctest_cmd(**o: Any) -> str: + return f"cargo test{cargo_flags(CargoOpts(**o))} --doc" + + +def _clippy_cmd(*, deny_warnings: bool = True, extra_lints: tuple[str, ...] = (), **o: Any) -> str: + mid = cargo_flags(CargoOpts(**o)) + trail = (["-D warnings"] if deny_warnings else []) + list(extra_lints) + suffix = (" -- " + " ".join(trail)) if trail else "" + return f"cargo clippy{mid}{suffix}" + + +def _fmt_cmd( + *, + all: bool = True, # noqa: A002 + check: bool = True, + flags: tuple[str, ...] = (), +) -> str: + toks = ["cargo fmt"] + if all: + toks.append("--all") + if check: + toks.append("--check") + toks += list(flags) + return " ".join(toks) + + +def _doc_cmd(*, no_deps: bool = True, document_private_items: bool = False, **o: Any) -> str: + doc_toks: list[str] = [] + if no_deps: + doc_toks.append("--no-deps") + if document_private_items: + doc_toks.append("--document-private-items") + prefix = (" " + " ".join(doc_toks)) if doc_toks else "" + return f"cargo doc{prefix}{cargo_flags(CargoOpts(**o))}" + + +def _doc_env(kw: dict[str, Any], *, deny_warnings: bool) -> None: + if deny_warnings: + user_env = kw.get("env") + merged = dict(user_env) if user_env else {} + merged.setdefault("RUSTDOCFLAGS", "-D warnings") + kw["env"] = merged + + @dataclass(frozen=True) class RustToolchain: """Rust toolchain install chain — constructed via ``hm.rust.toolchain()``. - Holds the install step produced by rustup. Action methods (``build``, - ``test``, ``clippy``, ``fmt``, ``doc``) attach leaves to ``installed``. + Holds the rustup install step. Action methods (``build``, ``test``, + ``doctest``, ``clippy``, ``fmt``, ``doc``, ``warmup``) attach leaves to + ``installed``. Every action accepts the shared cargo options (``packages``, + ``features``, ``all_features``, ``no_default_features``, ``target``, + ``release``, ``profile``, ``locked``, ``workspace``) plus a ``flags`` + escape hatch, and forwards Step kwargs (``label``, ``cache``, ``env``, + ``image`` …) unchanged. """ path: str @@ -59,23 +118,183 @@ def _emit(self, cargo: str, default_label: str, **kw: Any) -> Step: kw["label"] = default_label return self.installed.sh(self._wrap(cargo), **kw) - def build(self, *, release: bool = False, **kw: Any) -> Step: - flag = " --release" if release else "" - return self._emit(f"cargo build{flag}", ":rust: build", **kw) + def build( + self, + *, + workspace: bool = False, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + all_targets: bool = False, + release: bool = False, + profile: str | None = None, + locked: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, + ), + ":rust: build", + **kw, + ) - def test(self, *, release: bool = False, **kw: Any) -> Step: - flag = " --release" if release else "" - return self._emit(f"cargo test{flag}", ":rust: test", **kw) + def test( + self, + *, + nextest: bool = False, + workspace: bool = False, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + release: bool = False, + profile: str | None = None, + locked: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, + release=release, + profile=profile, + locked=locked, + flags=flags, + ), + ":rust: test", + **kw, + ) - def clippy(self, **kw: Any) -> Step: + def doctest( + self, + *, + workspace: bool = False, + packages: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + locked: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: return self._emit( - "cargo clippy --all-targets -- -D warnings", + _doctest_cmd( + workspace=workspace, + packages=packages, + all_features=all_features, + no_default_features=no_default_features, + features=features, + locked=locked, + flags=flags, + ), + ":rust: doctest", + **kw, + ) + + def clippy( + self, + *, + workspace: bool = False, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + all_targets: bool = True, + locked: bool = True, + deny_warnings: bool = True, + extra_lints: tuple[str, ...] = (), + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, + ), ":rust: clippy", **kw, ) - def fmt(self, **kw: Any) -> Step: - return self._emit("cargo fmt --check", ":rust: fmt", **kw) + def fmt( + self, + *, + all: bool = True, # noqa: A002 + check: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + return self._emit(_fmt_cmd(all=all, check=check, flags=flags), ":rust: fmt", **kw) + + def doc( + self, + *, + no_deps: bool = True, + document_private_items: bool = False, + workspace: bool = False, + packages: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + locked: bool = True, + deny_warnings: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + _doc_env(kw, deny_warnings=deny_warnings) + return self._emit( + _doc_cmd( + no_deps=no_deps, + document_private_items=document_private_items, + workspace=workspace, + packages=packages, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ), + ":rust: doc", + **kw, + ) def warmup(self, **kw: Any) -> Step: return self._emit( @@ -84,9 +303,6 @@ def warmup(self, **kw: Any) -> Step: **kw, ) - def doc(self, **kw: Any) -> Step: - return self._emit("cargo doc --no-deps", ":rust: doc", **kw) - @dataclass(frozen=True) class RustProject: 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 fe65c060..c39938b3 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -159,6 +159,73 @@ def test_warmup_in_pipeline(self): assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 assert len([c for c in cmds if "apt-get install" in c]) == 1 + def test_build_locked_by_default(self): + tc = hm.rust.toolchain(path=".") + assert tc.build().cmd.endswith("cargo build --locked") + + def test_build_unlocked(self): + tc = hm.rust.toolchain(path=".") + assert tc.build(locked=False).cmd.endswith("cargo build") + + def test_build_features(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(features=("a", "b"), release=True) + assert "cargo build --features a,b --release --locked" in s.cmd + + def test_build_packages(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(packages=("core", "cli")) + assert "cargo build -p core -p cli --locked" in s.cmd + + def test_test_all_features(self): + tc = hm.rust.toolchain(path=".") + assert "cargo test --all-features --locked" in tc.test(all_features=True).cmd + + def test_test_nextest_switches_runner(self): + tc = hm.rust.toolchain(path=".") + s = tc.test(nextest=True, workspace=True) + assert "cargo nextest run --workspace --locked" in s.cmd + + def test_doctest_appends_doc(self): + tc = hm.rust.toolchain(path=".") + assert tc.doctest(workspace=True).cmd.endswith("cargo test --workspace --locked --doc") + + def test_doctest_default_label(self): + tc = hm.rust.toolchain(path=".") + assert tc.doctest().label == ":rust: doctest" + + def test_clippy_all_targets_locked_deny(self): + tc = hm.rust.toolchain(path=".") + assert "cargo clippy --all-targets --locked -- -D warnings" in tc.clippy().cmd + + def test_clippy_extra_lints(self): + tc = hm.rust.toolchain(path=".") + s = tc.clippy(extra_lints=("-W clippy::pedantic",)) + assert "-- -D warnings -W clippy::pedantic" in s.cmd + + def test_clippy_no_deny(self): + tc = hm.rust.toolchain(path=".") + assert " -- " not in tc.clippy(deny_warnings=False).cmd + + def test_fmt_all_check_default(self): + tc = hm.rust.toolchain(path=".") + assert tc.fmt().cmd.endswith("cargo fmt --all --check") + + def test_doc_sets_rustdocflags_env(self): + tc = hm.rust.toolchain(path=".") + s = tc.doc() + assert "cargo doc --no-deps --locked" in s.cmd + assert s.env == {"RUSTDOCFLAGS": "-D warnings"} + + def test_doc_respects_user_rustdocflags(self): + tc = hm.rust.toolchain(path=".") + s = tc.doc(env={"RUSTDOCFLAGS": "-D rustdoc::all"}) + assert s.env == {"RUSTDOCFLAGS": "-D rustdoc::all"} + + def test_doc_no_deny(self): + tc = hm.rust.toolchain(path=".") + assert tc.doc(deny_warnings=False).env is None + # --- RustProject (hm.rust.project) --- From 2ecdfa40411f6043be048d4e197946c7366cb93a Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:29:39 +0000 Subject: [PATCH 04/15] fix(rust-sdk/py): expose all_targets on test, target/exclude on doctest --- crates/hm-dsl-engine/harmont-py/harmont/_rust.py | 6 ++++++ .../hm-dsl-engine/harmont-py/tests/test_rust.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index 389f67da..fe954873 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -165,6 +165,7 @@ def test( no_default_features: bool = False, features: tuple[str, ...] = (), target: str | None = None, + all_targets: bool = False, release: bool = False, profile: str | None = None, locked: bool = True, @@ -181,6 +182,7 @@ def test( no_default_features=no_default_features, features=features, target=target, + all_targets=all_targets, release=release, profile=profile, locked=locked, @@ -195,9 +197,11 @@ def doctest( *, workspace: bool = False, packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), all_features: bool = False, no_default_features: bool = False, features: tuple[str, ...] = (), + target: str | None = None, locked: bool = True, flags: tuple[str, ...] = (), **kw: Any, @@ -206,9 +210,11 @@ def doctest( _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, ), 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 c39938b3..2673133b 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -226,6 +226,22 @@ def test_doc_no_deny(self): tc = hm.rust.toolchain(path=".") assert tc.doc(deny_warnings=False).env is None + def test_test_all_targets(self): + tc = hm.rust.toolchain(path=".") + s = tc.test(all_targets=True, workspace=True) + assert "cargo test --workspace --all-targets --locked" in s.cmd + + 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") + + def test_clippy_extra_lints_without_deny(self): + tc = hm.rust.toolchain(path=".") + s = tc.clippy(deny_warnings=False, extra_lints=("-W clippy::pedantic",)) + assert s.cmd.rstrip().endswith("-- -W clippy::pedantic") + assert "-D warnings" not in s.cmd + # --- RustProject (hm.rust.project) --- From 5e9b13bb6cfa91a66a2b3e9c20d5319b10b97f4c Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:32:22 +0000 Subject: [PATCH 05/15] feat(rust-sdk/py): RustProject build/doc/doctest/ci + packages on every action --- .../hm-dsl-engine/harmont-py/harmont/_rust.py | 218 ++++++++++++++++-- .../harmont-py/tests/test_rust.py | 69 +++++- 2 files changed, 264 insertions(+), 23 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index fe954873..a864df28 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -314,45 +314,227 @@ def warmup(self, **kw: Any) -> Step: class RustProject: """High-level Rust CI DAG — constructed via ``hm.rust.project()``. - Wraps a ``RustToolchain`` and a pre-built warmup step. Action methods - (``test``, ``clippy``, ``fmt``) attach leaves to the warmup so - dependency compilation is shared across CI actions. + Action methods (``build``, ``test``, ``doctest``, ``clippy``, ``fmt``, + ``doc``) attach leaves to the shared warmup step so dependency compilation + is reused. ``ci()`` returns the standard DAG in one call. Methods default + to ``workspace=True``. """ toolchain: RustToolchain warmup: Step + def _emit(self, cargo: str, default_label: str, **kw: Any) -> Step: + if kw.get("label") is None: + kw["label"] = default_label + return self.warmup.sh(self.toolchain._wrap(cargo), **kw) # noqa: SLF001 + + def build( + self, + *, + workspace: bool = True, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + all_targets: bool = False, + release: bool = False, + profile: str | None = None, + locked: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, + ), + ":rust: build", + **kw, + ) + def test( self, *, + nextest: bool = False, + workspace: bool = True, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + all_targets: bool = False, + release: bool = False, + profile: str | None = None, + locked: bool = True, flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, + ), + ":rust: test", + **kw, + ) + + def doctest( + self, + *, + workspace: bool = True, packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + locked: bool = True, + flags: tuple[str, ...] = (), **kw: Any, ) -> Step: - scope = " ".join(f"-p {p}" for p in packages) if packages else "--workspace" - extra = (" " + " ".join(flags)) if flags else "" - return self.warmup.sh( - self.toolchain._wrap(f"cargo test {scope} --locked{extra}"), # noqa: SLF001 - label=kw.pop("label", ":rust: test"), + 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, + ), + ":rust: doctest", **kw, ) - def clippy(self, *, flags: tuple[str, ...] = (), **kw: Any) -> Step: - extra = (" " + " ".join(flags)) if flags else "" - return self.warmup.sh( - self.toolchain._wrap( # noqa: SLF001 - f"cargo clippy --workspace --tests --locked{extra} -- -D warnings" + def clippy( + self, + *, + workspace: bool = True, + packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + all_targets: bool = True, + locked: bool = True, + deny_warnings: bool = True, + extra_lints: tuple[str, ...] = (), + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + 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, ), - label=kw.pop("label", ":rust: clippy"), + ":rust: clippy", **kw, ) - def fmt(self, *, flags: tuple[str, ...] = (), **kw: Any) -> Step: - extra = (" " + " ".join(flags)) if flags else "" - return self.toolchain._emit( # noqa: SLF001 - f"cargo fmt --check{extra}", ":rust: fmt", **kw + def fmt( + self, + *, + all: bool = True, # noqa: A002 + check: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + # 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) + + def doc( + self, + *, + no_deps: bool = True, + document_private_items: bool = False, + workspace: bool = True, + packages: tuple[str, ...] = (), + all_features: bool = False, + no_default_features: bool = False, + features: tuple[str, ...] = (), + target: str | None = None, + locked: bool = True, + deny_warnings: bool = True, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + _doc_env(kw, deny_warnings=deny_warnings) + return self._emit( + _doc_cmd( + no_deps=no_deps, + document_private_items=document_private_items, + workspace=workspace, + packages=packages, + all_features=all_features, + no_default_features=no_default_features, + features=features, + target=target, + locked=locked, + flags=flags, + ), + ":rust: doc", + **kw, ) + def ci(self, *, nextest: bool = False, doc: bool = False) -> tuple[Step, ...]: + """The zero-config Rust CI DAG: test + clippy + fmt, sharing one warmup. + + With ``nextest=True`` the test step uses ``cargo nextest run`` and a + companion ``doctest()`` step is appended (nextest cannot run doctests). + With ``doc=True`` a ``doc()`` step is appended. + + Examples: + >>> import harmont as hm + >>> proj = hm.rust.project() + >>> hm.pipeline(list(proj.ci(nextest=True))) + """ + steps: list[Step] = [self.test(nextest=nextest)] + if nextest: + steps.append(self.doctest()) + steps.append(self.clippy()) + steps.append(self.fmt()) + if doc: + steps.append(self.doc()) + return tuple(steps) + def _make_rust( *, 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 2673133b..e59f30ef 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -280,20 +280,24 @@ def test_test_flags(self): def test_clippy_command(self): proj = hm.rust.project(path="cli") - assert "cargo clippy --workspace --tests --locked -- -D warnings" in proj.clippy().cmd + assert ( + "cargo clippy --workspace --all-targets --locked -- -D warnings" in proj.clippy().cmd + ) def test_clippy_flags(self): proj = hm.rust.project(path=".") step = proj.clippy(flags=("--fix",)) - assert "cargo clippy --workspace --tests --locked --fix -- -D warnings" in step.cmd + assert "cargo clippy --workspace --all-targets --locked --fix -- -D warnings" in step.cmd def test_fmt_command(self): proj = hm.rust.project(path="cli") - assert "cargo fmt --check" in proj.fmt().cmd + assert proj.fmt().cmd.endswith("cargo fmt --all --check") def test_fmt_flags(self): proj = hm.rust.project(path=".") - assert "cargo fmt --check --all" in proj.fmt(flags=("--all",)).cmd + assert ( + "cargo fmt --all --check --config-path x" in proj.fmt(flags=("--config-path", "x")).cmd + ) def test_test_chains_off_warmup(self): proj = hm.rust.project(path=".") @@ -334,7 +338,7 @@ def test_pipeline_ir(self): assert any("cargo build --workspace --tests --locked" in c for c in cmds) assert any("cargo test --workspace --locked" in c for c in cmds) assert any("cargo clippy" in c for c in cmds) - assert any("cargo fmt --check" in c for c in cmds) + assert any("cargo fmt --all --check" in c for c in cmds) assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 assert len([c for c in cmds if "apt-get install" in c]) == 1 @@ -343,3 +347,58 @@ def test_version_forwarded(self): p = hm.pipeline([proj.test()]) rustup = _step_by_substring(p, "sh.rustup.rs") assert "--default-toolchain 1.81.0" in rustup["cmd"] + + def test_test_packages(self): + proj = hm.rust.project(path=".") + step = proj.test(packages=("core",)) + assert "cargo test -p core --locked" in step.cmd + + def test_test_nextest(self): + proj = hm.rust.project(path=".") + assert "cargo nextest run --workspace --locked" in proj.test(nextest=True).cmd + + def test_build_method_exists(self): + proj = hm.rust.project(path=".") + assert "cargo build --workspace --locked" in proj.build().cmd + assert proj.build().parent is proj.warmup + + def test_doctest_method(self): + proj = hm.rust.project(path=".") + assert proj.doctest().cmd.endswith("cargo test --workspace --locked --doc") + assert proj.doctest().label == ":rust: doctest" + + def test_clippy_packages(self): + proj = hm.rust.project(path=".") + step = proj.clippy(packages=("core",)) + assert "cargo clippy -p core --all-targets --locked -- -D warnings" in step.cmd + + def test_doc_method(self): + proj = hm.rust.project(path=".") + s = proj.doc() + assert "cargo doc --no-deps --workspace --locked" in s.cmd + assert s.env == {"RUSTDOCFLAGS": "-D warnings"} + + def test_ci_returns_test_clippy_fmt(self): + proj = hm.rust.project(path=".") + steps = proj.ci() + labels = [s.label for s in steps] + assert labels == [":rust: test", ":rust: clippy", ":rust: fmt"] + + def test_ci_nextest_adds_doctest(self): + proj = hm.rust.project(path=".") + steps = proj.ci(nextest=True) + labels = [s.label for s in steps] + assert labels == [":rust: test", ":rust: doctest", ":rust: clippy", ":rust: fmt"] + assert any("cargo nextest run" in s.cmd for s in steps) + assert any(s.cmd.endswith("--doc") for s in steps) + + def test_ci_doc_flag_adds_doc(self): + proj = hm.rust.project(path=".") + labels = [s.label for s in proj.ci(doc=True)] + assert ":rust: doc" in labels + + def test_ci_in_pipeline(self): + proj = hm.rust.project(path="cli") + p = hm.pipeline(list(proj.ci()), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 From f5bf4266408e53222d9f0adf05e6af6a1b620c66 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:37:17 +0000 Subject: [PATCH 06/15] polish(rust-sdk/py): accurate ci() docstring + exclude= on doc() --- crates/hm-dsl-engine/harmont-py/harmont/_rust.py | 7 ++++++- crates/hm-dsl-engine/harmont-py/tests/test_rust.py | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index a864df28..d8e80cd4 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -275,6 +275,7 @@ def doc( document_private_items: bool = False, workspace: bool = False, packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), all_features: bool = False, no_default_features: bool = False, features: tuple[str, ...] = (), @@ -291,6 +292,7 @@ def doc( document_private_items=document_private_items, workspace=workspace, packages=packages, + exclude=exclude, all_features=all_features, no_default_features=no_default_features, features=features, @@ -487,6 +489,7 @@ def doc( document_private_items: bool = False, workspace: bool = True, packages: tuple[str, ...] = (), + exclude: tuple[str, ...] = (), all_features: bool = False, no_default_features: bool = False, features: tuple[str, ...] = (), @@ -503,6 +506,7 @@ def doc( document_private_items=document_private_items, workspace=workspace, packages=packages, + exclude=exclude, all_features=all_features, no_default_features=no_default_features, features=features, @@ -515,7 +519,8 @@ def doc( ) def ci(self, *, nextest: bool = False, doc: bool = False) -> tuple[Step, ...]: - """The zero-config Rust CI DAG: test + clippy + fmt, sharing one warmup. + """The zero-config Rust CI DAG. test and clippy chain off the shared + warmup; fmt runs off the toolchain install step, in parallel. With ``nextest=True`` the test step uses ``cargo nextest run`` and a companion ``doctest()`` step is appended (nextest cannot run doctests). 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 e59f30ef..2df83edb 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -397,6 +397,11 @@ def test_ci_doc_flag_adds_doc(self): labels = [s.label for s in proj.ci(doc=True)] assert ":rust: doc" in labels + def test_doc_exclude(self): + proj = hm.rust.project(path=".") + s = proj.doc(exclude=("integration",)) + assert "cargo doc --no-deps --workspace --exclude integration --locked" in s.cmd + def test_ci_in_pipeline(self): proj = hm.rust.project(path="cli") p = hm.pipeline(list(proj.ci()), default_image="ubuntu:24.04") From 52b528aaa4afb0d0b75169a987ad217467c27cf2 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:39:13 +0000 Subject: [PATCH 07/15] feat(rust-sdk/py): cargo-hack feature_powerset helper --- .../hm-dsl-engine/harmont-py/harmont/_rust.py | 70 +++++++++++++++++++ .../harmont-py/tests/test_rust.py | 36 ++++++++++ 2 files changed, 106 insertions(+) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index d8e80cd4..702a2189 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -9,6 +9,7 @@ from __future__ import annotations import re +import shlex from dataclasses import dataclass from typing import TYPE_CHECKING, Any @@ -94,6 +95,34 @@ def _doc_env(kw: dict[str, Any], *, deny_warnings: bool) -> None: kw["env"] = merged +def _hack_cmd( + *, + subcommand: str = "check", + depth: int = 2, + each_feature: bool = False, + no_dev_deps: bool = True, + skip: tuple[str, ...] = (), + include_features: tuple[str, ...] = (), + keep_going: bool = False, + flags: tuple[str, ...] = (), +) -> str: + toks = ["cargo hack", subcommand] + if each_feature: + toks.append("--each-feature") + else: + toks += ["--feature-powerset", "--depth", str(depth)] + if no_dev_deps: + toks.append("--no-dev-deps") + if skip: + toks.append("--skip " + ",".join(shlex.quote(s) for s in skip)) + if include_features: + toks.append("--include-features " + ",".join(shlex.quote(s) for s in include_features)) + if keep_going: + toks.append("--keep-going") + toks += list(flags) + return " ".join(toks) + + @dataclass(frozen=True) class RustToolchain: """Rust toolchain install chain — constructed via ``hm.rust.toolchain()``. @@ -311,6 +340,44 @@ def warmup(self, **kw: Any) -> Step: **kw, ) + def feature_powerset( + self, + *, + subcommand: str = "check", + depth: int = 2, + each_feature: bool = False, + no_dev_deps: bool = True, + skip: tuple[str, ...] = (), + include_features: tuple[str, ...] = (), + keep_going: bool = False, + flags: tuple[str, ...] = (), + **kw: Any, + ) -> Step: + """Run a cargo-hack feature sweep (powerset, or ``--each-feature``). + + Installs ``cargo-hack`` (cached forever) then runs the sweep. Mirrors + the tokio/reqwest/tracing CI idiom: ``--feature-powerset --depth 2 + --no-dev-deps``. + """ + installed_hack = self._emit( + "cargo install cargo-hack --locked", + ":rust: install cargo-hack", + cache=CacheForever(env_keys=()), + ) + cmd = _hack_cmd( + subcommand=subcommand, + depth=depth, + each_feature=each_feature, + no_dev_deps=no_dev_deps, + skip=skip, + include_features=include_features, + keep_going=keep_going, + flags=flags, + ) + if kw.get("label") is None: + kw["label"] = ":rust: feature-powerset" + return installed_hack.sh(self._wrap(cmd), **kw) + @dataclass(frozen=True) class RustProject: @@ -540,6 +607,9 @@ def ci(self, *, nextest: bool = False, doc: bool = False) -> tuple[Step, ...]: steps.append(self.doc()) return tuple(steps) + def feature_powerset(self, **kw: Any) -> Step: + return self.toolchain.feature_powerset(**kw) + def _make_rust( *, 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 2df83edb..6f96a093 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -242,6 +242,37 @@ def test_clippy_extra_lints_without_deny(self): assert s.cmd.rstrip().endswith("-- -W clippy::pedantic") assert "-D warnings" not in s.cmd + def test_feature_powerset_default(self): + tc = hm.rust.toolchain(path=".") + s = tc.feature_powerset() + assert "cargo hack check --feature-powerset --depth 2 --no-dev-deps" in s.cmd + + def test_feature_powerset_installs_cargo_hack(self): + tc = hm.rust.toolchain(path="cli") + s = tc.feature_powerset() + p = hm.pipeline([s], default_image="ubuntu:24.04") + cmds = _cmds(p) + assert any("cargo install cargo-hack --locked" in c for c in cmds) + + def test_feature_powerset_each_feature(self): + tc = hm.rust.toolchain(path=".") + s = tc.feature_powerset(each_feature=True) + assert "--each-feature" in s.cmd + assert "--feature-powerset" not in s.cmd + + def test_feature_powerset_skip_and_keep_going(self): + tc = hm.rust.toolchain(path=".") + s = tc.feature_powerset(subcommand="test", skip=("__tls", "http3"), keep_going=True) + expected = ( + "cargo hack test --feature-powerset --depth 2" + " --no-dev-deps --skip __tls,http3 --keep-going" + ) + assert expected in s.cmd + + def test_feature_powerset_label(self): + tc = hm.rust.toolchain(path=".") + assert tc.feature_powerset().label == ":rust: feature-powerset" + # --- RustProject (hm.rust.project) --- @@ -407,3 +438,8 @@ def test_ci_in_pipeline(self): p = hm.pipeline(list(proj.ci()), default_image="ubuntu:24.04") cmds = _cmds(p) assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 + + def test_feature_powerset_delegates(self): + proj = hm.rust.project(path=".") + s = proj.feature_powerset(subcommand="clippy") + assert "cargo hack clippy --feature-powerset --depth 2 --no-dev-deps" in s.cmd From 3ad745e98b23f72b4d19724e9b43a8b0b49df4f8 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:44:36 +0000 Subject: [PATCH 08/15] fix(rust-sdk/py): path-independent cargo-hack install for cache sharing --- crates/hm-dsl-engine/harmont-py/harmont/_rust.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py index 702a2189..f43f85c1 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_rust.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_rust.py @@ -359,9 +359,12 @@ def feature_powerset( the tokio/reqwest/tracing CI idiom: ``--feature-powerset --depth 2 --no-dev-deps``. """ - installed_hack = self._emit( - "cargo install cargo-hack --locked", - ":rust: install cargo-hack", + # Global install — no crate dir needed, so skip _wrap's `cd ` + # and source the cargo env directly. Keeps the CacheForever key + # identical across toolchains regardless of path. + installed_hack = self.installed.sh( + ". $HOME/.cargo/env && cargo install cargo-hack --locked", + label=":rust: install cargo-hack", cache=CacheForever(env_keys=()), ) cmd = _hack_cmd( From 6cdd669f702e06846ae3fe5d52fd516454bd65ce Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:45:31 +0000 Subject: [PATCH 09/15] test(rust-sdk/py): shell-injection regression coverage --- crates/hm-dsl-engine/harmont-py/tests/test_rust.py | 14 ++++++++++++++ 1 file changed, 14 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 6f96a093..97dfbd61 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -443,3 +443,17 @@ def test_feature_powerset_delegates(self): proj = hm.rust.project(path=".") s = proj.feature_powerset(subcommand="clippy") assert "cargo hack clippy --feature-powerset --depth 2 --no-dev-deps" in s.cmd + + +def test_no_shell_injection_via_packages(): + tc = hm.rust.toolchain(path=".") + s = tc.build(packages=("a; touch /tmp/pwned",)) + # The malicious value is single-quoted, so the shell sees one -p argument. + assert "-p 'a; touch /tmp/pwned'" in s.cmd + assert "; touch /tmp/pwned --locked" not in s.cmd + + +def test_no_shell_injection_via_target(): + tc = hm.rust.toolchain(path=".") + s = tc.build(target="x; rm -rf /") + assert "--target 'x; rm -rf /'" in s.cmd From 0a85f7a0158caad94ec2499dd66b78e0d687ecc9 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:48:34 +0000 Subject: [PATCH 10/15] feat(rust-sdk/ts): add shared cargo argument assembler Ports harmont-py/harmont/_cargo.py to TypeScript: CargoOpts interface, shQuote (shlex.quote-identical), and cargoFlags assembler with full validation (exclude/workspace pairing, allFeatures/features conflict, release/profile conflict). 18 tests, all passing, tsc clean. --- .../harmont-ts/src/toolchains/cargo.ts | 96 +++++++++++++++++++ .../harmont-ts/tests/toolchains/cargo.test.ts | 85 ++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts create mode 100644 crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts new file mode 100644 index 00000000..7fedf4e2 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/cargo.ts @@ -0,0 +1,96 @@ +// Shared cargo-argument assembly for the Rust toolchain helper. +// +// Mirrors harmont-py/harmont/_cargo.py exactly so both DSLs emit identical +// cargo command strings. User-supplied *values* are shell-quoted; raw `flags` +// pass through verbatim. `exclude` pairs with `--workspace` (cargo requires it). + +export interface CargoOpts { + readonly workspace?: boolean; + readonly packages?: readonly string[]; + readonly exclude?: readonly string[]; + readonly allFeatures?: boolean; + readonly noDefaultFeatures?: boolean; + readonly features?: readonly string[]; + readonly target?: string; + readonly allTargets?: boolean; + readonly release?: boolean; + readonly profile?: string; + readonly locked?: boolean; + readonly flags?: readonly string[]; +} + +// POSIX single-quote escaping, byte-for-byte identical to Python's shlex.quote: +// safe characters are left bare; everything else is wrapped in single quotes +// with embedded single quotes rendered as '"'"'. The empty string becomes ''. +const SAFE = /^[A-Za-z0-9_@%+=:,./-]+$/; +export function shQuote(s: string): string { + if (s.length === 0) return "''"; + if (SAFE.test(s)) return s; + return "'" + s.replace(/'/g, "'\"'\"'") + "'"; +} + +function validate(o: CargoOpts): void { + if (o.allFeatures && ((o.features?.length ?? 0) > 0 || o.noDefaultFeatures)) { + throw new Error( + "rust: --all-features conflicts with features/noDefaultFeatures\n" + + ` observed: allFeatures=true, features=${JSON.stringify(o.features ?? [])}, ` + + `noDefaultFeatures=${o.noDefaultFeatures ?? false}\n` + + " → pass allFeatures alone, or list explicit features without allFeatures", + ); + } + if (o.release && o.profile !== undefined) { + throw new Error( + "rust: release conflicts with profile\n" + + ` observed: release=true, profile=${JSON.stringify(o.profile)}\n` + + ' → use profile: "release" (identical effect) or drop one', + ); + } + if (o.exclude?.length) { + if (o.packages?.length) { + throw new Error( + "rust: exclude cannot combine with packages\n" + + ` observed: packages=${JSON.stringify(o.packages)}, exclude=${JSON.stringify(o.exclude)}\n` + + " → --exclude pairs with --workspace; packages already selects explicitly, so drop one", + ); + } + if (!o.workspace) { + throw new Error( + "rust: exclude requires workspace\n" + + ` observed: exclude=${JSON.stringify(o.exclude)} without workspace=true\n` + + " → cargo --exclude only applies to --workspace; pass workspace: true", + ); + } + } +} + +export function cargoFlags(o: CargoOpts): string { + validate(o); + const toks: string[] = []; + + if (o.packages?.length) { + for (const p of o.packages) toks.push(`-p ${shQuote(p)}`); + } else if (o.workspace) { + toks.push("--workspace"); + for (const e of o.exclude ?? []) toks.push(`--exclude ${shQuote(e)}`); + } + + if (o.allTargets) toks.push("--all-targets"); + + if (o.allFeatures) { + toks.push("--all-features"); + } else { + if (o.noDefaultFeatures) toks.push("--no-default-features"); + if (o.features?.length) toks.push(`--features ${shQuote(o.features.join(","))}`); + } + + if (o.target !== undefined) toks.push(`--target ${shQuote(o.target)}`); + + if (o.profile !== undefined) toks.push(`--profile ${shQuote(o.profile)}`); + else if (o.release) toks.push("--release"); + + if (o.locked ?? true) toks.push("--locked"); + + if (o.flags?.length) toks.push(...o.flags); + + return toks.length ? " " + toks.join(" ") : ""; +} diff --git a/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts new file mode 100644 index 00000000..99929ae2 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/cargo.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { cargoFlags, shQuote } from "../../src/toolchains/cargo.js"; + +describe("shQuote", () => { + it("leaves simple identifiers alone", () => { + expect(shQuote("harmont-core")).toBe("harmont-core"); + }); + it("quotes values with shell metacharacters", () => { + expect(shQuote("a; rm -rf /")).toBe("'a; rm -rf /'"); + }); + it("escapes embedded single quotes like shlex.quote", () => { + expect(shQuote("a'b")).toBe("'a'\"'\"'b'"); + }); + it("quotes the empty string", () => { + expect(shQuote("")).toBe("''"); + }); +}); + +describe("cargoFlags", () => { + it("emits only --locked for empty opts", () => { + expect(cargoFlags({})).toBe(" --locked"); + }); + it("locked can be disabled", () => { + expect(cargoFlags({ locked: false })).toBe(""); + }); + it("workspace scope", () => { + expect(cargoFlags({ workspace: true })).toBe(" --workspace --locked"); + }); + it("packages take precedence and quote values", () => { + expect(cargoFlags({ workspace: true, packages: ["a", "b c"] })).toBe( + " -p a -p 'b c' --locked", + ); + }); + it("exclude pairs with workspace", () => { + expect(cargoFlags({ workspace: true, exclude: ["b"] })).toBe( + " --workspace --exclude b --locked", + ); + }); + it("exclude without workspace throws", () => { + expect(() => cargoFlags({ exclude: ["b"] })).toThrow("workspace"); + }); + it("exclude with packages throws", () => { + expect(() => cargoFlags({ packages: ["a"], exclude: ["b"] })).toThrow("exclude"); + }); + it("all-features", () => { + expect(cargoFlags({ allFeatures: true })).toBe(" --all-features --locked"); + }); + it("features joined comma", () => { + expect(cargoFlags({ features: ["x", "y"] })).toBe(" --features x,y --locked"); + }); + it("no-default-features with features", () => { + expect(cargoFlags({ noDefaultFeatures: true, features: ["x"] })).toBe( + " --no-default-features --features x --locked", + ); + }); + it("full token order", () => { + expect( + cargoFlags({ + packages: ["core"], + allTargets: true, + noDefaultFeatures: true, + features: ["a", "b"], + target: "x86_64-unknown-linux-gnu", + profile: "ci", + flags: ["--keep-going"], + }), + ).toBe( + " -p core --all-targets --no-default-features --features a,b" + + " --target x86_64-unknown-linux-gnu --profile ci --locked --keep-going", + ); + }); + it("flags are verbatim", () => { + expect(cargoFlags({ locked: false, flags: ["--features=a b"] })).toBe( + " --features=a b", + ); + }); + it("throws on all-features + features conflict", () => { + expect(() => cargoFlags({ allFeatures: true, features: ["x"] })).toThrow( + "all-features", + ); + }); + it("throws on release + profile conflict", () => { + expect(() => cargoFlags({ release: true, profile: "ci" })).toThrow("profile"); + }); +}); From be139bd088347c8fa35a8a415b8412829355137b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:55:19 +0000 Subject: [PATCH 11/15] feat(rust-sdk/ts): full cargo options, nextest/doctest, ci(), cargo-hack at parity --- .../harmont-ts/src/toolchains/index.ts | 2 +- .../harmont-ts/src/toolchains/rust.ts | 277 ++++++++++++++++-- .../harmont-ts/tests/toolchains/rust.test.ts | 74 ++++- 3 files changed, 314 insertions(+), 39 deletions(-) diff --git a/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts b/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts index 5e5d8747..c65a8d49 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/index.ts @@ -1,6 +1,6 @@ export { js, ts, JsProject, type JsOptions } from "./js.js"; export { go, GoToolchain, type GoOptions } from "./go.js"; -export { rust, RustToolchain, RustProject, type RustToolchainOptions, type RustProjectOptions } from "./rust.js"; +export { rust, RustToolchain, RustProject, type RustToolchainOptions, type RustProjectOptions, type FeaturePowersetOptions } from "./rust.js"; export { python, PythonToolchain, type PythonOptions } from "./python.js"; export { cmake, 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 6a334a25..12ab8749 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts @@ -1,6 +1,7 @@ import type { Step, StepOptions } from "../step.js"; import { type CachePolicy, forever, onChange } from "../cache.js"; import { makeInstallChain } from "./shared.js"; +import { type CargoOpts, cargoFlags } from "./cargo.js"; const APT_PACKAGES = [ "curl", @@ -24,6 +25,109 @@ export interface RustProjectOptions extends RustToolchainOptions { } type ActionOptions = Omit; +type CargoActionOptions = CargoOpts & ActionOptions; + +export interface FeaturePowersetOptions extends ActionOptions { + readonly subcommand?: string; + readonly depth?: number; + readonly eachFeature?: boolean; + readonly noDevDeps?: boolean; + readonly skip?: readonly string[]; + readonly includeFeatures?: readonly string[]; + readonly keepGoing?: boolean; + readonly flags?: readonly string[]; +} + +// --- pure command builders (shared by both classes) --- + +function splitCargo(opts: CargoActionOptions | undefined): { + cargo: CargoOpts; + step: ActionOptions; +} { + const o = (opts ?? {}) as CargoActionOptions; + const { + workspace, + packages, + exclude, + allFeatures, + noDefaultFeatures, + features, + target, + allTargets, + release, + profile, + locked, + flags, + ...step + } = o; + return { + cargo: { + workspace, + packages, + exclude, + allFeatures, + noDefaultFeatures, + features, + target, + allTargets, + release, + profile, + locked, + flags, + }, + step: step as ActionOptions, + }; +} + +function buildCmd(c: CargoOpts): string { + return `cargo build${cargoFlags(c)}`; +} +function testCmd(c: CargoOpts, nextest: boolean): string { + return `${nextest ? "cargo nextest run" : "cargo test"}${cargoFlags(c)}`; +} +function doctestCmd(c: CargoOpts): string { + return `cargo test${cargoFlags(c)} --doc`; +} +function clippyCmd( + c: CargoOpts, + denyWarnings: boolean, + extraLints: readonly string[], +): string { + const mid = cargoFlags(c); + const trail = [...(denyWarnings ? ["-D warnings"] : []), ...extraLints]; + return `cargo clippy${mid}${trail.length ? " -- " + trail.join(" ") : ""}`; +} +function fmtCmd(all: boolean, check: boolean, flags: readonly string[]): string { + const toks = ["cargo fmt"]; + if (all) toks.push("--all"); + if (check) toks.push("--check"); + toks.push(...flags); + return toks.join(" "); +} +function docCmd(c: CargoOpts, noDeps: boolean, privateItems: boolean): string { + const pre: string[] = []; + if (noDeps) pre.push("--no-deps"); + if (privateItems) pre.push("--document-private-items"); + return `cargo doc${pre.length ? " " + pre.join(" ") : ""}${cargoFlags(c)}`; +} +function hackCmd(o: FeaturePowersetOptions): string { + const toks = ["cargo hack", o.subcommand ?? "check"]; + if (o.eachFeature) toks.push("--each-feature"); + else toks.push("--feature-powerset", "--depth", String(o.depth ?? 2)); + if (o.noDevDeps ?? true) toks.push("--no-dev-deps"); + if (o.skip?.length) toks.push("--skip " + o.skip.join(",")); + if (o.includeFeatures?.length) + toks.push("--include-features " + o.includeFeatures.join(",")); + if (o.keepGoing) toks.push("--keep-going"); + toks.push(...(o.flags ?? [])); + return toks.join(" "); +} +function denyDocEnv(step: ActionOptions, deny: boolean): ActionOptions { + if (!deny) return step; + return { ...step, env: { RUSTDOCFLAGS: "-D warnings", ...(step.env ?? {}) } }; +} + +// --- classes --- export class RustToolchain { readonly path: string; @@ -45,30 +149,66 @@ export class RustToolchain { ); } - build(opts?: ActionOptions & { release?: boolean }): Step { - const cmd = opts?.release ? "cargo build --release" : "cargo build"; - return this._cargo(cmd, ":rust: build", opts); + build(opts?: CargoActionOptions): Step { + const { cargo, step } = splitCargo(opts); + return this._cargo(buildCmd(cargo), ":rust: build", step); + } + + test(opts?: CargoActionOptions & { nextest?: boolean }): Step { + const { nextest, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo(rest); + return this._cargo(testCmd(cargo, nextest ?? false), ":rust: test", step); } - test(opts?: ActionOptions & { release?: boolean }): Step { - const cmd = opts?.release ? "cargo test --release" : "cargo test"; - return this._cargo(cmd, ":rust: test", opts); + doctest(opts?: CargoActionOptions): Step { + const { cargo, step } = splitCargo(opts); + return this._cargo(doctestCmd(cargo), ":rust: doctest", step); } - clippy(opts?: ActionOptions): Step { + clippy( + opts?: CargoActionOptions & { + denyWarnings?: boolean; + extraLints?: readonly string[]; + }, + ): Step { + const { denyWarnings, extraLints, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ allTargets: true, ...rest }); return this._cargo( - "cargo clippy --all-targets -- -D warnings", + clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), ":rust: clippy", - opts, + step, ); } - fmt(opts?: ActionOptions): Step { - return this._cargo("cargo fmt --check", ":rust: fmt", opts); + fmt( + opts?: ActionOptions & { + all?: boolean; + check?: boolean; + flags?: readonly string[]; + }, + ): Step { + const { all, check, flags, ...step } = opts ?? {}; + return this._cargo( + fmtCmd(all ?? true, check ?? true, flags ?? []), + ":rust: fmt", + step, + ); } - doc(opts?: ActionOptions): Step { - return this._cargo("cargo doc --no-deps", ":rust: doc", opts); + doc( + opts?: CargoActionOptions & { + noDeps?: boolean; + documentPrivateItems?: boolean; + denyWarnings?: boolean; + }, + ): Step { + const { noDeps, documentPrivateItems, denyWarnings, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo(rest); + return this._cargo( + docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), + ":rust: doc", + denyDocEnv(step, denyWarnings ?? true), + ); } warmup(opts?: ActionOptions): Step { @@ -78,6 +218,30 @@ export class RustToolchain { opts, ); } + + featurePowerset(opts?: FeaturePowersetOptions): Step { + const { + subcommand, + depth, + eachFeature, + noDevDeps, + skip, + includeFeatures, + keepGoing, + flags, + ...step + } = opts ?? {}; + // Global install — no crate dir, so don't cd; keeps the forever-cache key + // identical across toolchains regardless of path. + const installedHack = this._installed.sh( + ". $HOME/.cargo/env && cargo install cargo-hack --locked", + { label: ":rust: install cargo-hack", cache: forever() }, + ); + return installedHack.sh( + `. $HOME/.cargo/env && cd ${this.path} && ${hackCmd({ subcommand, depth, eachFeature, noDevDeps, skip, includeFeatures, keepGoing, flags })}`, + { label: ":rust: feature-powerset", ...step }, + ); + } } export class RustProject { @@ -89,33 +253,84 @@ export class RustProject { this.warmup = warmup; } - test(opts?: { flags?: readonly string[]; packages?: readonly string[] } & ActionOptions): Step { - const scope = opts?.packages?.length - ? opts.packages.map(p => `-p ${p}`).join(" ") - : "--workspace"; - const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; + private _emit(cmd: string, label: string, step: ActionOptions): Step { return this.warmup.sh( - `. $HOME/.cargo/env && cd ${this.toolchain.path} && cargo test ${scope} --locked${extra}`, - { label: ":rust: test", ...opts }, + `. $HOME/.cargo/env && cd ${this.toolchain.path} && ${cmd}`, + { label, ...step }, ); } - clippy(opts?: { flags?: readonly string[] } & ActionOptions): Step { - const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; - return this.warmup.sh( - `. $HOME/.cargo/env && cd ${this.toolchain.path} && cargo clippy --workspace --tests --locked${extra} -- -D warnings`, - { label: ":rust: clippy", ...opts }, + build(opts?: CargoActionOptions): Step { + const { cargo, step } = splitCargo({ workspace: true, ...opts }); + return this._emit(buildCmd(cargo), ":rust: build", step); + } + + test(opts?: CargoActionOptions & { nextest?: boolean }): Step { + const { nextest, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ workspace: true, ...rest }); + return this._emit(testCmd(cargo, nextest ?? false), ":rust: test", step); + } + + doctest(opts?: CargoActionOptions): Step { + const { cargo, step } = splitCargo({ workspace: true, ...opts }); + return this._emit(doctestCmd(cargo), ":rust: doctest", step); + } + + clippy( + opts?: CargoActionOptions & { + denyWarnings?: boolean; + extraLints?: readonly string[]; + }, + ): Step { + const { denyWarnings, extraLints, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ workspace: true, allTargets: true, ...rest }); + return this._emit( + clippyCmd(cargo, denyWarnings ?? true, extraLints ?? []), + ":rust: clippy", + step, ); } - fmt(opts?: { flags?: readonly string[] } & ActionOptions): Step { - const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; - return this.toolchain._cargo( - `cargo fmt --check${extra}`, - ":rust: fmt", - opts, + fmt( + opts?: ActionOptions & { + all?: boolean; + check?: boolean; + flags?: readonly string[]; + }, + ): Step { + // fmt has no warmup dependency; chain off install like the toolchain does. + return this.toolchain.fmt(opts); + } + + doc( + opts?: CargoActionOptions & { + noDeps?: boolean; + documentPrivateItems?: boolean; + denyWarnings?: boolean; + }, + ): Step { + const { noDeps, documentPrivateItems, denyWarnings, ...rest } = opts ?? {}; + const { cargo, step } = splitCargo({ workspace: true, ...rest }); + return this._emit( + docCmd(cargo, noDeps ?? true, documentPrivateItems ?? false), + ":rust: doc", + denyDocEnv(step, denyWarnings ?? true), ); } + + featurePowerset(opts?: FeaturePowersetOptions): Step { + return this.toolchain.featurePowerset(opts); + } + + ci(opts?: { nextest?: boolean; doc?: boolean }): Step[] { + const nextest = opts?.nextest ?? false; + const steps: Step[] = [this.test({ nextest })]; + if (nextest) steps.push(this.doctest()); + steps.push(this.clippy()); + steps.push(this.fmt()); + if (opts?.doc) steps.push(this.doc()); + return steps; + } } function makeToolchain(opts?: RustToolchainOptions): RustToolchain { 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 f12a3ef9..b387eac5 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 @@ -59,13 +59,13 @@ describe("rust.toolchain", () => { it("clippy runs with -D warnings", () => { const r = rust.toolchain(); expect(r.clippy()._cmd).toContain( - "cargo clippy --all-targets -- -D warnings", + "cargo clippy --all-targets --locked -- -D warnings", ); }); it("fmt runs cargo fmt --check", () => { const r = rust.toolchain(); - expect(r.fmt()._cmd).toContain("cargo fmt --check"); + expect(r.fmt()._cmd).toContain("cargo fmt --all --check"); }); it("doc runs cargo doc --no-deps", () => { @@ -148,6 +148,36 @@ describe("rust.toolchain", () => { expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); expect(ir.version).toBe("0"); }); + + it("build is locked by default", () => { + expect(rust.toolchain().build()._cmd!.endsWith("cargo build --locked")).toBe(true); + }); + + it("test nextest switches runner", () => { + expect(rust.toolchain().test({ nextest: true, workspace: true })._cmd).toContain( + "cargo nextest run --workspace --locked", + ); + }); + + it("doctest appends --doc", () => { + expect( + rust.toolchain().doctest({ workspace: true })._cmd!.endsWith( + "cargo test --workspace --locked --doc", + ), + ).toBe(true); + }); + + it("doc sets RUSTDOCFLAGS env", () => { + const s = rust.toolchain().doc(); + expect(s._cmd).toContain("cargo doc --no-deps --locked"); + expect(s._env).toEqual({ RUSTDOCFLAGS: "-D warnings" }); + }); + + it("feature_powerset installs cargo-hack", () => { + const s = rust.toolchain().featurePowerset(); + expect(s._cmd).toContain("cargo hack check --feature-powerset --depth 2 --no-dev-deps"); + expect(s._parent!._cmd).toContain("cargo install cargo-hack --locked"); + }); }); describe("rust.project", () => { @@ -158,9 +188,9 @@ describe("rust.project", () => { ); expect(proj.test()._cmd).toContain("cargo test --workspace --locked"); expect(proj.clippy()._cmd).toContain( - "cargo clippy --workspace --tests --locked", + "cargo clippy --workspace --all-targets --locked", ); - expect(proj.fmt()._cmd).toContain("cargo fmt --check"); + expect(proj.fmt()._cmd).toContain("cargo fmt --all --check"); }); it("warmup has implicit CacheOnChange on Cargo.lock, Cargo.toml, and *.rs", () => { @@ -200,14 +230,14 @@ describe("rust.project", () => { it("clippy flags are inserted before --", () => { const proj = rust.project({ path: "." }); expect(proj.clippy({ flags: ["--fix"] })._cmd).toContain( - "cargo clippy --workspace --tests --locked --fix -- -D warnings", + "cargo clippy --workspace --all-targets --locked --fix -- -D warnings", ); }); it("fmt flags are appended", () => { const proj = rust.project({ path: "." }); - expect(proj.fmt({ flags: ["--all"] })._cmd).toContain( - "cargo fmt --check --all", + expect(proj.fmt({ flags: ["--config-path", "x"] })._cmd).toContain( + "cargo fmt --all --check --config-path x", ); }); @@ -270,4 +300,34 @@ describe("rust.project", () => { const rustup = stepBySubstring(ir, "sh.rustup.rs"); expect(rustup.cmd).toContain("--default-toolchain 1.81.0"); }); + + it("build/doc/doctest exist", () => { + const p = rust.project({ path: "." }); + expect(p.build()._cmd).toContain("cargo build --workspace --locked"); + expect(p.doctest()._cmd!.endsWith("cargo test --workspace --locked --doc")).toBe(true); + expect(p.doc()._cmd).toContain("cargo doc --no-deps --workspace --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"]); + expect(p.ci({ nextest: true }).map((s) => s._label)).toEqual([ + ":rust: test", + ":rust: doctest", + ":rust: clippy", + ":rust: fmt", + ]); + }); + + it("clippy accepts packages", () => { + expect(rust.project({ path: "." }).clippy({ packages: ["core"] })._cmd).toContain( + "cargo clippy -p core --all-targets --locked -- -D warnings", + ); + }); + + it("no shell injection via packages", () => { + expect(rust.toolchain().build({ packages: ["a; touch /tmp/x"] })._cmd).toContain( + "-p 'a; touch /tmp/x'", + ); + }); }); From 3f6e6fc3d82af1ec6e1f0a3a35e44f22e6fe62a3 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 22:58:21 +0000 Subject: [PATCH 12/15] test(rust-sdk): cross-language golden command parity --- .../harmont-py/tests/test_rust_parity.py | 28 +++++++++++++++++++ .../tests/toolchains/rust-parity.test.ts | 24 ++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py create mode 100644 crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts 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 new file mode 100644 index 00000000..81a63984 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py @@ -0,0 +1,28 @@ +"""Golden cargo strings — must stay byte-identical to the TS parity test.""" + +from __future__ import annotations + +import harmont as hm + + +def _tail(cmd: str) -> str: + # strip the ". $HOME/.cargo/env && cd . && " prefix for comparison + marker = "cd . && " + return cmd[cmd.index(marker) + len(marker) :] + + +def test_golden_commands(): + p = hm.rust.project(path=".") + assert _tail(p.test(features=("a", "b"), nextest=True).cmd) == ( + "cargo nextest run --workspace --features a,b --locked" + ) + assert _tail(p.clippy(all_features=True).cmd) == ( + "cargo clippy --workspace --all-targets --all-features --locked -- -D warnings" + ) + assert _tail(p.fmt().cmd) == "cargo fmt --all --check" + assert _tail(p.doc(document_private_items=True).cmd) == ( + "cargo doc --no-deps --document-private-items --workspace --locked" + ) + assert _tail(p.build(packages=("core",), target="wasm32-unknown-unknown").cmd) == ( + "cargo build -p core --target wasm32-unknown-unknown --locked" + ) 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 new file mode 100644 index 00000000..ed7b61e4 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/tests/toolchains/rust-parity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { rust } from "../../src/toolchains/rust.js"; + +const tail = (cmd: string) => + cmd.slice(cmd.indexOf("cd . && ") + "cd . && ".length); + +describe("rust parity golden strings", () => { + const p = rust.project({ path: "." }); + it("matches the Python golden commands", () => { + expect(tail(p.test({ features: ["a", "b"], nextest: true })._cmd!)).toBe( + "cargo nextest run --workspace --features a,b --locked", + ); + expect(tail(p.clippy({ allFeatures: true })._cmd!)).toBe( + "cargo clippy --workspace --all-targets --all-features --locked -- -D warnings", + ); + expect(tail(p.fmt()._cmd!)).toBe("cargo fmt --all --check"); + expect(tail(p.doc({ documentPrivateItems: true })._cmd!)).toBe( + "cargo doc --no-deps --document-private-items --workspace --locked", + ); + expect( + tail(p.build({ packages: ["core"], target: "wasm32-unknown-unknown" })._cmd!), + ).toBe("cargo build -p core --target wasm32-unknown-unknown --locked"); + }); +}); From 2f3273e999996725714dfb519e6d797d3980ed1b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 23:01:25 +0000 Subject: [PATCH 13/15] docs(rust-sdk): examples use project().ci(nextest=True); regen rust-release fixtures --- examples/rust/.hm/pipeline.py | 19 ++++++++++++------- examples/rust/.hm/pipeline.ts | 12 +++++++----- tests/e2e/fixtures/python/rust-release.json | 11 ++++++----- tests/e2e/fixtures/ts/rust-release.json | 11 ++++++----- 4 files changed, 31 insertions(+), 22 deletions(-) diff --git a/examples/rust/.hm/pipeline.py b/examples/rust/.hm/pipeline.py index e27a1490..3b9cf350 100644 --- a/examples/rust/.hm/pipeline.py +++ b/examples/rust/.hm/pipeline.py @@ -1,20 +1,25 @@ -"""Rust example pipeline.""" +"""Rust example pipeline — the one-call CI DAG.""" + 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( "ci", - env={"CI": "true"}, + env={"CI": "true", "RUST_BACKTRACE": "1"}, default_image="ubuntu:24.04", 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(nextest=True) runs `cargo nextest run` and adds a doctest step + # (nextest can't run doctests), plus clippy + fmt. + return project.ci(nextest=True) diff --git a/examples/rust/.hm/pipeline.ts b/examples/rust/.hm/pipeline.ts index 1347195e..4986fe33 100644 --- a/examples/rust/.hm/pipeline.ts +++ b/examples/rust/.hm/pipeline.ts @@ -1,16 +1,18 @@ import { pipeline, push, type PipelineDefinition } from "@harmont/hm"; import { rust } from "@harmont/hm/toolchains"; -const project = rust.toolchain({ path: "." }); +// project() warms a shared dependency cache so test/clippy/fmt reuse one compile. +const project = rust.project({ path: "." }); const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - pipeline: pipeline( - [project.build(), project.test(), project.clippy(), project.fmt()], - { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, - ), + // ci({ nextest: true }) → cargo nextest run + doctest + clippy + fmt. + pipeline: pipeline(project.ci({ nextest: true }), { + env: { CI: "true", RUST_BACKTRACE: "1" }, + defaultImage: "ubuntu:24.04", + }), }, ]; diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json index cd40ff9d..43152fcd 100644 --- a/tests/e2e/fixtures/python/rust-release.json +++ b/tests/e2e/fixtures/python/rust-release.json @@ -77,7 +77,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", "key": "build", "label": ":rust: build" } @@ -89,7 +89,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", "key": "test", "label": ":rust: test" } @@ -101,7 +101,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", "key": "clippy", "label": ":rust: clippy" } @@ -113,7 +113,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", "key": "fmt", "label": ":rust: fmt" } @@ -122,10 +122,11 @@ "env": { "CI": "true", "DEBIAN_FRONTEND": "noninteractive", + "RUSTDOCFLAGS": "-D warnings", "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", "key": "doc", "label": ":rust: doc" } diff --git a/tests/e2e/fixtures/ts/rust-release.json b/tests/e2e/fixtures/ts/rust-release.json index cd40ff9d..43152fcd 100644 --- a/tests/e2e/fixtures/ts/rust-release.json +++ b/tests/e2e/fixtures/ts/rust-release.json @@ -77,7 +77,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo build", + "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", "key": "build", "label": ":rust: build" } @@ -89,7 +89,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo test", + "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", "key": "test", "label": ":rust: test" } @@ -101,7 +101,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets -- -D warnings", + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", "key": "clippy", "label": ":rust: clippy" } @@ -113,7 +113,7 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --check", + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", "key": "fmt", "label": ":rust: fmt" } @@ -122,10 +122,11 @@ "env": { "CI": "true", "DEBIAN_FRONTEND": "noninteractive", + "RUSTDOCFLAGS": "-D warnings", "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps", + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", "key": "doc", "label": ":rust: doc" } From bf515d4c717020c6153378a05be5a8d551452d1c Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 23:10:28 +0000 Subject: [PATCH 14/15] fix(rust-sdk/ts): shell-quote cargo-hack skip/include values (parity + security) --- crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py | 3 +++ crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts | 6 +++--- .../harmont-ts/tests/toolchains/rust-parity.test.ts | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) 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 81a63984..ca6f9c6f 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 @@ -26,3 +26,6 @@ def test_golden_commands(): assert _tail(p.build(packages=("core",), target="wasm32-unknown-unknown").cmd) == ( "cargo build -p core --target wasm32-unknown-unknown --locked" ) + assert _tail( + p.feature_powerset(subcommand="check", skip=("a b", "c")).cmd + ) == "cargo hack check --feature-powerset --depth 2 --no-dev-deps --skip 'a b',c" 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 12ab8749..a62e169c 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/toolchains/rust.ts @@ -1,7 +1,7 @@ import type { Step, StepOptions } from "../step.js"; import { type CachePolicy, forever, onChange } from "../cache.js"; import { makeInstallChain } from "./shared.js"; -import { type CargoOpts, cargoFlags } from "./cargo.js"; +import { type CargoOpts, cargoFlags, shQuote } from "./cargo.js"; const APT_PACKAGES = [ "curl", @@ -115,9 +115,9 @@ function hackCmd(o: FeaturePowersetOptions): string { if (o.eachFeature) toks.push("--each-feature"); else toks.push("--feature-powerset", "--depth", String(o.depth ?? 2)); if (o.noDevDeps ?? true) toks.push("--no-dev-deps"); - if (o.skip?.length) toks.push("--skip " + o.skip.join(",")); + if (o.skip?.length) toks.push("--skip " + o.skip.map(shQuote).join(",")); if (o.includeFeatures?.length) - toks.push("--include-features " + o.includeFeatures.join(",")); + toks.push("--include-features " + o.includeFeatures.map(shQuote).join(",")); if (o.keepGoing) toks.push("--keep-going"); toks.push(...(o.flags ?? [])); return toks.join(" "); 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 ed7b61e4..c7456da9 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 @@ -20,5 +20,8 @@ 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"); + 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"); }); }); From 801e67a4f84fc1656927ec4f9e053c4d0968d721 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Thu, 11 Jun 2026 23:58:58 +0000 Subject: [PATCH 15/15] fix(rust-sdk/example): commit Cargo.lock for --locked; ci() without nextest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example switched to rust.project().ci(), whose warmup runs 'cargo build --workspace --tests --locked' — which needs a committed Cargo.lock. Add the (dependency-free) lockfile. Drop nextest=True from the runnable example since the example-runner VM only rustup-installs stable (no cargo-nextest); nextest stays documented as an opt-in. --- examples/rust/.hm/pipeline.py | 6 +++--- examples/rust/.hm/pipeline.ts | 5 +++-- examples/rust/Cargo.lock | 7 +++++++ 3 files changed, 13 insertions(+), 5 deletions(-) create mode 100644 examples/rust/Cargo.lock diff --git a/examples/rust/.hm/pipeline.py b/examples/rust/.hm/pipeline.py index 3b9cf350..25581cbe 100644 --- a/examples/rust/.hm/pipeline.py +++ b/examples/rust/.hm/pipeline.py @@ -20,6 +20,6 @@ def project() -> RustProject: triggers=[hm.push(branch="main")], ) def ci(project: hm.Target[RustProject]) -> tuple[hm.Step, ...]: - # ci(nextest=True) runs `cargo nextest run` and adds a doctest step - # (nextest can't run doctests), plus clippy + fmt. - return project.ci(nextest=True) + # ci() is the zero-config DAG: test + clippy + fmt, all sharing one warmup. + # Pass nextest=True if cargo-nextest is available to also split out doctests. + return project.ci() diff --git a/examples/rust/.hm/pipeline.ts b/examples/rust/.hm/pipeline.ts index 4986fe33..55da673f 100644 --- a/examples/rust/.hm/pipeline.ts +++ b/examples/rust/.hm/pipeline.ts @@ -8,8 +8,9 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - // ci({ nextest: true }) → cargo nextest run + doctest + clippy + fmt. - pipeline: pipeline(project.ci({ nextest: true }), { + // ci() → test + clippy + fmt, sharing one warmup. Add { nextest: true } + // when cargo-nextest is available to also split out doctests. + pipeline: pipeline(project.ci(), { env: { CI: "true", RUST_BACKTRACE: "1" }, defaultImage: "ubuntu:24.04", }), diff --git a/examples/rust/Cargo.lock b/examples/rust/Cargo.lock new file mode 100644 index 00000000..b347024c --- /dev/null +++ b/examples/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "harmont-example-rust" +version = "0.1.0"