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

102 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-27 05:51 +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._toml import load_toml 

30from rhiza_hooks._version import parse_version, same_version, version_at_least 

31 

32CARGO_FILE = "Cargo.toml" 

33TOOLCHAIN_FILE = "rust-toolchain.toml" 

34LEGACY_TOOLCHAIN_FILE = "rust-toolchain" 

35 

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

37_MSRV_TABLES = { 

38 "package": ("package",), 

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

40} 

41 

42 

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

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

45 

46 Args: 

47 data: Parsed TOML document. 

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

49 

50 Returns: 

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

52 """ 

53 current: Any = data 

54 for key in keys: 

55 if not isinstance(current, dict): 

56 return {} 

57 current = current.get(key) 

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

59 

60 

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

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

63 value = table.get(key) 

64 if not isinstance(value, str): 

65 return None 

66 return value.strip() or None 

67 

68 

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

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

71 

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

73 is tried first and plain text is the fallback. 

74 

75 Args: 

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

77 

78 Returns: 

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

80 declares no channel. 

81 """ 

82 if not path.exists(): 

83 return None 

84 try: 

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

86 except (OSError, UnicodeDecodeError): 

87 return None 

88 

89 stripped = text.strip() 

90 if not stripped: 

91 return None 

92 

93 try: 

94 data = tomllib.loads(stripped) 

95 except tomllib.TOMLDecodeError: 

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

97 return stripped 

98 

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

100 

101 

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

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

104 

105 Args: 

106 repo_root: Root directory of the repository. 

107 

108 Returns: 

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

110 exist and actually declare a channel. 

111 """ 

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

113 

114 data = load_toml(repo_root / TOOLCHAIN_FILE) 

115 if data is not None: 

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

117 if channel is not None: 

118 channels[TOOLCHAIN_FILE] = channel 

119 

120 legacy = read_legacy_toolchain(repo_root / LEGACY_TOOLCHAIN_FILE) 

121 if legacy is not None: 

122 channels[LEGACY_TOOLCHAIN_FILE] = legacy 

123 

124 return channels 

125 

126 

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

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

129 

130 Args: 

131 repo_root: Root directory of the repository. 

132 

133 Returns: 

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

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

136 """ 

137 data = load_toml(repo_root / CARGO_FILE) 

138 if data is None: 

139 return {} 

140 

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

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

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

144 if value is not None: 

145 versions[label] = value 

146 return versions 

147 

148 

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

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

151 modern = channels.get(TOOLCHAIN_FILE) 

152 legacy = channels.get(LEGACY_TOOLCHAIN_FILE) 

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

154 return [] 

155 return [ 

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

157 ] 

158 

159 

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

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

162 package = msrvs.get("package") 

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

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

165 return [] 

166 return [ 

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

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

169 ] 

170 

171 

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

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

174 

175 Args: 

176 channel_version: Parsed components of the pinned toolchain channel. 

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

178 

179 Returns: 

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

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

182 

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

184 True 

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

186 False 

187 

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

189 treated as a lower one: 

190 

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

192 False 

193 

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

195 

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

197 False 

198 """ 

199 msrv_version = parse_version(msrv) 

200 if msrv_version is None: 

201 return False 

202 return not version_at_least(channel_version, msrv_version) 

203 

204 

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

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

207 

208 Args: 

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

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

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

212 

213 Returns: 

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

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

216 """ 

217 channel_version = parse_version(channel) 

218 if channel_version is None: 

219 return [] 

220 return [ 

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

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

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

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

225 if _is_below_msrv(channel_version, msrv) 

226 ] 

227 

228 

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

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

231 return [ 

232 error 

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

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

235 ] 

236 

237 

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

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

240 

241 Args: 

242 repo_root: Root directory of the repository. 

243 

244 Returns: 

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

246 declares no Rust versions at all). 

247 """ 

248 channels = get_toolchain_channels(repo_root) 

249 msrvs = get_cargo_rust_versions(repo_root) 

250 

251 return [ 

252 *_check_channels_agree(channels), 

253 *_check_msrvs_agree(msrvs), 

254 *_check_channel_satisfies_msrv(channels, msrvs), 

255 ] 

256 

257 

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

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

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

261 parser.add_argument( 

262 "filenames", 

263 nargs="*", 

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

265 ) 

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

267 

268 repo_root = find_repo_root() 

269 errors = check_version_consistency(repo_root) 

270 

271 if errors: 

272 for error in errors: 

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

274 return 1 

275 

276 return 0 

277 

278 

279if __name__ == "__main__": 

280 sys.exit(main())