Skip to main content

zlayer_toolchain/
source_build.rs

1//! Build runtime toolchains **from source** into a self-contained, absolute
2//! cache keg — "our apt-get" for the macOS sandbox, which has no package
3//! manager.
4//!
5//! # Why source-build instead of a Homebrew bottle
6//!
7//! Relocating a Homebrew *bottle* (rewriting its baked `@@HOMEBREW_PREFIX@@`
8//! install-name placeholders) is a dead end: the rewrite is length-preserving
9//! and silently skips placeholders shorter than the cache prefix, so the keg's
10//! binary keeps `@@HOMEBREW_PREFIX@@/...` `LC_LOAD_DYLIB` paths. Under a darwin
11//! Seatbelt container those paths don't exist and macOS strips `DYLD_*` from
12//! the signed binary, so dyld aborts (`Symbol not found … Abort trap: 6`).
13//!
14//! Building from source at an absolute keg prefix sidesteps both failure modes:
15//! every `LC_LOAD_DYLIB` is an absolute path to a macOS system library
16//! (`/usr/lib/...`) or an absolute sibling-keg path — never `@@HOMEBREW@@` — and
17//! the compiled `sysconfdir`/`prefix` live inside the keg, so the tool reads its
18//! own config instead of `/etc/*` (which EPERMs under the deny-default profile).
19//!
20//! # A fully generic, data-driven build
21//!
22//! There is **no per-formula recipe table**. Everything the build needs is
23//! derived from the Homebrew formula JSON we already parse plus the extracted
24//! source tree:
25//!
26//! - **Dependencies come from the formula's data.** Anything in
27//!   `uses_from_macos` is provided by macOS itself (the Seatbelt profile already
28//!   grants `/usr/lib` + `/usr/include` via the Command Line Tools) so it is
29//!   skipped. Every *other* `dependency` / `build_dependency` is resolved
30//!   **recursively** as a sibling keg via [`crate::ensure_macos_keg`] and wired
31//!   onto the build with *absolute* keg paths — build tools land on `PATH`,
32//!   libraries contribute `-I<keg>/include` / `-L<keg>/lib` /
33//!   `-Wl,-rpath,<keg>/lib` / `PKG_CONFIG_PATH=<keg>/lib/pkgconfig`. So git's
34//!   `gettext`/`pcre2` and jq's `oniguruma` become resolved kegs automatically —
35//!   no `NO_GETTEXT`, no `--with-oniguruma=builtin`, no hardcoded skip lists.
36//! - **The build system is autodetected** from the extracted tree (a generated
37//!   `configure`, a `CMake` self-host `bootstrap`, a `CMakeLists.txt`, a bare
38//!   `Makefile`, or an autotools project shipped as `configure.ac`).
39//! - **Irreducible env is derived from the install layout, not the name.** A keg
40//!   that installed `<keg>/libexec/git-core` gets `GIT_EXEC_PATH` pointed there —
41//!   true for any git-exec-helper tool, asserted by layout, never by `== "git"`.
42//!
43//! If a formula's generic build fails (a custom `install do` / patches the
44//! generic build can't reproduce), [`ensure_from_source`] falls through to
45//! [`crate::brew_emulate`], which runs the formula's *real* Homebrew recipe at
46//! the keg prefix — so genuinely-custom formulae still work with **zero**
47//! hardcoding.
48
49use std::collections::HashMap;
50use std::path::{Path, PathBuf};
51
52use tracing::{info, warn};
53
54use crate::error::{Result, ToolchainError};
55use crate::formula::{self, Formula};
56use crate::manifest::{KegManifest, KegSource};
57
58/// Resolved source-build coordinates for a tool.
59#[derive(Debug, Clone)]
60pub struct SourceSpec {
61    /// Stable version string (e.g. `2.55.0`).
62    pub version: String,
63    /// URL of the source tarball.
64    pub tarball_url: String,
65    /// The source tarball's sha256 (bare hex, `sha256:` prefix stripped). Empty
66    /// when the formula publishes no `urls.stable.checksum`.
67    pub sha256: String,
68    /// Runtime dependency formula names.
69    pub dependencies: Vec<String>,
70    /// Build-only dependency formula names.
71    pub build_dependencies: Vec<String>,
72    /// Dependency names macOS itself provides (need no keg).
73    pub macos_provided: Vec<String>,
74}
75
76/// Which build system a recipe drives, autodetected from the extracted tree.
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum BuildSystem {
79    /// `./configure --prefix=<keg> && make && make install` (a generated
80    /// `configure`, or `configure.ac`/`autogen.sh` regenerated first).
81    Autotools,
82    /// `cmake -DCMAKE_INSTALL_PREFIX=<keg> … && cmake --build && cmake --install`
83    /// using a `cmake` resolved as a build-dependency keg.
84    CMake,
85    /// A self-hosting `CMake` (`bootstrap` + `CMakeLists.txt`, i.e. cmake itself):
86    /// `./bootstrap --prefix=<keg> --parallel=N && make && make install`.
87    CMakeBootstrap,
88    /// A bare hand-written `Makefile`: `make prefix=<keg> … install`.
89    MakePrefix,
90}
91
92/// Host architecture token used in cache keys (`arm64` / `x86_64`).
93fn arch_token() -> &'static str {
94    match std::env::consts::ARCH {
95        "aarch64" => "arm64",
96        other => other,
97    }
98}
99
100/// Fetch the formula and extract its source-build coordinates.
101///
102/// # Errors
103///
104/// Returns [`ToolchainError::RegistryError`] if the formula cannot be fetched
105/// or carries no stable version / source URL.
106pub async fn resolve_source_spec(formula: &str) -> Result<SourceSpec> {
107    let info: Formula = formula::fetch_formula(formula).await?;
108
109    let version = info
110        .stable_version()
111        .ok_or_else(|| ToolchainError::RegistryError {
112            message: format!("formula {formula} has no stable version"),
113        })?
114        .to_string();
115
116    let tarball_url = info
117        .stable_url()
118        .ok_or_else(|| ToolchainError::RegistryError {
119            message: format!("formula {formula} has no stable source URL"),
120        })?
121        .to_string();
122
123    Ok(SourceSpec {
124        version,
125        tarball_url,
126        sha256: info.stable_checksum().unwrap_or_default(),
127        dependencies: info.dependencies.clone(),
128        build_dependencies: info.build_dependencies.clone(),
129        macos_provided: info.macos_provided(),
130    })
131}
132
133/// Build `formula` from source into a self-contained keg under `cache_dir`,
134/// writing a [`KegManifest`] and returning the keg path.
135///
136/// Idempotent: a `<keg>/.ready` marker (written LAST, after the manifest)
137/// short-circuits a populated keg. A cold build removes any partial keg, builds
138/// into a scratch dir, installs into the keg prefix, writes the manifest, then
139/// stamps `.ready`.
140///
141/// # Errors
142///
143/// Propagates formula-resolution, download, extraction, dependency, and build
144/// failures.
145pub async fn ensure_from_source(
146    formula: &str,
147    cache_dir: &Path,
148    lockfile: Option<&crate::ToolchainLockfile>,
149) -> Result<PathBuf> {
150    let mut spec = resolve_source_spec(formula).await?;
151
152    // A lock hit pins the exact version + source URL + digest (consume-only).
153    // The dependency graph still comes from the resolved formula.
154    if let Some(locked) = lockfile.and_then(|l| {
155        use crate::ToolchainLockfileExt;
156        l.lookup(formula, "macos", arch_token())
157    }) {
158        spec.version = locked.version.clone();
159        spec.tarball_url = locked.url.clone();
160        spec.sha256 = locked.sha256.clone();
161    }
162
163    let keg = cache_dir.join(format!("{formula}-{}-{}", spec.version, arch_token()));
164    let ready_marker = keg.join(".ready");
165
166    if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
167        return Ok(keg);
168    }
169
170    // Try the generic build first. If it can't build this formula — no build
171    // system detected (a custom `install do` with no
172    // `configure`/`CMakeLists.txt`/`Makefile`, e.g. a `cargo install` / `go
173    // build` formula), a required dependency keg won't build, or the build
174    // errors in a way the generic flags don't cover — fall back to running the
175    // formula's REAL Homebrew recipe at the keg prefix (`brew_emulate`).
176    match try_generic_source_build(formula, &spec, &keg, cache_dir, lockfile).await {
177        Ok(()) => Ok(keg),
178        Err(e) => {
179            warn!(
180                formula,
181                error = %e,
182                "generic source build failed; falling back to brew-emulate at the keg prefix"
183            );
184            // Drop the partial generic keg so the brew-emulate install starts
185            // from a clean prefix (it reuses the same `<keg>` cache key).
186            let _ = tokio::fs::remove_dir_all(&keg).await;
187            crate::brew_emulate::ensure_via_brew(formula, &spec, cache_dir).await
188        }
189    }
190}
191
192/// Attempt the generic (autotools/CMake/Makefile) source build into `keg`.
193///
194/// On success the keg is fully populated: manifest written and `.ready` stamped.
195/// On any failure (detection or build) the caller falls back to
196/// [`crate::brew_emulate`]. Kept as a separate fallible step so the fallback
197/// decision is a single `match` at the call site.
198async fn try_generic_source_build(
199    formula: &str,
200    spec: &SourceSpec,
201    keg: &Path,
202    cache_dir: &Path,
203    lockfile: Option<&crate::ToolchainLockfile>,
204) -> Result<()> {
205    // Fresh build. Remove any partial keg so a crashed prior attempt can't leave
206    // a half-installed prefix that confuses the installer.
207    let _ = tokio::fs::remove_dir_all(keg).await;
208    tokio::fs::create_dir_all(keg).await?;
209
210    let scratch = keg.join(".build");
211    tokio::fs::create_dir_all(&scratch).await?;
212
213    // 1. Resolve dependencies as sibling kegs and assemble the build env. macOS-
214    //    provided deps (`uses_from_macos`) are skipped; everything else is built
215    //    recursively as a keg with absolute paths.
216    let (build_env, resolved_build_deps) =
217        resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
218
219    // 2. Download + extract the source tarball into `<scratch>/src`.
220    let src_dir = download_and_extract(formula, spec, &scratch).await?;
221
222    // 3. Autodetect the build system and run it into the keg prefix.
223    let system = detect_build_system(&src_dir).await?;
224    info!(formula, ?system, "detected build system");
225    run_build(formula, &src_dir, keg, system, &build_env).await?;
226
227    // 4. Write the manifest, clean scratch, then stamp `.ready` LAST.
228    let manifest = build_manifest(formula, spec, keg, resolved_build_deps).await;
229    manifest.write_to_keg(keg).await?;
230
231    if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
232        warn!(error = %e, "failed to clean source scratch dir (non-fatal)");
233    }
234    tokio::fs::write(keg.join(".ready"), b"").await?;
235
236    Ok(())
237}
238
239/// Build the [`KegManifest`] for a freshly-installed source keg, deriving any
240/// irreducible runtime env from the **installed layout** (never the formula
241/// name): a keg that produced `<keg>/libexec/git-core` gets `GIT_EXEC_PATH`
242/// pointed there, so git discovers its exec-helpers out of the keg.
243async fn build_manifest(
244    formula: &str,
245    spec: &SourceSpec,
246    keg: &Path,
247    build_deps: Vec<String>,
248) -> KegManifest {
249    let mut path_dirs = Vec::new();
250    let bin = keg.join("bin");
251    if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
252        path_dirs.push(bin.display().to_string());
253    }
254
255    let mut env: HashMap<String, String> = HashMap::new();
256    // Layout-derived, not name-derived: any tool that installs git's exec-helper
257    // dir needs `GIT_EXEC_PATH` to find them out of the keg rather than the host.
258    let git_exec = keg.join("libexec/git-core");
259    if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
260        env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
261    }
262
263    KegManifest {
264        tool: formula.to_string(),
265        version: spec.version.clone(),
266        arch: arch_token().to_string(),
267        platform: "macos".to_string(),
268        path_dirs,
269        env,
270        source: KegSource::SourceBuild {
271            url: spec.tarball_url.clone(),
272            sha256: spec.sha256.clone(),
273        },
274        build_deps,
275        provisioned_at: chrono::Utc::now().to_rfc3339(),
276    }
277}
278
279/// Environment accumulated for a source build: `PATH` plus linker/include/
280/// pkg-config flags pointing at resolved dependency kegs.
281#[derive(Debug, Default, Clone)]
282struct BuildEnv {
283    path_prefix: Vec<String>,
284    cppflags: Vec<String>,
285    ldflags: Vec<String>,
286    pkg_config_path: Vec<String>,
287}
288
289/// Resolve a formula's build + runtime dependencies into sibling kegs and build
290/// the [`BuildEnv`], **purely from the formula's data**:
291///
292/// - Deps macOS itself provides (`uses_from_macos`) are skipped — the Command
293///   Line Tools already expose them under `/usr/lib` + `/usr/include`, which the
294///   Seatbelt profile grants.
295/// - Every other build dependency is **required** (its absence fails the build,
296///   routing the formula to brew-emulate) and its `bin` dirs go on `PATH`.
297/// - Every other runtime dependency is best-effort (a missing optional library
298///   should not abort) and is wired via `-I`/`-L`/`-rpath`/`PKG_CONFIG_PATH`.
299///
300/// Resolution is **recursive**: [`crate::ensure_macos_keg`] re-enters the whole
301/// provisioning pipeline for each dep (prebuilt-lang fetch or another source
302/// build), so an arbitrarily deep dependency graph is materialized as kegs.
303async fn resolve_dependencies(
304    formula: &str,
305    spec: &SourceSpec,
306    cache_dir: &Path,
307    lockfile: Option<&crate::ToolchainLockfile>,
308) -> Result<(BuildEnv, Vec<String>)> {
309    let mut env = BuildEnv::default();
310    let mut resolved_build_deps = Vec::new();
311
312    // A dependency provided by macOS itself needs no keg.
313    let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
314
315    // Build dependencies — required; their bin dirs go on PATH.
316    for dep in &spec.build_dependencies {
317        if is_macos_provided(dep) {
318            continue;
319        }
320        let keg = Box::pin(crate::ensure_macos_keg(dep, cache_dir, lockfile)).await?;
321        let manifest = KegManifest::load_or_synthesize(&keg).await?;
322        env.path_prefix.extend(manifest.path_dirs.clone());
323        add_dep_link_flags(&mut env, &keg);
324        resolved_build_deps.push(dep.clone());
325        info!(formula, dep, keg = %keg.display(), "resolved build dependency keg");
326    }
327
328    // Runtime dependencies — best effort; wire link/include/pkg-config flags so
329    // the binary's load commands reference the absolute dependency-keg path.
330    for dep in &spec.dependencies {
331        if is_macos_provided(dep) {
332            continue;
333        }
334        match Box::pin(crate::ensure_macos_keg(dep, cache_dir, lockfile)).await {
335            Ok(keg) => {
336                if let Ok(manifest) = KegManifest::load_or_synthesize(&keg).await {
337                    env.path_prefix.extend(manifest.path_dirs.clone());
338                }
339                add_dep_link_flags(&mut env, &keg);
340                info!(formula, dep, keg = %keg.display(), "resolved runtime dependency keg");
341            }
342            Err(e) => warn!(
343                formula, dep, error = %e,
344                "runtime dependency keg unavailable; continuing without it"
345            ),
346        }
347    }
348
349    Ok((env, resolved_build_deps))
350}
351
352/// Add `-I`/`-L`/`-rpath`/pkg-config entries for a dependency keg so the build
353/// links against its absolute path (never `@@HOMEBREW@@`).
354fn add_dep_link_flags(env: &mut BuildEnv, keg: &Path) {
355    let include = keg.join("include");
356    let lib = keg.join("lib");
357    if include.is_dir() {
358        env.cppflags.push(format!("-I{}", include.display()));
359    }
360    if lib.is_dir() {
361        env.ldflags.push(format!("-L{}", lib.display()));
362        env.ldflags.push(format!("-Wl,-rpath,{}", lib.display()));
363        let pc = lib.join("pkgconfig");
364        if pc.is_dir() {
365            env.pkg_config_path.push(pc.display().to_string());
366        }
367    }
368}
369
370/// Download the source tarball and extract it (stripping the top-level dir) into
371/// `<scratch>/src`. macOS `tar` (libarchive) auto-detects the compression, so a
372/// single `tar xf` handles `.tar.xz` / `.tar.gz` / `.tgz` / `.tar.bz2`.
373async fn download_and_extract(formula: &str, spec: &SourceSpec, scratch: &Path) -> Result<PathBuf> {
374    let tar_name = spec
375        .tarball_url
376        .rsplit('/')
377        .next()
378        .filter(|s| !s.is_empty())
379        .unwrap_or("source.tar");
380    let tar_path = scratch.join(tar_name);
381    info!(url = %spec.tarball_url, "downloading {formula} source tarball");
382    // Stream + verify against the formula's `urls.stable.checksum` when present;
383    // otherwise the digest is computed (trust-on-first-download) and recorded in
384    // the manifest. A mismatch deletes the partial and aborts the build.
385    let expected = (!spec.sha256.is_empty()).then_some(spec.sha256.as_str());
386    crate::package_index::download_verified(&spec.tarball_url, &tar_path, expected).await?;
387
388    let src_dir = scratch.join("src");
389    let _ = tokio::fs::remove_dir_all(&src_dir).await;
390    tokio::fs::create_dir_all(&src_dir).await?;
391    let untar = tokio::process::Command::new("tar")
392        .arg("xf")
393        .arg(&tar_path)
394        .args(["--strip-components", "1", "-C"])
395        .arg(&src_dir)
396        .output()
397        .await?;
398    if !untar.status.success() {
399        return Err(ToolchainError::RegistryError {
400            message: format!(
401                "failed to extract {formula} source: {}",
402                String::from_utf8_lossy(&untar.stderr)
403            ),
404        });
405    }
406    Ok(src_dir)
407}
408
409/// Detect the build system of an extracted source tree, preferring the path that
410/// needs the least extra tooling and is canonical for the project shape:
411///
412/// 1. `bootstrap` (+ `CMakeLists.txt`) → a self-hosting `CMake` (cmake itself).
413/// 2. a generated `configure` → autotools (ready to run).
414/// 3. a hand-written top-level `Makefile`/`GNUmakefile` → make-prefix (git).
415/// 4. `CMakeLists.txt` → `CMake` (built with a `cmake` keg).
416/// 5. `configure.ac`/`configure.in`/`autogen.sh`/`bootstrap.sh` → autotools
417///    (regenerate `configure` first).
418///
419/// # Errors
420///
421/// Returns [`ToolchainError::RegistryError`] if no recognised build system is
422/// present.
423async fn detect_build_system(src_dir: &Path) -> Result<BuildSystem> {
424    let exists = |rel: &str| {
425        let p = src_dir.join(rel);
426        async move { tokio::fs::try_exists(&p).await.unwrap_or(false) }
427    };
428
429    let has_bootstrap = exists("bootstrap").await || exists("bootstrap.sh").await;
430    let has_cmakelists = exists("CMakeLists.txt").await;
431
432    if has_bootstrap && has_cmakelists {
433        // Self-hosting CMake (e.g. cmake's own source): its `bootstrap` builds a
434        // minimal cmake then generates the real build — can't use a cmake keg to
435        // build cmake. The bootstrap wrapper bakes `--prefix` into the build.
436        Ok(BuildSystem::CMakeBootstrap)
437    } else if exists("configure").await {
438        Ok(BuildSystem::Autotools)
439    } else if exists("Makefile").await || exists("GNUmakefile").await {
440        // A ready top-level Makefile (not Makefile.in) is a hand-written build
441        // (git, simple C tools): `make prefix=<keg> install`.
442        Ok(BuildSystem::MakePrefix)
443    } else if has_cmakelists {
444        Ok(BuildSystem::CMake)
445    } else if exists("configure.ac").await
446        || exists("configure.in").await
447        || exists("autogen.sh").await
448        || has_bootstrap
449    {
450        // Autotools project shipped without a generated `configure`. Generate it
451        // (needs autoconf/automake/libtool on PATH from build-dep kegs).
452        Ok(BuildSystem::Autotools)
453    } else {
454        Err(ToolchainError::RegistryError {
455            message: format!(
456                "could not detect a build system \
457                 (configure/CMakeLists.txt/Makefile/bootstrap) in {}",
458                src_dir.display()
459            ),
460        })
461    }
462}
463
464/// Run the detected build system into the keg prefix using the host CLT plus the
465/// resolved dependency kegs (no per-formula flags — every quirk is data-driven).
466#[allow(clippy::too_many_lines)]
467async fn run_build(
468    formula: &str,
469    src_dir: &Path,
470    keg: &Path,
471    system: BuildSystem,
472    build_env: &BuildEnv,
473) -> Result<()> {
474    let jobs = std::thread::available_parallelism()
475        .map_or(4, std::num::NonZero::get)
476        .to_string();
477    let keg_str = keg.display().to_string();
478
479    match system {
480        BuildSystem::MakePrefix => {
481            // Hand-written Makefile: one `make -jN prefix=<keg> install`. The
482            // Makefile bakes its own install/sysconfdir from `prefix=`.
483            let mut cmd = tokio::process::Command::new("make");
484            cmd.current_dir(src_dir)
485                .arg(format!("-j{jobs}"))
486                .arg(format!("prefix={keg_str}"))
487                .arg("install");
488            run_cmd(formula, "make install", &mut cmd, build_env).await?;
489        }
490        BuildSystem::CMakeBootstrap => {
491            // Self-hosting CMake: bootstrap (bakes the install prefix), then make.
492            run_cmd(
493                formula,
494                "bootstrap",
495                tokio::process::Command::new("./bootstrap")
496                    .current_dir(src_dir)
497                    .arg(format!("--prefix={keg_str}"))
498                    .arg(format!("--parallel={jobs}")),
499                build_env,
500            )
501            .await?;
502            run_cmd(
503                formula,
504                "make",
505                tokio::process::Command::new("make")
506                    .current_dir(src_dir)
507                    .arg(format!("-j{jobs}")),
508                build_env,
509            )
510            .await?;
511            run_cmd(
512                formula,
513                "make install",
514                tokio::process::Command::new("make")
515                    .current_dir(src_dir)
516                    .arg("install"),
517                build_env,
518            )
519            .await?;
520        }
521        BuildSystem::Autotools => {
522            // Generate `configure` if the tarball shipped only configure.ac.
523            if !src_dir.join("configure").is_file() {
524                let autogen = src_dir.join("autogen.sh");
525                if autogen.is_file() {
526                    run_cmd(
527                        formula,
528                        "autogen.sh",
529                        tokio::process::Command::new("sh")
530                            .current_dir(src_dir)
531                            .arg("autogen.sh"),
532                        build_env,
533                    )
534                    .await?;
535                } else {
536                    run_cmd(
537                        formula,
538                        "autoreconf",
539                        tokio::process::Command::new("autoreconf")
540                            .current_dir(src_dir)
541                            .arg("-fi"),
542                        build_env,
543                    )
544                    .await?;
545                }
546            }
547
548            let mut configure = tokio::process::Command::new("./configure");
549            configure
550                .current_dir(src_dir)
551                .arg(format!("--prefix={keg_str}"));
552            run_cmd(formula, "configure", &mut configure, build_env).await?;
553
554            let mut make = tokio::process::Command::new("make");
555            make.current_dir(src_dir).arg(format!("-j{jobs}"));
556            run_cmd(formula, "make", &mut make, build_env).await?;
557
558            run_cmd(
559                formula,
560                "make install",
561                tokio::process::Command::new("make")
562                    .current_dir(src_dir)
563                    .arg("install"),
564                build_env,
565            )
566            .await?;
567        }
568        BuildSystem::CMake => {
569            let build_dir = src_dir.join("_zl_build");
570            let mut configure = tokio::process::Command::new("cmake");
571            configure
572                .current_dir(src_dir)
573                .arg("-S")
574                .arg(".")
575                .arg("-B")
576                .arg(&build_dir)
577                .arg(format!("-DCMAKE_INSTALL_PREFIX={keg_str}"))
578                .arg("-DCMAKE_BUILD_TYPE=Release");
579            run_cmd(formula, "cmake configure", &mut configure, build_env).await?;
580
581            run_cmd(
582                formula,
583                "cmake build",
584                tokio::process::Command::new("cmake")
585                    .current_dir(src_dir)
586                    .arg("--build")
587                    .arg(&build_dir)
588                    .arg("-j")
589                    .arg(&jobs),
590                build_env,
591            )
592            .await?;
593
594            run_cmd(
595                formula,
596                "cmake install",
597                tokio::process::Command::new("cmake")
598                    .current_dir(src_dir)
599                    .arg("--install")
600                    .arg(&build_dir),
601                build_env,
602            )
603            .await?;
604        }
605    }
606    Ok(())
607}
608
609/// Apply the [`BuildEnv`] to a command and run it, returning a build error with
610/// the tail of stderr on failure. The host environment is inherited; `PATH`,
611/// `CPPFLAGS`, `LDFLAGS` and `PKG_CONFIG_PATH` are prepended/augmented.
612async fn run_cmd(
613    formula: &str,
614    step: &str,
615    cmd: &mut tokio::process::Command,
616    env: &BuildEnv,
617) -> Result<()> {
618    // PATH: dependency keg bins first, then the host CLT/system paths.
619    let host_path = std::env::var("PATH").unwrap_or_default();
620    let system_path = "/usr/bin:/bin:/usr/sbin:/sbin";
621    let mut path_parts: Vec<String> = env.path_prefix.clone();
622    if !host_path.is_empty() {
623        path_parts.push(host_path);
624    }
625    path_parts.push(system_path.to_string());
626    cmd.env("PATH", path_parts.join(":"));
627
628    if !env.cppflags.is_empty() {
629        cmd.env("CPPFLAGS", env.cppflags.join(" "));
630    }
631    if !env.ldflags.is_empty() {
632        cmd.env("LDFLAGS", env.ldflags.join(" "));
633    }
634    if !env.pkg_config_path.is_empty() {
635        cmd.env("PKG_CONFIG_PATH", env.pkg_config_path.join(":"));
636    }
637
638    info!(formula, step, "running source build step");
639    let out = cmd.output().await?;
640    if !out.status.success() {
641        let tail = String::from_utf8_lossy(&out.stderr)
642            .lines()
643            .rev()
644            .take(25)
645            .collect::<Vec<_>>()
646            .into_iter()
647            .rev()
648            .collect::<Vec<_>>()
649            .join("\n");
650        return Err(ToolchainError::RegistryError {
651            message: format!("{formula} `{step}` failed:\n{tail}"),
652        });
653    }
654    Ok(())
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    #[tokio::test]
662    async fn detect_autotools_from_configure() {
663        let tmp = tempfile::tempdir().unwrap();
664        tokio::fs::write(tmp.path().join("configure"), b"#!/bin/sh\n")
665            .await
666            .unwrap();
667        assert_eq!(
668            detect_build_system(tmp.path()).await.unwrap(),
669            BuildSystem::Autotools
670        );
671    }
672
673    #[tokio::test]
674    async fn detect_cmake_from_cmakelists() {
675        let tmp = tempfile::tempdir().unwrap();
676        tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(x)\n")
677            .await
678            .unwrap();
679        assert_eq!(
680            detect_build_system(tmp.path()).await.unwrap(),
681            BuildSystem::CMake
682        );
683    }
684
685    #[tokio::test]
686    async fn detect_make_from_bare_makefile() {
687        let tmp = tempfile::tempdir().unwrap();
688        tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
689            .await
690            .unwrap();
691        assert_eq!(
692            detect_build_system(tmp.path()).await.unwrap(),
693            BuildSystem::MakePrefix
694        );
695    }
696
697    /// A self-hosting `CMake` (cmake itself ships `bootstrap` + `CMakeLists.txt`)
698    /// must bootstrap, NOT try to build with a cmake we don't yet have.
699    #[tokio::test]
700    async fn detect_cmake_bootstrap_for_self_host() {
701        let tmp = tempfile::tempdir().unwrap();
702        tokio::fs::write(tmp.path().join("bootstrap"), b"#!/bin/sh\n")
703            .await
704            .unwrap();
705        tokio::fs::write(tmp.path().join("CMakeLists.txt"), b"project(cmake)\n")
706            .await
707            .unwrap();
708        assert_eq!(
709            detect_build_system(tmp.path()).await.unwrap(),
710            BuildSystem::CMakeBootstrap
711        );
712    }
713
714    /// A generated `configure` wins over a sibling `Makefile.in`; and a tree with
715    /// only `configure.ac` is still autotools (regenerated first).
716    #[tokio::test]
717    async fn detect_autotools_from_configure_ac_only() {
718        let tmp = tempfile::tempdir().unwrap();
719        tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([x],[1])\n")
720            .await
721            .unwrap();
722        assert_eq!(
723            detect_build_system(tmp.path()).await.unwrap(),
724            BuildSystem::Autotools
725        );
726    }
727
728    /// A hand-written `Makefile` that also ships `configure.ac` (git's shape:
729    /// no generated `configure`) takes the ready Makefile path, not the
730    /// regenerate-with-autotools path.
731    #[tokio::test]
732    async fn detect_prefers_ready_makefile_over_configure_ac() {
733        let tmp = tempfile::tempdir().unwrap();
734        tokio::fs::write(tmp.path().join("Makefile"), b"all:\n\ttrue\n")
735            .await
736            .unwrap();
737        tokio::fs::write(tmp.path().join("configure.ac"), b"AC_INIT([git],[1])\n")
738            .await
739            .unwrap();
740        assert_eq!(
741            detect_build_system(tmp.path()).await.unwrap(),
742            BuildSystem::MakePrefix
743        );
744    }
745
746    #[tokio::test]
747    async fn detect_fails_on_unknown_tree() {
748        let tmp = tempfile::tempdir().unwrap();
749        tokio::fs::write(tmp.path().join("README"), b"hi\n")
750            .await
751            .unwrap();
752        assert!(detect_build_system(tmp.path()).await.is_err());
753    }
754
755    #[test]
756    fn dep_link_flags_use_absolute_keg_paths() {
757        let tmp = tempfile::tempdir().unwrap();
758        let keg = tmp.path();
759        std::fs::create_dir_all(keg.join("include")).unwrap();
760        std::fs::create_dir_all(keg.join("lib/pkgconfig")).unwrap();
761        let mut env = BuildEnv::default();
762        add_dep_link_flags(&mut env, keg);
763        assert!(env.cppflags.iter().any(|f| f.contains("/include")));
764        assert!(env.ldflags.iter().any(|f| f.starts_with("-L")));
765        assert!(env
766            .ldflags
767            .iter()
768            .any(|f| f.contains("-Wl,-rpath,") && !f.contains("@@HOMEBREW")));
769        assert!(env.pkg_config_path.iter().any(|p| p.contains("pkgconfig")));
770    }
771
772    /// `resolve_dependencies` skips `uses_from_macos` deps without touching the
773    /// network: a spec whose every dep is macOS-provided resolves to an empty
774    /// build env and no resolved build deps.
775    #[tokio::test]
776    async fn macos_provided_deps_are_skipped_offline() {
777        let tmp = tempfile::tempdir().unwrap();
778        let spec = SourceSpec {
779            version: "1.0".to_string(),
780            tarball_url: "https://example/x.tar.gz".to_string(),
781            sha256: String::new(),
782            dependencies: vec!["curl".to_string(), "zlib".to_string()],
783            build_dependencies: vec!["expat".to_string()],
784            macos_provided: vec!["curl".to_string(), "zlib".to_string(), "expat".to_string()],
785        };
786        let (env, build_deps) = resolve_dependencies("demo", &spec, tmp.path(), None)
787            .await
788            .expect("all-macos-provided deps resolve offline");
789        assert!(build_deps.is_empty(), "no keg deps to resolve");
790        assert!(env.path_prefix.is_empty());
791        assert!(env.cppflags.is_empty());
792        assert!(env.ldflags.is_empty());
793        assert!(env.pkg_config_path.is_empty());
794    }
795
796    /// The manifest's env is derived from the installed LAYOUT, not the formula
797    /// name: a keg with `libexec/git-core` gets `GIT_EXEC_PATH`; one without
798    /// gets none — for the same formula name either way.
799    #[tokio::test]
800    async fn manifest_env_is_layout_derived_not_name_derived() {
801        let spec = SourceSpec {
802            version: "2.55.0".to_string(),
803            tarball_url: "https://example/git.tar.xz".to_string(),
804            sha256: String::new(),
805            dependencies: vec![],
806            build_dependencies: vec![],
807            macos_provided: vec![],
808        };
809
810        // Keg WITH the git-core exec dir → GIT_EXEC_PATH present.
811        let with_dir = tempfile::tempdir().unwrap();
812        tokio::fs::create_dir_all(with_dir.path().join("libexec/git-core"))
813            .await
814            .unwrap();
815        let m = build_manifest("git", &spec, with_dir.path(), vec![]).await;
816        assert_eq!(
817            m.env.get("GIT_EXEC_PATH"),
818            Some(
819                &with_dir
820                    .path()
821                    .join("libexec/git-core")
822                    .display()
823                    .to_string()
824            )
825        );
826
827        // Same formula NAME, no git-core dir → no GIT_EXEC_PATH (layout-driven).
828        let without_dir = tempfile::tempdir().unwrap();
829        let m2 = build_manifest("git", &spec, without_dir.path(), vec![]).await;
830        assert!(!m2.env.contains_key("GIT_EXEC_PATH"));
831    }
832}