1use 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#[derive(Debug, Clone)]
60pub struct SourceSpec {
61 pub version: String,
63 pub tarball_url: String,
65 pub sha256: String,
68 pub dependencies: Vec<String>,
70 pub build_dependencies: Vec<String>,
72 pub macos_provided: Vec<String>,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78enum BuildSystem {
79 Autotools,
82 CMake,
85 CMakeBootstrap,
88 MakePrefix,
90}
91
92fn arch_token() -> &'static str {
94 match std::env::consts::ARCH {
95 "aarch64" => "arm64",
96 other => other,
97 }
98}
99
100pub 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
133pub 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 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 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 let _ = tokio::fs::remove_dir_all(&keg).await;
187 crate::brew_emulate::ensure_via_brew(formula, &spec, cache_dir).await
188 }
189 }
190}
191
192async 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 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 let (build_env, resolved_build_deps) =
217 resolve_dependencies(formula, spec, cache_dir, lockfile).await?;
218
219 let src_dir = download_and_extract(formula, spec, &scratch).await?;
221
222 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 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
239async 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 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#[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
289async 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 let is_macos_provided = |dep: &str| spec.macos_provided.iter().any(|d| d == dep);
314
315 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 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
352fn 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
370async 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 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
409async 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 Ok(BuildSystem::CMakeBootstrap)
437 } else if exists("configure").await {
438 Ok(BuildSystem::Autotools)
439 } else if exists("Makefile").await || exists("GNUmakefile").await {
440 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 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#[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 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 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 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
609async fn run_cmd(
613 formula: &str,
614 step: &str,
615 cmd: &mut tokio::process::Command,
616 env: &BuildEnv,
617) -> Result<()> {
618 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 #[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 #[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 #[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 #[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 #[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 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 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}