Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"`)
Expand Down
12 changes: 8 additions & 4 deletions CODEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@ 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 ...`

## 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

Expand Down
22 changes: 11 additions & 11 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions _cargo
Original file line number Diff line number Diff line change
@@ -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 "$@"
6 changes: 3 additions & 3 deletions _rustc
Original file line number Diff line number Diff line change
@@ -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 "$@"
6 changes: 3 additions & 3 deletions _rustfmt
Original file line number Diff line number Diff line change
@@ -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 "$@"
4 changes: 3 additions & 1 deletion ci/dev-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
2 changes: 1 addition & 1 deletion ci/dev-tools/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion ci/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions ci/hooks/test_tool_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions ci/hooks/tool_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ")

Expand Down Expand Up @@ -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:
Expand Down
100 changes: 38 additions & 62 deletions ci/trampoline.py
Original file line number Diff line number Diff line change
@@ -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] <subcommand> <argv...>`."""
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)
Expand Down
21 changes: 20 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading