use async_trait::async_trait;
use eyre::{WrapErr, bail};
use std::collections::{HashMap, HashSet};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use super::{InstallOpts, PackageRequest, PackageState, PackageStatus, SystemPackageManager};
use crate::config::Settings;
use crate::result::Result;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::ui::progress_report::{ProgressIcon, SingleReport};
mod api;
mod cask;
mod elf;
mod fetch;
mod macho;
mod maintenance;
mod pour;
mod prefix;
mod relocate;
mod resolve;
mod source;
mod tag;
mod tap;
pub(crate) struct BrewManager {}
pub(crate) use cask::{
BrewCaskManager, apply_cask_prune_plan, cask_formula_dependencies, cask_prune_plan,
};
pub(crate) use maintenance::{apply_prune_plan, default_tap_url, linked_formulae, prune_plan};
pub(crate) fn package_root(name: &str) -> Result<PathBuf> {
let parts = name.split('/').collect::<Vec<_>>();
if !matches!(parts.len(), 1 | 3)
|| parts.iter().any(|part| {
part.is_empty()
|| part
.chars()
.any(|c| c.is_whitespace() || c.is_control() || matches!(c, ':' | '\\'))
|| !is_normal_formula_component(part)
})
{
bail!(
"invalid brew formula {name:?}; use brew:<formula> or brew:<owner>/<tap>/<formula> with normal, nonempty path components"
);
}
if parts.len() == 3 && parts[0] == "homebrew" && parts[1] == "cask" {
bail!("brew:{name}: the Homebrew cask namespace is unsupported for formula lookup");
}
let formula = request_formula_name(name);
assert!(
is_normal_formula_component(formula),
"validated formula must normalize to exactly one normal path component"
);
pour::strict_package_root(formula)
.wrap_err_with(|| format!("failed to locate installed brew:{name}"))
}
fn is_normal_formula_component(name: &str) -> bool {
let mut components = Path::new(name).components();
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
impl BrewManager {
pub(crate) fn new() -> Self {
Self {}
}
fn split_tapped<'a>(
&self,
pkgs: &'a [PackageRequest],
) -> (Vec<&'a PackageRequest>, Vec<&'a PackageRequest>) {
pkgs.iter().partition(|p| is_tapped_formula(&p.name))
}
fn repair_records(
&self,
pkgs: &[PackageRequest],
opts: &InstallOpts,
) -> Result<Vec<PackageRequest>> {
let mut repaired = vec![];
for request in pkgs {
let name = request_formula_name(&request.name);
let state = linked_package_state(&request.version, pour::linked_state(name));
if matches!(state, PackageState::NeedsRepair { .. })
&& pour::repair_link_record(name, opts.dry_run)?
{
repaired.push(request.clone());
}
}
Ok(repaired)
}
async fn install_via_pour(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
if let Some(p) = pkgs.iter().find(|p| p.version.is_some()) {
bail!(
"brew bottles are only published for a formula's current version ('{p}'): \
pin via the formula name instead (e.g. \"brew:postgresql@17\")"
);
}
let roots: Vec<String> = pkgs.iter().map(|p| p.name.clone()).collect();
let closure = resolve::resolve_closure_with_taps(pkgs, !opts.dry_run).await?;
for rf in &closure {
if rf.on_request
&& !roots.contains(&rf.formula.name)
&& let Some(alias) = roots
.iter()
.find(|r| rf.formula.names().any(|n| n == r.as_str()))
{
warn!(
"'{alias}' resolves to '{}' — use the canonical name in [bootstrap.packages] \
so `mise bootstrap packages status` can track it",
rf.formula.name
);
}
}
let mut to_pour: Vec<_> = vec![];
for rf in &closure {
let pkg_version = rf.formula.pkg_version()?;
if !pour::keg_installed(&rf.formula.name, &pkg_version) {
to_pour.push(rf.clone());
}
}
if to_pour.is_empty() {
info!("brew: all formulae already poured");
return Ok(());
}
let source_builds: Vec<_> = to_pour
.iter()
.filter(|rf| !source::has_bottle(&rf.formula))
.collect();
for rf in &source_builds {
source::check_buildable(&rf.formula)?;
}
if opts.dry_run {
prefix::bootstrap(true)?;
for rf in &to_pour {
let origin = if rf.on_request {
"requested"
} else {
"dependency"
};
if source::has_bottle(&rf.formula) {
miseprintln!(
"pour {}/{} ({origin})",
rf.formula.name,
rf.formula.pkg_version()?,
);
} else {
miseprintln!(
"build {}/{} from source ({origin}, {})",
rf.formula.name,
rf.formula.pkg_version()?,
source::missing_bottle_reason(&rf.formula),
);
}
}
return Ok(());
}
if prefix::sudo_invoking_user().is_some() {
warn!(
"running under sudo — poured files will be owned by root; run \
`mise bootstrap packages apply` without sudo instead (mise elevates itself \
for the one-time prefix setup)"
);
}
prefix::bootstrap(false)?;
prefix::setup_linux_runtime()?;
if !source_builds.is_empty() {
info!(
"brew: building from source (no bottle for this machine): {}",
source_builds
.iter()
.map(|rf| rf.formula.name.clone())
.collect::<Vec<_>>()
.join(", "),
);
}
let mpr = MultiProgressReport::get();
mpr.init_footer(false, "install", to_pour.len());
let pkg_versions = to_pour
.iter()
.map(|rf| rf.formula.pkg_version())
.collect::<Result<Vec<_>>>()?;
let reports = to_pour
.iter()
.map(|rf| Arc::<dyn SingleReport>::from(mpr.add(&format!("brew:{}", rf.formula.name))))
.collect::<Vec<_>>();
let bottles = to_pour
.iter()
.map(|rf| {
source::has_bottle(&rf.formula)
.then(|| rf.formula.bottle_files().and_then(tag::select))
.flatten()
.map(|(tag, bottle)| (tag.to_string(), bottle.clone()))
})
.collect::<Vec<_>>();
let closure = Arc::new(closure);
let bottle_jobs = bottles
.iter()
.enumerate()
.filter_map(|(index, bottle)| {
let (tag, bottle) = bottle.as_ref()?.clone();
let rf = to_pour[index].clone();
let pkg_version = pkg_versions[index].clone();
let closure = closure.clone();
let pr = reports[index].clone();
Some(async move {
let result = async {
let tarball = fetch::fetch_bottle(
&rf.formula.name,
&pkg_version,
&bottle,
Some(&*pr),
)
.await?;
tokio::task::spawn_blocking(move || {
pour::prepare_bottle(&rf, &tag, &bottle, &tarball, &closure, &*pr)
})
.await
.wrap_err("brew bottle preparation task failed")?
}
.await;
(index, result)
})
})
.collect::<Vec<_>>();
let mut bottle_jobs =
fetch::concurrently(bottle_jobs, crate::jobs::normalize(Settings::get().jobs));
let mut prepared = HashMap::new();
let mut completed = HashSet::new();
let mut failure = None;
for (index, rf) in to_pour.iter().enumerate() {
let pkg_version = &pkg_versions[index];
let pr = &reports[index];
let bottle = &bottles[index];
let installed = match bottle {
Some(_) => {
while !prepared.contains_key(&index) && failure.is_none() {
let Some((completed, result)) = bottle_jobs.next().await else {
unreachable!("every selected bottle has a preparation job");
};
match result {
Ok(bottle) => {
prepared.insert(completed, bottle);
}
Err(err) => {
reports[completed]
.finish_with_icon("failed".to_string(), ProgressIcon::Error);
failure = Some((completed, err));
}
}
}
if failure.is_some() {
break;
}
let bottle = prepared
.remove(&index)
.expect("every selected bottle was prepared");
pour::install_prepared(bottle, &**pr).map(|()| pkg_version.clone())
}
None => {
if failure.is_some() {
break;
}
let build = source::build(rf, &closure, &**pr);
tokio::pin!(build);
let mut jobs_open = true;
loop {
tokio::select! {
biased;
job = bottle_jobs.next(), if jobs_open => match job {
Some((completed, Ok(bottle))) => {
prepared.insert(completed, bottle);
}
Some((completed, Err(err))) => {
reports[completed].finish_with_icon(
"failed".to_string(),
ProgressIcon::Error,
);
if failure.is_none() {
failure = Some((completed, err));
bottle_jobs.cancel_pending();
}
}
None => jobs_open = false,
},
result = &mut build => break result.map(|()| pkg_version.clone()),
}
}
}
};
let version = match installed {
Ok(version) => version,
Err(err) => {
pr.finish_with_icon("failed".to_string(), ProgressIcon::Error);
if failure.is_none() {
failure = Some((index, err));
}
break;
}
};
pr.finish_with_message(version);
completed.insert(index);
mpr.footer_inc(1);
if failure.is_some() {
break;
}
}
if failure.is_some() {
bottle_jobs.cancel_pending();
}
while let Some((index, result)) = bottle_jobs.next().await {
if let Err(err) = result {
reports[index].finish_with_icon("failed".to_string(), ProgressIcon::Error);
if failure.is_none() {
failure = Some((index, err));
}
}
}
if let Some((failed, err)) = failure {
for (index, pending) in reports.iter().enumerate() {
if index != failed && !completed.contains(&index) {
pending.abandon();
}
}
mpr.footer_finish();
if let Err(runtime_err) = prefix::setup_linux_runtime() {
return Err(err.wrap_err(format!(
"failed to finish Linux runtime setup after a partial brew install: {runtime_err:#}"
)));
}
return Err(err);
}
mpr.footer_finish();
prefix::setup_linux_runtime()?;
Ok(())
}
}
#[async_trait(?Send)]
impl SystemPackageManager for BrewManager {
fn name(&self) -> &str {
"brew"
}
fn is_available(&self) -> bool {
cfg!(all(target_os = "macos", target_arch = "aarch64"))
|| cfg!(all(
target_os = "linux",
any(target_arch = "x86_64", target_arch = "aarch64")
))
}
fn unavailable_reason(&self) -> String {
"only available on arm64 macos and x86_64/arm64 linux".to_string()
}
fn supports_version_pins(&self) -> bool {
false
}
async fn installed(&self, pkgs: &[PackageRequest]) -> Result<Vec<PackageStatus>> {
let mut statuses = Vec::with_capacity(pkgs.len());
for req in pkgs {
let linked_name = request_formula_name(&req.name);
let linked = pour::linked_state(linked_name);
let state = linked_package_state(&req.version, linked);
statuses.push(PackageStatus {
request: req.clone(),
state,
});
}
Ok(statuses)
}
async fn install(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
let repaired = self.repair_records(pkgs, opts)?;
let remaining = pkgs
.iter()
.filter(|request| !repaired.contains(request))
.cloned()
.collect::<Vec<_>>();
let (tapped, core) = self.split_tapped(&remaining);
if !core.is_empty() {
let core = core
.into_iter()
.map(normalize_core_request)
.collect::<Vec<_>>();
self.install_via_pour(&core, opts).await?;
}
if !tapped.is_empty() {
let tapped = tapped.into_iter().cloned().collect::<Vec<_>>();
self.install_via_pour(&tapped, opts).await?;
}
Ok(())
}
async fn upgrade(&self, pkgs: &[PackageRequest], opts: &InstallOpts) -> Result<()> {
let repaired = self.repair_records(pkgs, opts)?;
let remaining = pkgs
.iter()
.filter(|request| request.version.is_none() || !repaired.contains(request))
.cloned()
.collect::<Vec<_>>();
let (tapped, core) = self.split_tapped(&remaining);
if !core.is_empty() {
let core = core
.into_iter()
.map(normalize_core_request)
.collect::<Vec<_>>();
self.install_via_pour(&core, opts).await?;
}
if !tapped.is_empty() {
let tapped = tapped.into_iter().cloned().collect::<Vec<_>>();
self.install_via_pour(&tapped, opts).await?;
}
Ok(())
}
}
fn is_tapped_formula(name: &str) -> bool {
crate::system::brew_tap_name(name).is_some()
}
fn tapped_formula_name(name: &str) -> &str {
name.rsplit('/').next().unwrap_or(name)
}
fn core_formula_name(name: &str) -> &str {
match split_formula_name(name) {
Some(("homebrew", "core", formula)) => formula,
_ => name,
}
}
fn request_formula_name(name: &str) -> &str {
if is_tapped_formula(name) {
tapped_formula_name(name)
} else {
core_formula_name(name)
}
}
fn linked_package_state(
requested: &Option<String>,
linked: Option<(String, bool)>,
) -> PackageState {
match linked {
Some((version, _))
if requested.as_ref().is_some_and(|requested| {
version != *requested && !version.starts_with(&format!("{requested}_"))
}) =>
{
PackageState::VersionMismatch { installed: version }
}
Some((version, true)) => PackageState::NeedsRepair { installed: version },
Some((version, false)) => PackageState::Installed { version },
None => PackageState::Missing,
}
}
fn normalize_core_request(req: &PackageRequest) -> PackageRequest {
let mut req = req.clone();
req.name = core_formula_name(&req.name).to_string();
req
}
fn split_formula_name(name: &str) -> Option<(&str, &str, &str)> {
let mut parts = name.split('/');
let owner = parts.next()?;
let tap = parts.next()?;
let formula = parts.next()?;
if parts.next().is_some() || owner.is_empty() || tap.is_empty() || formula.is_empty() {
None
} else {
Some((owner, tap, formula))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tapped_formula_detection() {
assert!(!is_tapped_formula("jq"));
assert!(!is_tapped_formula("postgresql@17"));
assert!(!is_tapped_formula("homebrew/core/jq"));
assert!(is_tapped_formula("railwaycat/emacsmacport/emacs-mac"));
assert_eq!(core_formula_name("homebrew/core/jq"), "jq");
assert_eq!(core_formula_name("jq"), "jq");
assert_eq!(
tapped_formula_name("railwaycat/emacsmacport/emacs-mac"),
"emacs-mac"
);
}
#[test]
fn version_mismatch_takes_precedence_over_record_repair() {
assert_eq!(
linked_package_state(&Some("2.0".to_string()), Some(("1.0".to_string(), true))),
PackageState::VersionMismatch {
installed: "1.0".to_string()
}
);
assert_eq!(
linked_package_state(&Some("1.0".to_string()), Some(("1.0_1".to_string(), true))),
PackageState::NeedsRepair {
installed: "1.0_1".to_string()
}
);
}
}