#!/usr/bin/env bash
# Mutation check: prove a test can fail. Replaces an exact snippet in one
# source file (must match exactly once), runs the tests, and ALWAYS restores
# the file. Exit 0 = mutant killed (some test failed, as it should); 1 =
# mutant SURVIVED (tests too weak); 3 = mutant doesn't build (invalid —
# replace a statement with `{ }` instead of deleting a loop body line).
#
# Usage (from the repo whose tests must catch it — a game root, or this lib's root):
# <path>/mutate.sh <file> <old-snippet> <new-snippet>
# e.g. scripts/mutate.sh src/MyGame.Core/Loop/InputLatch.cs \
# '_jump |= sample.JumpPressed;' '_jump = sample.JumpPressed;'
# <path>/mutate.sh <file> <old> <new> -- <test command...>
# runs that command instead of the whole `dotnet test` (a filtered dotnet test, or any other runner: python
# unittest, ...); a dotnet command keeps the build / [FAIL] parsing, any other is killed by a non-zero exit.
set -uo pipefail
file=$1 old=$2 new=$3
shift 3
cmd=(dotnet test --nologo)
if [[ ${1:-} == -- ]]; then
shift
cmd=("$@")
fi
backup=$(mktemp)
cp "$file" "$backup"
trap 'cp "$backup" "$file"; rm -f "$backup"' EXIT
python3 - "$file" "$old" "$new" <<'EOF' || exit 2
import sys
path, old, new = sys.argv[1:]
text = open(path, encoding="utf-8").read()
n = text.count(old)
if n != 1:
sys.exit(f"snippet matches {n} times in {path} (need exactly 1)")
open(path, "w", encoding="utf-8").write(text.replace(old, new))
EOF
result=$("${cmd[@]}" 2>&1)
status=$?
if [[ ${cmd[0]} != dotnet ]]; then
if (( status != 0 )); then
echo "KILLED (exit $status):"; tail -3 <<<"$result"; exit 0
fi
echo "SURVIVED: no test failed — strengthen the tests"; exit 1
fi
if grep -q "error CS" <<<"$result"; then
echo "INVALID: mutant doesn't build"; grep -m3 "error CS" <<<"$result"; exit 3
fi
if grep -q "Failed!" <<<"$result"; then
echo "KILLED by:"; grep "\[FAIL\]" <<<"$result" | sed -E 's/^\[xUnit[^]]*\] +//; s/ \[FAIL\]$//' | sort -u; exit 0
fi
echo "SURVIVED: no test failed — strengthen the tests"; exit 1