From dd0bbe24424b8260317d3a7386f33f424270d97f Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Apr 2026 16:38:35 -0700 Subject: [PATCH] feat: route Rust toolchain trampolines through soldr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces fbuild's hand-rolled PATH-munging trampolines with calls to soldr, which resolves each tool via \`rustup which\` so the rustup-managed toolchain is always used without per-call environment shaping. - \`ci/trampoline.py\`: cargo/rustc/rustfmt/clippy_driver functions now exec \`soldr [--no-cache] \` instead of discovering \`.cargo/bin\` and prepending it to PATH. \`cargo\` passes \`--no-cache\` so the previous bare-cargo semantics are preserved — soldr's own RUSTC_WRAPPER/zccache path is not inserted. \`_run_cargo_bin\` (backing \`run_fbuild\` / \`run_fbuild_daemon\`) also routes through soldr. - \`_cargo\` / \`_rustc\` / \`_rustfmt\` bash shims: one-liners that \`exec uv run soldr ...\`. Same user-facing behavior as before. - \`ci/dev-tools/pyproject.toml\`: pulls \`soldr>=0.7.0\` as a Python dep so the \`soldr\` binary is available in the venv that the uv-run trampolines execute under. - \`ci/hooks/tool_guard.py\`: accepts \`soldr ...\` as a valid prefix alongside \`uv run\` and the \`_cargo\` trampolines. New test case covers \`soldr cargo\`, \`soldr --no-cache cargo\`, \`soldr rustc\`, \`soldr rustfmt\`. - Docs: \`CLAUDE.md\`, \`CODEX.md\`, \`ci/dev-tools/README.md\`, \`ci/hooks/README.md\` list \`soldr ...\` as an approved form and explain that the cargo trampoline uses \`--no-cache\` to preserve prior semantics. Cargo.lock picks up the existing 2.1.14 → 2.1.15 version alignment (\`cargo check\` regenerated it during local verification). uv.lock updates with the new soldr dep. Closes #53 Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- CODEX.md | 12 +++-- Cargo.lock | 22 ++++---- _cargo | 7 +-- _rustc | 6 +-- _rustfmt | 6 +-- ci/dev-tools/README.md | 4 +- ci/dev-tools/pyproject.toml | 2 +- ci/hooks/README.md | 2 +- ci/hooks/test_tool_guard.py | 6 +++ ci/hooks/tool_guard.py | 8 +-- ci/trampoline.py | 100 ++++++++++++++---------------------- uv.lock | 21 +++++++- 13 files changed, 103 insertions(+), 95 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92fb91b6..1bf845c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ fbuild is a PlatformIO-compatible embedded build tool (11 crates). See @docs/CLA ## Essential Rules -- **Always use `uv run` or `_cargo`/`_rustc`/`_rustfmt` trampolines to execute Rust commands.** Bare cargo/rustc are blocked by hook. Both `uv run` trampolines (via `pyproject.toml`) and shell trampolines (`_cargo`, `_rustc`, `_rustfmt`) prepend `~/.cargo/bin` to PATH, ensuring the rustup-managed toolchain is always used. +- **Always use `uv run`, `soldr`, or `_cargo`/`_rustc`/`_rustfmt` trampolines to execute Rust commands.** Bare cargo/rustc are blocked by hook. All three forms resolve through [soldr](https://github.com/zackees/soldr), which uses `rustup which` to pick the rustup-managed toolchain; the cargo path adds `--no-cache` so the previous bare-cargo semantics are preserved. - **Always use `uv` for Python.** Bare `python`/`pip` are blocked by hook. Use `uv run ...` or `uv pip ...`. - MSRV: 1.75 | Edition: 2021 | Toolchain: 1.94.1 pinned in `rust-toolchain.toml` (clippy + rustfmt) - CI: Linux, macOS, Windows. All warnings denied (`RUSTFLAGS="-D warnings"`) diff --git a/CODEX.md b/CODEX.md index 92ac2946..ba74371c 100644 --- a/CODEX.md +++ b/CODEX.md @@ -4,12 +4,15 @@ Codex working notes for this repo. Start with [CLAUDE.md](./CLAUDE.md) for the f ## Mandatory command rules -- Always run Rust tooling through `uv run` or the repo trampolines. +- Always run Rust tooling through `uv run`, `soldr`, or the repo trampolines. - Never run bare `cargo`, `rustc`, `rustfmt`, `clippy-driver`, `python`, or `pip`. - Approved Rust forms in this repo are: - `uv run cargo ...` - `uv run rustc ...` - `uv run rustfmt ...` + - `soldr cargo ...` + - `soldr rustc ...` + - `soldr rustfmt ...` - `./_cargo ...` - `./_rustc ...` - `./_rustfmt ...` @@ -17,9 +20,10 @@ Codex working notes for this repo. Start with [CLAUDE.md](./CLAUDE.md) for the f ## Why - Repo hooks enforce this. -- `uv run cargo ...` works because `ci/dev-tools` registers `cargo`/`rustc`/`rustfmt` as repo-local uv scripts that dispatch through `ci/trampoline.py`. -- The uv scripts and shell trampolines both make sure the rustup-managed toolchain is used instead of stale system or Chocolatey installs. -- If you bypass them, you can hit wrong-toolchain errors or failures like `Cannot find .cargo/bin. Run ./install first.` +- All three forms dispatch through [soldr](https://github.com/zackees/soldr), which resolves each tool via `rustup which` so the rustup-managed toolchain is always used instead of a stale system or Chocolatey install. +- `uv run cargo ...` works because `ci/dev-tools` registers `cargo`/`rustc`/`rustfmt` as repo-local uv scripts that now dispatch through `ci/trampoline.py` → `soldr`. +- The `cargo` path passes `--no-cache` so the previous bare-cargo semantics are preserved (no RUSTC_WRAPPER / managed zccache inserted). +- If you bypass them, you can hit wrong-toolchain errors. ## Use these diff --git a/Cargo.lock b/Cargo.lock index b7fff062..248f811c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -520,7 +520,7 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fbuild-build" -version = "2.1.14" +version = "2.1.15" dependencies = [ "async-trait", "fbuild-config", @@ -540,7 +540,7 @@ dependencies = [ [[package]] name = "fbuild-cli" -version = "2.1.14" +version = "2.1.15" dependencies = [ "blake3", "clap", @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "fbuild-config" -version = "2.1.14" +version = "2.1.15" dependencies = [ "fbuild-core", "include_dir", @@ -575,7 +575,7 @@ dependencies = [ [[package]] name = "fbuild-core" -version = "2.1.14" +version = "2.1.15" dependencies = [ "serde", "serde_json", @@ -587,7 +587,7 @@ dependencies = [ [[package]] name = "fbuild-daemon" -version = "2.1.14" +version = "2.1.15" dependencies = [ "async-trait", "axum", @@ -622,7 +622,7 @@ dependencies = [ [[package]] name = "fbuild-deploy" -version = "2.1.14" +version = "2.1.15" dependencies = [ "async-trait", "fbuild-config", @@ -642,7 +642,7 @@ dependencies = [ [[package]] name = "fbuild-packages" -version = "2.1.14" +version = "2.1.15" dependencies = [ "bzip2", "fbuild-config", @@ -667,14 +667,14 @@ dependencies = [ [[package]] name = "fbuild-paths" -version = "2.1.14" +version = "2.1.15" dependencies = [ "fbuild-core", ] [[package]] name = "fbuild-python" -version = "2.1.14" +version = "2.1.15" dependencies = [ "base64", "fbuild-core", @@ -693,7 +693,7 @@ dependencies = [ [[package]] name = "fbuild-serial" -version = "2.1.14" +version = "2.1.15" dependencies = [ "async-trait", "base64", @@ -715,7 +715,7 @@ dependencies = [ [[package]] name = "fbuild-test-support" -version = "2.1.14" +version = "2.1.15" dependencies = [ "tempfile", "tokio", diff --git a/_cargo b/_cargo index 99bf9a0f..96d01979 100644 --- a/_cargo +++ b/_cargo @@ -1,4 +1,5 @@ #!/bin/bash -# Trampoline: ensures rustup toolchain is used for cargo. -export PATH="$HOME/.cargo/bin:$PATH" -exec cargo "$@" +# Trampoline: runs cargo through soldr so the rustup-managed toolchain +# is always used. --no-cache keeps the compilation wrapper off to +# match the previous bare-cargo behavior. +exec uv run soldr --no-cache cargo "$@" diff --git a/_rustc b/_rustc index 5189fe31..7678126e 100644 --- a/_rustc +++ b/_rustc @@ -1,4 +1,4 @@ #!/bin/bash -# Trampoline: ensures rustup toolchain is used for rustc. -export PATH="$HOME/.cargo/bin:$PATH" -exec rustc "$@" +# Trampoline: runs rustc through soldr so the rustup-managed toolchain +# is always used. soldr resolves rustc via `rustup which`. +exec uv run soldr rustc "$@" diff --git a/_rustfmt b/_rustfmt index 9fcc81d7..2e84016c 100644 --- a/_rustfmt +++ b/_rustfmt @@ -1,4 +1,4 @@ #!/bin/bash -# Trampoline: ensures rustup toolchain is used for rustfmt. -export PATH="$HOME/.cargo/bin:$PATH" -exec rustfmt "$@" +# Trampoline: runs rustfmt through soldr so the rustup-managed toolchain +# is always used. soldr resolves rustfmt via `rustup which`. +exec uv run soldr rustfmt "$@" diff --git a/ci/dev-tools/README.md b/ci/dev-tools/README.md index 563e2375..a59aff66 100644 --- a/ci/dev-tools/README.md +++ b/ci/dev-tools/README.md @@ -2,6 +2,8 @@ Pip-installable package that registers Rust toolchain trampolines as console scripts, so `uv run cargo` resolves to the rustup-managed toolchain without polluting global installs. +The trampolines route through [soldr](https://github.com/zackees/soldr) (pulled in via the `soldr>=0.7.0` dependency), which uses `rustup which` to pick the right toolchain. `cargo` is invoked with `--no-cache` so the previous bare-cargo semantics are preserved — no RUSTC_WRAPPER is inserted. + ## Contents -- **`pyproject.toml`** -- Defines console script entry points: `cargo`, `rustc`, `rustfmt`, `clippy-driver`, `run_fbuild`, `run_fbuild_daemon`, `publish` +- **`pyproject.toml`** -- Declares the `soldr>=0.7.0` dependency and defines console script entry points: `cargo`, `rustc`, `rustfmt`, `clippy-driver`, `run_fbuild`, `run_fbuild_daemon`, `publish` diff --git a/ci/dev-tools/pyproject.toml b/ci/dev-tools/pyproject.toml index 99c6557e..8fffc673 100644 --- a/ci/dev-tools/pyproject.toml +++ b/ci/dev-tools/pyproject.toml @@ -3,7 +3,7 @@ name = "fbuild-dev-tools" version = "0.0.0" description = "Repo-local Rust toolchain trampolines for fbuild development" requires-python = ">=3.10" -dependencies = [] +dependencies = ["soldr>=0.7.0"] [project.scripts] cargo = "ci.trampoline:cargo" diff --git a/ci/hooks/README.md b/ci/hooks/README.md index 6e71ec6c..6fa16d08 100644 --- a/ci/hooks/README.md +++ b/ci/hooks/README.md @@ -5,7 +5,7 @@ Python scripts invoked by Claude Code lifecycle hooks, configured in `.claude/se ## Contents - **`board_context.py`** -- UserPromptSubmit: detects board-related prompts (board names, MCU keywords, board error patterns) and injects guidance about the `/board-support` skill, relevant commands, and external board registries -- **`tool_guard.py`** -- PreToolUse: blocks bare `cargo`/`rustc`/`rustfmt` and bare `python`/`pip` commands across shell tool variants such as Bash and PowerShell, requiring `uv run` or `_cargo`/`_rustc`/`_rustfmt` trampolines +- **`tool_guard.py`** -- PreToolUse: blocks bare `cargo`/`rustc`/`rustfmt` and bare `python`/`pip` commands across shell tool variants such as Bash and PowerShell, requiring `uv run`, `soldr ...`, or `_cargo`/`_rustc`/`_rustfmt` trampolines - **`lint.py`** -- PostToolUse: runs per-file rustfmt + clippy on edited `.rs` files - **`readme_guard.py`** -- PostToolUse: ensures every directory containing edited files has a `README.md` - **`check-on-start.py`** -- SessionStart: captures a git fingerprint so the stop hook can detect changes diff --git a/ci/hooks/test_tool_guard.py b/ci/hooks/test_tool_guard.py index 9b09b45a..79276ab9 100644 --- a/ci/hooks/test_tool_guard.py +++ b/ci/hooks/test_tool_guard.py @@ -15,6 +15,12 @@ def test_blocks_bare_cargo(self): def test_allows_uv_run_wrapped_cargo(self): self.assertIsNone(check_command("uv run cargo test")) + def test_allows_soldr_wrapped_cargo(self): + self.assertIsNone(check_command("soldr cargo test")) + self.assertIsNone(check_command("soldr --no-cache cargo build")) + self.assertIsNone(check_command("soldr rustc --version")) + self.assertIsNone(check_command("soldr rustfmt --check src/lib.rs")) + def test_blocks_bare_python(self): result = check_command("python ci/script.py") self.assertIsNotNone(result) diff --git a/ci/hooks/tool_guard.py b/ci/hooks/tool_guard.py index 6fcde53a..e98554cf 100644 --- a/ci/hooks/tool_guard.py +++ b/ci/hooks/tool_guard.py @@ -16,7 +16,7 @@ RUST_TOOLS = {"cargo", "rustc", "rustfmt", "clippy-driver", "cargo-clippy", "cargo-fmt"} PYTHON_TOOLS = {"python", "python3", "pip", "pip3"} -ALLOWED_PREFIXES = ("uv run ", "uv pip ") +ALLOWED_PREFIXES = ("uv run ", "uv pip ", "soldr ") TRAMPOLINE_PREFIXES = ("./_cargo ", "./_rustc ", "./_rustfmt ", "_cargo ", "_rustc ", "_rustfmt ") @@ -66,9 +66,9 @@ def check_command(command): if first_word in RUST_TOOLS: return ( first_word, - f"Use `uv run {first_word} ...` or `_cargo`/`_rustc`/`_rustfmt` " - f"trampolines instead of bare `{first_word}`. " - f"These ensure the correct Rust toolchain is used.", + f"Use `uv run {first_word} ...`, `soldr {first_word} ...`, or " + f"a `_cargo`/`_rustc`/`_rustfmt` trampoline instead of bare " + f"`{first_word}`. These ensure the correct Rust toolchain is used.", ) if first_word in PYTHON_TOOLS: diff --git a/ci/trampoline.py b/ci/trampoline.py index 34285e5c..9b5590ca 100644 --- a/ci/trampoline.py +++ b/ci/trampoline.py @@ -1,97 +1,73 @@ """Rust toolchain trampolines. -Ensures the rustup-managed toolchain is on PATH before executing -the real Rust tool. Registered as project scripts in pyproject.toml -so they can be invoked via `uv run cargo ...`, `uv run rustfmt ...`, etc. +Routes cargo/rustc/rustfmt/clippy-driver through soldr so the +rustup-managed toolchain is always used, without per-call PATH +munging. Registered as project scripts in pyproject.toml so they can +be invoked via `uv run cargo ...`, `uv run rustfmt ...`, etc. + +Why soldr: +- soldr resolves each tool via `rustup which`, which respects + `rust-toolchain.toml` the same way the old PATH-based trampolines + did, but without requiring PATH to be pre-shaped. +- `soldr --no-cache cargo` preserves the prior bare-cargo semantics + (no RUSTC_WRAPPER, no managed zccache) so this migration is + behavior-preserving for CI and local dev. Adopting soldr's built-in + zccache wrapper is a separate, deliberate decision. """ -import os import shutil import subprocess import sys from pathlib import Path -def _cargo_bin_from_tool(tool_name): - """Derive a rustup-managed .cargo/bin directory from a tool on PATH.""" - tool_path = shutil.which(tool_name) - if not tool_path: - return None - - bin_dir = os.path.dirname(os.path.abspath(tool_path)) - rustup_name = "rustup.exe" if os.name == "nt" else "rustup" - rustup_path = os.path.join(bin_dir, rustup_name) - if os.path.isfile(rustup_path): - return bin_dir - return None - - -def _find_cargo_bin(): - """Find a Rust tool bin directory for the active rustup toolchain.""" - for candidate in [ - os.environ.get("CARGO_HOME", ""), - os.path.join(os.path.expanduser("~"), ".cargo"), - os.path.join(os.environ.get("USERPROFILE", ""), ".cargo"), - ]: - if candidate: - bin_dir = os.path.join(candidate, "bin") - if os.path.isdir(bin_dir): - return bin_dir - - for tool_name in ("rustup", "cargo", "rustc"): - bin_dir = _cargo_bin_from_tool(tool_name) - if bin_dir: - return bin_dir - return None - - -def _run_tool(tool_name): - """Prepend .cargo/bin to PATH and exec the given tool.""" - cargo_bin = _find_cargo_bin() - if not cargo_bin: - print("error: Cannot find .cargo/bin. Run ./install first.", file=sys.stderr) +def _soldr_prefix(no_cache: bool): + """Return the argv prefix that runs soldr, with `--no-cache` if asked.""" + if not shutil.which("soldr"): + print( + "error: `soldr` not found on PATH. Run ./install (or `uv sync`) " + "to install fbuild-dev-tools, which pulls soldr in as a dependency.", + file=sys.stderr, + ) sys.exit(1) + prefix = ["soldr"] + if no_cache: + prefix.append("--no-cache") + return prefix - os.environ["PATH"] = cargo_bin + os.pathsep + os.environ.get("PATH", "") - if not shutil.which(tool_name): - print(f"error: {tool_name} not found in {cargo_bin}.", file=sys.stderr) - sys.exit(1) - - result = subprocess.run([tool_name] + sys.argv[1:]) +def _run_via_soldr(subcommand: str, *, no_cache: bool): + """Exec `soldr [--no-cache] `.""" + cmd = _soldr_prefix(no_cache) + [subcommand] + sys.argv[1:] + result = subprocess.run(cmd) sys.exit(result.returncode) def cargo(): - _run_tool("cargo") + # --no-cache keeps soldr's RUSTC_WRAPPER / zccache path off, matching + # the previous bare-cargo behavior of this trampoline. + _run_via_soldr("cargo", no_cache=True) def rustc(): - _run_tool("rustc") + _run_via_soldr("rustc", no_cache=False) def rustfmt(): - _run_tool("rustfmt") + _run_via_soldr("rustfmt", no_cache=False) def clippy_driver(): - _run_tool("clippy-driver") + _run_via_soldr("clippy-driver", no_cache=False) def _run_cargo_bin(package): - """Run a cargo binary with the correct toolchain on PATH.""" - cargo_bin = _find_cargo_bin() - if not cargo_bin: - print("error: Cannot find .cargo/bin. Run ./install first.", file=sys.stderr) - sys.exit(1) - - os.environ["PATH"] = cargo_bin + os.pathsep + os.environ.get("PATH", "") - + """Run a cargo binary with the correct toolchain via soldr.""" extra = sys.argv[1:] - # Strip leading '--' that uv inserts + # Strip leading '--' that uv inserts. if extra and extra[0] == "--": extra = extra[1:] - cmd = ["cargo", "run", "-p", package] + cmd = _soldr_prefix(no_cache=True) + ["cargo", "run", "-p", package] if extra: cmd.append("--") cmd.extend(extra) diff --git a/uv.lock b/uv.lock index bced79c5..e5d7ae68 100644 --- a/uv.lock +++ b/uv.lock @@ -4,7 +4,7 @@ requires-python = ">=3.10" [[package]] name = "fbuild" -version = "2.1.14" +version = "2.1.15" source = { editable = "." } dependencies = [ { name = "zccache" }, @@ -25,6 +25,25 @@ dev = [{ name = "fbuild-dev-tools", editable = "ci/dev-tools" }] name = "fbuild-dev-tools" version = "0.0.0" source = { editable = "ci/dev-tools" } +dependencies = [ + { name = "soldr" }, +] + +[package.metadata] +requires-dist = [{ name = "soldr", specifier = ">=0.7.0" }] + +[[package]] +name = "soldr" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/3b/c03ade86970c66712753986377ba1adfa9693cf73acbfabf9dcc70482c3b/soldr-0.7.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:009f08913325f807cb48bd58525a664102c63a1838cc7ed25d7dc053dfdbb380", size = 2775435, upload-time = "2026-04-16T23:08:58.003Z" }, + { url = "https://files.pythonhosted.org/packages/57/2c/38b0366677e5a7b1f3e8a612f7787a07e04b81177bb2c9e034308eef39e7/soldr-0.7.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:27e5ad1933c213c37da1c0354ebe961d5a443c0d3cc30a458ba25b8bbcddf644", size = 2651596, upload-time = "2026-04-16T23:09:00.01Z" }, + { url = "https://files.pythonhosted.org/packages/29/d6/827acd12fc8d4c291cfd6c71daf59e73a23282584db7aa1ec135f0a51311/soldr-0.7.0-py3-none-manylinux_2_39_aarch64.whl", hash = "sha256:0804c5e12bffe1219f573d66e666de15f2b032175cae83b8f75e56c8a7a1d2ce", size = 2888157, upload-time = "2026-04-16T23:09:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e3/468ac6e5f86f5b5f966a5de8dd605f00efcd5c7a05f47844d6c5966b338a/soldr-0.7.0-py3-none-manylinux_2_39_x86_64.whl", hash = "sha256:003b75cf684c1be78ede590690283a646fead8ca287b03c63671dca833ad29d0", size = 2961724, upload-time = "2026-04-16T23:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bc/bc4182dfde721d8042fffd9b80bd6322e0961dc58bfbae04da7e4b72f857/soldr-0.7.0-py3-none-win_amd64.whl", hash = "sha256:1b749e51595abca5b325f37b6217104f19cbabbf73b895558a75b9f2522d9bef", size = 2557703, upload-time = "2026-04-16T23:09:05.158Z" }, + { url = "https://files.pythonhosted.org/packages/b1/76/e3a348a05251b5a80df0ece4e9c2c830b1ce1adb9418d288060618b71e14/soldr-0.7.0-py3-none-win_arm64.whl", hash = "sha256:9e297c34296ebbac3036f9011fbd0b0c7409a5765b9770626280f4600f1ebdb3", size = 2439090, upload-time = "2026-04-16T23:09:06.593Z" }, +] [[package]] name = "zccache"