diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6ed05dc9d5..b015cbf966 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -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 - diff --git a/.github/workflows/brew-rs.yml b/.github/workflows/brew-rs.yml new file mode 100644 index 0000000000..c10c851df5 --- /dev/null +++ b/.github/workflows/brew-rs.yml @@ -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 diff --git a/.gitignore b/.gitignore index df9f83c9e0..5f82018bba 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index f6b74622bc..d675aa09e4 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -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 diff --git a/Library/Homebrew/cmd/vendor-install.sh b/Library/Homebrew/cmd/vendor-install.sh index 6d482ed6e7..2717338147 100644 --- a/Library/Homebrew/cmd/vendor-install.sh +++ b/Library/Homebrew/cmd/vendor-install.sh @@ -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}" diff --git a/Library/Homebrew/rust/brew-rs/AGENTS.md b/Library/Homebrew/rust/brew-rs/AGENTS.md new file mode 100644 index 0000000000..b2342c0e36 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/AGENTS.md @@ -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. diff --git a/Library/Homebrew/rust/brew-rs/Cargo.lock b/Library/Homebrew/rust/brew-rs/Cargo.lock new file mode 100644 index 0000000000..6b4d12c433 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/Cargo.lock @@ -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" diff --git a/Library/Homebrew/rust/brew-rs/Cargo.toml b/Library/Homebrew/rust/brew-rs/Cargo.toml new file mode 100644 index 0000000000..161fc7e6a1 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/Cargo.toml @@ -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" diff --git a/Library/Homebrew/rust/brew-rs/README.md b/Library/Homebrew/rust/brew-rs/README.md new file mode 100644 index 0000000000..3f8a5c7ace --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/README.md @@ -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. diff --git a/Library/Homebrew/rust/brew-rs/Rakefile b/Library/Homebrew/rust/brew-rs/Rakefile new file mode 100644 index 0000000000..ddff5dd709 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/Rakefile @@ -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 diff --git a/Library/Homebrew/rust/brew-rs/src/app.rs b/Library/Homebrew/rust/brew-rs/src/app.rs new file mode 100644 index 0000000000..a6aaf14fab --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/app.rs @@ -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 { + let args: Vec = 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), + } +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/info.rs b/Library/Homebrew/rust/brew-rs/src/commands/info.rs new file mode 100644 index 0000000000..cb165820e1 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/info.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "info") +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/install.rs b/Library/Homebrew/rust/brew-rs/src/commands/install.rs new file mode 100644 index 0000000000..0079f74650 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/install.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "install") +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/list.rs b/Library/Homebrew/rust/brew-rs/src/commands/list.rs new file mode 100644 index 0000000000..3aa5af428e --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/list.rs @@ -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 { + 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), +} + +fn list_formula_paths(cellar: &Path, prefix: &Path, name: &str) -> BrewResult { + 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> { + 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 { + Ok(FormulaPaths::Paths(list_paths(prefix)?)) +} + +fn list_cask_paths(caskroom: &Path, name: &str) -> BrewResult>> { + 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> { + Ok(homebrew::list_files(path)? + .into_iter() + .map(|path| path.display().to_string()) + .collect()) +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/mod.rs b/Library/Homebrew/rust/brew-rs/src/commands/mod.rs new file mode 100644 index 0000000000..315d4b994f --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/mod.rs @@ -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; diff --git a/Library/Homebrew/rust/brew-rs/src/commands/reinstall.rs b/Library/Homebrew/rust/brew-rs/src/commands/reinstall.rs new file mode 100644 index 0000000000..6496cd8862 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/reinstall.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "reinstall") +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/search.rs b/Library/Homebrew/rust/brew-rs/src/commands/search.rs new file mode 100644 index 0000000000..8a58bd745d --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/search.rs @@ -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 { + 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 { + let matched_names = names + .iter() + .filter(|name| matcher.matches(name)) + .cloned() + .collect::>(); + 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::>(); + 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() +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/uninstall.rs b/Library/Homebrew/rust/brew-rs/src/commands/uninstall.rs new file mode 100644 index 0000000000..f1ac457da2 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/uninstall.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "uninstall") +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/update.rs b/Library/Homebrew/rust/brew-rs/src/commands/update.rs new file mode 100644 index 0000000000..ee756e0b9a --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/update.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "update") +} diff --git a/Library/Homebrew/rust/brew-rs/src/commands/upgrade.rs b/Library/Homebrew/rust/brew-rs/src/commands/upgrade.rs new file mode 100644 index 0000000000..6306727de8 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/commands/upgrade.rs @@ -0,0 +1,7 @@ +use crate::BrewResult; +use crate::delegate; +use std::process::ExitCode; + +pub fn run(args: &[String]) -> BrewResult { + delegate::run_with_warning(args, "upgrade") +} diff --git a/Library/Homebrew/rust/brew-rs/src/delegate.rs b/Library/Homebrew/rust/brew-rs/src/delegate.rs new file mode 100644 index 0000000000..04398efe7d --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/delegate.rs @@ -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 { + run_command(args) +} + +pub(crate) fn run_with_warning(args: &[String], command_name: &str) -> BrewResult { + eprintln!("Warning: brew-rs is handing {command_name} back to the Ruby backend."); + run_command(args) +} + +fn run_command(args: &[String]) -> BrewResult { + 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 + )) +} diff --git a/Library/Homebrew/rust/brew-rs/src/homebrew.rs b/Library/Homebrew/rust/brew-rs/src/homebrew.rs new file mode 100644 index 0000000000..6f476e5998 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/homebrew.rs @@ -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 { + Ok(env_path("HOMEBREW_CACHE")?.join("api")) +} + +pub(crate) fn cellar_path() -> BrewResult { + env_path("HOMEBREW_CELLAR") +} + +pub(crate) fn caskroom_path() -> BrewResult { + env_path("HOMEBREW_CASKROOM") +} + +pub(crate) fn prefix_path() -> BrewResult { + env_path("HOMEBREW_PREFIX") +} + +pub(crate) fn brew_file() -> BrewResult { + env_path("HOMEBREW_BREW_FILE") +} + +pub(crate) fn read_lines(path: &Path) -> BrewResult> { + 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> { + list_directories(path) +} + +pub(crate) fn installed_versions(path: &Path) -> BrewResult> { + list_directories(path) +} + +pub(crate) fn list_files(path: &Path) -> BrewResult> { + 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::>(); + 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 { + env::var_os(name) + .map(PathBuf::from) + .ok_or_else(|| anyhow!("{name} is not set")) +} + +fn list_directories(path: &Path) -> BrewResult> { + if !path.exists() { + return Ok(Vec::new()); + } + + let mut entries = fs::read_dir(path) + .with_context(|| format!("Failed to list {}", path.display()))? + .collect::, _>>() + .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()) +} diff --git a/Library/Homebrew/rust/brew-rs/src/lib.rs b/Library/Homebrew/rust/brew-rs/src/lib.rs new file mode 100644 index 0000000000..213e6ff721 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/lib.rs @@ -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 = Result; diff --git a/Library/Homebrew/rust/brew-rs/src/main.rs b/Library/Homebrew/rust/brew-rs/src/main.rs new file mode 100644 index 0000000000..7fbaa3a864 --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/main.rs @@ -0,0 +1,5 @@ +use std::process::ExitCode; + +fn main() -> ExitCode { + brew_rs::main() +} diff --git a/Library/Homebrew/rust/brew-rs/src/matcher.rs b/Library/Homebrew/rust/brew-rs/src/matcher.rs new file mode 100644 index 0000000000..39df86a45f --- /dev/null +++ b/Library/Homebrew/rust/brew-rs/src/matcher.rs @@ -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 { + 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() +} diff --git a/Library/Homebrew/test/cmd/brew_rs_spec.rb b/Library/Homebrew/test/cmd/brew_rs_spec.rb new file mode 100644 index 0000000000..6dfb3c3b10 --- /dev/null +++ b/Library/Homebrew/test/cmd/brew_rs_spec.rb @@ -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 diff --git a/Library/Homebrew/test/cmd/vendor-install_spec.rb b/Library/Homebrew/test/cmd/vendor-install_spec.rb new file mode 100644 index 0000000000..f13b5c5ff9 --- /dev/null +++ b/Library/Homebrew/test/cmd/vendor-install_spec.rb @@ -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 diff --git a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb index 5a0e716d7d..f2fede1de7 100644 --- a/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb +++ b/Library/Homebrew/test/support/helper/spec/shared_context/integration_test.rb @@ -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