Coverage for src/rhiza_task/tasks/rust.py: 100%
68 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:13 +0000
1"""The Rust language layer: rust.mk, as tasks.
3The gate names are python.mk's, deliberately: ``install``, ``test``, ``coverage``,
4``typecheck``, ``docs-coverage``, ``security``, ``license``, ``deps``, ``all``. That is
5the contract the reusable workflows and ``book`` depend on -- `rhiza_ci.yml` calls
6``make typecheck`` without knowing what the repository is written in, and rust.mk's own
7header says so. Only the engine differs.
9Nothing here goes through uv. cargo is not a Python tool and rustup is not a uv-managed
10toolchain, so the provisioning half of the make recipe has no analogue: what is left is
11:func:`~rhiza_task.uv.tool`, an argument vector, and the guards.
12"""
14from __future__ import annotations
16import os
17from pathlib import Path
19from ..config import Config
20from ..spec import Failed, Guard, have, task
21from ..uv import tool
22from .quality import install_hooks
24CARGO_TOOLS = ("cargo-nextest", "cargo-llvm-cov", "cargo-deny", "cargo-machete")
25"""The cargo subcommands the gates need, in rust.mk's order.
27A named tuple rather than a literal in the recipe, for the reason
28:data:`~rhiza_task.tasks.python.PYTEST_WITHS` is one: what a gate provisions is part of
29its contract, and this is the only place CI can assert on it.
30"""
32MANIFEST = Guard(file="Cargo.toml", reason="no Cargo.toml")
33"""What every Rust gate is guarded on.
35A file rather than a folder: cargo finds ``src/`` itself from the manifest, and a crate
36that renames it is still a crate. This is the flat analogue of python.mk's
37``if [ -d ${SOURCE_FOLDER} ]``.
38"""
41@task("install", "install the toolchain and fetch dependencies", section="Rust", layer="rust", needs=("setup",))
42def install(cfg: Config) -> None:
43 """Materialise the pinned toolchain, fetch dependencies, install the git hooks.
45 ``rustup show`` is what materialises ``rust-toolchain.toml``'s channel and components,
46 because rustup installs a pinned toolchain lazily -- so this is a provisioning step
47 despite reading like a query.
49 Args:
50 cfg: The resolved config.
52 Raises:
53 Failed: When rustup is absent, or a step exits non-zero.
54 """
55 if not have("rustup"):
56 raise Failed(1, "rustup not found -- install it from https://rustup.rs (or: brew install rustup)")
58 if (cfg.root / "rust-toolchain.toml").is_file():
59 print("[INFO] installing the toolchain pinned in rust-toolchain.toml")
60 tool("rustup", "show", cwd=cfg.root)
61 else:
62 print("[WARN] no rust-toolchain.toml; using the active default toolchain")
64 if (cfg.root / "Cargo.toml").is_file():
65 # --locked first, unlocked as a fallback: rust.mk's `|| $(CARGO) fetch`, which
66 # exists because a crate without a committed Cargo.lock is legitimate.
67 if tool("cargo", "fetch", "--locked", cwd=cfg.root, check=False):
68 tool("cargo", "fetch", cwd=cfg.root)
69 else:
70 print("[WARN] no Cargo.toml; skipping fetch")
72 install_hooks(cfg)
75@task("cargo-tools", "install the cargo subcommands the gates need", section="Rust", layer="rust")
76def cargo_tools(cfg: Config) -> None:
77 """Install the missing cargo subcommands, via cargo-binstall where it helps.
79 binstall fetches a prebuilt binary where the project publishes one and falls back to a
80 source build, which is the difference between seconds and minutes on CI.
82 The one subtlety, carried over from rust.mk rather than rediscovered: ``cargo install``
83 puts binaries in ``$CARGO_HOME/bin``, which is *not* necessarily on PATH --
84 ``brew install rustup`` leaves the shims in Homebrew's bin and never links
85 ``~/.cargo/bin``. cargo resolves ``cargo <sub>`` by searching that directory as well as
86 PATH, so the gates work either way; what does not work is a bare ``command -v
87 cargo-nextest``. So presence is probed in both places, and binstall is invoked as a
88 cargo subcommand.
90 Args:
91 cfg: The resolved config.
92 """
93 if not have("cargo-binstall") and not (_cargo_bin() / "cargo-binstall").exists():
94 print("[INFO] installing cargo-binstall")
95 tool("cargo", "install", "cargo-binstall", "--locked", cwd=cfg.root)
97 missing = [t for t in CARGO_TOOLS if not have(t) and not (_cargo_bin() / t).exists()]
98 if not missing:
99 print("[INFO] all cargo tools already installed")
100 return
101 print(f"[INFO] installing: {' '.join(missing)}")
102 tool("cargo", "binstall", "--no-confirm", "--locked", *missing, cwd=cfg.root)
105@task(
106 "test",
107 "run the test suite with nextest, then the doctests",
108 section="Rust",
109 layer="rust",
110 needs=("install", "cargo-tools"),
111 guards=(MANIFEST,),
112)
113def test(cfg: Config) -> None:
114 """Run ``cargo nextest`` over all targets, then ``cargo test --doc``.
116 Both, not either: nextest does not run doctests, and a doctest is a real test. This is
117 the Rust analogue of the retry loop in python.mk being the interesting part of ``test``
118 -- here the interesting part is that one command is not enough.
120 Args:
121 cfg: The resolved config.
122 """
123 reports = cfg.root / "_tests"
124 reports.mkdir(parents=True, exist_ok=True)
125 tool("cargo", "nextest", "run", "--all-targets", *cfg.cargo_flags, cwd=cfg.root)
126 print("[INFO] running doctests")
127 tool("cargo", "test", "--doc", *cfg.cargo_flags, cwd=cfg.root)
130@task(
131 "coverage",
132 "measure coverage and write _tests/coverage.xml",
133 section="Rust",
134 layer="rust",
135 needs=("install", "cargo-tools"),
136 guards=(MANIFEST,),
137)
138def coverage(cfg: Config) -> None:
139 """Measure coverage with cargo-llvm-cov, enforcing the same floor the Python layer has.
141 Cobertura XML at exactly ``_tests/coverage.xml``, which is not a detail: it is the path
142 book.mk's badge step reads, so a Rust project gets a measured coverage badge on its
143 docs site for the same reason a Python one does.
145 Args:
146 cfg: The resolved config.
147 """
148 (cfg.root / "_tests" / "html-coverage").mkdir(parents=True, exist_ok=True)
149 print(f"[INFO] measuring coverage (floor: {cfg.coverage_fail_under}%)")
150 tool(
151 "cargo",
152 "llvm-cov",
153 "nextest",
154 "--all-targets",
155 *cfg.cargo_flags,
156 "--fail-under-lines",
157 str(cfg.coverage_fail_under),
158 "--cobertura",
159 "--output-path",
160 "_tests/coverage.xml",
161 cwd=cfg.root,
162 )
163 tool("cargo", "llvm-cov", "report", "--html", "--output-dir", "_tests/html-coverage", cwd=cfg.root)
166@task(
167 "typecheck",
168 "lint with clippy, warnings as errors",
169 section="Rust",
170 layer="rust",
171 needs=("install",),
172 guards=(MANIFEST,),
173)
174def typecheck(cfg: Config) -> None:
175 """Run clippy over all targets with warnings denied.
177 rustc already type-checks, so the parity entry for ``typecheck`` is the lint that
178 catches what compiling does not -- the same relationship ``go vet`` has to the Go
179 compiler.
181 Args:
182 cfg: The resolved config.
183 """
184 tool("cargo", "clippy", "--all-targets", *cfg.cargo_flags, "--", "-D", "warnings", cwd=cfg.root)
187@task(
188 "docs-coverage",
189 "fail on any undocumented public item",
190 section="Rust",
191 layer="rust",
192 needs=("install",),
193 guards=(MANIFEST,),
194)
195def docs_coverage(cfg: Config) -> None:
196 """Build the docs with ``missing_docs`` denied.
198 interrogate's 100% floor expressed in rustdoc's own terms: pass/fail on an undocumented
199 public item rather than a percentage, because rustdoc has no percentage to report.
201 Args:
202 cfg: The resolved config.
203 """
204 tool(
205 "cargo",
206 "doc",
207 "--no-deps",
208 *cfg.cargo_flags,
209 cwd=cfg.root,
210 env={"RUSTDOCFLAGS": "-D missing_docs -D rustdoc::broken_intra_doc_links"},
211 )
214@task(
215 "security",
216 "scan dependencies for known advisories",
217 section="Rust",
218 layer="rust",
219 needs=("install", "cargo-tools"),
220 guards=(MANIFEST,),
221)
222def security(cfg: Config) -> None:
223 """Run ``cargo deny check advisories``.
225 Args:
226 cfg: The resolved config.
227 """
228 tool("cargo", "deny", "check", "advisories", cwd=cfg.root)
231@task(
232 "license",
233 "run the licence compliance scan",
234 section="Rust",
235 layer="rust",
236 needs=("install", "cargo-tools"),
237 guards=(MANIFEST,),
238)
239def license_(cfg: Config) -> None:
240 """Run ``cargo deny check licenses``.
242 The allow-list lives in ``deny.toml`` rather than in this argument vector, which is why
243 ``license_fail_on`` -- pip-licenses' flag, and Python-only -- does not appear here. No
244 guard on that file: cargo-deny falls back to its own defaults and says so, and a gate
245 that skipped instead would be the "green gate measuring nothing" this port exists to
246 stop shipping.
248 Args:
249 cfg: The resolved config.
250 """
251 tool("cargo", "deny", "check", "licenses", cwd=cfg.root)
254@task(
255 "deps",
256 "report unused dependencies",
257 section="Rust",
258 layer="rust",
259 needs=("install", "cargo-tools"),
260 guards=(MANIFEST,),
261)
262def deps(cfg: Config) -> None:
263 """Run cargo-machete, the deptry analogue.
265 Args:
266 cfg: The resolved config.
267 """
268 tool("cargo", "machete", cwd=cfg.root)
271@task(
272 "all",
273 "run every gate, as CI does",
274 section="Rust",
275 layer="rust",
276 needs=("fmt", "test", "docs-coverage", "security", "deps", "license", "typecheck", "rhiza-test"),
277)
278def all_(cfg: Config) -> None:
279 """Aggregate, with rust.mk's prerequisite list. The body is empty because ``needs`` is it.
281 Args:
282 cfg: Unused; the prerequisites do the work.
283 """
286def _cargo_bin() -> Path:
287 """Return the directory ``cargo install`` writes to.
289 The two variables cargo itself reads, in the order cargo reads them, then its default.
290 Resolved here rather than assumed, because the whole point of the probe is that this
291 directory is often not on PATH.
293 Returns:
294 The cargo binary directory.
295 """
296 root = os.environ.get("CARGO_INSTALL_ROOT") or os.environ.get("CARGO_HOME") or (Path.home() / ".cargo")
297 return Path(root) / "bin"