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}" + ); +}