# Use bash from PATH instead of /bin/sh — uniform behaviour on macOS/Linux/CI.
SHELL := /usr/bin/env bash
# -e fail on first error, -u error on unset vars, -o pipefail propagate pipe failures.
.SHELLFLAGS := -euo pipefail -c
# `make` with no target shows help, not the first recipe.
.DEFAULT_GOAL := help

UV ?= uv
# The toolchain is pinned in rust-toolchain.toml, so plain `cargo` already resolves to 1.97.0.
CARGO ?= cargo

# Paths the Python gates run on. This is a Rust project with two Python corners: the throwaway
# corpus measurement, and `scripts/coverage_report.py`, which reads the coverage reports.
#
# These are LINT paths and they are deliberately NOT the coverage denominator, which is
# `[tool.coverage.run] source` in pyproject.toml and lists `corpus` and `scripts`. The two lists
# overlap but are not the same question: lint asks "is this file well formed", coverage asks "how
# much of this file did the tests run". `tests/unit` is linted and deliberately NOT in the
# denominator, because a denominator holding the tests climbs whenever someone writes more of them.
LINT_PATHS ?= corpus scripts tests/unit
TY_PATHS ?= corpus scripts tests/unit

# Corpus measurement inputs.
LOCK ?= corpus/corpus.lock

# Where the two coverage tools drop their machine-readable reports and cargo puts its instrumented
# build. Under `target/`, which `.gitignore` already covers, so a coverage run leaves nothing behind
# in the working tree. Nothing generated by these targets is committed.
COV_DIR ?= target/coverage

# A coverage artifact in the working tree is a POSTCONDITION OF A RUN, and only a run can check it.
# `tests/cli.rs` asserts the repository root right after the nested cargo whose leak was traced —
# one snapshot, of one directory, taken inside a suite that runs in parallel. It proves that route
# and nothing wider. This is the wider half: the whole tree, after everything has finished.
# `target/` is excluded because that is where profiles belong.
#
# Measured 2026-08-01: `touch tests/default_planted_0_1.profraw` then `make rust.test` exited 0 and
# said nothing, while `git status` showed the file untracked — `*.profraw` is deliberately NOT
# gitignored, since `git status` noticing is the only reason the original leak was ever found.
#
# Wired into `rust.test`, `rust.cov` and `cov`. `rust.cov` needs it in its own right: cargo runs a
# BUILD SCRIPT with cwd = the package root, so a `build-script-build` compiled under instrumentation
# writes its profile next to Cargo.toml the moment a plain build re-runs it. Three such executables
# were found in `target/release/build/tooprolix-*/` on 2026-08-01, all dated before the fix in
# `tests/cli.rs` landed; cargo's fingerprint cannot see the instrumentation, so they are reused
# until `cargo clean --release -p tooprolix` removes them.
#
# TWO LIMITS, stated rather than papered over. It is a postcondition, so make skips it whenever the
# preceding command fails — a run that BOTH fails and leaks reports only the failure. And it is a
# snapshot after the fact, not a watch: it says a profile is there, never which process wrote it.
CHECK_NO_PROFRAW = @found=$$(find . -name '*.profraw' -not -path './target/*' -not -path './.git/*'); \
	if [ -n "$$found" ]; then \
		echo "error: an instrumented binary wrote an LLVM profile into the working tree:" >&2; \
		printf '  %s\n' $$found >&2; \
		echo "       untracked, ungitignored, one \`git add .\` from being committed. Delete it," >&2; \
		echo "       then find what ran instrumented outside cargo-llvm-cov's target directory." >&2; \
		exit 1; \
	fi

.PHONY: help lint.fix lint.check type test corpus.measure \
	rust.fmt rust.fmt.check rust.lint rust.test rust.doc rust.build \
	rust.cov py.cov cov

help: ## Show this help
	@awk 'BEGIN {FS = ":.*##"; printf "Usage: make <target>\n\nTargets:\n"} \
	/^[a-zA-Z_.-]+:.*##/ {printf "\033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)

# `--only-group`, not `--group`, in all three Python gates below. It installs the group WITHOUT
# building this project. This is a LIVE constraint, not a precaution: the maturin `[build-system]` is
# in pyproject.toml now, so `--group` makes uv attempt an editable build of the Rust extension and
# every Python gate dies with "'--editable'] returned non-zero exit status 1". Do not "fix" these to
# `--group`; verified consequence, not style.
lint.fix: ## Format and autofix the Python code with ruff (LINT_PATHS)
	@$(UV) run --only-group lint ruff format $(LINT_PATHS)
	@$(UV) run --only-group lint ruff check --fix $(LINT_PATHS)

lint.check: ## Check formatting and lint rules with ruff (LINT_PATHS) — CI mode
	@$(UV) run --only-group lint ruff format --check $(LINT_PATHS)
	@$(UV) run --only-group lint ruff check $(LINT_PATHS)

type: ## Check types with ty (TY_PATHS)
	@# Both groups, because TY_PATHS includes tests/unit and those files import pytest.
	@# With only the `type` group, ty cannot resolve that import and reports
	@# `unresolved-import` — which looked green here only because a leftover .venv already
	@# had pytest in it. From a clean checkout, i.e. in every isolated CI job, it fails.
	@$(UV) run --only-group type --only-group test ty check $(TY_PATHS)

test: ## Run the Python tests (pytest, tests/unit)
	@$(UV) run --only-group test pytest

corpus.measure: ## Measure the pinned prose corpus and print the distributions
	@uv run python3 corpus/measure.py --lock $(LOCK)

# The four Rust gates below are one CI job each (cargo-fmt / cargo-clippy / cargo-test /
# cargo-doc) and are what every later task has to keep green. `--locked` everywhere: Cargo.lock
# is committed, so a gate that silently re-resolved it would not be testing the code CI builds.

rust.fmt: ## Format the Rust code with rustfmt
	@$(CARGO) fmt

rust.fmt.check: ## Check Rust formatting without writing (CI mode)
	@$(CARGO) fmt --check

rust.lint: ## Lint the Rust code with clippy, warnings are errors
	@$(CARGO) clippy --all-targets --locked -- -D warnings

rust.test: ## Run the Rust tests (unit + doctests)
	@$(CARGO) test --locked
	$(CHECK_NO_PROFRAW)

# Rustdoc as a GATE, not a byproduct. `cargo doc` reports a broken intra-doc link as a warning and
# still exits 0, so before this target the crate carried 5 of them on `main` and every gate was
# green. `RUSTDOCFLAGS="-D warnings"` is what turns them into exit 101.
#
# `--document-private-items` is NOT free and the justification here is measured, not inherited.
# Earlier revisions of the audit task claimed both variants gave the same single warning, so the
# flag "opens nothing". Measured 2026-07-29 at 962678d: the plain run reports 5 diagnostics and the
# flag reports 6. The one it adds is a genuinely dangling link -- `render_failures` in the rustdoc
# of `python_files`, naming a function that exists nowhere in `src/`. Without the flag rustdoc never
# documents the private `python_files` at all, so it never resolves the link and the dead reference
# stays invisible. Mutation-proved both ways: with the link broken and the flag dropped, the gate
# goes back to exit 0.
#
# `--no-deps`: we gate OUR docs, not our dependencies'.
rust.doc: ## Build the rustdoc and fail on any warning (broken or private intra-doc links)
	@RUSTDOCFLAGS="-D warnings" $(CARGO) doc --locked --no-deps --document-private-items

# The linkage guard on the shipped binary. It is what proves `tooprolix` runs where no interpreter
# does — the promise the wheel makes by carrying a native executable instead of an extension module.
#
# IT ASSERTS THE LINKAGE, NOT THE COMPILATION, and the difference is the whole guard. `cargo
# build` alone cannot tell "it compiles" from "it needs no interpreter": a build that links
# libpython exits 0 just the same, and only `otool -L`/`ldd` on the artifact can say so.
#
# AND THE MUTATION THAT PROVES IT IS NOT THE OBVIOUS ONE. Adding `pyo3 = "0.29.0"` back to
# `[dependencies]` leaves this target **GREEN** — measured 2026-07-31, `otool -L` still lists only
# `libSystem.B.dylib`, because rustc links no crate nothing references.
#
# The mutation that DOES turn it red is the declaration plus a real `use`: with `pyo3` in
# `[dependencies]` and a `pyo3::Python::attach(...)` in `src/lib.rs`, this target fails with
#
#     /opt/homebrew/opt/python@3.14/Frameworks/.../Python (compatibility version 3.14.0, ...)
#     error: ... links the Python library above; the wheel promises a self-contained binary.
#
# which is also the shape a real regression takes — nobody re-adds the dependency without using it.
#
# `otool -L` on macOS, `ldd` on Linux (both CI runners and this laptop are covered; nothing else
# runs it). Deliberately NOT a `cargo tree`/feature-graph check: that grades a proxy for the
# artifact rather than the artifact.
#
# THE PATH COMES FROM CARGO, and hardcoding it fails silently. `target/debug/tooprolix` is not
# where cargo necessarily writes — both `CARGO_TARGET_DIR` and `build.target-dir` in
# `.cargo/config.toml` move it — so with the target directory moved this recipe inspected a stale
# clean binary left at `target/debug/` and printed `ok: ... links no libpython` while cargo had just
# linked a libpython-carrying one elsewhere. `--message-format=json` makes cargo name the executable
# it just linked, so the guard reads the artifact it actually produced.
#
# An empty listing is treated as a FAILURE, not a pass, and so is cargo reporting no executable at
# all. A missing or renamed `otool`/`ldd` would otherwise make the grep match nothing and the guard
# report success without having looked.
rust.build: ## Build the binary and prove it links no libpython (it must run without an interpreter)
	@json="$$($(CARGO) build --locked --message-format=json)"; \
	rc=$$?; \
	if [ $$rc -ne 0 ]; then exit $$rc; fi; \
	binary="$$(printf '%s\n' "$$json" | sed -n 's/.*"executable":"\([^"]*\)".*/\1/p' | tail -1)"; \
	if [ -z "$$binary" ]; then \
		echo "error: cargo reported no executable; the linkage guard has nothing to inspect." >&2; \
		exit 1; \
	fi; \
	case "$$(uname -s)" in \
		Darwin) linked="$$(otool -L $$binary)" ;; \
		*)      linked="$$(ldd $$binary)" ;; \
	esac; \
	if [ -z "$$linked" ]; then \
		echo "error: could not read the dynamic dependencies of $$binary; the linkage guard did not run." >&2; \
		exit 1; \
	fi; \
	if printf '%s\n' "$$linked" | grep -i python; then \
		echo "error: $$binary links the Python library above; the wheel promises a self-contained binary." >&2; \
		echo "       A dependency that is merely declared is fine; one that is USED links it in." >&2; \
		exit 1; \
	fi; \
	printf '%s\n' "$$linked"; \
	echo "ok: $$binary links no libpython"

# -----------------------------------------------------------------------------------------------
# Coverage. Two numbers, never one: the Rust crate is the product and the Python side is `corpus/`
# throwaway research tooling plus the `scripts/` release gates, so a combined percentage would
# average things measured against unrelated denominators and look more precise than either
# (EPIC scope guard).
#
# These targets PRINT a number and write a JSON report to `$(COV_DIR)`; there is no badge. The
# repository is private until the PyPI flip so no badge host can read it, and the projects worth
# comparing against publish none either — ruff, uv, tokio, serde, ripgrep, cargo, maturin, polars,
# httpx and starlette all measure coverage without advertising it. Revisit at publication.
#
# The value here is not the percentage, it is `scripts/coverage_report.py` refusing a report that
# measured less than it claims: drop `branch = true`, orphan a module from the `mod` tree, or let a
# file fall out of coverage.py's discovery, and the number goes UP with nothing else to show for it.
# A silently shrinking denominator is the failure that matters.
#
# There is deliberately NO `--fail-under` / threshold on either target: a threshold would be a
# number picked to match today's code rather than a decision.
#
# What these numbers do NOT cover, said out loud because a percentage implies it measured
# everything it could:
#   - `build.rs` is a BUILD SCRIPT. cargo compiles and runs it on the host before the crate exists,
#     so `cargo llvm-cov` does not instrument it and it appears in no row of the Rust report. Its
#     198 lines of civil-date arithmetic and git containment are therefore unmeasured, not 100%
#     (`wc -l build.rs`, 2026-08-01 at `ebd7d70`; verified instrumentation-free the same day —
#     the build-script executable in the llvm-cov target dir carries no `__llvm_prf` section).
#   - A STALE OBJECT IN THE TARGET DIRECTORY IS PART OF THE DENOMINATOR. `cargo llvm-cov` hands
#     `llvm-cov export` the objects it finds, including ones no current target builds, and their
#     coverage mappings merge in by FILENAME. Measured 2026-08-01: a `libtooprolix.dylib` left by a
#     removed pyo3 build on 2026-07-29 (the crate is `crate-type = ["rlib"]`) added 1 024 phantom
#     lines at zero coverage and made this target print 78.6% where the same profraw prints 98.2% —
#     the figure CI, on a clean runner, had been printing all along. `cargo llvm-cov clean
#     --workspace` does NOT remove it.
#
#     THIS IS ONLY PARTLY GUARDED, and the difference matters before anyone trusts the number.
#     `scripts/coverage_report.py` rejects THAT phantom, because it arrived as `src/lib.rs`, a file
#     whose text defines no function (`test_a_file_with_no_functions_may_not_be_measured_either`).
#     A phantom landing under a function-bearing file is NOT rejected — measured 2026-08-01 by
#     injecting 1 024 phantom lines under `src/cli.rs` into the real report: the guard accepted it
#     and the figure read 78.6% again. The general shape stays open; see the comment on that check
#     for the two cheap invariants that were measured and rejected, and for what closing it needs.
#   - `tests/volume_corpus.rs`'s `volume_finds_something_on_the_corpus` is `#[ignore]`d (it needs
#     `corpus/checkouts/` on disk), so the lines only it reaches count as uncovered. That is the
#     truth about a test that does not run in CI, and un-ignoring it here would be buying coverage
#     with a gate that cannot run.
#   - `tests/adversarial_bench.rs`'s `adversarial_headers_stay_within_the_line_rate_budget` is the
#     second `#[ignore]`d test. It is a wall-clock instrument — it needs `--release` to mean
#     anything (a debug build measures the profile, not the algorithm) and it writes 2 000 files
#     twice over. Its two non-ignored halves,
#     `the_generated_headers_are_adversarial_by_construction` and
#     `the_timed_trees_are_the_ones_the_recorded_numbers_were_measured_on`, DO run in CI, so neither
#     the fixture nor the timed trees can rot unnoticed while only the timed half is skipped.
#   - THE COVERAGE RUN IS NOT THE TEST RUN. `cargo llvm-cov` skips doctests unless `--doctests` is
#     passed, so the `Doc-tests tooprolix` target is absent from `make rust.cov` entirely and code
#     reached only by a doctest is reported uncovered.
#     `--doctests` is NOT available here, and that is measured rather than assumed: on the pinned
#     stable 1.97.0 it fails with `error: 2 nightly options were parsed`, because cargo-llvm-cov
#     drives rustdoc with `-Z unstable-options --persist-doctests`. Getting the doctests into the
#     number would mean moving the whole repository to a nightly toolchain.
#   - Rust BRANCH coverage is not reported at all: the `Branches` column of llvm-cov reads `-` on
#     the pinned stable 1.97.0 (it needs a nightly-only flag). The Rust number is LINE coverage. The
#     Python number has `branch = true` and folds branches into its figure — the two are reported
#     separately for this reason among others, and are not comparable to each other.

rust.cov: ## Measure Rust line coverage and print it
	@mkdir -p $(COV_DIR)
	@$(CARGO) llvm-cov --locked --summary-only \
		--json --output-path $(COV_DIR)/llvm-cov.json
	@$(UV) run --no-project python3 scripts/coverage_report.py \
		--report $(COV_DIR)/llvm-cov.json --format llvm-cov
	$(CHECK_NO_PROFRAW)

py.cov: ## Measure Python coverage of corpus/ and scripts/ and print it
	@mkdir -p $(COV_DIR)
	@# `--cov` with no argument means "use [tool.coverage.run] from pyproject.toml", which is where
	@# the denominator (`source = ["corpus", "scripts"]`, NOT tests/unit) and `branch = true` are
	@# pinned and commented. Passing paths here would put the denominator in two places.
	@$(UV) run --only-group test pytest --cov --cov-report=term \
		--cov-report=json:$(COV_DIR)/coverage.json
	@$(UV) run --no-project python3 scripts/coverage_report.py \
		--report $(COV_DIR)/coverage.json --format coverage.py

cov: rust.cov py.cov ## Measure coverage for both languages and print both numbers
	$(CHECK_NO_PROFRAW)
