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
« 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.
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._toml import load_toml
30from rhiza_hooks._version import parse_version, same_version, version_at_least
32CARGO_FILE = "Cargo.toml"
33TOOLCHAIN_FILE = "rust-toolchain.toml"
34LEGACY_TOOLCHAIN_FILE = "rust-toolchain"
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}
43def _table(data: dict[str, Any], *keys: str) -> dict[str, Any]:
44 """Walk nested TOML tables, returning an empty table for any missing hop.
46 Args:
47 data: Parsed TOML document.
48 *keys: Table names to descend through, outermost first.
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 {}
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
69def read_legacy_toolchain(path: Path) -> str | None:
70 """Read the channel from a legacy ``rust-toolchain`` file.
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.
75 Args:
76 path: Path to the ``rust-toolchain`` file.
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
89 stripped = text.strip()
90 if not stripped:
91 return None
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
99 return _string_value(_table(data, "toolchain"), "channel")
102def get_toolchain_channels(repo_root: Path) -> dict[str, str]:
103 """Collect the pinned toolchain channels declared in the repository.
105 Args:
106 repo_root: Root directory of the repository.
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] = {}
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
120 legacy = read_legacy_toolchain(repo_root / LEGACY_TOOLCHAIN_FILE)
121 if legacy is not None:
122 channels[LEGACY_TOOLCHAIN_FILE] = legacy
124 return channels
127def get_cargo_rust_versions(repo_root: Path) -> dict[str, str]:
128 """Collect the MSRVs declared in ``Cargo.toml``.
130 Args:
131 repo_root: Root directory of the repository.
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 {}
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
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 ]
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 ]
172def _is_below_msrv(channel_version: tuple[int, ...], msrv: str) -> bool:
173 """Whether *channel_version* is below the MSRV *msrv*.
175 Args:
176 channel_version: Parsed components of the pinned toolchain channel.
177 msrv: Raw ``rust-version`` text from ``Cargo.toml``.
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.
183 >>> _is_below_msrv((1, 70, 0), "1.75")
184 True
185 >>> _is_below_msrv((1, 75, 0), "1.75")
186 False
188 Comparison is component-wise after zero-padding, so a shorter MSRV is not
189 treated as a lower one:
191 >>> _is_below_msrv((1, 75, 0), "1.75.0")
192 False
194 A named channel gives nothing to compare against, and reports no violation:
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)
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.
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.
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 ]
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 ]
238def check_version_consistency(repo_root: Path) -> list[str]:
239 """Check Rust version consistency across project files.
241 Args:
242 repo_root: Root directory of the repository.
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)
251 return [
252 *_check_channels_agree(channels),
253 *_check_msrvs_agree(msrvs),
254 *_check_channel_satisfies_msrv(channels, msrvs),
255 ]
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
268 repo_root = find_repo_root()
269 errors = check_version_consistency(repo_root)
271 if errors:
272 for error in errors:
273 print(f"ERROR: {error}", file=sys.stderr)
274 return 1
276 return 0
279if __name__ == "__main__":
280 sys.exit(main())