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
« 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.
4A Rust project states its version in up to three places:
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).
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"""
20from __future__ import annotations
22import argparse
23import sys
24import tomllib
25from pathlib import Path
26from typing import Any
28from rhiza_hooks._repo import find_repo_root
29from rhiza_hooks._version import parse_version, same_version, version_at_least
31CARGO_FILE = "Cargo.toml"
32TOOLCHAIN_FILE = "rust-toolchain.toml"
33LEGACY_TOOLCHAIN_FILE = "rust-toolchain"
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}
42def _table(data: dict[str, Any], *keys: str) -> dict[str, Any]:
43 """Walk nested TOML tables, returning an empty table for any missing hop.
45 Args:
46 data: Parsed TOML document.
47 *keys: Table names to descend through, outermost first.
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 {}
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
68def _load_toml(path: Path) -> dict[str, Any] | None:
69 """Parse a TOML file.
71 Args:
72 path: File to read.
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
88def read_legacy_toolchain(path: Path) -> str | None:
89 """Read the channel from a legacy ``rust-toolchain`` file.
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.
94 Args:
95 path: Path to the ``rust-toolchain`` file.
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
108 stripped = text.strip()
109 if not stripped:
110 return None
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
118 return _string_value(_table(data, "toolchain"), "channel")
121def get_toolchain_channels(repo_root: Path) -> dict[str, str]:
122 """Collect the pinned toolchain channels declared in the repository.
124 Args:
125 repo_root: Root directory of the repository.
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] = {}
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
139 legacy = read_legacy_toolchain(repo_root / LEGACY_TOOLCHAIN_FILE)
140 if legacy is not None:
141 channels[LEGACY_TOOLCHAIN_FILE] = legacy
143 return channels
146def get_cargo_rust_versions(repo_root: Path) -> dict[str, str]:
147 """Collect the MSRVs declared in ``Cargo.toml``.
149 Args:
150 repo_root: Root directory of the repository.
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 {}
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
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 ]
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 ]
191def _is_below_msrv(channel_version: tuple[int, ...], msrv: str) -> bool:
192 """Whether *channel_version* is below the MSRV *msrv*.
194 Args:
195 channel_version: Parsed components of the pinned toolchain channel.
196 msrv: Raw ``rust-version`` text from ``Cargo.toml``.
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.
202 >>> _is_below_msrv((1, 70, 0), "1.75")
203 True
204 >>> _is_below_msrv((1, 75, 0), "1.75")
205 False
207 Comparison is component-wise after zero-padding, so a shorter MSRV is not
208 treated as a lower one:
210 >>> _is_below_msrv((1, 75, 0), "1.75.0")
211 False
213 A named channel gives nothing to compare against, and reports no violation:
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)
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.
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.
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 ]
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 ]
257def check_version_consistency(repo_root: Path) -> list[str]:
258 """Check Rust version consistency across project files.
260 Args:
261 repo_root: Root directory of the repository.
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)
270 return [
271 *_check_channels_agree(channels),
272 *_check_msrvs_agree(msrvs),
273 *_check_channel_satisfies_msrv(channels, msrvs),
274 ]
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
287 repo_root = find_repo_root()
288 errors = check_version_consistency(repo_root)
290 if errors:
291 for error in errors:
292 print(f"ERROR: {error}", file=sys.stderr)
293 return 1
295 return 0
298if __name__ == "__main__": # pragma: no mutate
299 sys.exit(main())