brew-rs: add an opt-in Rust frontend path

Homebrew's Ruby and Bash startup cost is most noticeable on common API-backed
commands, so add a tightly gated Rust frontend experiment for search, info,
list, and the essential mutating commands. The goal is to make iteration on a
faster CLI possible without changing the default user experience or taking on
the risk of reimplementing all of FormulaInstaller in Rust on day one.

Keep the risk contained by only enabling the Rust path on supported Tier 1
default-prefix installs when both HOMEBREW_DEVELOPER and
HOMEBREW_EXPERIMENTAL_RUST_FRONTEND are set, and by continuing to delegate
complex install, upgrade, uninstall, and update behaviour back to the existing
Homebrew frontend for v1 correctness. That lets us measure the frontend win
first and only move more logic out of Ruby when profiling proves it is worth
the complexity.

Build brew-rs through vendor-install and run Rust subtree tasks through
portable Ruby's rake so local development, first-run bootstrapping, and CI all
use the same path instead of bespoke scripts. Add integration coverage, a
dedicated workflow, hyperfine benchmarks, Dependabot support, and Rust-specific
docs so the experiment is easy to validate, benchmark, and evolve.
This commit is contained in:
Mike McQuaid
2026-03-22 14:50:32 +00:00
parent 6c6f30cc08
commit 5afdff6b57
28 changed files with 1560 additions and 4 deletions
+15 -1
View File
@@ -19,6 +19,21 @@ updates:
- dependency-type: all
cooldown:
default-days: 7
- package-ecosystem: cargo
directory: "/Library/Homebrew/rust/brew-rs"
schedule:
interval: weekly
day: friday
time: '08:00'
timezone: Etc/UTC
groups:
cargo:
patterns:
- "*"
allow:
- dependency-type: all
cooldown:
default-days: 7
- package-ecosystem: devcontainers
directory: "/"
schedule:
@@ -81,4 +96,3 @@ updates:
- dependency-type: all
cooldown:
default-days: 7
+125
View File
@@ -0,0 +1,125 @@
name: brew-rs
on:
push:
branches:
- main
pull_request:
merge_group:
permissions:
contents: read
env:
HOMEBREW_DEVELOPER: 1
HOMEBREW_EXPERIMENTAL_RUST_FRONTEND: 1
HOMEBREW_NO_AUTO_UPDATE: 1
HOMEBREW_NO_ENV_HINTS: 1
HOMEBREW_NO_INSTALL_CLEANUP: 1
defaults:
run:
shell: bash -xeuo pipefail {0}
jobs:
brew-rs:
name: ${{ matrix.name }}
if: github.repository_owner == 'Homebrew'
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
include:
- name: brew-rs (Linux)
runs-on: ubuntu-latest
- name: brew-rs (macOS)
runs-on: macos-26
steps:
- name: Set up Homebrew
id: set-up-homebrew
uses: Homebrew/actions/setup-homebrew@b7f2e4e4cd0fc6580ebf3ecee0d10ac27bc02ce6 # main
with:
core: true
cask: false
- uses: Homebrew/actions/cache-homebrew-prefix@b7f2e4e4cd0fc6580ebf3ecee0d10ac27bc02ce6 # main
env:
HOMEBREW_EXPERIMENTAL_RUST_FRONTEND:
with:
install: hyperfine rust
workflow-key: brew-rs
uninstall: true
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Cache Bundler RubyGems
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
with:
path: ${{ steps.set-up-homebrew.outputs.gems-path }}
key: ${{ matrix.runs-on }}-brew-rs-rubygems-${{ steps.set-up-homebrew.outputs.gems-hash }}
restore-keys: ${{ matrix.runs-on }}-brew-rs-rubygems-
- name: Install Bundler RubyGems
run: brew install-bundler-gems --groups=tests
- name: Add portable Ruby to PATH
run: |
portable_ruby_bindir="${GITHUB_WORKSPACE}/Library/Homebrew/vendor/portable-ruby/current/bin"
[[ -x "${portable_ruby_bindir}/ruby" ]] || ./bin/brew vendor-install ruby
if [[ ! -x "${portable_ruby_bindir}/rake" ]]
then
"${portable_ruby_bindir}/gem" install rake --no-document
fi
echo "${portable_ruby_bindir}" >> "${GITHUB_PATH}"
- name: Build brew-rs
working-directory: Library/Homebrew/rust/brew-rs
run: rake build
- name: Check brew-rs formatting
working-directory: Library/Homebrew/rust/brew-rs
run: rake fmt
- name: Lint brew-rs
working-directory: Library/Homebrew/rust/brew-rs
run: rake clippy
- name: Test brew-rs crate
working-directory: Library/Homebrew/rust/brew-rs
run: rake test
- name: Run brew-rs integration tests
run: ./bin/brew tests --only=cmd/brew_rs --no-parallel
- name: Benchmark brew-rs
working-directory: Library/Homebrew/rust/brew-rs
run: rake benchmark
- name: Stage brew-rs into default-prefix Homebrew
env:
BREW_RS_STAGE_REPOSITORY: ${{ steps.set-up-homebrew.outputs.repository-path }}
working-directory: Library/Homebrew/rust/brew-rs
run: rake stage
- name: Prepare smoke test formula
run: brew uninstall --force hello || true
- name: Smoke-test brew-rs install
run: brew install hello
- name: Smoke-test brew-rs upgrade
run: brew upgrade hello
- name: Smoke-test brew-rs uninstall
run: brew uninstall hello
- name: Smoke-test brew-rs update
run: brew update --quiet --force
- name: Upload brew-rs artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: brew-rs-${{ runner.os }}-${{ runner.arch }}
path: Library/Homebrew/vendor/brew-rs/brew-rs
+3
View File
@@ -13,11 +13,14 @@
/Library/Homebrew/doc
/Library/Homebrew/prof
/Library/Homebrew/test/.gem
/Library/Homebrew/test/.cargo/
/Library/Homebrew/test/.subversion
/Library/Homebrew/test/coverage
/Library/Homebrew/test/junit
/Library/Homebrew/test/fs_leak_log
/Library/Homebrew/vendor/portable-ruby
/Library/Homebrew/vendor/brew-rs/
/Library/Homebrew/rust/brew-rs/target
/Library/Taps
/Library/PinnedTaps
/Library/Homebrew/.byebug_history
+71
View File
@@ -286,6 +286,49 @@ check-array-membership() {
fi
}
rust-frontend-enabled() {
if [[ -z "${HOMEBREW_DEVELOPER:-}" ]]
then
return 1
elif [[ -z "${HOMEBREW_EXPERIMENTAL_RUST_FRONTEND:-}" ]]
then
return 1
fi
if [[ -n "${HOMEBREW_NO_INSTALL_FROM_API:-}" ]]
then
return 1
fi
case "${HOMEBREW_COMMAND}" in
search | info | list | install | reinstall | update | upgrade | uninstall) ;;
*) return 1 ;;
esac
if [[ -z "${HOMEBREW_INTEGRATION_TEST:-}" && "${HOMEBREW_PREFIX}" != "${HOMEBREW_DEFAULT_PREFIX}" ]]
then
return 1
fi
if [[ -n "${HOMEBREW_MACOS}" ]]
then
if [[ "${HOMEBREW_PROCESSOR}" != "arm64" ]]
then
return 1
fi
elif [[ -n "${HOMEBREW_LINUX}" ]]
then
if [[ "${HOMEBREW_PROCESSOR}" != "arm64" && "${HOMEBREW_PROCESSOR}" != "x86_64" ]]
then
return 1
fi
else
return 1
fi
return 0
}
# These variables are set from various Homebrew scripts.
# shellcheck disable=SC2154
auto-update() {
@@ -1093,6 +1136,34 @@ unset SUDO
# Remove internal variables
unset HOMEBREW_INTERNAL_ALLOW_PACKAGES_FROM_PATHS
if rust-frontend-enabled
then
HOMEBREW_RUST_BREW_FILE="${HOMEBREW_LIBRARY}/Homebrew/vendor/brew-rs/brew-rs"
source "${HOMEBREW_LIBRARY}/Homebrew/cmd/vendor-install.sh"
if ! brew-rs-vendor-up-to-date
then
cargo_path="$(PATH="${HOMEBREW_PATH:-${PATH}}" command -v cargo)"
if [[ ! -x "${cargo_path}" ]]
then
opoo "HOMEBREW_EXPERIMENTAL_RUST_FRONTEND is set but cargo from the rust formula was not found; falling back to the Ruby frontend."
else
homebrew-vendor-install brew-rs || exit $?
# Close the vendor-install lock FD opened by the sourced helper.
exec 200>&-
fi
fi
if [[ -x "${HOMEBREW_RUST_BREW_FILE}" ]]
then
[[ "${HOMEBREW_ARG_COUNT}" -gt 0 ]] && set -- "${HOMEBREW_COMMAND}" "$@"
auto-update "$@"
opoo "using the experimental brew-rs Rust frontend."
exec "${HOMEBREW_RUST_BREW_FILE}" "$@"
fi
fi
if [[ -n "${HOMEBREW_BASH_COMMAND}" ]]
then
# source rather than executing directly to ensure the entire file is read into
+51
View File
@@ -12,6 +12,24 @@ source "${HOMEBREW_LIBRARY}/Homebrew/utils/ruby.sh"
VENDOR_DIR="${HOMEBREW_LIBRARY}/Homebrew/vendor"
brew-rs-vendor-up-to-date() {
local brew_rs_source_dir="${HOMEBREW_LIBRARY}/Homebrew/rust/brew-rs"
local vendor_root="${VENDOR_DIR}/brew-rs"
local vendor_binary="${vendor_root}/bin/brew-rs"
local cargo_lock_path="${brew_rs_source_dir}/Cargo.lock"
if [[ -x "${vendor_root}/brew-rs" &&
-x "${vendor_binary}" &&
"${brew_rs_source_dir}/Cargo.toml" -ot "${vendor_binary}" &&
(! -f "${cargo_lock_path}" || "${cargo_lock_path}" -ot "${vendor_binary}") &&
-z "$(find "${brew_rs_source_dir}/src" -type f -newer "${vendor_binary}" -print -quit)" ]]
then
return 0
fi
return 1
}
set_ruby_variables() {
# Handle the case where /usr/local/bin/brew is run under arm64.
# It's a x86_64 installation there (we refuse to install arm64 binaries) so
@@ -291,6 +309,10 @@ homebrew-vendor-install() {
local option
local url_var
local sha_var
local cargo_lock_path
local cargo_path
local vendor_binary
local vendor_root
unset VENDOR_PHYSICAL_PROCESSOR
unset VENDOR_PROCESSOR
@@ -336,6 +358,35 @@ homebrew-vendor-install() {
[[ -z "${VENDOR_NAME}" ]] && odie "This command requires a vendor target!"
[[ -n "${HOMEBREW_DEBUG}" ]] && set -x
if [[ "${VENDOR_NAME}" == "brew-rs" ]]
then
[[ -n "${HOMEBREW_DEVELOPER:-}" && -n "${HOMEBREW_EXPERIMENTAL_RUST_FRONTEND:-}" ]] ||
odie "brew-rs vendor-install requires HOMEBREW_DEVELOPER=1 and HOMEBREW_EXPERIMENTAL_RUST_FRONTEND=1."
cargo_path="$(PATH="${HOMEBREW_PATH:-${PATH}}" command -v cargo)"
[[ -x "${cargo_path}" ]] || odie "brew-rs vendor-install requires 'cargo' from the 'rust' formula."
brew_rs_source_dir="${HOMEBREW_LIBRARY}/Homebrew/rust/brew-rs"
vendor_root="${VENDOR_DIR}/brew-rs"
if brew-rs-vendor-up-to-date
then
return 0
fi
lock "vendor-install ${VENDOR_NAME}"
if brew-rs-vendor-up-to-date
then
return 0
fi
[[ -n "${HOMEBREW_QUIET}" ]] || ohai "Building brew-rs" >&2
PATH="${HOMEBREW_PATH:-${PATH}}" "${cargo_path}" install --path "${brew_rs_source_dir}" --locked --force --root "${vendor_root}" \
2> >(grep -Fv "be sure to add" >&2) || odie "Failed to build brew-rs!"
ln -sfn "bin/brew-rs" "${vendor_root}/brew-rs"
return 0
fi
if [[ -z "${VENDOR_PHYSICAL_PROCESSOR}" ]]
then
VENDOR_PHYSICAL_PROCESSOR="${HOMEBREW_PHYSICAL_PROCESSOR}"
+16
View File
@@ -0,0 +1,16 @@
# Rust Frontend Notes
- Build and install `brew-rs` through `./bin/brew vendor-install brew-rs`.
- Keep the vendored binary at `Library/Homebrew/vendor/brew-rs/brew-rs`.
- `brew.sh` should stay a thin gate and dispatch layer.
- Prefer reusing existing Homebrew Ruby and Bash behavior for correctness in v1 instead of mirroring complex logic in Rust.
- Respect existing Homebrew cache, Cellar, Caskroom, logs, temp, and metadata paths.
- Keep Rust command entrypoints in `src/commands/` with one file per command where practical.
- Before running `rake`, prepend `Library/Homebrew/vendor/portable-ruby/current/bin` to `PATH` and install `rake` there if it is missing.
- Run tasks from `Library/Homebrew/rust/brew-rs` with `rake ...`.
- Use `rake build` to vendor the binary locally.
- Use `rake check` for Rust formatting, lint, and Rust tests.
- Use `BREW_RS_STAGE_REPOSITORY=/path/to/Homebrew rake stage` to stage into another checkout.
- Use `rake benchmark` for Ruby vs Rust benchmarks.
- Run `./bin/brew typecheck` and `./bin/brew lgtm` for repo-wide verification.
- Outside the default prefix, benchmarks should cover read commands and skip mutating commands.
+204
View File
@@ -0,0 +1,204 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "brew-rs"
version = "0.1.0"
dependencies = [
"anyhow",
"regex",
"rust-fuzzy-search",
"serde",
"serde_json",
"walkdir",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "rust-fuzzy-search"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a157657054ffe556d8858504af8a672a054a6e0bd9e8ee531059100c0fa11bb2"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "brew-rs"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
[dependencies]
# Ergonomic error propagation for the CLI and command dispatch layers.
anyhow = "1.0.100"
# Supports `brew search /REGEX/` matching.
regex = "1.12.2"
# Adds fuzzy fallback suggestions for plain `brew search` queries.
rust-fuzzy-search = "0.1.1"
# Deserialize structured Homebrew API and metadata payloads.
serde = { version = "1.0.228", features = ["derive"] }
# Read and write JSON API cache entries and command output.
serde_json = "1.0.145"
# Traverse installed formula and cask state under existing Homebrew paths.
walkdir = "2.5.0"
+122
View File
@@ -0,0 +1,122 @@
# brew-rs
`brew-rs` is the opt-in Rust frontend for a small set of `brew` commands.
Today the gated command set is `search`, `info`, `list`, `install`,
`reinstall`, `update`, `upgrade`, and `uninstall`.
Meaningful Rust behavior currently exists for `search` and `list`.
`info` and the mutating commands currently print a warning to `stderr` and
delegate back to the existing Ruby frontend for correctness.
It is intentionally built through `brew vendor-install brew-rs`, which runs
standard Cargo commands under the hood instead of a bespoke build wrapper.
## Pre-step
Export both Rust frontend gate variables before building or running `brew-rs`:
```bash
export HOMEBREW_DEVELOPER=1
export HOMEBREW_EXPERIMENTAL_RUST_FRONTEND=1
```
Before running `rake`, add Homebrew's portable Ruby to `PATH` and install
`rake` there if needed:
```bash
portable_ruby_bindir="$PWD/Library/Homebrew/vendor/portable-ruby/current/bin"
[[ -x "${portable_ruby_bindir}/ruby" ]] || ./bin/brew vendor-install ruby
if [[ ! -x "${portable_ruby_bindir}/rake" ]]
then
"${portable_ruby_bindir}/gem" install rake --no-document
fi
export PATH="${portable_ruby_bindir}:${PATH}"
```
## Build
```bash
cd Library/Homebrew/rust/brew-rs
rake build
```
## Enable
```bash
./bin/brew search jq
```
The first supported Rust-backed command will run `brew vendor-install brew-rs`
automatically and skip rebuilding when the vendored binary is already up-to-date.
If `cargo` from the `rust` formula is not available yet, `brew` prints a warning
to `stderr` and falls back to the existing Ruby frontend instead of failing.
When `brew-rs` is used, `brew` prints an experimental warning to `stderr`.
If a command is not meaningfully implemented in Rust yet, `brew-rs` also
prints a handoff warning before delegating back to Ruby.
## Rust Checks
```bash
cd Library/Homebrew/rust/brew-rs
rake check
```
## Homebrew Checks
```bash
HOMEBREW_NO_AUTO_UPDATE=1 ./bin/brew tests --only=cmd/brew_rs --no-parallel
HOMEBREW_NO_AUTO_UPDATE=1 ./bin/brew typecheck
HOMEBREW_NO_AUTO_UPDATE=1 ./bin/brew lgtm
```
The `cmd/brew_rs` integration spec is intentionally small because integration
tests are slow. It currently covers the Rust-owned `search` and `list` flows.
## Benchmark
The `benchmark` task compares the Ruby and Rust frontends with `hyperfine` for
the commands currently gated through `brew-rs`.
`rake` should be run with Homebrew's portable Ruby bin directory at the front
of `PATH`. The benchmark prints the normal `hyperfine` output along with each
command's stdout/stderr. Outside the default Homebrew prefix it benchmarks
`search`, `info`, and `list`, then skips `install`, `reinstall`, `upgrade`,
`uninstall`, and `update`. On a default-prefix Tier 1 install it runs the
write benchmarks for real.
Right now only `search` and `list` do meaningful work in Rust. The `info`
benchmark measures Rust dispatch plus an immediate handoff back to Ruby, and
the mutating-command benchmarks do the same for the existing Ruby/Bash install
path. If the vendored binary is missing, the benchmark task builds it first
with `vendor-install`.
```bash
brew install hyperfine
cd Library/Homebrew/rust/brew-rs
rake benchmark
```
## Tier 1 Smoke Test
Run these on a default-prefix Tier 1 Homebrew install outside this repository checkout:
```bash
brew install hello
brew reinstall hello
brew upgrade hello
brew uninstall hello
brew update --quiet --force
```
## Next Steps
The current plan is to keep correctness-sensitive install lifecycle work in
Ruby while moving the heavy bottle path into Rust in small slices.
- Keep `brew.sh` as a thin gate and dispatch layer.
- Keep using existing Homebrew paths, caches, and metadata formats.
- Move more read-only behavior into Rust when it produces clear wins.
- For formula installs, move bottle fetch and pour into Rust first.
- Keep `post_install` and the more involved finish/finalization steps in Ruby
until narrower shared helpers exist and profiling shows a clear benefit to
moving more of that logic.
+282
View File
@@ -0,0 +1,282 @@
# frozen_string_literal: true
require "fileutils"
require "open3"
require "pathname"
require "rake"
require "shellwords"
module BrewRsTasks
module_function
ROOT = Pathname(__dir__).join("../../../..").realpath.freeze
RUST_DIR = Pathname(__dir__).realpath.freeze
BREW = (ROOT/"bin/brew").to_s.freeze
def executable_on_path?(command)
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).any? do |directory|
path = File.join(directory, command)
File.file?(path) && File.executable?(path)
end
end
def run_command(*command, env: {}, chdir: ROOT)
success = Dir.chdir(chdir) do
system(env, *command, exception: false)
end
return if success
raise "Command failed: #{Shellwords.join(command)}"
end
def capture_command(*command, env: {}, chdir: ROOT)
output, status = Dir.chdir(chdir) do
Open3.capture2e(env, *command)
end
return output.strip if status.success?
raise "Command failed: #{Shellwords.join(command)}\n#{output}"
end
def shell_command(*command, env: {})
Shellwords.join(["env"] + env.map { |key, value| "#{key}=#{value}" } + command)
end
def frontend_env
{
"HOMEBREW_DEVELOPER" => "1",
"HOMEBREW_EXPERIMENTAL_RUST_FRONTEND" => "1",
}
end
def benchmark_env(current_prefix:, default_prefix:)
env = {
"HOMEBREW_DEVELOPER" => "1",
"HOMEBREW_NO_AUTO_UPDATE" => "1",
"HOMEBREW_NO_INSTALL_CLEANUP" => "1",
}
env["HOMEBREW_INTEGRATION_TEST"] = "1" if current_prefix != default_prefix
env
end
def default_prefix
system_name = capture_command("uname", "-s")
machine = capture_command("uname", "-m")
if system_name == "Darwin" && machine == "arm64"
"/opt/homebrew"
elsif system_name == "Linux"
"/home/linuxbrew/.linuxbrew"
else
"/usr/local"
end
end
def run_benchmark(title, ruby_command:, rust_command:, prepare: nil, cleanup: nil, runs: 3, warmup: 1)
command = ["hyperfine", "--show-output", "--runs", runs.to_s, "--warmup", warmup.to_s]
command += ["--prepare", prepare] if prepare
command += ["--cleanup", cleanup] if cleanup
command += [ruby_command, rust_command]
puts
puts "==> #{title}"
run_command(*command)
end
end
desc "Build and vendor brew-rs through Homebrew vendor-install"
task :build do
BrewRsTasks.run_command(BrewRsTasks::BREW, "vendor-install", "brew-rs", env: BrewRsTasks.frontend_env)
end
desc "Check brew-rs formatting"
task :fmt do
BrewRsTasks.run_command(
"cargo",
"fmt",
"--manifest-path",
(BrewRsTasks::RUST_DIR/"Cargo.toml").to_s,
"--check",
)
end
desc "Lint brew-rs"
task :clippy do
BrewRsTasks.run_command(
"cargo",
"clippy",
"--manifest-path",
(BrewRsTasks::RUST_DIR/"Cargo.toml").to_s,
"--all-targets",
"--locked",
"--",
"-D",
"warnings",
)
end
desc "Run brew-rs Rust tests"
task :test do
BrewRsTasks.run_command(
"cargo",
"test",
"--manifest-path",
(BrewRsTasks::RUST_DIR/"Cargo.toml").to_s,
"--locked",
)
end
desc "Run brew-rs build, formatting, lint, and Rust tests"
task check: [:build, :fmt, :clippy, :test]
desc "Benchmark brew-rs against the existing frontend"
task :benchmark do
unless BrewRsTasks.executable_on_path?("hyperfine")
abort "hyperfine is required. Install it with: brew install hyperfine"
end
binary_path = BrewRsTasks::ROOT/"Library/Homebrew/vendor/brew-rs/brew-rs"
Rake::Task["build"].invoke unless binary_path.executable?
abort "Rust frontend binary is missing at #{binary_path}" unless binary_path.executable?
current_prefix = BrewRsTasks.capture_command(BrewRsTasks::BREW, "--prefix")
current_default_prefix = BrewRsTasks.default_prefix
common_env = BrewRsTasks.benchmark_env(
current_prefix: current_prefix,
default_prefix: current_default_prefix,
)
rust_env = common_env.merge("HOMEBREW_EXPERIMENTAL_RUST_FRONTEND" => "1")
formula_name = ENV.fetch("BREW_RS_BENCHMARK_FORMULA", "hello")
BrewRsTasks.run_benchmark(
"brew search jq",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "search", "jq", env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "search", "jq", env: rust_env),
runs: 5,
warmup: 2,
)
BrewRsTasks.run_benchmark(
"brew info git",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "info", "git", env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "info", "git", env: rust_env),
runs: 5,
warmup: 2,
)
BrewRsTasks.run_benchmark(
"brew list",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "list", env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "list", env: rust_env),
runs: 5,
warmup: 2,
)
if current_prefix != current_default_prefix
puts
puts "==> Skipping write benchmarks outside the default prefix (#{current_default_prefix})"
next
end
cleanup_command =
"#{BrewRsTasks.shell_command(BrewRsTasks::BREW, "uninstall", "--force", formula_name,
env: common_env)} &>/dev/null || true"
ensure_installed_command =
"#{BrewRsTasks.shell_command(BrewRsTasks::BREW, "list", "--versions", formula_name, env: common_env)} " \
"&>/dev/null || " \
"#{BrewRsTasks.shell_command(BrewRsTasks::BREW, "install", formula_name, env: common_env)} &>/dev/null"
BrewRsTasks.run_benchmark(
"brew install #{formula_name}",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "install", formula_name, env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "install", formula_name, env: rust_env),
prepare: cleanup_command,
cleanup: cleanup_command,
runs: 2,
warmup: 0,
)
BrewRsTasks.run_benchmark(
"brew upgrade #{formula_name}",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "upgrade", formula_name, env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "upgrade", formula_name, env: rust_env),
prepare: ensure_installed_command,
runs: 2,
warmup: 0,
)
BrewRsTasks.run_benchmark(
"brew reinstall #{formula_name}",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "reinstall", formula_name, env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "reinstall", formula_name, env: rust_env),
prepare: ensure_installed_command,
runs: 2,
warmup: 0,
)
BrewRsTasks.run_benchmark(
"brew uninstall #{formula_name}",
ruby_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "uninstall", formula_name, env: common_env),
rust_command: BrewRsTasks.shell_command(BrewRsTasks::BREW, "uninstall", formula_name, env: rust_env),
prepare: ensure_installed_command,
runs: 2,
warmup: 0,
)
BrewRsTasks.run_benchmark(
"brew update --quiet --force",
ruby_command: BrewRsTasks.shell_command(
BrewRsTasks::BREW,
"update",
"--quiet",
"--force",
env: common_env,
),
rust_command: BrewRsTasks.shell_command(
BrewRsTasks::BREW,
"update",
"--quiet",
"--force",
env: rust_env,
),
runs: 1,
warmup: 0,
)
end
desc "Stage brew-rs into another Homebrew checkout and build it there"
task :stage do
repository = Pathname(ENV.fetch("BREW_RS_STAGE_REPOSITORY")).realpath
FileUtils.install(
BrewRsTasks::ROOT/"Library/Homebrew/brew.sh",
repository/"Library/Homebrew/brew.sh",
mode: 0644,
)
FileUtils.install(
BrewRsTasks::ROOT/"Library/Homebrew/cmd/vendor-install.sh",
repository/"Library/Homebrew/cmd/vendor-install.sh",
mode: 0755,
)
FileUtils.mkdir_p(repository/"Library/Homebrew/rust")
FileUtils.rm_rf(repository/"Library/Homebrew/rust/brew-rs")
BrewRsTasks.run_command(
"rsync",
"-a",
"--delete",
"--exclude",
"target",
"#{BrewRsTasks::RUST_DIR}/",
"#{repository}/Library/Homebrew/rust/brew-rs/",
)
BrewRsTasks.run_command(
(repository/"bin/brew").to_s,
"vendor-install",
"brew-rs",
env: BrewRsTasks.frontend_env,
chdir: repository,
)
end
+34
View File
@@ -0,0 +1,34 @@
use crate::BrewResult;
use crate::commands;
use crate::delegate;
use std::env;
use std::process::ExitCode;
pub(crate) fn main() -> ExitCode {
match run() {
Ok(code) => code,
Err(error) => {
eprintln!("{error}");
ExitCode::FAILURE
}
}
}
fn run() -> BrewResult<ExitCode> {
let args: Vec<String> = env::args().skip(1).collect();
if args.is_empty() {
return delegate::run(&args);
}
match args[0].as_str() {
"search" => commands::search::run(&args),
"info" => commands::info::run(&args),
"list" => commands::list::run(&args),
"install" => commands::install::run(&args),
"reinstall" => commands::reinstall::run(&args),
"update" => commands::update::run(&args),
"upgrade" => commands::upgrade::run(&args),
"uninstall" => commands::uninstall::run(&args),
_ => delegate::run(&args),
}
}
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "info")
}
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "install")
}
@@ -0,0 +1,131 @@
use crate::BrewResult;
use crate::delegate;
use crate::homebrew;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
if args[1..]
.iter()
.any(|arg| arg.starts_with('-') || arg.contains('/'))
{
return delegate::run(args);
}
let cellar = homebrew::cellar_path()?;
let prefix = homebrew::prefix_path()?;
let caskroom = homebrew::caskroom_path()?;
if args.len() == 1 {
let formulae = homebrew::installed_names(&cellar)?;
let casks = homebrew::installed_names(&caskroom)?;
homebrew::print_sections(&formulae, &casks);
return Ok(ExitCode::SUCCESS);
}
let mut missing = Vec::new();
let mut listed_any = false;
for name in &args[1..] {
match list_formula_paths(&cellar, &prefix, name)? {
FormulaPaths::Paths(paths) => {
if listed_any {
println!();
}
println!("{}", paths.join("\n"));
listed_any = true;
continue;
}
FormulaPaths::Delegate => return delegate::run(args),
FormulaPaths::Missing => {}
}
if let Some(paths) = list_cask_paths(&caskroom, name)? {
if listed_any {
println!();
}
println!("{}", paths.join("\n"));
listed_any = true;
continue;
}
missing.push(name.clone());
}
if !missing.is_empty() {
for name in missing {
eprintln!("No such keg or cask: {name}");
}
return Ok(ExitCode::FAILURE);
}
Ok(ExitCode::SUCCESS)
}
enum FormulaPaths {
Delegate,
Missing,
Paths(Vec<String>),
}
fn list_formula_paths(cellar: &Path, prefix: &Path, name: &str) -> BrewResult<FormulaPaths> {
let rack = cellar.join(name);
let versions = homebrew::installed_versions(&rack)?;
if versions.is_empty() {
return Ok(FormulaPaths::Missing);
}
if let Some(prefix) = current_keg_path(prefix, &rack, name)? {
return list_formula_paths_in(&prefix);
}
if versions.len() == 1 {
return list_formula_paths_in(&rack.join(&versions[0]));
}
Ok(FormulaPaths::Delegate)
}
fn current_keg_path(prefix: &Path, rack: &Path, name: &str) -> BrewResult<Option<PathBuf>> {
for path in [
prefix.join("opt").join(name),
prefix.join("var/homebrew/linked").join(name),
] {
let metadata = match fs::symlink_metadata(&path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() && path.is_dir() {
let resolved = fs::canonicalize(&path)?;
if resolved.starts_with(rack) {
return Ok(Some(resolved));
}
}
}
Ok(None)
}
fn list_formula_paths_in(prefix: &Path) -> BrewResult<FormulaPaths> {
Ok(FormulaPaths::Paths(list_paths(prefix)?))
}
fn list_cask_paths(caskroom: &Path, name: &str) -> BrewResult<Option<Vec<String>>> {
let cask_directory = caskroom.join(name);
if !cask_directory.is_dir() {
return Ok(None);
}
Ok(Some(list_paths(&cask_directory)?))
}
fn list_paths(path: &Path) -> BrewResult<Vec<String>> {
Ok(homebrew::list_files(path)?
.into_iter()
.map(|path| path.display().to_string())
.collect())
}
@@ -0,0 +1,8 @@
pub mod info;
pub mod install;
pub mod list;
pub mod reinstall;
pub mod search;
pub mod uninstall;
pub mod update;
pub mod upgrade;
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "reinstall")
}
@@ -0,0 +1,60 @@
use crate::BrewResult;
use crate::delegate;
use crate::homebrew;
use crate::matcher::Matcher;
use rust_fuzzy_search::fuzzy_search_threshold;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
if args.len() != 2 || args[1].starts_with('-') {
return delegate::run(args);
}
let api_cache = homebrew::cache_api_path()?;
let formula_names = match homebrew::read_lines(&api_cache.join("formula_names.txt")) {
Ok(names) if !names.is_empty() => names,
_ => return delegate::run(args),
};
let cask_names = match homebrew::read_lines(&api_cache.join("cask_names.txt")) {
Ok(names) => names,
Err(_) => return delegate::run(args),
};
let matcher = Matcher::try_from(args[1].as_str())?;
let matched_formulae = matched_names(&formula_names, &matcher);
let matched_casks = matched_names(&cask_names, &matcher);
if matched_formulae.is_empty() && matched_casks.is_empty() {
eprintln!("No formulae or casks found for {:?}.", args[1]);
return Ok(ExitCode::FAILURE);
}
homebrew::print_sections(&matched_formulae, &matched_casks);
Ok(ExitCode::SUCCESS)
}
fn matched_names(names: &[String], matcher: &Matcher) -> Vec<String> {
let matched_names = names
.iter()
.filter(|name| matcher.matches(name))
.cloned()
.collect::<Vec<_>>();
if !matched_names.is_empty() {
return matched_names;
}
let Matcher::String(query) = matcher else {
return matched_names;
};
if query.len() < 3 {
return matched_names;
}
let candidates = names.iter().map(String::as_str).collect::<Vec<_>>();
let mut similar = fuzzy_search_threshold(query, &candidates, 0.5);
similar.sort_by(|(_, left), (_, right)| right.total_cmp(left));
similar
.into_iter()
.map(|(name, _)| name.to_string())
.collect()
}
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "uninstall")
}
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "update")
}
@@ -0,0 +1,7 @@
use crate::BrewResult;
use crate::delegate;
use std::process::ExitCode;
pub fn run(args: &[String]) -> BrewResult<ExitCode> {
delegate::run_with_warning(args, "upgrade")
}
@@ -0,0 +1,29 @@
use crate::BrewResult;
use crate::homebrew;
use anyhow::Context;
use std::process::ExitCode;
use std::process::{Command, Stdio};
pub(crate) fn run(args: &[String]) -> BrewResult<ExitCode> {
run_command(args)
}
pub(crate) fn run_with_warning(args: &[String], command_name: &str) -> BrewResult<ExitCode> {
eprintln!("Warning: brew-rs is handing {command_name} back to the Ruby backend.");
run_command(args)
}
fn run_command(args: &[String]) -> BrewResult<ExitCode> {
let status = Command::new(homebrew::brew_file()?)
.args(args)
.env_remove("HOMEBREW_EXPERIMENTAL_RUST_FRONTEND")
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.context("Failed to delegate to brew")?;
Ok(ExitCode::from(
status.code().unwrap_or(1).clamp(0, 255) as u8
))
}
@@ -0,0 +1,114 @@
use crate::BrewResult;
use anyhow::{Context, anyhow};
use std::env;
use std::fs;
use std::io::{self, IsTerminal};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
pub(crate) fn cache_api_path() -> BrewResult<PathBuf> {
Ok(env_path("HOMEBREW_CACHE")?.join("api"))
}
pub(crate) fn cellar_path() -> BrewResult<PathBuf> {
env_path("HOMEBREW_CELLAR")
}
pub(crate) fn caskroom_path() -> BrewResult<PathBuf> {
env_path("HOMEBREW_CASKROOM")
}
pub(crate) fn prefix_path() -> BrewResult<PathBuf> {
env_path("HOMEBREW_PREFIX")
}
pub(crate) fn brew_file() -> BrewResult<PathBuf> {
env_path("HOMEBREW_BREW_FILE")
}
pub(crate) fn read_lines(path: &Path) -> BrewResult<Vec<String>> {
let contents =
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
Ok(contents
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(ToString::to_string)
.collect())
}
pub(crate) fn installed_names(path: &Path) -> BrewResult<Vec<String>> {
list_directories(path)
}
pub(crate) fn installed_versions(path: &Path) -> BrewResult<Vec<String>> {
list_directories(path)
}
pub(crate) fn list_files(path: &Path) -> BrewResult<Vec<PathBuf>> {
if !path.exists() {
return Err(anyhow!("Failed to list {}", path.display()));
}
let mut files = WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| entry.into_path())
.collect::<Vec<_>>();
files.sort();
Ok(files)
}
pub(crate) fn print_sections(formulae: &[String], casks: &[String]) {
let stdout_is_tty = io::stdout().is_terminal();
if stdout_is_tty && !formulae.is_empty() && !casks.is_empty() {
println!("Formulae");
println!("{}", formulae.join("\n"));
println!();
println!("Casks");
println!("{}", casks.join("\n"));
return;
}
if !formulae.is_empty() {
println!("{}", formulae.join("\n"));
}
if !formulae.is_empty() && !casks.is_empty() {
println!();
}
if !casks.is_empty() {
println!("{}", casks.join("\n"));
}
}
fn env_path(name: &str) -> BrewResult<PathBuf> {
env::var_os(name)
.map(PathBuf::from)
.ok_or_else(|| anyhow!("{name} is not set"))
}
fn list_directories(path: &Path) -> BrewResult<Vec<String>> {
if !path.exists() {
return Ok(Vec::new());
}
let mut entries = fs::read_dir(path)
.with_context(|| format!("Failed to list {}", path.display()))?
.collect::<std::result::Result<Vec<_>, _>>()
.with_context(|| format!("Failed to list {}", path.display()))?;
entries.sort_by_key(|entry| entry.file_name());
Ok(entries
.into_iter()
.filter_map(|entry| {
entry
.file_type()
.ok()
.filter(|file_type| file_type.is_dir())
.map(|_| entry)
})
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect())
}
+16
View File
@@ -0,0 +1,16 @@
#![forbid(unsafe_code)]
mod app;
mod commands;
mod delegate;
mod homebrew;
mod matcher;
use anyhow::Result;
use std::process::ExitCode;
pub fn main() -> ExitCode {
app::main()
}
type BrewResult<T> = Result<T>;
@@ -0,0 +1,5 @@
use std::process::ExitCode;
fn main() -> ExitCode {
brew_rs::main()
}
@@ -0,0 +1,44 @@
use crate::BrewResult;
use regex::Regex;
pub(crate) enum Matcher {
Regex(Regex),
String(String),
}
impl TryFrom<&str> for Matcher {
type Error = anyhow::Error;
fn try_from(query: &str) -> BrewResult<Self> {
if query.len() > 2 && query.starts_with('/') && query.ends_with('/') {
Regex::new(&query[1..query.len() - 1])
.map(Self::Regex)
.map_err(|error| anyhow::anyhow!("{query} is not a valid regex: {error}"))
} else {
Ok(Self::String(simplify_string(query)))
}
}
}
impl Matcher {
pub(crate) fn matches(&self, value: &str) -> bool {
match self {
Self::Regex(regex) => regex.is_match(value),
Self::String(string) => simplify_string(value).contains(string),
}
}
}
fn simplify_string(value: &str) -> String {
value
.chars()
.filter_map(|character| {
let lowered = character.to_ascii_lowercase();
if lowered.is_ascii_alphanumeric() || lowered == '@' || lowered == '+' {
Some(lowered)
} else {
None
}
})
.collect()
}
+106
View File
@@ -0,0 +1,106 @@
# frozen_string_literal: true
# This spec exercises brew.sh dispatch rather than a Ruby class API.
# rubocop:disable RSpec/DescribeClass
RSpec.describe "brew-rs" do
let(:brew_rs_bin) { HOMEBREW_LIBRARY_PATH/"vendor/brew-rs/brew-rs" }
let(:brew_rs_cache) { Pathname(TEST_TMPDIR)/"brew-rs-cache" }
let(:brew_rs_runtime_env) do
{
"HOMEBREW_BREW_SH" => (HOMEBREW_LIBRARY_PATH.parent.parent/"bin/brew").to_s,
"HOMEBREW_NO_COLOR" => "1",
"HOMEBREW_DEVELOPER" => "1",
"HOMEBREW_EXPERIMENTAL_RUST_FRONTEND" => "1",
}
end
let(:brew_rs_env) { brew_rs_runtime_env.merge("HOMEBREW_CACHE" => brew_rs_cache.to_s) }
let(:api_cache) { brew_rs_cache/"api" }
let(:runtime_prefix) { HOMEBREW_LIBRARY_PATH.parent.parent }
before do
skip "brew-rs is not built." unless brew_rs_bin.executable?
end
after do
FileUtils.rm_rf brew_rs_cache
end
it "uses the brew-rs search flow", :integration_test do
api_cache.mkpath
(api_cache/"formula_names.txt").write("testball\n")
(api_cache/"cask_names.txt").write("local-caffeine\n")
expect do
expect { brew_sh "search", "l", brew_rs_env }.to be_a_success
end.to output(/testball\n\nlocal-caffeine\n/).to_stdout
end
it "uses fuzzy search when plain-text search has no exact matches", :integration_test do
api_cache.mkpath
(api_cache/"formula_names.txt").write("testball\n")
(api_cache/"cask_names.txt").write("")
expect do
expect { brew_sh "search", "testbal", brew_rs_env }.to be_a_success
end.to output("testball\n").to_stdout
end
it "matches the Ruby info output", :integration_test do
formula_path = setup_test_formula "testball"
ruby_env = brew_rs_runtime_env.dup.tap { |env| env.delete("HOMEBREW_EXPERIMENTAL_RUST_FRONTEND") }
brew_file = brew_rs_runtime_env.fetch("HOMEBREW_BREW_SH")
ruby_stdout, _ruby_stderr, ruby_status = Open3.capture3(ruby_env, brew_file, "info", formula_path.to_s)
rust_stdout, rust_stderr, rust_status = Open3.capture3(brew_rs_runtime_env, brew_file, "info", formula_path.to_s)
expect(ruby_status.success?).to be true
expect(rust_status.success?).to be true
expect(rust_stdout).to eq(ruby_stdout)
expect(Tty.strip_ansi(rust_stderr)).to include("Warning: using the experimental brew-rs Rust frontend.")
end
it "uses the brew-rs list flow", :integration_test do
runtime_cellar = runtime_prefix/"Cellar"
runtime_caskroom = runtime_prefix/"Caskroom"
begin
FileUtils.rm_rf runtime_cellar/"foo"
FileUtils.rm_rf runtime_caskroom/"local-caffeine"
(runtime_cellar/"foo/1.0/bin").mkpath
(runtime_cellar/"foo/1.0/bin/foo").write("foo")
(runtime_caskroom/"local-caffeine/1.2.3").mkpath
expect do
expect { brew_sh "list", brew_rs_runtime_env }.to be_a_success
end.to output(/foo.*local-caffeine/m).to_stdout
ensure
FileUtils.rm_rf runtime_cellar/"foo"
FileUtils.rm_rf runtime_caskroom/"local-caffeine"
end
end
it "uses the linked keg when listing formula files", :integration_test do
runtime_cellar = runtime_prefix/"Cellar"
linked_file = runtime_cellar/"linked-formula/1.0/bin/linked-formula"
begin
FileUtils.rm_rf runtime_cellar/"linked-formula"
FileUtils.rm_f runtime_prefix/"opt/linked-formula"
(runtime_cellar/"linked-formula/1.0/bin").mkpath
linked_file.write("foo")
(runtime_cellar/"linked-formula/2.0/bin").mkpath
(runtime_cellar/"linked-formula/2.0/bin/linked-formula-newer").write("foo")
(runtime_prefix/"opt").mkpath
FileUtils.ln_sf(runtime_cellar/"linked-formula/1.0", runtime_prefix/"opt/linked-formula")
expect do
expect { brew_sh "list", "linked-formula", brew_rs_runtime_env }.to be_a_success
end.to output(%r{linked-formula/1\.0/bin/linked-formula\n\z}).to_stdout
ensure
FileUtils.rm_rf runtime_cellar/"linked-formula"
FileUtils.rm_f runtime_prefix/"opt/linked-formula"
end
end
end
# rubocop:enable RSpec/DescribeClass
@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "open3"
require "shellwords"
# This spec exercises a shell helper rather than a Ruby class API.
# rubocop:disable RSpec/DescribeClass
RSpec.describe "vendor-install.sh" do
let(:vendor_dir) { mktmpdir("vendor-install")/"vendor" }
let(:vendor_root) { vendor_dir/"brew-rs" }
let(:vendor_binary) { vendor_root/"bin/brew-rs" }
let(:vendor_symlink) { vendor_root/"brew-rs" }
let(:vendor_install_sh) { HOMEBREW_LIBRARY_PATH/"cmd/vendor-install.sh" }
let(:check_vendor_script) do
<<~SH
source #{Shellwords.escape(vendor_install_sh.to_s)}
VENDOR_DIR=#{Shellwords.escape(vendor_dir.to_s)}
brew-rs-vendor-up-to-date
SH
end
before do
vendor_binary.dirname.mkpath
vendor_binary.write("#!/bin/bash\n")
vendor_binary.chmod(0755)
File.utime(Time.at(2_000_000_000), Time.at(2_000_000_000), vendor_binary)
end
it "does not recreate the brew-rs launcher symlink while checking freshness" do
_stdout, _stderr, status = Open3.capture3(
{ "HOMEBREW_LIBRARY" => HOMEBREW_LIBRARY_PATH.to_s },
"/bin/bash",
"-c",
check_vendor_script,
)
expect([status.success?, vendor_symlink.exist?]).to eq([false, false])
end
it "treats the launcher symlink as part of the up-to-date vendor layout" do
FileUtils.ln_s("bin/brew-rs", vendor_symlink)
_stdout, _stderr, status = Open3.capture3(
{ "HOMEBREW_LIBRARY" => HOMEBREW_LIBRARY_PATH.to_s },
"/bin/bash",
"-c",
check_vendor_script,
)
expect(status.success?).to be true
end
end
# rubocop:enable RSpec/DescribeClass
@@ -58,7 +58,7 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin
# properly merge coverage results.
def command_id
Thread.current[:brew_integration_test_number] ||= 0
"#{ENV.fetch("TEST_ENV_NUMBER", "")}:#{Thread.current[:brew_integration_test_number] += 1}"
"#{Process.pid}:#{ENV.fetch("TEST_ENV_NUMBER", "")}:#{Thread.current[:brew_integration_test_number] += 1}"
end
# Runs a `brew` command with the test configuration
@@ -121,12 +121,19 @@ RSpec.shared_context "integration test" do # rubocop:disable RSpec/ContextWordin
end
def brew_sh(*args)
env = args.last.is_a?(Hash) ? args.pop : {}
env = {
"HOMEBREW_USE_RUBY_FROM_PATH" => ENV.fetch("HOMEBREW_USE_RUBY_FROM_PATH", nil),
"HOMEBREW_CACHE" => HOMEBREW_CACHE.to_s,
}
"HOMEBREW_INTEGRATION_TEST" => command_id,
}.merge(env)
Bundler.with_unbundled_env do
stdout, stderr, status = Open3.capture3(env, "#{ENV.fetch("HOMEBREW_PREFIX")}/bin/brew", *args)
brew_sh_path = env.delete("HOMEBREW_BREW_SH") || "#{ENV.fetch("HOMEBREW_PREFIX")}/bin/brew"
stdout, stderr, status = Open3.capture3(
env,
brew_sh_path,
*args,
)
$stdout.print stdout
$stderr.print stderr
status