1use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39
40pub mod brew_emulate;
41pub mod error;
42pub mod formula;
43pub mod lockfile;
44pub mod manifest;
45pub mod package_index;
46pub mod prebuilt;
47pub mod source_build;
48pub mod windows;
49
50pub use error::{Result, ToolchainError};
51pub use lockfile::{LockedTool, ToolchainLockfile, ToolchainLockfileExt, LOCKFILE_NAME};
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum ToolPlatform {
63 MacOS,
65 Windows,
67}
68
69#[derive(Debug, Clone)]
76pub struct ToolchainHandle {
77 pub install_dir: PathBuf,
79 pub path_dirs: Vec<String>,
81 pub env: HashMap<String, String>,
83}
84
85fn arch_token() -> &'static str {
87 match std::env::consts::ARCH {
88 "aarch64" => "arm64",
89 other => other,
90 }
91}
92
93fn split_pkg(pkg: &str) -> (&str, &str) {
99 match pkg.split_once('@') {
100 Some((_, ver)) if !ver.is_empty() => (pkg, ver),
101 _ => (pkg, "latest"),
102 }
103}
104
105pub async fn ensure_toolchain(
123 pkg: &str,
124 platform: ToolPlatform,
125 cache_dir: &Path,
126 lockfile: Option<&ToolchainLockfile>,
127) -> Result<ToolchainHandle> {
128 match platform {
129 ToolPlatform::Windows => {
130 let keg = windows::ensure_windows_keg(pkg, cache_dir, lockfile).await?;
131 build_handle_from_keg(keg).await
132 }
133 ToolPlatform::MacOS => {
134 let keg = ensure_macos_keg(pkg, cache_dir, lockfile).await?;
135 build_handle_from_keg(keg).await
136 }
137 }
138}
139
140pub(crate) async fn ensure_macos_keg(
148 pkg: &str,
149 cache_dir: &Path,
150 lockfile: Option<&ToolchainLockfile>,
151) -> Result<PathBuf> {
152 let (formula, _version) = split_pkg(pkg);
153 if prebuilt::is_prebuilt_formula(formula) {
154 prebuilt::ensure_prebuilt(formula, cache_dir, lockfile).await
157 } else {
158 source_build::ensure_from_source(formula, cache_dir, lockfile).await
161 }
162}
163
164fn vendor_arch(arch: &str) -> &str {
167 if arch == "x86_64" {
168 "amd64"
169 } else {
170 arch
171 }
172}
173
174fn platform_token(platform: ToolPlatform) -> &'static str {
176 match platform {
177 ToolPlatform::MacOS => "macos",
178 ToolPlatform::Windows => "windows",
179 }
180}
181
182fn sanitize(tool: &str) -> String {
184 tool.chars()
185 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
186 .collect()
187}
188
189pub async fn resolve_locked_tool(
201 tool: &str,
202 platform: ToolPlatform,
203 arch: &str,
204) -> Result<LockedTool> {
205 let (formula, _version) = split_pkg(tool);
206 let (version, url, expected): (String, String, Option<String>) = match platform {
207 ToolPlatform::MacOS => {
208 if prebuilt::is_prebuilt_formula(formula) {
209 let r = prebuilt::resolve_prebuilt(formula, vendor_arch(arch)).await?;
210 (r.version, r.url, r.sha256)
211 } else {
212 let spec = source_build::resolve_source_spec(formula).await?;
213 let sha = (!spec.sha256.is_empty()).then_some(spec.sha256);
214 (spec.version, spec.tarball_url, sha)
215 }
216 }
217 ToolPlatform::Windows => windows::resolve_locked_windows(formula).await?,
218 };
219
220 let tmp = std::env::temp_dir().join(format!(
222 "zlayer-lock-{}-{arch}-{}",
223 sanitize(tool),
224 std::process::id()
225 ));
226 let sha256 = package_index::download_verified(&url, &tmp, expected.as_deref()).await?;
227 let _ = tokio::fs::remove_file(&tmp).await;
228
229 Ok(LockedTool {
230 tool: tool.to_string(),
231 platform: platform_token(platform).to_string(),
232 arch: arch.to_string(),
233 version,
234 url,
235 sha256,
236 resolved_at: chrono::Utc::now().to_rfc3339(),
237 })
238}
239
240async fn build_handle_from_keg(keg: PathBuf) -> Result<ToolchainHandle> {
243 let manifest = manifest::KegManifest::load_or_synthesize(&keg).await?;
244 Ok(ToolchainHandle {
245 install_dir: keg,
246 path_dirs: manifest.path_dirs,
247 env: manifest.env,
248 })
249}
250
251pub async fn probe_ready_toolchain(
263 pkg: &str,
264 _platform: ToolPlatform,
265 cache_dir: &Path,
266) -> Option<ToolchainHandle> {
267 let (formula, _version) = split_pkg(pkg);
270 let keg = newest_ready_keg(formula, cache_dir).await?;
271 build_handle_from_keg(keg).await.ok()
272}
273
274async fn newest_ready_keg(formula: &str, cache_dir: &Path) -> Option<PathBuf> {
277 let prefix = format!("{formula}-");
278 let arch_suffix = format!("-{}", arch_token());
279 let mut entries = tokio::fs::read_dir(cache_dir).await.ok()?;
280 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
281 while let Ok(Some(entry)) = entries.next_entry().await {
282 let name = entry.file_name();
283 let Some(name) = name.to_str() else { continue };
284 if !name.starts_with(&prefix) || !name.ends_with(&arch_suffix) {
285 continue;
286 }
287 let keg = entry.path();
288 if !tokio::fs::try_exists(keg.join(".ready"))
289 .await
290 .unwrap_or(false)
291 {
292 continue;
293 }
294 let mtime = entry
295 .metadata()
296 .await
297 .ok()
298 .and_then(|m| m.modified().ok())
299 .unwrap_or(std::time::UNIX_EPOCH);
300 if best.as_ref().is_none_or(|(t, _)| mtime >= *t) {
301 best = Some((mtime, keg));
302 }
303 }
304 best.map(|(_, keg)| keg)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::manifest::{KegManifest, KegSource};
311
312 #[test]
313 fn split_pkg_plain_defaults_to_latest() {
314 assert_eq!(split_pkg("git"), ("git", "latest"));
315 }
316
317 #[test]
318 fn split_pkg_versioned_keeps_full_formula() {
319 assert_eq!(split_pkg("openssl@3"), ("openssl@3", "3"));
320 }
321
322 #[test]
323 fn split_pkg_trailing_at_is_latest() {
324 assert_eq!(split_pkg("weird@"), ("weird@", "latest"));
325 }
326
327 #[tokio::test]
332 async fn windows_non_portable_formula_is_not_implemented() {
333 let tmp = tempfile::tempdir().unwrap();
334 let err = ensure_toolchain("cowsay", ToolPlatform::Windows, tmp.path(), None)
335 .await
336 .unwrap_err();
337 assert!(matches!(err, ToolchainError::NotImplemented(_)));
338 }
339
340 async fn seed_legacy_git_keg(cache_dir: &Path, version: &str) -> PathBuf {
344 let keg = cache_dir.join(format!("git-{version}-{}", arch_token()));
345 tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
346 tokio::fs::create_dir_all(keg.join("libexec/git-core"))
347 .await
348 .unwrap();
349 tokio::fs::create_dir_all(keg.join("etc")).await.unwrap();
350 tokio::fs::write(keg.join("etc/gitconfig"), b"")
351 .await
352 .unwrap();
353 tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
354 keg
355 }
356
357 async fn seed_keg_with_manifest(cache_dir: &Path, tool: &str, version: &str) -> PathBuf {
360 let keg = cache_dir.join(format!("{tool}-{version}-{}", arch_token()));
361 let bin = keg.join("bin");
362 tokio::fs::create_dir_all(&bin).await.unwrap();
363 let mut env = HashMap::new();
364 env.insert("FOO".to_string(), "bar".to_string());
365 let manifest = KegManifest {
366 tool: tool.to_string(),
367 version: version.to_string(),
368 arch: arch_token().to_string(),
369 platform: "macos".to_string(),
370 path_dirs: vec![bin.display().to_string()],
371 env,
372 source: KegSource::SourceBuild {
373 url: String::new(),
374 sha256: String::new(),
375 },
376 build_deps: vec![],
377 provisioned_at: "2026-06-30T00:00:00Z".to_string(),
378 };
379 manifest.write_to_keg(&keg).await.unwrap();
380 tokio::fs::write(keg.join(".ready"), b"").await.unwrap();
381 keg
382 }
383
384 #[tokio::test]
388 async fn handle_synthesized_for_legacy_git_keg_drops_dyld() {
389 let tmp = tempfile::tempdir().unwrap();
390 let keg = seed_legacy_git_keg(tmp.path(), "2.55.0").await;
391
392 let handle = build_handle_from_keg(keg.clone()).await.unwrap();
393
394 assert_eq!(handle.install_dir, keg);
395 assert_eq!(
396 handle.path_dirs,
397 vec![keg.join("bin").display().to_string()]
398 );
399 assert_eq!(
400 handle.env.get("GIT_EXEC_PATH"),
401 Some(&keg.join("libexec/git-core").display().to_string())
402 );
403 assert!(!handle.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
404 assert!(!handle.env.contains_key("GIT_CONFIG_SYSTEM"));
405 }
406
407 #[tokio::test]
410 async fn handle_reads_manifest_when_present() {
411 let tmp = tempfile::tempdir().unwrap();
412 let keg = seed_keg_with_manifest(tmp.path(), "jq", "1.8.2").await;
413
414 let handle = build_handle_from_keg(keg.clone()).await.unwrap();
415 assert_eq!(handle.install_dir, keg);
416 assert_eq!(
417 handle.path_dirs,
418 vec![keg.join("bin").display().to_string()]
419 );
420 assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
421 }
422
423 #[tokio::test]
426 async fn probe_ready_returns_handle_for_ready_keg() {
427 let tmp = tempfile::tempdir().unwrap();
428 let keg = seed_legacy_git_keg(tmp.path(), "2.55.0").await;
429
430 let handle = probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
431 .await
432 .expect("ready keg should be probed without install");
433
434 assert_eq!(handle.install_dir, keg);
435 assert_eq!(
436 handle.env.get("GIT_EXEC_PATH"),
437 Some(&keg.join("libexec/git-core").display().to_string())
438 );
439 }
440
441 #[tokio::test]
443 async fn probe_ready_is_generic_across_tools() {
444 let tmp = tempfile::tempdir().unwrap();
445 let keg = seed_keg_with_manifest(tmp.path(), "jq", "1.8.2").await;
446
447 let handle = probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
448 .await
449 .expect("ready jq keg should be probed");
450 assert_eq!(handle.install_dir, keg);
451 assert_eq!(handle.env.get("FOO"), Some(&"bar".to_string()));
452 }
453
454 #[tokio::test]
457 async fn probe_ready_returns_none_without_ready_marker() {
458 let tmp = tempfile::tempdir().unwrap();
459 let keg = tmp.path().join(format!("git-2.55.0-{}", arch_token()));
460 tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
461
462 assert!(
463 probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
464 .await
465 .is_none(),
466 "an unstamped keg must not be injected"
467 );
468 }
469
470 #[tokio::test]
472 async fn probe_ready_returns_none_for_cold_or_unsupported() {
473 let tmp = tempfile::tempdir().unwrap();
474 assert!(
475 probe_ready_toolchain("git", ToolPlatform::MacOS, tmp.path())
476 .await
477 .is_none(),
478 "cold cache should probe None"
479 );
480 assert!(
481 probe_ready_toolchain("jq", ToolPlatform::MacOS, tmp.path())
482 .await
483 .is_none(),
484 "absent tool should probe None"
485 );
486 assert!(
487 probe_ready_toolchain("git", ToolPlatform::Windows, tmp.path())
488 .await
489 .is_none(),
490 "cold cache probes None on every platform"
491 );
492 }
493
494 #[tokio::test]
501 #[ignore = "live build test; fetches + compiles git and jq from source (macOS + CLT only)"]
502 async fn ensure_git_and_jq_build_from_source_and_run() {
503 let tmp = tempfile::tempdir().unwrap();
504
505 for (tool, version_needle) in [("git", "git version"), ("jq", "jq-")] {
506 let handle = ensure_toolchain(tool, ToolPlatform::MacOS, tmp.path(), None)
507 .await
508 .unwrap_or_else(|e| panic!("{tool} toolchain should build from source: {e}"));
509
510 let bin = handle.install_dir.join("bin").join(tool);
511 assert!(
512 bin.exists(),
513 "{tool} binary should exist at <keg>/bin/{tool}"
514 );
515
516 let manifest = KegManifest::read_from_keg(&handle.install_dir)
518 .await
519 .unwrap()
520 .unwrap_or_else(|| panic!("{tool} keg must have a manifest"));
521 assert_eq!(manifest.tool, tool);
522
523 let bytes = tokio::fs::read(&bin).await.expect("read binary");
525 assert!(
526 !contains_subslice_bytes(&bytes, b"@@HOMEBREW"),
527 "source-built {tool} must contain NO @@HOMEBREW@@ references"
528 );
529
530 let mut cmd = tokio::process::Command::new(&bin);
531 for (k, v) in &handle.env {
532 cmd.env(k, v);
533 }
534 let out = cmd.arg("--version").output().await.expect("run --version");
535 assert!(out.status.success(), "{tool} --version should succeed");
536 assert!(
537 String::from_utf8_lossy(&out.stdout).contains(version_needle)
538 || String::from_utf8_lossy(&out.stderr).contains(version_needle),
539 "{tool} --version output should contain '{version_needle}'"
540 );
541 }
542 }
543
544 fn contains_subslice_bytes(haystack: &[u8], needle: &[u8]) -> bool {
546 if needle.is_empty() || haystack.len() < needle.len() {
547 return false;
548 }
549 haystack
550 .windows(needle.len())
551 .any(|window| window == needle)
552 }
553}