from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
import tomllib
import typing as typ
from pathlib import Path
RESOLVER_VARIABLE: typ.Final = "CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS"
Runner: typ.TypeAlias = typ.Callable[[list[str], Path], None]
class ManifestError(Exception):
def read_manifest(manifest: Path) -> str:
try:
return manifest.read_text(encoding="utf-8")
except OSError as err:
message = f"cannot read {manifest}: {err}"
raise ManifestError(message) from err
def parse_rust_version(text: str, source: str) -> str:
try:
package = tomllib.loads(text).get("package", {})
except tomllib.TOMLDecodeError as err:
message = f"{source} is not valid TOML: {err}"
raise ManifestError(message) from err
version = package.get("rust-version")
if not isinstance(version, str) or not version:
message = f"{source} declares no package.rust-version"
raise ManifestError(message)
return version
def declared_rust_version(
manifest: Path, reader: typ.Callable[[Path], str] = read_manifest
) -> str:
return parse_rust_version(reader(manifest), str(manifest))
def commands_for(version: str) -> list[list[str]]:
return [
["rustup", "toolchain", "install", version, "--profile", "minimal"],
["cargo", f"+{version}", "generate-lockfile"],
["cargo", f"+{version}", "check", "--locked", "--all-targets", "--all-features"],
]
COMPILER_OVERRIDES: typ.Final = ("RUSTC", "CARGO_BUILD_RUSTC", "RUSTDOC", "CARGO_BUILD_RUSTDOC")
def subprocess_environment(inherited: typ.Mapping[str, str]) -> dict[str, str]:
kept = {key: value for key, value in inherited.items() if key not in COMPILER_OVERRIDES}
return {**kept, RESOLVER_VARIABLE: "fallback", "RUSTFLAGS": ""}
def run_in(command: list[str], repo: Path) -> None:
environment = subprocess_environment(os.environ)
subprocess.run(command, cwd=repo, env=environment, check=True)
def check_msrv(repo: Path, runner: Runner = run_in) -> str:
version = declared_rust_version(repo / "Cargo.toml")
lockfile = repo / "Cargo.lock"
with tempfile.TemporaryDirectory() as scratch:
backup = Path(scratch) / "Cargo.lock"
shutil.copy2(lockfile, backup)
try:
for command in commands_for(version):
runner(command, repo)
finally:
shutil.copy2(backup, lockfile)
return version
def main() -> int:
try:
version = check_msrv(Path.cwd())
except ManifestError as err:
sys.stderr.write(f"msrv_check: {err}\n")
return 2
except subprocess.CalledProcessError as err:
sys.stderr.write(f"msrv_check: {' '.join(err.cmd)} failed with {err.returncode}\n")
return 1
sys.stdout.write(f"builds at the declared rust-version {version}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())