Coverage for src/rhiza_hooks/check_rust_version.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 06:14 +0000

1#!/usr/bin/env python3 

2"""Check that the Rust version is consistent across project files. 

3 

4A Rust project states its version in up to three places: 

5 

6* ``rust-toolchain.toml`` — ``[toolchain] channel``, the toolchain rustup 

7 installs for this checkout; 

8* ``rust-toolchain`` — the legacy form of the same file, either TOML or a bare 

9 channel name on a single line; 

10* ``Cargo.toml`` — ``rust-version`` under ``[package]`` and/or 

11 ``[workspace.package]``, the crate's minimum supported Rust version (MSRV). 

12 

13The hook enforces that the two toolchain files agree with each other, that the 

14two MSRV declarations agree with each other, and that the pinned toolchain is 

15not older than the declared MSRV (a pin below the MSRV cannot build the crate). 

16Named channels (``stable``, ``beta``, ``nightly-2024-01-01``) carry no version 

17number, so they are accepted without comparison. 

18""" 

19 

20from __future__ import annotations 

21 

22import argparse 

23import sys 

24import tomllib 

25from pathlib import Path 

26from typing import Any 

27 

28from rhiza_hooks._repo import find_repo_root 

29from rhiza_hooks._version import parse_version, same_version, version_at_least 

30 

31CARGO_FILE = "Cargo.toml" 

32TOOLCHAIN_FILE = "rust-toolchain.toml" 

33LEGACY_TOOLCHAIN_FILE = "rust-toolchain" 

34 

35# Cargo.toml tables that may declare an MSRV, keyed by the label used in errors. 

36_MSRV_TABLES = { 

37 "package": ("package",), 

38 "workspace.package": ("workspace", "package"), 

39} 

40 

41 

42def _table(data: dict[str, Any], *keys: str) -> dict[str, Any]: 

43 """Walk nested TOML tables, returning an empty table for any missing hop. 

44 

45 Args: 

46 data: Parsed TOML document. 

47 *keys: Table names to descend through, outermost first. 

48 

49 Returns: 

50 The nested table, or an empty dict if any hop is absent or not a table. 

51 """ 

52 current: Any = data 

53 for key in keys: 

54 if not isinstance(current, dict): 

55 return {} 

56 current = current.get(key) 

57 return current if isinstance(current, dict) else {} 

58 

59 

60def _string_value(table: dict[str, Any], key: str) -> str | None: 

61 """Return ``table[key]`` as a stripped string, or None if absent/blank/non-string.""" 

62 value = table.get(key) 

63 if not isinstance(value, str): 

64 return None 

65 return value.strip() or None 

66 

67 

68def _load_toml(path: Path) -> dict[str, Any] | None: 

69 """Parse a TOML file. 

70 

71 Args: 

72 path: File to read. 

73 

74 Returns: 

75 The parsed document, or None when the file is missing, unreadable, or 

76 malformed. As in the Python-version hook, an unusable file is treated as 

77 "unspecified" rather than crashing the commit. 

78 """ 

79 if not path.exists(): 

80 return None 

81 try: 

82 with path.open("rb") as handle: 

83 return tomllib.load(handle) 

84 except (tomllib.TOMLDecodeError, OSError): 

85 return None 

86 

87 

88def read_legacy_toolchain(path: Path) -> str | None: 

89 """Read the channel from a legacy ``rust-toolchain`` file. 

90 

91 rustup accepts either the modern TOML form or a bare channel name, so TOML 

92 is tried first and plain text is the fallback. 

93 

94 Args: 

95 path: Path to the ``rust-toolchain`` file. 

96 

97 Returns: 

98 The channel string, or None if the file is missing, unreadable, or 

99 declares no channel. 

100 """ 

101 if not path.exists(): 

102 return None 

103 try: 

104 text = path.read_text(encoding="utf-8") 

105 except (OSError, UnicodeDecodeError): 

106 return None 

107 

108 stripped = text.strip() 

109 if not stripped: 

110 return None 

111 

112 try: 

113 data = tomllib.loads(stripped) 

114 except tomllib.TOMLDecodeError: 

115 # Not TOML: the whole file is the channel name (the legacy format). 

116 return stripped 

117 

118 return _string_value(_table(data, "toolchain"), "channel") 

119 

120 

121def get_toolchain_channels(repo_root: Path) -> dict[str, str]: 

122 """Collect the pinned toolchain channels declared in the repository. 

123 

124 Args: 

125 repo_root: Root directory of the repository. 

126 

127 Returns: 

128 Mapping of filename to channel string, containing only the files that 

129 exist and actually declare a channel. 

130 """ 

131 channels: dict[str, str] = {} 

132 

133 data = _load_toml(repo_root / TOOLCHAIN_FILE) 

134 if data is not None: 

135 channel = _string_value(_table(data, "toolchain"), "channel") 

136 if channel is not None: 

137 channels[TOOLCHAIN_FILE] = channel 

138 

139 legacy = read_legacy_toolchain(repo_root / LEGACY_TOOLCHAIN_FILE) 

140 if legacy is not None: 

141 channels[LEGACY_TOOLCHAIN_FILE] = legacy 

142 

143 return channels 

144 

145 

146def get_cargo_rust_versions(repo_root: Path) -> dict[str, str]: 

147 """Collect the MSRVs declared in ``Cargo.toml``. 

148 

149 Args: 

150 repo_root: Root directory of the repository. 

151 

152 Returns: 

153 Mapping of table label (``package`` / ``workspace.package``) to the 

154 ``rust-version`` string declared there. 

155 """ 

156 data = _load_toml(repo_root / CARGO_FILE) 

157 if data is None: 

158 return {} 

159 

160 versions: dict[str, str] = {} 

161 for label, keys in _MSRV_TABLES.items(): 

162 value = _string_value(_table(data, *keys), "rust-version") 

163 if value is not None: 

164 versions[label] = value 

165 return versions 

166 

167 

168def _check_channels_agree(channels: dict[str, str]) -> list[str]: 

169 """Report a disagreement between ``rust-toolchain.toml`` and ``rust-toolchain``.""" 

170 modern = channels.get(TOOLCHAIN_FILE) 

171 legacy = channels.get(LEGACY_TOOLCHAIN_FILE) 

172 if modern is None or legacy is None or same_version(modern, legacy): 

173 return [] 

174 return [ 

175 f"Rust toolchain mismatch: {TOOLCHAIN_FILE} pins channel {modern}, but {LEGACY_TOOLCHAIN_FILE} pins {legacy}" 

176 ] 

177 

178 

179def _check_msrvs_agree(msrvs: dict[str, str]) -> list[str]: 

180 """Report a disagreement between the ``[package]`` and ``[workspace.package]`` MSRVs.""" 

181 package = msrvs.get("package") 

182 workspace = msrvs.get("workspace.package") 

183 if package is None or workspace is None or same_version(package, workspace): 

184 return [] 

185 return [ 

186 f"Rust version mismatch: {CARGO_FILE} [package] rust-version is {package}, " 

187 f"but [workspace.package] rust-version is {workspace}" 

188 ] 

189 

190 

191def _is_below_msrv(channel_version: tuple[int, ...], msrv: str) -> bool: 

192 """Whether *channel_version* is below the MSRV *msrv*. 

193 

194 Args: 

195 channel_version: Parsed components of the pinned toolchain channel. 

196 msrv: Raw ``rust-version`` text from ``Cargo.toml``. 

197 

198 Returns: 

199 True only when *msrv* carries a version number that the channel fails to 

200 reach; False for a non-numeric MSRV, which gives nothing to compare. 

201 

202 >>> _is_below_msrv((1, 70, 0), "1.75") 

203 True 

204 >>> _is_below_msrv((1, 75, 0), "1.75") 

205 False 

206 

207 Comparison is component-wise after zero-padding, so a shorter MSRV is not 

208 treated as a lower one: 

209 

210 >>> _is_below_msrv((1, 75, 0), "1.75.0") 

211 False 

212 

213 A named channel gives nothing to compare against, and reports no violation: 

214 

215 >>> _is_below_msrv((1, 70, 0), "stable") 

216 False 

217 """ 

218 msrv_version = parse_version(msrv) 

219 if msrv_version is None: 

220 return False 

221 return not version_at_least(channel_version, msrv_version) 

222 

223 

224def _channel_msrv_violations(source: str, channel: str, msrvs: dict[str, str]) -> list[str]: 

225 """Report every declared MSRV that the toolchain pinned in *source* fails to satisfy. 

226 

227 Args: 

228 source: Filename the channel was declared in, used in the error message. 

229 channel: Raw channel string, e.g. ``"1.75.0"`` or ``"stable"``. 

230 msrvs: Declared MSRVs, keyed by the ``Cargo.toml`` table label. 

231 

232 Returns: 

233 One error per unsatisfied MSRV, ordered by table label; empty for a named 

234 channel (stable/beta/nightly-<date>), which has no version to compare. 

235 """ 

236 channel_version = parse_version(channel) 

237 if channel_version is None: 

238 return [] 

239 return [ 

240 f"Rust version mismatch: {source} pins channel {channel}, " 

241 f"but {CARGO_FILE} [{label}] rust-version is {msrv} " 

242 f"(the pinned toolchain must be at least the MSRV)" 

243 for label, msrv in sorted(msrvs.items()) 

244 if _is_below_msrv(channel_version, msrv) 

245 ] 

246 

247 

248def _check_channel_satisfies_msrv(channels: dict[str, str], msrvs: dict[str, str]) -> list[str]: 

249 """Report every pinned toolchain that is older than a declared MSRV.""" 

250 return [ 

251 error 

252 for source, channel in sorted(channels.items()) 

253 for error in _channel_msrv_violations(source, channel, msrvs) 

254 ] 

255 

256 

257def check_version_consistency(repo_root: Path) -> list[str]: 

258 """Check Rust version consistency across project files. 

259 

260 Args: 

261 repo_root: Root directory of the repository. 

262 

263 Returns: 

264 List of error messages (empty if consistent, or if the repository 

265 declares no Rust versions at all). 

266 """ 

267 channels = get_toolchain_channels(repo_root) 

268 msrvs = get_cargo_rust_versions(repo_root) 

269 

270 return [ 

271 *_check_channels_agree(channels), 

272 *_check_msrvs_agree(msrvs), 

273 *_check_channel_satisfies_msrv(channels, msrvs), 

274 ] 

275 

276 

277def main(argv: list[str] | None = None) -> int: 

278 """Main entry point for the hook.""" 

279 parser = argparse.ArgumentParser(description="Check Rust version consistency") 

280 parser.add_argument( 

281 "filenames", 

282 nargs="*", 

283 help="Filenames (ignored, checks repo root)", 

284 ) 

285 parser.parse_args(argv) # validate/consume pre-commit's filename args; result unused 

286 

287 repo_root = find_repo_root() 

288 errors = check_version_consistency(repo_root) 

289 

290 if errors: 

291 for error in errors: 

292 print(f"ERROR: {error}", file=sys.stderr) 

293 return 1 

294 

295 return 0 

296 

297 

298if __name__ == "__main__": # pragma: no mutate 

299 sys.exit(main())