use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use crate::error::{Result, ToolchainError};
use crate::formula::{self, Formula};
use crate::manifest::{KegManifest, KegSource};
#[derive(Debug, Clone)]
pub struct SourceSpec {
pub version: String,
pub tarball_url: String,
pub sha256: String,
pub dependencies: Vec<String>,
pub build_dependencies: Vec<String>,
pub macos_provided: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BuildSystem {
Autotools,
CMake,
CMakeBootstrap,
MakePrefix,
}
fn arch_token() -> &'static str {
match std::env::consts::ARCH {
"aarch64" => "arm64",
other => other,
}
}
pub async fn resolve_source_spec(formula: &str) -> Result<SourceSpec> {
let info: Formula = formula::fetch_formula(formula).await?;
let version = info
.stable_version()
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("formula {formula} has no stable version"),
})?
.to_string();
let tarball_url = info
.stable_url()
.ok_or_else(|| ToolchainError::RegistryError {
message: format!("formula {formula} has no stable source URL"),
})?
.to_string();
Ok(SourceSpec {
version,
tarball_url,
sha256: info.stable_checksum().unwrap_or_default(),
dependencies: info.dependencies.clone(),
build_dependencies: info.build_dependencies.clone(),
macos_provided: info.macos_provided(),
})
}
pub async fn ensure_from_source(
formula: &str,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<PathBuf> {
let mut spec = resolve_source_spec(formula).await?;
if let Some(locked) = lockfile.and_then(|l| {
use crate::ToolchainLockfileExt;
l.lookup(formula, "macos", arch_token())
}) {
spec.version = locked.version.clone();
spec.tarball_url = locked.url.clone();
spec.sha256 = locked.sha256.clone();
}
let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
let ready_marker = keg.join(".ready");
if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
return Ok(keg);
}
match try_generic_source_build(formula, &spec, &keg, cache_dir, lockfile).await {
Ok(()) => Ok(keg),
Err(e) => {
warn!(
formula,
error = %e,
"generic source build failed; falling back to brew-emulate at the keg prefix"
);
let _ = tokio::fs::remove_dir_all(&keg).await;
crate::brew_emulate::ensure_via_brew(formula, &spec, cache_dir).await
}
}
}
async fn try_generic_source_build(
formula: &str,
spec: &SourceSpec,
keg: &Path,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<()> {
let _ = tokio::fs::remove_dir_all(keg).await;
tokio::fs::create_dir_all(keg).await?;
let scratch = keg.join(".build");
tokio::fs::create_dir_all(&scratch).await?;
let (build_env, resolved_build_deps) =
resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
let src_dir = download_and_extract(formula, spec, &scratch).await?;
let system = detect_build_system(&src_dir).await?;
info!(formula, ?system, "detected build system");
run_build(formula, &src_dir, keg, system, &build_env).await?;
let manifest = build_manifest(formula, spec, keg, resolved_build_deps).await;
manifest.write_to_keg(keg).await?;
if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
warn!(error = %e, "failed to clean source scratch dir (non-fatal)");
}
tokio::fs::write(keg.join(".ready"), b"").await?;
Ok(())
}
async fn build_manifest(
formula: &str,
spec: &SourceSpec,
keg: &Path,
build_deps: Vec<String>,
) -> KegManifest {
let mut path_dirs = Vec::new();
let bin = keg.join("bin");
if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
path_dirs.push(bin.display().to_string());
}
let mut env: HashMap<String, String> = HashMap::new();
let git_exec = keg.join("libexec/git-core");
if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
}
KegManifest {
tool: formula.to_string(),
version: spec.version.clone(),
arch: arch_token().to_string(),
platform: "macos".to_string(),
path_dirs,
env,
source: KegSource::SourceBuild {
url: spec.tarball_url.clone(),
sha256: spec.sha256.clone(),
},
build_deps,
provisioned_at: chrono::Utc::now().to_rfc3339(),
}
}
#[derive(Debug, Default, Clone)]
struct BuildEnv {
path_prefix: Vec<String>,
cppflags: Vec<String>,
ldflags: Vec<String>,
pkg_config_path: Vec<String>,
}
async fn resolve_dependencies(
formula: &str,
spec: &SourceSpec,
cache_dir: &Path,
lockfile: Option<&crate::ToolchainLockfile>,
) -> Result<(BuildEnv, Vec<String>)> {
let mut env = BuildEnv::default();
let mut resolved_build_deps = Vec::new();
let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
for dep in &spec.build_dependencies {
if is_macos_provided(dep) {
continue;
}
let keg = Box::pin(crate::ensure_macos_keg(dep, cache_dir, lockfile)).await?;
let manifest = KegManifest::load_or_synthesize(&keg).await?;
env.path_prefix.extend(manifest.path_dirs.clone());
add_dep_link_flags(&mut env, &keg);
resolved_build_deps.push(dep.clone());
info!(formula, dep, keg = %keg.display(), "resolved build dependency keg");
}
for dep in &spec.dependencies {
if is_macos_provided(dep) {
continue;
}
match Box::pin(crate::ensure_macos_keg(dep, cache_dir, lockfile)).await {
Ok(keg) => {
if let Ok(manifest) = KegManifest::load_or_synthesize(&keg).await {
env.path_prefix.extend(manifest.path_dirs.clone());
}
add_dep_link_flags(&mut env, &keg);
info!(formula, dep, keg = %keg.display(), "resolved runtime dependency keg");
}
Err(e) => warn!(
formula, dep, error = %e,
"runtime dependency keg unavailable; continuing without it"
),
}
}
Ok((env, resolved_build_deps))
}
fn add_dep_link_flags(env: &mut BuildEnv, keg: &Path) {
let include = keg.join("include");
let lib = keg.join("lib");
if include.is_dir() {
env.cppflags.push(format!("-I{}", include.display()));
}
if lib.is_dir() {
env.ldflags.push(format!("-L{}", lib.display()));
env.ldflags.push(format!("-Wl,-rpath,{}", lib.display()));
let pc = lib.join("pkgconfig");
if pc.is_dir() {
env.pkg_config_path.push(pc.display().to_string());
}
}
}
async fn download_and_extract(formula: &str, spec: &SourceSpec, scratch: &Path) -> Result<PathBuf> {
let tar_name = spec
.tarball_url
.rsplit('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or("source.tar");
let tar_path = scratch.join(tar_name);
info!(url = %spec.tarball_url, "downloading {formula} source tarball");
let expected = (!spec.sha256.is_empty()).then_some(spec.sha256.as_str());
crate::package_index::download_verified(&spec.tarball_url, &tar_path, expected).await?;
let src_dir = scratch.join("src");
let _ = tokio::fs::remove_dir_all(&src_dir).await;
tokio::fs::create_dir_all(&src_dir).await?;
let untar = tokio::process::Command::new("tar")
.arg("xf")
.arg(&tar_path)
.args(["--strip-components", "1", "-C"])
.arg(&src_dir)
.output()
.await?;
if !untar.status.success() {
return Err(ToolchainError::RegistryError {
message: format!(
"failed to extract {formula} source: {}",
String::from_utf8_lossy(&untar.stderr)
),
});
}
Ok(src_dir)
}
async fn detect_build_system(src_dir: &Path) -> Result<BuildSystem> {
let exists = |rel: &str| {
let p = src_dir.join(rel);
async move { tokio::fs::try_exists(&p).await.unwrap_or(false) }
};
let has_bootstrap = exists("bootstrap").await || exists("bootstrap.sh").await;
let has_cmakelists = exists("CMakeLists.txt").await;
if has_bootstrap && has_cmakelists {
Ok(BuildSystem::CMakeBootstrap)
} else if exists("configure").await {
Ok(BuildSystem::Autotools)
} else if exists("Makefile").await || exists("GNUmakefile").await {
Ok(BuildSystem::MakePrefix)
} else if has_cmakelists {
Ok(BuildSystem::CMake)
} else if exists("configure.ac").await
|| exists("configure.in").await
|| exists("autogen.sh").await
|| has_bootstrap
{
Ok(BuildSystem::Autotools)
} else {
Err(ToolchainError::RegistryError {
message: format!(
"could not detect a build system \
(configure/CMakeLists.txt/Makefile/bootstrap) in {}",
src_dir.display()
),
})
}
}
#[allow(clippy::too_many_lines)]
async fn run_build(
formula: &str,
src_dir: &Path,
keg: &Path,
system: BuildSystem,
build_env: &BuildEnv,
) -> Result<()> {
let jobs = std::thread::available_parallelism()
.map_or(4, std::num::NonZero::get)
.to_string();
let keg_str = keg.display().to_string();
match system {
BuildSystem::MakePrefix => {
let mut cmd = tokio::process::Command::new("make");
cmd.current_dir(src_dir)
.arg(format!("-j{jobs}"))
.arg(format!("prefix={keg_str}"))
.arg("install");
run_cmd(formula, "make install", &mut cmd, build_env).await?;
}
BuildSystem::CMakeBootstrap => {
run_cmd(
formula,
"bootstrap",
tokio::process::Command::new("./bootstrap")
.current_dir(src_dir)
.arg(format!("--prefix={keg_str}"))
.arg(format!("--parallel={jobs}")),
build_env,
)
.await?;
run_cmd(
formula,
"make",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg(format!("-j{jobs}")),
build_env,
)
.await?;
run_cmd(
formula,
"make install",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg("install"),
build_env,
)
.await?;
}
BuildSystem::Autotools => {
if !src_dir.join("configure").is_file() {
let autogen = src_dir.join("autogen.sh");
if autogen.is_file() {
run_cmd(
formula,
"autogen.sh",
tokio::process::Command::new("sh")
.current_dir(src_dir)
.arg("autogen.sh"),
build_env,
)
.await?;
} else {
run_cmd(
formula,
"autoreconf",
tokio::process::Command::new("autoreconf")
.current_dir(src_dir)
.arg("-fi"),
build_env,
)
.await?;
}
}
let mut configure = tokio::process::Command::new("./configure");
configure
.current_dir(src_dir)
.arg(format!("--prefix={keg_str}"));
run_cmd(formula, "configure", &mut configure, build_env).await?;
let mut make = tokio::process::Command::new("make");
make.current_dir(src_dir).arg(format!("-j{jobs}"));
run_cmd(formula, "make", &mut make, build_env).await?;
run_cmd(
formula,
"make install",
tokio::process::Command::new("make")
.current_dir(src_dir)
.arg("install"),
build_env,
)
.await?;
}
BuildSystem::CMake => {
let build_dir = src_dir.join("_zl_build");
let mut configure = tokio::process::Command::new("cmake");
configure
.current_dir(src_dir)
.arg("-S")
.arg(".")
.arg("-B")
.arg(&build_dir)
.arg(format!("-DCMAKE_INSTALL_PREFIX={keg_str}"))
.arg("-DCMAKE_BUILD_TYPE=Release");
run_cmd(formula, "cmake configure", &mut configure, build_env).await?;
run_cmd(
formula,
"cmake build",
tokio::process::Command::new("cmake")
.current_dir(src_dir)
.arg("--build")
.arg(&build_dir)
.arg("-j")
.arg(&jobs),
build_env,
)
.await?;
run_cmd(
formula,
"cmake install",
tokio::process::Command::new("cmake")
.current_dir(src_dir)
.arg("--install")
.arg(&build_dir),
build_env,
)
.await?;
}
}
Ok(())
}
async fn run_cmd(
formula: &str,
step: &str,
cmd: &mut tokio::process::Command,
env: &BuildEnv,
) -> Result<()> {
let host_path = std::env::var("PATH").unwrap_or_default();
let system_path = "/usr/bin:/bin:/usr/sbin:/sbin";
let mut path_parts: Vec<String> = env.path_prefix.clone();
if !host_path.is_empty() {
path_parts.push(host_path);
}
path_parts.push(system_path.to_string());
cmd.env("PATH", path_parts.join(":"));
if !env.cppflags.is_empty() {
cmd.env("CPPFLAGS", env.cppflags.join(" "));
}
if !env.ldflags.is_empty() {
cmd.env("LDFLAGS", env.ldflags.join(" "));
}
if !env.pkg_config_path.is_empty() {
cmd.env("PKG_CONFIG_PATH", env.pkg_config_path.join(":"));
}
info!(formula, step, "running source build step");
let out = cmd.output().await?;
if !out.status.success() {
let tail = String::from_utf8_lossy(&out.stderr)
.lines()
.rev()
.take(25)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n");
return Err(ToolchainError::RegistryError {
message: format!("{formula} `{step}` failed:\n{tail}"),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn detect_autotools_from_configure() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("configure"), b"#!/bin/sh\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::Autotools
);
}
#[tokio::test]
async fn detect_cmake_from_cmakelists() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(x)\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::CMake
);
}
#[tokio::test]
async fn detect_make_from_bare_makefile() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::MakePrefix
);
}
#[tokio::test]
async fn detect_cmake_bootstrap_for_self_host() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("bootstrap"), b"#!/bin/sh\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(cmake)\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::CMakeBootstrap
);
}
#[tokio::test]
async fn detect_autotools_from_configure_ac_only() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([x],[1])\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::Autotools
);
}
#[tokio::test]
async fn detect_prefers_ready_makefile_over_configure_ac() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
.await
.unwrap();
tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([git],[1])\n")
.await
.unwrap();
assert_eq!(
detect_build_system(tmp.path()).await.unwrap(),
BuildSystem::MakePrefix
);
}
#[tokio::test]
async fn detect_fails_on_unknown_tree() {
let tmp = tempfile::tempdir().unwrap();
tokio::fs::write(tmp.path().join("README"), b"hi\n")
.await
.unwrap();
assert!(detect_build_system(tmp.path()).await.is_err());
}
#[test]
fn dep_link_flags_use_absolute_keg_paths() {
let tmp = tempfile::tempdir().unwrap();
let keg = tmp.path();
std::fs::create_dir_all(keg.join("include")).unwrap();
std::fs::create_dir_all(keg.join("lib/pkgconfig")).unwrap();
let mut env = BuildEnv::default();
add_dep_link_flags(&mut env, keg);
assert!(env.cppflags.iter().any(|f| f.contains("/include")));
assert!(env.ldflags.iter().any(|f| f.starts_with("-L")));
assert!(env
.ldflags
.iter()
.any(|f| f.contains("-Wl,-rpath,") && !f.contains("@@HOMEBREW")));
assert!(env.pkg_config_path.iter().any(|p| p.contains("pkgconfig")));
}
#[tokio::test]
async fn macos_provided_deps_are_skipped_offline() {
let tmp = tempfile::tempdir().unwrap();
let spec = SourceSpec {
version: "1.0".to_string(),
tarball_url: "https://example/x.tar.gz".to_string(),
sha256: String::new(),
dependencies: vec!["curl".to_string(), "zlib".to_string()],
build_dependencies: vec!["expat".to_string()],
macos_provided: vec!["curl".to_string(), "zlib".to_string(), "expat".to_string()],
};
let (env, build_deps) = resolve_dependencies("demo", &spec, tmp.path(), None)
.await
.expect("all-macos-provided deps resolve offline");
assert!(build_deps.is_empty(), "no keg deps to resolve");
assert!(env.path_prefix.is_empty());
assert!(env.cppflags.is_empty());
assert!(env.ldflags.is_empty());
assert!(env.pkg_config_path.is_empty());
}
#[tokio::test]
async fn manifest_env_is_layout_derived_not_name_derived() {
let spec = SourceSpec {
version: "2.55.0".to_string(),
tarball_url: "https://example/git.tar.xz".to_string(),
sha256: String::new(),
dependencies: vec![],
build_dependencies: vec![],
macos_provided: vec![],
};
let with_dir = tempfile::tempdir().unwrap();
tokio::fs::create_dir_all(with_dir.path().join("libexec/git-core"))
.await
.unwrap();
let m = build_manifest("git", &spec, with_dir.path(), vec![]).await;
assert_eq!(
m.env.get("GIT_EXEC_PATH"),
Some(
&with_dir
.path()
.join("libexec/git-core")
.display()
.to_string()
)
);
let without_dir = tempfile::tempdir().unwrap();
let m2 = build_manifest("git", &spec, without_dir.path(), vec![]).await;
assert!(!m2.env.contains_key("GIT_EXEC_PATH"));
}
}