from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import tomllib
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO_ROOT = HERE.parent
MUTANTS_TOML = REPO_ROOT / ".cargo" / "mutants.toml"
BASELINE = HERE / "mutants-exclude-baseline.txt"
LOOSE_PATTERNS = frozenset({"tests::"})
_ANSI_SGR_RE = re.compile(r"\x1b\[[0-9;]*m")
def strip_ansi(line: str) -> str:
return _ANSI_SGR_RE.sub("", line)
def load_exclude_re(mutants_toml: Path) -> list[str]:
with mutants_toml.open("rb") as f:
doc = tomllib.load(f)
return list(doc.get("exclude_re", []))
def full_mutant_list() -> list[str]:
proc = subprocess.run(
["cargo", "mutants", "--list", "--no-config", "--colors", "never"],
cwd=REPO_ROOT,
capture_output=True,
text=True,
check=True,
env={**os.environ, "CARGO_TERM_COLOR": "never"},
)
return [strip_ansi(line) for line in proc.stdout.splitlines() if line.strip()]
def match_counts(patterns: list[str], lines: list[str]) -> Counter[str]:
counts: Counter[str] = Counter()
compiled = [(p, re.compile(p)) for p in patterns]
for line in lines:
for pattern, regex in compiled:
if regex.search(line):
counts[pattern] += 1
return counts
def read_baseline(path: Path) -> dict[str, int]:
out: dict[str, int] = {}
if not path.is_file():
return out
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
count_str, pattern = line.split("\t", 1)
out[pattern] = int(count_str)
return out
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--mutants-toml", type=Path, default=MUTANTS_TOML)
ap.add_argument("--baseline", type=Path, default=BASELINE)
ap.add_argument("--write", action="store_true", help="record the current counts")
args = ap.parse_args(argv)
patterns = load_exclude_re(args.mutants_toml)
if not patterns:
print("error: compared nothing (exclude_re is empty)", file=sys.stderr)
return 1
lines = full_mutant_list()
counts = match_counts(patterns, lines)
if args.write:
text = "# pattern's current match count against `cargo mutants --list --no-config`\n"
text += "# written by scripts/mutants_exclude_check.py --write\n"
text += "".join(f"{counts[p]}\t{p}\n" for p in patterns)
args.baseline.write_text(text, encoding="utf-8", newline="\n")
print(f"wrote {len(patterns)} entries to {args.baseline}")
return 0
baseline = read_baseline(args.baseline)
errors = []
for pattern in patterns:
got = counts[pattern]
if got == 0:
errors.append(f"matches 0 mutants (no proof left to exclude anything): {pattern!r}")
continue
if pattern in LOOSE_PATTERNS:
continue
want = baseline.get(pattern)
if want is None:
errors.append(f"not in baseline (run --write): {pattern!r}")
elif got != want:
errors.append(f"match count changed from {want} to {got}: {pattern!r}")
for stale in set(baseline) - set(patterns):
errors.append(f"baseline entry no longer in exclude_re (stale): {stale!r}")
for e in errors:
print(f"error: {e}", file=sys.stderr)
print(f"compared: {len(patterns)} entries, {len(lines)} mutants in the full list")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())