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

1"""The Rust language layer: rust.mk, as tasks. 

2 

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. 

8 

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""" 

13 

14from __future__ import annotations 

15 

16import os 

17from pathlib import Path 

18 

19from ..config import Config 

20from ..spec import Failed, Guard, have, task 

21from ..uv import tool 

22from .quality import install_hooks 

23 

24CARGO_TOOLS = ("cargo-nextest", "cargo-llvm-cov", "cargo-deny", "cargo-machete") 

25"""The cargo subcommands the gates need, in rust.mk's order. 

26 

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""" 

31 

32MANIFEST = Guard(file="Cargo.toml", reason="no Cargo.toml") 

33"""What every Rust gate is guarded on. 

34 

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""" 

39 

40 

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. 

44 

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. 

48 

49 Args: 

50 cfg: The resolved config. 

51 

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)") 

57 

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") 

63 

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") 

71 

72 install_hooks(cfg) 

73 

74 

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. 

78 

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. 

81 

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. 

89 

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) 

96 

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) 

103 

104 

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``. 

115 

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. 

119 

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) 

128 

129 

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. 

140 

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. 

144 

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) 

164 

165 

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. 

176 

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. 

180 

181 Args: 

182 cfg: The resolved config. 

183 """ 

184 tool("cargo", "clippy", "--all-targets", *cfg.cargo_flags, "--", "-D", "warnings", cwd=cfg.root) 

185 

186 

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. 

197 

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. 

200 

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 ) 

212 

213 

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``. 

224 

225 Args: 

226 cfg: The resolved config. 

227 """ 

228 tool("cargo", "deny", "check", "advisories", cwd=cfg.root) 

229 

230 

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``. 

241 

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. 

247 

248 Args: 

249 cfg: The resolved config. 

250 """ 

251 tool("cargo", "deny", "check", "licenses", cwd=cfg.root) 

252 

253 

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. 

264 

265 Args: 

266 cfg: The resolved config. 

267 """ 

268 tool("cargo", "machete", cwd=cfg.root) 

269 

270 

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. 

280 

281 Args: 

282 cfg: Unused; the prerequisites do the work. 

283 """ 

284 

285 

286def _cargo_bin() -> Path: 

287 """Return the directory ``cargo install`` writes to. 

288 

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. 

292 

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"