from __future__ import annotations
import importlib.util
import os
import subprocess
import sys
import typing as typ
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT_PATH = REPO_ROOT / "scripts" / "msrv_check.py"
SPEC = importlib.util.spec_from_file_location("msrv_check", SCRIPT_PATH)
assert SPEC is not None, f"cannot build an import spec for {SCRIPT_PATH}"
msrv_check = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None, f"import spec for {SCRIPT_PATH} has no loader"
sys.modules[SPEC.name] = msrv_check
SPEC.loader.exec_module(msrv_check)
def make_repo(root: Path, rust_version: str | None = "1.92") -> Path:
line = f'rust-version = "{rust_version}"\n' if rust_version else ""
(root / "Cargo.toml").write_text(f'[package]\nname = "x"\nversion = "0.1.0"\n{line}')
(root / "Cargo.lock").write_text("committed\n")
return root
class Recorder:
def __init__(self, fail_on: str | None = None) -> None:
self.commands: list[list[str]] = []
self.fail_on = fail_on
def __call__(self, command: list[str], repo: Path) -> None:
self.commands.append(command)
if "generate-lockfile" in command:
(repo / "Cargo.lock").write_text("resolved\n")
if self.fail_on and self.fail_on in command:
message = f"{self.fail_on} failed"
raise RuntimeError(message)
def test_the_steps_install_resolve_then_check_at_the_declared_version(tmp_path: Path) -> None:
recorder = Recorder()
version = msrv_check.check_msrv(make_repo(tmp_path), recorder)
assert version == "1.92"
assert recorder.commands == [
["rustup", "toolchain", "install", "1.92", "--profile", "minimal"],
["cargo", "+1.92", "generate-lockfile"],
["cargo", "+1.92", "check", "--locked", "--all-targets", "--all-features"],
]
@pytest.mark.parametrize("failing", [None, "check"])
def test_the_committed_lockfile_comes_back(tmp_path: Path, failing: str | None) -> None:
repo = make_repo(tmp_path)
recorder = Recorder(fail_on=failing)
if failing:
with pytest.raises(RuntimeError):
msrv_check.check_msrv(repo, recorder)
else:
msrv_check.check_msrv(repo, recorder)
assert (repo / "Cargo.lock").read_text() == "committed\n"
def test_a_manifest_without_rust_version_is_refused(tmp_path: Path) -> None:
repo = make_repo(tmp_path, rust_version=None)
with pytest.raises(msrv_check.ManifestError, match="rust-version"):
msrv_check.check_msrv(repo, Recorder())
def test_a_manifest_that_is_not_toml_is_named_as_such() -> None:
with pytest.raises(msrv_check.ManifestError, match="not valid TOML"):
msrv_check.parse_rust_version("[package\n", "Cargo.toml")
def test_a_missing_manifest_is_a_manifest_error(tmp_path: Path) -> None:
with pytest.raises(msrv_check.ManifestError, match="cannot read"):
msrv_check.read_manifest(tmp_path / "Cargo.toml")
def test_the_reader_is_injected() -> None:
version = msrv_check.declared_rust_version(
Path("Cargo.toml"), reader=lambda _path: '[package]\nrust-version = "1.99"\n'
)
assert version == "1.99"
def test_the_resolver_prefers_releases_the_version_can_build() -> None:
assert msrv_check.RESOLVER_VARIABLE == "CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS"
@pytest.mark.parametrize("override", msrv_check.COMPILER_OVERRIDES)
def test_an_inherited_compiler_cannot_replace_the_declared_one(override: str) -> None:
inherited = {override: "/opt/newer/rustc", "HOME": "/home/x", "RUSTFLAGS": "-D warnings"}
environment = msrv_check.subprocess_environment(inherited)
assert override not in environment
assert environment["HOME"] == "/home/x"
assert environment["RUSTFLAGS"] == ""
assert environment[msrv_check.RESOLVER_VARIABLE] == "fallback"
def test_run_in_passes_that_environment_to_the_subprocess(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("RUSTC", "/opt/newer/rustc")
monkeypatch.setenv(msrv_check.RESOLVER_VARIABLE, "error")
calls: list[tuple[list[str], dict[str, typ.Any]]] = []
monkeypatch.setattr(
msrv_check.subprocess,
"run",
lambda command, **options: calls.append((command, options)),
)
msrv_check.run_in(["cargo", "check"], tmp_path)
[(command, options)] = calls
assert command == ["cargo", "check"]
assert options["cwd"] == tmp_path
assert options["check"] is True
assert "RUSTC" not in options["env"]
assert options["env"][msrv_check.RESOLVER_VARIABLE] == "fallback"
assert options["env"]["RUSTFLAGS"] == ""
def jobs_running(command: str) -> list[str]:
document = yaml.safe_load((REPO_ROOT / ".github" / "workflows" / "ci.yml").read_text())
return [
name
for name, job in document["jobs"].items()
if any(step.get("run", "").strip() == command for step in job.get("steps", []))
]
def test_ci_runs_the_msrv_guard() -> None:
assert jobs_running("make msrv"), "no ci.yml job runs `make msrv`"
def test_the_make_target_runs_the_script() -> None:
makefile = (REPO_ROOT / "Makefile").read_text()
recipe = makefile.split("\nmsrv:", 1)[1].split("\n\n", 1)[0]
assert "scripts/msrv_check.py" in recipe
def test_the_declared_version_is_one_the_script_can_read() -> None:
assert msrv_check.declared_rust_version(REPO_ROOT / "Cargo.toml")
FAKE_TOOL = """#!/bin/sh
# Records one line per call: tool, arguments, directory, and the variables the
# check must control. `cargo generate-lockfile` rewrites the lockfile, and a
# check fails when FAKE_FAIL names it.
printf '%s|%s|%s|flags=[%s]|rustc=[%s]|resolver=[%s]\\n' \
"$(basename "$0")" "$*" "$PWD" "$RUSTFLAGS" "${RUSTC-unset}" \
"$CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS" >> "$FAKE_LOG"
case "$*" in
*generate-lockfile*) echo resolved > Cargo.lock ;;
esac
case "$*" in
*"$FAKE_FAIL"*) exit 1 ;;
esac
"""
def run_cli(repo: Path, bin_dir: Path, fail_on: str) -> tuple[subprocess.CompletedProcess[str], list[str]]:
bin_dir.mkdir()
for tool in ("rustup", "cargo"):
path = bin_dir / tool
path.write_text(FAKE_TOOL)
path.chmod(0o755)
log = repo / "calls.log"
environment = {
**os.environ,
"PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}",
"FAKE_LOG": str(log),
"FAKE_FAIL": fail_on,
"RUSTC": "/opt/newer/rustc",
"RUSTFLAGS": "-D warnings",
}
result = subprocess.run( [sys.executable, str(SCRIPT_PATH)],
cwd=repo,
env=environment,
capture_output=True,
text=True,
check=False,
)
lines = log.read_text().splitlines() if log.exists() else []
return result, lines
def test_the_command_line_runs_the_steps_in_the_repository(tmp_path: Path) -> None:
(tmp_path / "repo").mkdir()
repo = make_repo(tmp_path / "repo")
result, calls = run_cli(repo, tmp_path / "bin", fail_on="never-matches")
assert result.returncode == 0, result.stderr
assert "builds at the declared rust-version 1.92" in result.stdout
assert [call.split("|")[0:2] for call in calls] == [
["rustup", "toolchain install 1.92 --profile minimal"],
["cargo", "+1.92 generate-lockfile"],
["cargo", "+1.92 check --locked --all-targets --all-features"],
]
for call in calls:
_tool, _args, directory, flags, compiler, resolver = call.split("|")
assert Path(directory).resolve() == repo.resolve()
assert flags == "flags=[]"
assert compiler == "rustc=[unset]"
assert resolver == "resolver=[fallback]"
assert (repo / "Cargo.lock").read_text() == "committed\n"
def test_a_failing_build_fails_the_command_and_restores_the_lockfile(tmp_path: Path) -> None:
(tmp_path / "repo").mkdir()
repo = make_repo(tmp_path / "repo")
result, _calls = run_cli(repo, tmp_path / "bin", fail_on="check")
assert result.returncode == 1
assert "check" in result.stderr
assert (repo / "Cargo.lock").read_text() == "committed\n"
def test_a_manifest_without_rust_version_exits_with_two(tmp_path: Path) -> None:
(tmp_path / "repo").mkdir()
repo = make_repo(tmp_path / "repo", rust_version=None)
result, calls = run_cli(repo, tmp_path / "bin", fail_on="never-matches")
assert result.returncode == 2
assert "rust-version" in result.stderr
assert calls == []