diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b880725..31341bb 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -125,3 +125,36 @@ jobs:
with:
shared-key: docs
- run: cargo doc --no-deps --all-features
+
+ pre-commit:
+ name: pre-commit
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.x'
+ # Pin `pre-commit` so CI stays reproducible. The e2e tests assert
+ # on specific pre-commit diagnostic wording (e.g. "No hook with
+ # id ..."); an unpinned upstream release could change that
+ # wording and cause nondeterministic CI failures.
+ - run: pip install 'pre-commit==4.6.0'
+ - uses: Swatinem/rust-cache@v2
+ with:
+ shared-key: pre-commit
+ # `cargo test --test pre_commit_e2e` exercises pre-commit
+ # end-to-end: AC2 (`ac2_pre_commit_run_all_files_passes_on_current_tree`)
+ # runs `pre-commit run --all-files` on the repo root and also
+ # asserts the tree stays drift-free; AC3 covers the fmt-bad
+ # commit path; AC4 covers the manual-stage clippy hook and its
+ # default-stage absence. AC2 supersedes a standalone
+ # `pre-commit run --all-files` step here — it does the same hook
+ # invocation plus the drift check, so running both would
+ # duplicate the hook suite and burn CI time. Without pre-commit
+ # on PATH, the e2e tests silently skip — installing it above is
+ # what makes AC2/AC3/AC4 actually machine-verified rather than
+ # self-attested.
+ - run: cargo test --test pre_commit_e2e
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..a174b03
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,71 @@
+# ENG-4687: local pre-commit gate for rustyroute.
+#
+# Mirrors the framework patterns from spotship/backend's config
+# (top-level exclude, default_stages, rev: v5.0.0) and
+# spotship/swiftsure's non-Python hygiene baseline. Substitutes
+# `cargo fmt --check` for Python-specific linting. Clippy is opt-in
+# via --hook-stage manual because it is too slow for every commit. CI
+# (.github/workflows/ci.yaml) is the network-side enforcement; this
+# file is the laptop-side early signal.
+#
+# Locked by tests/pre_commit_config.rs — see that file for the
+# invariants asserted against silent drift.
+
+exclude: |
+ (?x)(
+ ^target/|
+ ^data/|
+ ^vendor/eurostat-marnet/.*\.gpkg$|
+ \.rkyv$
+ )
+default_stages: [pre-commit]
+
+repos:
+ - repo: https://github.com/pre-commit/pre-commit-hooks
+ rev: v5.0.0
+ hooks:
+ - id: trailing-whitespace
+ args: [--markdown-linebreak-ext=md]
+ - id: end-of-file-fixer
+ - id: check-json
+ - id: check-toml
+ - id: check-yaml
+ - id: check-merge-conflict
+ - id: check-case-conflict
+ - id: check-added-large-files
+ - id: check-executables-have-shebangs
+ - id: check-vcs-permalinks
+ - id: forbid-new-submodules
+ - id: mixed-line-ending
+ args: [--fix=lf]
+ - id: detect-private-key
+
+ - repo: local
+ hooks:
+ - id: cargo-fmt-check
+ # Checks the root crate. A second hook
+ # (`cargo-fmt-check-downstream`) covers the nested
+ # `tests/downstream_consumer/` sub-package, which is its own
+ # `[package]` (not a workspace member) — so the root
+ # `cargo fmt --check` does NOT traverse into it. The two
+ # invocations live in separate hooks rather than chaining a
+ # single shell command, so neither entry depends on a shell
+ # being available — relevant on Windows CI runners.
+ name: cargo fmt --check (root)
+ entry: cargo fmt --check
+ language: system
+ pass_filenames: false
+ types: [rust]
+ - id: cargo-fmt-check-downstream
+ name: cargo fmt --check (downstream_consumer)
+ entry: cargo fmt --manifest-path tests/downstream_consumer/Cargo.toml --check
+ language: system
+ pass_filenames: false
+ types: [rust]
+ - id: cargo-clippy
+ name: cargo clippy --no-deps
+ entry: cargo clippy --no-deps -- -D warnings
+ language: system
+ pass_filenames: false
+ always_run: true
+ stages: [manual]
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index ddf22ed..31961c0 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -81,4 +81,3 @@ For answers to common questions about this code of conduct, see the FAQ at [http
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
-
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 4babd34..37af38e 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -69,7 +69,27 @@ See for the full text of the DCO.
## Local validation
-Before opening a pull request, run:
+First-time setup — install [pre-commit](https://pre-commit.com) itself
+(via pipx, pip, or Homebrew), then enable the git hook so format and
+file-hygiene checks run on every `git commit`:
+
+```sh
+pipx install pre-commit # or: pip install pre-commit / brew install pre-commit
+pre-commit install
+```
+
+To run the managed hooks explicitly (e.g. before pushing):
+
+```sh
+pre-commit run --all-files
+pre-commit run --hook-stage manual # runs cargo clippy --no-deps -- -D warnings
+```
+
+The pre-commit gate runs `cargo fmt --check` plus lightweight file
+hygiene (trailing whitespace, EOF newlines, YAML/TOML/JSON syntax,
+merge-conflict markers, private keys). Clippy and the rest of the
+full local validation suite remain manual. Before opening a pull
+request, run:
```sh
cargo fmt --check
diff --git a/tests/downstream_consumer/src/lib.rs b/tests/downstream_consumer/src/lib.rs
index a22fe67..9e52ad5 100644
--- a/tests/downstream_consumer/src/lib.rs
+++ b/tests/downstream_consumer/src/lib.rs
@@ -12,8 +12,8 @@ pub fn exercise_public_api() -> (u32, u32, u32) {
assert_eq!(loaded.resolution_km(), 50);
// Path 2: Graph::from_bytes via the static BYTES_50KM const.
- let static_g = Graph::from_bytes(rustyroute::data::BYTES_50KM)
- .expect("Graph::from_bytes(BYTES_50KM)");
+ let static_g =
+ Graph::from_bytes(rustyroute::data::BYTES_50KM).expect("Graph::from_bytes(BYTES_50KM)");
// Sanity: both observations agree on shape.
assert_eq!(loaded.node_count(), static_g.node_count());
diff --git a/tests/pre_commit_config.rs b/tests/pre_commit_config.rs
new file mode 100644
index 0000000..285e80f
--- /dev/null
+++ b/tests/pre_commit_config.rs
@@ -0,0 +1,311 @@
+//! ENG-4687: lock the structural invariants of `.pre-commit-config.yaml`.
+//!
+//! Why these tests exist
+//! ---------------------
+//! `.pre-commit-config.yaml` is the artifact of ENG-4687. Most of its
+//! behavior can only be observed by invoking the `pre-commit` tool
+//! itself (rustfmt failure prints a diff, hook installation creates a
+//! `.git/hooks/pre-commit` shim). Those are not in scope for `cargo
+//! test`.
+//!
+//! What IS in scope here is the small set of invariants whose silent
+//! drift would turn the gate into a no-op or pull in inappropriate
+//! Python-specific hooks. Mirroring `tests/ci_workflow.rs`, we lock
+//! these with string-level assertions instead of adding a YAML parser
+//! dev-dependency.
+//!
+//! Invariants locked:
+//! - File exists at the crate root.
+//! - `default_stages: [pre-commit]` is present.
+//! - Top-level `exclude:` covers target/, data/, vendored .gpkg,
+//! and *.rkyv.
+//! - `pre-commit-hooks` is pinned to v5.0.0 (matches backend's
+//! pinning convention).
+//! - Required baseline hook ids are present.
+//! - Both `cargo fmt --check` hooks (root + downstream) carry the
+//! three per-block contract attributes (`language: system`,
+//! `pass_filenames: false`, `types: [rust]`) — plus the `entry:`
+//! line, asserted separately above as an exact match.
+//! - `cargo clippy` hook is on `stages: [manual]`.
+//! - Python-specific backend hooks/repos are ABSENT.
+
+use std::fs;
+use std::path::PathBuf;
+
+fn config_path() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(".pre-commit-config.yaml")
+}
+
+fn read_config() -> String {
+ let p = config_path();
+ fs::read_to_string(&p).unwrap_or_else(|e| panic!("failed to read {}: {e}", p.display()))
+}
+
+// ---------------------------------------------------------------------------
+// File presence
+// ---------------------------------------------------------------------------
+
+#[test]
+fn pre_commit_config_exists_at_crate_root() {
+ let p = config_path();
+ assert!(
+ p.exists(),
+ ".pre-commit-config.yaml must exist at the crate root (got: {})",
+ p.display()
+ );
+}
+
+// ---------------------------------------------------------------------------
+// default_stages
+// ---------------------------------------------------------------------------
+
+#[test]
+fn default_stages_is_pre_commit() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("default_stages: [pre-commit]"),
+ "`.pre-commit-config.yaml` must declare `default_stages: [pre-commit]` \
+ so unmarked hooks run on commit (not on push or manual). \
+ Without it, hook stage routing is implementation-defined."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Top-level exclude
+// ---------------------------------------------------------------------------
+
+#[test]
+fn top_level_exclude_covers_target() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("^target/"),
+ "top-level `exclude:` must cover `^target/` so Cargo build output is skipped"
+ );
+}
+
+#[test]
+fn top_level_exclude_covers_data_dir() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("^data/"),
+ "top-level `exclude:` must cover `^data/` so generated graph archives are skipped"
+ );
+}
+
+#[test]
+fn top_level_exclude_covers_rkyv() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("\\.rkyv$"),
+ "top-level `exclude:` must cover `\\.rkyv$` so generated rkyv archives are skipped"
+ );
+}
+
+#[test]
+fn top_level_exclude_covers_vendored_gpkg() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("vendor/eurostat-marnet/.*\\.gpkg$"),
+ "top-level `exclude:` must cover `vendor/eurostat-marnet/.*\\.gpkg$` so the \
+ intentionally vendored MARNET GeoPackages are skipped — without this, \
+ `check-added-large-files` and `end-of-file-fixer` would trip on them."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// pre-commit-hooks revision pin
+// ---------------------------------------------------------------------------
+
+#[test]
+fn pre_commit_hooks_pinned_to_v5_0_0() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("repo: https://github.com/pre-commit/pre-commit-hooks"),
+ "must include the upstream pre-commit/pre-commit-hooks repo"
+ );
+ assert!(
+ cfg.contains("rev: v5.0.0"),
+ "pre-commit-hooks must be pinned to `rev: v5.0.0` (not master, not a \
+ later version) to match the workspace convention in spotship/backend."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Baseline hook ids present
+// ---------------------------------------------------------------------------
+
+#[test]
+fn baseline_hooks_present() {
+ let cfg = read_config();
+ for needle in [
+ "id: trailing-whitespace",
+ "id: end-of-file-fixer",
+ "id: check-json",
+ "id: check-toml",
+ "id: check-yaml",
+ "id: check-merge-conflict",
+ "id: check-case-conflict",
+ "id: detect-private-key",
+ ] {
+ assert!(
+ cfg.contains(needle),
+ "`.pre-commit-config.yaml` must include `{needle}` from the v5.0.0 baseline"
+ );
+ }
+}
+
+// ---------------------------------------------------------------------------
+// cargo fmt --check hook contract
+// ---------------------------------------------------------------------------
+
+#[test]
+fn cargo_fmt_check_hook_contract() {
+ let cfg = read_config();
+
+ // Two hooks: the root crate and the nested downstream_consumer
+ // sub-package (its own [package], not a workspace member — so
+ // the root `cargo fmt --check` does NOT traverse into it). Split
+ // into two hooks (rather than chained via `bash -c`) so neither
+ // entry depends on bash being on PATH — relevant on Windows
+ // runners in the CI matrix.
+ assert!(
+ cfg.contains("id: cargo-fmt-check"),
+ "must define a local hook with `id: cargo-fmt-check` covering the root crate"
+ );
+ assert!(
+ cfg.contains("id: cargo-fmt-check-downstream"),
+ "must define a second local hook with `id: cargo-fmt-check-downstream` for the \
+ nested tests/downstream_consumer sub-package — the root `cargo fmt --check` \
+ does not traverse into a non-workspace-member [package], so AC3 would silently \
+ miss fmt drift in that sub-crate without an explicit hook."
+ );
+
+ // Neither entry may rely on a shell. `bash -c '...'` would fail
+ // on Windows pre-commit runs that don't have bash on PATH.
+ assert!(
+ !cfg.contains("bash -c"),
+ "cargo-fmt-check hooks must not use `bash -c` — bash is not guaranteed on \
+ Windows runners. Split chained commands into separate hooks instead."
+ );
+
+ // Find every `entry:` line and check the cargo-fmt-check entries
+ // verbatim (modulo leading whitespace). A `cfg.contains(...)`
+ // substring check would let `entry: cargo fmt --check --all` pass
+ // even though `--all` would silently expand coverage to workspace
+ // members the hook does not intend to format here.
+ let entries: Vec<&str> = cfg
+ .lines()
+ .map(str::trim)
+ .filter(|line| line.starts_with("entry:"))
+ .collect();
+
+ // The root hook must be `entry: cargo fmt --check` — exactly. No
+ // trailing args (no `--all`, no extra manifest, etc.) — the
+ // sub-package gets its own dedicated hook below.
+ assert!(
+ entries.contains(&"entry: cargo fmt --check"),
+ "cargo-fmt-check (root) hook entry must be exactly `cargo fmt --check` — \
+ without `--check`, the hook would silently reformat files instead of \
+ failing on drift; with extra args (e.g. `--all`) the coverage would \
+ silently shift. Found entries: {entries:?}"
+ );
+
+ // The downstream hook must target the sub-package's manifest
+ // explicitly and use `--check`.
+ assert!(
+ entries.contains(
+ &"entry: cargo fmt --manifest-path tests/downstream_consumer/Cargo.toml --check"
+ ),
+ "cargo-fmt-check-downstream hook entry must be exactly `cargo fmt \
+ --manifest-path tests/downstream_consumer/Cargo.toml --check` — without \
+ `--manifest-path`, cargo would find the root manifest and skip the \
+ sub-package. Found entries: {entries:?}"
+ );
+
+ // The three contract attributes for a `cargo fmt`-style local hook.
+ // Both fmt hooks (root + downstream) must carry these. We assert
+ // per-hook (by locating each `- id:` line and reading up to the
+ // next `- id:`) rather than counting occurrences across the whole
+ // file — a global count of `>= 2` could be satisfied by other
+ // hooks that also use `language: system` and `pass_filenames:
+ // false` (e.g. cargo-clippy), masking a silently dropped attribute
+ // on one of the fmt hooks.
+ let lines: Vec<&str> = cfg.lines().collect();
+ for hook_id in ["cargo-fmt-check", "cargo-fmt-check-downstream"] {
+ let header = format!("- id: {hook_id}");
+ let start = lines
+ .iter()
+ .position(|line| line.trim_start() == header)
+ .unwrap_or_else(|| panic!("hook `{hook_id}` not found in config"));
+ let end = lines[start + 1..]
+ .iter()
+ .position(|line| line.trim_start().starts_with("- id:"))
+ .map(|offset| start + 1 + offset)
+ .unwrap_or(lines.len());
+ let block = &lines[start..end];
+
+ for needle in ["language: system", "pass_filenames: false", "types: [rust]"] {
+ assert!(
+ block.iter().any(|line| line.contains(needle)),
+ "hook `{hook_id}` must include `{needle}` in its own block — without \
+ it the hook would either need a managed Rust toolchain (`language: \
+ rust` isn't supported for `cargo fmt`), pass filenames as positional \
+ args (which `cargo fmt` rejects), or fire on every commit even when \
+ no Rust file is staged. Block scanned:\n{block:#?}"
+ );
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// cargo clippy hook is manual-stage
+// ---------------------------------------------------------------------------
+
+#[test]
+fn cargo_clippy_hook_is_manual_stage() {
+ let cfg = read_config();
+ assert!(
+ cfg.contains("id: cargo-clippy"),
+ "must define a local hook with `id: cargo-clippy`"
+ );
+ assert!(
+ cfg.contains("entry: cargo clippy --no-deps -- -D warnings"),
+ "cargo-clippy hook must use `entry: cargo clippy --no-deps -- -D warnings` — \
+ matches the planning request and the manual smoke-test scope."
+ );
+ assert!(
+ cfg.contains("stages: [manual]"),
+ "cargo-clippy hook must be on `stages: [manual]` — clippy is too slow \
+ for every commit, so it is opt-in via `pre-commit run --hook-stage manual`."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Python-specific backend hooks are ABSENT
+// ---------------------------------------------------------------------------
+
+#[test]
+fn python_specific_backend_hooks_are_absent() {
+ // Lowercase the config once so the forbidden-substring check
+ // is case-insensitive. This catches both `djLint` (the upstream
+ // repo casing) and `djlint` (the lowercase hook id) without
+ // needing to enumerate every casing variant.
+ let cfg = read_config().to_lowercase();
+ for forbidden in [
+ // Python-runtime hooks from pre-commit-hooks v5
+ "id: debug-statements",
+ "id: check-builtin-literals",
+ "id: check-docstring-first",
+ // Python tooling repos copied wholesale from backend
+ "ruff-pre-commit",
+ "django-upgrade",
+ "djlint",
+ ] {
+ assert!(
+ !cfg.contains(forbidden),
+ "`.pre-commit-config.yaml` must NOT include `{forbidden}` (case-insensitive) \
+ — this is a Python-specific hook/repo from backend's config and does not \
+ apply to a Rust crate. Catches accidental wholesale copy of backend config."
+ );
+ }
+}
diff --git a/tests/pre_commit_e2e.rs b/tests/pre_commit_e2e.rs
new file mode 100644
index 0000000..477c7cb
--- /dev/null
+++ b/tests/pre_commit_e2e.rs
@@ -0,0 +1,556 @@
+//! ENG-4687 end-to-end tests: shell out to the real `pre-commit` tool
+//! and assert against the user-observable behaviour of the hooks.
+//!
+//! Scope vs. `tests/pre_commit_config.rs`
+//! --------------------------------------
+//! `pre_commit_config.rs` locks the YAML's *invariants* via string
+//! assertions (cheap, no external dependencies, runs on every `cargo
+//! test`). That file proves AC5 ("invariants locked against silent
+//! drift").
+//!
+//! The tests in this file cover the *runtime* acceptance criteria that
+//! require actually executing the hooks:
+//!
+//! - AC2: `pre-commit run --all-files` passes on the current tree.
+//! - AC3: Committing a fmt-bad `.rs` file fails the
+//! `cargo-fmt-check` hook with a non-zero exit and a diff.
+//! - AC4: `pre-commit run --hook-stage manual cargo-clippy` runs
+//! clippy.
+//!
+//! Skip semantics
+//! --------------
+//! These tests shell out to the system `pre-commit` binary. If it is
+//! not on `PATH` (e.g. a Rust-only contributor without Python), the
+//! test prints a skip message to stderr and `return`s early without
+//! asserting — matches the spec's "AC1 manual" framing where
+//! pre-commit install is a per-clone setup, not a cargo-test prereq.
+//! Cargo still reports these as passing (no panic == pass for a
+//! `#[test] fn` returning `()`), so the skip is invisible to the
+//! suite's pass/fail count. The CI workflow's dedicated `pre-commit`
+//! job (`.github/workflows/ci.yaml`) installs `pre-commit` via `pip`
+//! and then runs `cargo test --test pre_commit_e2e` so AC2/AC3/AC4
+//! are exercised for real on every PR; the other CI jobs (fmt,
+//! clippy, test-matrix, ...) do NOT install pre-commit, so this file
+//! silently skips there by design.
+//!
+//! Isolation
+//! ---------
+//! AC3 mutates a worktree and creates a commit. To avoid touching the
+//! checked-out rustyroute repo, the test materialises a throwaway git
+//! repo via `tempfile::tempdir()` (which auto-cleans on drop, even on
+//! panic) with the *real* `.pre-commit-config.yaml` copied in, plus
+//! the minimum Cargo scaffolding so `cargo fmt --check` has something
+//! to check.
+
+use std::ffi::OsString;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/// Returns the repo root (the directory containing this crate's
+/// `Cargo.toml`).
+fn repo_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+}
+
+/// Returns true if a `--version` probe of `bin` succeeds. Used to test
+/// for `pre-commit` and `git` on PATH without taking a dependency on
+/// the `which` crate.
+fn bin_on_path(bin: &str) -> bool {
+ Command::new(bin)
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+fn cargo_bin() -> String {
+ std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())
+}
+
+/// Build a PATH value with `cargo`'s parent directory prepended to
+/// the current `PATH`, using the platform's native separator (`:` on
+/// Unix, `;` on Windows). Pre-commit hooks shell out to `cargo` and
+/// `rustfmt`/`clippy-driver`; on Windows runners those binaries live
+/// next to the `cargo` we were launched with (typically
+/// `~/.rustup/toolchains/.../bin`), not on the inherited PATH.
+fn cargo_augmented_path() -> OsString {
+ let cargo = cargo_bin();
+ let cargo_dir = Path::new(&cargo).parent().map(Path::to_path_buf);
+
+ let mut entries: Vec = Vec::new();
+ if let Some(dir) = cargo_dir
+ && !dir.as_os_str().is_empty()
+ {
+ entries.push(dir);
+ }
+ if let Some(path) = std::env::var_os("PATH") {
+ entries.extend(std::env::split_paths(&path));
+ }
+
+ std::env::join_paths(&entries).expect("PATH entries must not contain the platform separator")
+}
+
+/// If pre-commit or git is missing, print why and return false.
+/// Callers should early-return when this is false.
+fn prereqs_available(test_name: &str) -> bool {
+ if !bin_on_path("pre-commit") {
+ eprintln!(
+ "skipping {test_name}: pre-commit not found on PATH \
+ (install with `pip install pre-commit`)"
+ );
+ return false;
+ }
+ if !bin_on_path("git") {
+ eprintln!("skipping {test_name}: git not found on PATH");
+ return false;
+ }
+ true
+}
+
+/// Returns true if `cargo --version` succeeds with the
+/// cargo-augmented PATH (so rustfmt / clippy-driver discovery uses the
+/// rustup toolchain dir alongside the active `cargo`). Used by AC3 and
+/// AC4 to skip with a clear message when the rustfmt or clippy
+/// toolchain component isn't installed, rather than failing the test
+/// with a misleading pre-commit error.
+fn cargo_subcommand_available(subcommand: &str) -> bool {
+ Command::new(cargo_bin())
+ .env("PATH", cargo_augmented_path())
+ .args([subcommand, "--version"])
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false)
+}
+
+/// Run a command, capture stdout+stderr, return (status, combined output).
+fn run(cmd: &mut Command) -> (std::process::ExitStatus, String) {
+ let out = cmd.output().expect("spawn command");
+ let mut combined = String::new();
+ combined.push_str(&String::from_utf8_lossy(&out.stdout));
+ combined.push_str(&String::from_utf8_lossy(&out.stderr));
+ (out.status, combined)
+}
+
+/// Build a throwaway git repo in `dir` with:
+/// - the real `.pre-commit-config.yaml` from this repo
+/// - a minimal `Cargo.toml` so `cargo fmt --check` has something to do
+/// - the `tests/downstream_consumer/` sub-package layout (because the
+/// hook entry runs `cargo fmt --manifest-path tests/downstream_consumer/Cargo.toml --check`
+/// — if the manifest is missing the hook fails for an unrelated reason)
+/// - an initial commit so subsequent `git commit` invocations work
+///
+/// Returns the path to the seeded repo root.
+fn seed_tmp_repo(dir: &Path) -> PathBuf {
+ let repo = dir.to_path_buf();
+
+ // git init + identity (pre-commit requires a git repo; commit needs author).
+ let (st, out) = run(Command::new("git").arg("init").arg("-q").arg(&repo));
+ assert!(st.success(), "git init failed: {out}");
+ for (k, v) in [
+ ("user.email", "e2e@rustyroute.test"),
+ ("user.name", "E2E Test"),
+ ("commit.gpgsign", "false"),
+ ] {
+ let (st, out) = run(Command::new("git")
+ .current_dir(&repo)
+ .args(["config", k, v]));
+ assert!(st.success(), "git config {k} failed: {out}");
+ }
+
+ // Copy the real .pre-commit-config.yaml.
+ let cfg_src = repo_root().join(".pre-commit-config.yaml");
+ let cfg_dst = repo.join(".pre-commit-config.yaml");
+ fs::copy(&cfg_src, &cfg_dst).expect("copy .pre-commit-config.yaml");
+
+ // Minimal root Cargo.toml + src/lib.rs (formatted).
+ fs::write(
+ repo.join("Cargo.toml"),
+ "[package]\n\
+ name = \"e2e_root\"\n\
+ version = \"0.0.0\"\n\
+ edition = \"2024\"\n",
+ )
+ .unwrap();
+ fs::create_dir_all(repo.join("src")).unwrap();
+ fs::write(
+ repo.join("src/lib.rs"),
+ "pub fn hello() -> u32 {\n 42\n}\n",
+ )
+ .unwrap();
+
+ // Stub the downstream_consumer sub-package because the hook entry
+ // references `tests/downstream_consumer/Cargo.toml` explicitly.
+ let sub = repo.join("tests/downstream_consumer");
+ fs::create_dir_all(sub.join("src")).unwrap();
+ fs::write(
+ sub.join("Cargo.toml"),
+ "[package]\n\
+ name = \"e2e_sub\"\n\
+ version = \"0.0.0\"\n\
+ edition = \"2024\"\n",
+ )
+ .unwrap();
+ fs::write(sub.join("src/lib.rs"), "pub fn ping() -> u32 {\n 1\n}\n").unwrap();
+
+ // Initial commit so future commits have a parent.
+ let (st, out) = run(Command::new("git").current_dir(&repo).args(["add", "-A"]));
+ assert!(st.success(), "git add failed: {out}");
+ let (st, out) = run(Command::new("git").current_dir(&repo).args([
+ "commit",
+ "-q",
+ "--no-verify",
+ "-m",
+ "seed",
+ ]));
+ assert!(st.success(), "git commit (seed) failed: {out}");
+
+ repo
+}
+
+// ---------------------------------------------------------------------------
+// AC2: pre-commit run --all-files passes on a clean rustyroute tree
+// ---------------------------------------------------------------------------
+//
+// This runs against the ACTUAL repo root (not a tempdir) — that is the
+// point of AC2: the rustyroute working tree should be hook-clean.
+//
+// If the suite fails, the test prints pre-commit's stdout/stderr so the
+// failing hook id is visible in CI logs. The test snapshots
+// `git status --porcelain` before and after the run; if the tree was
+// clean going in and pre-commit modified any files, that counts as a
+// hook-modified-tree failure even if pre-commit somehow exited 0 — an
+// auto-fixer that "succeeds" by silently rewriting files would still
+// break AC2's "main-equivalent tree is hook-clean" contract. We
+// capture the diff (for the error message), restore the tree, then
+// assert.
+//
+// If the worktree is NOT clean going in (the contributor has local
+// unstaged/staged/untracked work), the test skips early instead of
+// running the suite — several configured hooks are auto-fixers
+// (end-of-file-fixer, trailing-whitespace, mixed-line-ending) and the
+// drift-recovery path is intentionally disabled when there's
+// pre-existing local work, so running here would silently rewrite the
+// contributor's in-progress changes. AC2 is specifically about a
+// main-equivalent clean tree; running on a dirty tree would be testing
+// a different scenario anyway.
+
+#[test]
+fn ac2_pre_commit_run_all_files_passes_on_current_tree() {
+ if !prereqs_available("ac2_pre_commit_run_all_files_passes_on_current_tree") {
+ return;
+ }
+ let root = repo_root();
+
+ // Snapshot working-tree state before running. `git status
+ // --porcelain` is empty only when the tree has no unstaged
+ // changes, no staged changes, AND no untracked files — `git diff
+ // --quiet` alone would treat a tree with staged or untracked
+ // work as "clean" and the drift-recovery path below would then
+ // `git checkout -- .` over a contributor's in-progress work.
+ let (pre_status_st, pre_status_out) = run(Command::new("git")
+ .current_dir(&root)
+ .args(["status", "--porcelain"]));
+ let pre_clean = pre_status_st.success() && pre_status_out.is_empty();
+
+ // If we're not on a clean tree, skip rather than run. Several
+ // configured hooks are auto-fixers (end-of-file-fixer,
+ // trailing-whitespace, mixed-line-ending) and the drift-recovery
+ // path below is disabled when `pre_clean` is false (we don't want
+ // to `git checkout -- .` over a contributor's in-progress work).
+ // Running the hook suite here would therefore silently rewrite
+ // their changes — worse than failing — and AC2 is about a
+ // main-equivalent clean tree anyway, not whatever transient state
+ // is sitting in someone's worktree mid-edit.
+ if !pre_clean {
+ eprintln!(
+ "skipping ac2_pre_commit_run_all_files_passes_on_current_tree: \
+ working tree has local changes (status --porcelain non-empty). \
+ AC2 is verified against a clean tree; rerun after committing \
+ or stashing."
+ );
+ return;
+ }
+
+ let (st, output) = run(Command::new("pre-commit").current_dir(&root).args([
+ "run",
+ "--all-files",
+ "--color",
+ "never",
+ ]));
+
+ // If a hook is an auto-fixer (end-of-file-fixer, trailing-whitespace,
+ // mixed-line-ending) it will both modify files AND return non-zero.
+ // We early-returned above when the tree wasn't clean, so any drift
+ // observed here came from pre-commit. Capture the post-run diff,
+ // then restore the worktree, then assert.
+ let post_drift: Option = {
+ let (post_status_st, post_status_out) = run(Command::new("git")
+ .current_dir(&root)
+ .args(["status", "--porcelain"]));
+ if post_status_st.success() && post_status_out.is_empty() {
+ None
+ } else {
+ let (_diff_st, diff) = run(Command::new("git").current_dir(&root).args(["diff"]));
+ // Safe to fully reset here: the pre-check used
+ // `git status --porcelain` to confirm the tree was truly
+ // clean (no unstaged, no staged, no untracked), so anything
+ // we see now must have come from pre-commit. Restore
+ // tracked-file edits AND remove any untracked artifacts
+ // (`git checkout -- .` alone leaves untracked files behind).
+ let _ = run(Command::new("git")
+ .current_dir(&root)
+ .args(["checkout", "--", "."]));
+ let _ = run(Command::new("git")
+ .current_dir(&root)
+ .args(["clean", "-fd"]));
+ Some(if diff.is_empty() {
+ post_status_out
+ } else {
+ diff
+ })
+ }
+ };
+
+ assert!(
+ st.success(),
+ "AC2 violated: `pre-commit run --all-files` failed on the current tree.\n\
+ A failing hook means a contributor running `git commit` will be blocked\n\
+ until the offending file is fixed. The acceptance criterion requires the\n\
+ main-equivalent tree to be hook-clean.\n\n\
+ pre-commit output:\n{output}"
+ );
+
+ // Even if pre-commit exited 0, a hook may have silently rewritten
+ // a file. That violates AC2's "the main-equivalent tree is
+ // hook-clean" guarantee just as much as a non-zero exit would.
+ assert!(
+ post_drift.is_none(),
+ "AC2 violated: a pre-commit hook modified files on the current tree even \
+ though pre-commit exited 0. AC2 requires the tree to be hook-clean — an \
+ auto-fixer rewriting files is a fail even without a non-zero exit.\n\n\
+ tree diff after pre-commit run:\n{}\n\n\
+ pre-commit output:\n{output}",
+ post_drift.as_deref().unwrap_or("")
+ );
+}
+
+// ---------------------------------------------------------------------------
+// AC3: cargo-fmt-check fails on staged-but-misformatted Rust
+// ---------------------------------------------------------------------------
+//
+// Materialise a throwaway repo, introduce a mis-indented `.rs` file,
+// stage it, and run `pre-commit run cargo-fmt-check`. We expect a
+// non-zero exit AND output that contains the substring `Diff` (rustfmt
+// prints `Diff in ` when `--check` finds drift). We do NOT
+// require any specific diff text — only that the hook flags the drift.
+
+#[test]
+fn ac3_cargo_fmt_check_fails_on_misformatted_rust() {
+ if !prereqs_available("ac3_cargo_fmt_check_fails_on_misformatted_rust") {
+ return;
+ }
+ // AC3 invokes `cargo fmt --check` via pre-commit. If the rustfmt
+ // toolchain component is missing, the hook would fail for an
+ // unrelated reason (cargo can't find rustfmt) and the test would
+ // surface as a misleading AC3 failure. Skip with a clear message
+ // instead — the dedicated `pre-commit` CI job installs the rustfmt
+ // component so it always runs there.
+ if !cargo_subcommand_available("fmt") {
+ eprintln!(
+ "skipping ac3_cargo_fmt_check_fails_on_misformatted_rust: \
+ `cargo fmt` not available (rustfmt toolchain component missing). \
+ Install with `rustup component add rustfmt`."
+ );
+ return;
+ }
+
+ let tmp = tempfile::tempdir().expect("create tempdir");
+ let repo = seed_tmp_repo(tmp.path());
+
+ // Write a misformatted Rust file (double space, weird indentation)
+ // — rustfmt will refuse this with `cargo fmt --check`.
+ fs::write(repo.join("src/lib.rs"), "pub fn bad( ) ->u32{42 }\n").unwrap();
+ let (st, out) = run(Command::new("git")
+ .current_dir(&repo)
+ .args(["add", "src/lib.rs"]));
+ assert!(st.success(), "stage bad file failed: {out}");
+
+ // Pre-commit needs CARGO/rustfmt on PATH. The hook uses
+ // `language: system`, so we just need the real cargo.
+ let augmented_path = cargo_augmented_path();
+
+ let (st, output) = run(Command::new("pre-commit")
+ .current_dir(&repo)
+ .env("PATH", &augmented_path)
+ .args(["run", "cargo-fmt-check", "--color", "never"]));
+
+ assert!(
+ !st.success(),
+ "AC3 violated: cargo-fmt-check passed on misformatted code.\n\
+ The hook should reject `pub fn bad( ) ->u32{{42 }}` because rustfmt's \n\
+ `--check` mode exits non-zero when reformatting would change the file.\n\n\
+ pre-commit output:\n{output}"
+ );
+
+ // Rustfmt's --check output emits `Diff in ` for each file
+ // that would be reformatted. Require that exact signature — a
+ // broader match (e.g. on the bare substring `rustfmt`) would let
+ // an unrelated error like "rustfmt component not installed" pass
+ // AC3 without the contributor seeing a meaningful diff.
+ assert!(
+ output.contains("Diff in"),
+ "AC3 partial: cargo-fmt-check failed but output does not contain rustfmt's \
+ `Diff in` signature. Without that, the hook reported a failure but the \
+ contributor cannot tell which file needs reformatting. Likely cause: rustfmt \
+ exited non-zero for a non-drift reason (e.g. component missing, parse error).\
+ \n\nactual output:\n{output}"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// AC4: pre-commit run --hook-stage manual cargo-clippy invokes clippy
+// ---------------------------------------------------------------------------
+//
+// Manual-stage hooks only run when invoked explicitly. We assert that
+// `pre-commit run --hook-stage manual cargo-clippy` reaches the
+// clippy binary at all — its pass/fail is not the point here (clippy
+// on a 5-line tempdir crate is uninteresting). We confirm "clippy was
+// invoked" by looking for clippy's banner in the output, which always
+// includes `Checking e2e_root v0.0.0` (cargo) and either `clippy` in
+// the entry name or clippy's own diagnostics.
+
+#[test]
+fn ac4_manual_stage_invokes_clippy() {
+ if !prereqs_available("ac4_manual_stage_invokes_clippy") {
+ return;
+ }
+ // AC4 invokes `cargo clippy` via pre-commit. If clippy-driver isn't
+ // installed, the hook fails for an unrelated reason and the test
+ // surfaces as a misleading AC4 failure. Skip cleanly instead — the
+ // dedicated CI job installs the clippy toolchain component so this
+ // test runs there.
+ if !cargo_subcommand_available("clippy") {
+ eprintln!(
+ "skipping ac4_manual_stage_invokes_clippy: \
+ `cargo clippy` not available (clippy toolchain component missing). \
+ Install with `rustup component add clippy`."
+ );
+ return;
+ }
+
+ let tmp = tempfile::tempdir().expect("create tempdir");
+ let repo = seed_tmp_repo(tmp.path());
+
+ // We need cargo + clippy-driver on PATH inside the pre-commit
+ // subprocess.
+ let augmented_path = cargo_augmented_path();
+
+ // `--verbose` is required so pre-commit forwards the underlying
+ // command's stdout — by default the framework swallows output when
+ // a hook exits 0, leaving only the "Passed" banner and no evidence
+ // that cargo+clippy actually ran. With --verbose we see cargo's
+ // own `Checking ` banner.
+ //
+ // `CARGO_TERM_COLOR=never` strips cargo's ANSI escape codes so the
+ // substring match below sees `Checking ` (with a real space)
+ // instead of `Checking\x1b[0m ` — `--color never` only controls
+ // pre-commit's own colors, not what cargo emits when it inherits a
+ // TTY env from the CI runner.
+ let (st, output) = run(Command::new("pre-commit")
+ .current_dir(&repo)
+ .env("PATH", &augmented_path)
+ .env("CARGO_TERM_COLOR", "never")
+ .args([
+ "run",
+ "--hook-stage",
+ "manual",
+ "cargo-clippy",
+ "--color",
+ "never",
+ "--verbose",
+ ]));
+
+ // The hook should be RECOGNISED — pre-commit's "no hook with this
+ // id" message exits non-zero with a clear diagnostic; we don't
+ // want that.
+ assert!(
+ !output.contains("No hook with id `cargo-clippy`"),
+ "AC4 violated: pre-commit did not recognise the cargo-clippy hook id at the \
+ manual stage. The hook config may be missing `stages: [manual]` or have a \
+ typo in the id.\n\n\
+ pre-commit output:\n{output}"
+ );
+
+ // The hook should actually invoke cargo+clippy. We anchor the
+ // positive check on cargo's own output (`Checking `). The
+ // `cargo clippy --no-deps` substring is NOT a sufficient signal —
+ // it's also the hook's `name:` field in `.pre-commit-config.yaml`,
+ // which pre-commit prints regardless of whether the entry actually
+ // executed (e.g. if the clippy toolchain component is missing).
+ assert!(
+ output.contains("Checking e2e_root"),
+ "AC4 partial: cargo-clippy hook ran at manual stage but pre-commit output \
+ does not contain cargo's `Checking ` banner, so clippy did NOT \
+ actually execute against the seeded crate. Likely cause: missing clippy \
+ toolchain component, or the entry failed before reaching cargo.\n\n\
+ hook exit: {st:?}\n\
+ pre-commit output:\n{output}"
+ );
+}
+
+// ---------------------------------------------------------------------------
+// AC4 corollary: cargo-clippy does NOT run on the default pre-commit stage
+// ---------------------------------------------------------------------------
+//
+// The spec is explicit: clippy is opt-in. If it crept onto the default
+// stage, `pre-commit run --all-files` would invoke it (slow). Verify
+// the negative.
+
+#[test]
+fn ac4_clippy_does_not_run_on_default_stage() {
+ if !prereqs_available("ac4_clippy_does_not_run_on_default_stage") {
+ return;
+ }
+
+ let tmp = tempfile::tempdir().expect("create tempdir");
+ let repo = seed_tmp_repo(tmp.path());
+
+ let augmented_path = cargo_augmented_path();
+
+ // Ask pre-commit to run *just* cargo-clippy on the default stage.
+ // It should refuse (the hook is configured for manual only).
+ let (st, output) = run(Command::new("pre-commit")
+ .current_dir(&repo)
+ .env("PATH", &augmented_path)
+ .args(["run", "cargo-clippy", "--all-files", "--color", "never"]));
+
+ // Pre-commit emits a stage-mismatch diagnostic when asked to run
+ // a hook that is not active in the current stage. The wording is
+ // "No hook with id `cargo-clippy` in stage `pre-commit`" on
+ // current versions; we match the stable prefix
+ // `No hook with id `cargo-clippy`` to ride out trailing-wording
+ // tweaks across versions. Pre-commit exits non-zero in that case
+ // — assert both the diagnostic and the non-zero exit so a future
+ // version that filters the hook silently (exit 0, no banner)
+ // cannot pass the test by absence-of-evidence alone.
+ assert!(
+ !st.success(),
+ "AC4 corollary partial: pre-commit exited 0 when asked to run `cargo-clippy` on \
+ the default stage. The expected outcome is a non-zero exit with a \"No hook with \
+ id\" diagnostic — silent success means clippy may actually have run on the \
+ default stage.\n\nfull output:\n{output}"
+ );
+ assert!(
+ output.contains("No hook with id `cargo-clippy`"),
+ "AC4 corollary violated: pre-commit did not emit the expected stage-mismatch \
+ diagnostic (`No hook with id \\`cargo-clippy\\``) when asked to run the hook \
+ on the default stage. The hook must be opt-in via `--hook-stage manual` — \
+ clippy is too slow for every commit. Check that the hook keeps \
+ `stages: [manual]`.\n\nfull output:\n{output}"
+ );
+}