1use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use tracing::{debug, info, warn};
34
35use crate::error::{Result, ToolchainError};
36use crate::manifest::{KegManifest, KegSource};
37
38#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct PrebuiltResolution {
53 pub version: String,
55 pub url: String,
57 pub sha256: Option<String>,
59}
60
61pub(crate) async fn resolve_prebuilt(
68 formula: &str,
69 vendor_arch: &str,
70) -> Result<PrebuiltResolution> {
71 let (language, version_token) = formula_language_version(formula);
72 resolve_prebuilt_url(&language, &version_token, vendor_arch).await
73}
74
75#[must_use]
82#[allow(clippy::module_name_repetitions)]
83pub fn is_prebuilt_formula(formula: &str) -> bool {
84 let (language, _) = formula_language_version(formula);
85 matches!(
86 language.as_str(),
87 "go" | "node" | "rust" | "python" | "deno" | "bun" | "zig" | "java" | "graalvm"
88 )
89}
90
91#[allow(clippy::module_name_repetitions)]
110pub async fn ensure_prebuilt(
111 formula: &str,
112 cache_dir: &Path,
113 lockfile: Option<&crate::ToolchainLockfile>,
114) -> Result<PathBuf> {
115 use crate::ToolchainLockfileExt;
116 let (language, version_token) = formula_language_version(formula);
117
118 let (resolved_version, url, expected_sha) =
122 if let Some(locked) = lockfile.and_then(|l| l.lookup(formula, "macos", arch_token())) {
123 (
124 locked.version.clone(),
125 locked.url.clone(),
126 Some(locked.sha256.clone()),
127 )
128 } else {
129 let resolution = resolve_prebuilt_url(&language, &version_token, host_arch()).await?;
130 (resolution.version, resolution.url, resolution.sha256)
131 };
132
133 let keg = cache_dir.join(format!("{formula}-{resolved_version}-{}", arch_token()));
134 let ready_marker = keg.join(".ready");
135
136 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
137 return Ok(keg);
138 }
139
140 let _ = tokio::fs::remove_dir_all(&keg).await;
142 tokio::fs::create_dir_all(&keg).await?;
143
144 let scratch = keg.join(".download");
145 tokio::fs::create_dir_all(&scratch).await?;
146
147 info!(
148 formula,
149 language,
150 version = %resolved_version,
151 url = %url,
152 "fetching prebuilt toolchain archive"
153 );
154
155 let archive = scratch.join("archive");
158 let computed_sha =
159 crate::package_index::download_verified(&url, &archive, expected_sha.as_deref()).await?;
160
161 extract_toolchain(&language, &archive, &keg).await?;
163
164 if language == "rust" {
168 tokio::fs::create_dir_all(keg.join("cargo/bin")).await?;
169 tokio::fs::create_dir_all(keg.join("rustup")).await?;
170 }
171
172 if language == "node" {
179 tokio::fs::create_dir_all(keg.join("etc")).await?;
180 tokio::fs::write(keg.join("etc/openssl_sandbox.cnf"), b"").await?;
181 }
182
183 let manifest = build_manifest(&language, &resolved_version, &url, &computed_sha, &keg).await;
185 manifest.write_to_keg(&keg).await?;
186
187 if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
188 warn!(error = %e, "failed to clean prebuilt download scratch dir (non-fatal)");
189 }
190 tokio::fs::write(&ready_marker, b"").await?;
191
192 Ok(keg)
193}
194
195fn formula_language_version(formula: &str) -> (String, String) {
206 let (name, version) = match formula.split_once('@') {
207 Some((n, v)) if !v.is_empty() => (n, v.to_string()),
208 _ => (formula, "latest".to_string()),
209 };
210 (normalize_language(name), version)
211}
212
213fn normalize_language(name: &str) -> String {
219 let lower = name.to_ascii_lowercase();
220 if lower.contains("graalvm") {
221 return "graalvm".to_string();
222 }
223 let canonical = match lower.as_str() {
224 "go" | "golang" => "go",
225 "node" | "nodejs" => "node",
226 "rust" => "rust",
227 "python" | "python3" => "python",
228 "deno" => "deno",
229 "bun" => "bun",
230 "zig" => "zig",
231 "java" | "openjdk" => "java",
232 other => other,
233 };
234 canonical.to_string()
235}
236
237fn keg_path_dirs_and_env(language: &str, keg: &Path) -> (Vec<String>, HashMap<String, String>) {
254 let keg_str = keg.display().to_string();
255 let bin = keg.join("bin").display().to_string();
256 let mut env = HashMap::new();
257
258 let path_dirs = match language {
259 "go" => {
260 env.insert("GOROOT".to_string(), keg_str);
261 env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
262 vec![bin]
263 }
264 "rust" => {
265 env.insert(
266 "CARGO_HOME".to_string(),
267 keg.join("cargo").display().to_string(),
268 );
269 env.insert(
270 "RUSTUP_HOME".to_string(),
271 keg.join("rustup").display().to_string(),
272 );
273 vec![keg.join("cargo/bin").display().to_string(), bin]
274 }
275 "java" => {
276 env.insert("JAVA_HOME".to_string(), keg_str);
277 vec![bin]
278 }
279 "graalvm" => {
280 env.insert("JAVA_HOME".to_string(), keg_str.clone());
281 env.insert("GRAALVM_HOME".to_string(), keg_str);
282 vec![bin]
283 }
284 "node" => {
285 env.insert(
289 "OPENSSL_CONF".to_string(),
290 keg.join("etc/openssl_sandbox.cnf").display().to_string(),
291 );
292 vec![bin]
293 }
294 _ => vec![bin],
296 };
297
298 (path_dirs, env)
299}
300
301async fn build_manifest(
304 language: &str,
305 resolved_version: &str,
306 url: &str,
307 sha256: &str,
308 keg: &Path,
309) -> KegManifest {
310 let (candidates, env) = keg_path_dirs_and_env(language, keg);
311
312 let mut path_dirs = Vec::with_capacity(candidates.len());
313 for dir in candidates {
314 if tokio::fs::try_exists(&dir).await.unwrap_or(false) {
315 path_dirs.push(dir);
316 }
317 }
318
319 KegManifest {
320 tool: language.to_string(),
321 version: resolved_version.to_string(),
322 arch: arch_token().to_string(),
323 platform: "macos".to_string(),
324 path_dirs,
325 env,
326 source: KegSource::Prebuilt {
327 url: url.to_string(),
328 sha256: sha256.to_string(),
329 },
330 build_deps: Vec::new(),
331 provisioned_at: chrono::Utc::now().to_rfc3339(),
332 }
333}
334
335fn arch_token() -> &'static str {
342 match std::env::consts::ARCH {
343 "aarch64" => "arm64",
344 other => other,
345 }
346}
347
348fn host_arch() -> &'static str {
350 if cfg!(target_arch = "aarch64") {
351 "arm64"
352 } else {
353 "amd64"
354 }
355}
356
357async fn resolve_prebuilt_url(
363 language: &str,
364 version: &str,
365 arch: &str,
366) -> Result<PrebuiltResolution> {
367 match language {
368 "go" => resolve_go(version, arch).await,
369 "node" => resolve_node(version, arch).await,
370 "rust" => resolve_rust(version, arch).await,
371 "python" => resolve_python(version, arch).await,
372 "deno" => resolve_deno(version, arch).await,
373 "bun" => resolve_bun(version, arch).await,
374 "zig" => resolve_zig(version, arch).await,
375 "java" => resolve_java(version, arch).await,
376 "graalvm" => resolve_graalvm(version, arch).await,
377 other => Err(ToolchainError::RegistryError {
378 message: format!(
379 "no prebuilt toolchain provisioner for '{other}'. \
380 Supported: go, node, rust, python, deno, bun, zig, java, graalvm."
381 ),
382 }),
383 }
384}
385
386async fn resolve_go(version: &str, arch: &str) -> Result<PrebuiltResolution> {
396 let resolved = if version == "latest" {
397 resolve_go_version_from_api(version).await?
398 } else if version.matches('.').count() < 2 {
399 resolve_go_version_from_api(version)
402 .await
403 .unwrap_or_else(|_| format!("{version}.0"))
404 } else {
405 version.to_string()
406 };
407
408 let filename = format!("go{resolved}.darwin-{arch}.tar.gz");
409 let url = format!("https://go.dev/dl/{filename}");
410 let sha256 = fetch_go_sha256(&filename).await;
411 Ok(PrebuiltResolution {
412 version: resolved,
413 url,
414 sha256,
415 })
416}
417
418#[derive(serde::Deserialize)]
421struct GoFile {
422 #[serde(default)]
423 filename: String,
424 #[serde(default)]
425 sha256: String,
426}
427
428#[derive(serde::Deserialize)]
430struct GoReleaseFiles {
431 #[serde(default)]
432 files: Vec<GoFile>,
433}
434
435async fn fetch_go_sha256(filename: &str) -> Option<String> {
439 let releases: Vec<GoReleaseFiles> = reqwest::get("https://go.dev/dl/?mode=json")
440 .await
441 .ok()?
442 .json()
443 .await
444 .ok()?;
445 for release in &releases {
446 for file in &release.files {
447 if file.filename == filename && is_hex_sha256(&file.sha256) {
448 return Some(file.sha256.to_ascii_lowercase());
449 }
450 }
451 }
452 None
453}
454
455async fn resolve_go_version_from_api(version_prefix: &str) -> Result<String> {
457 let api_url = "https://go.dev/dl/?mode=json";
458 let response = reqwest::get(api_url)
459 .await
460 .map_err(|e| ToolchainError::RegistryError {
461 message: format!("Failed to fetch Go versions from {api_url}: {e}"),
462 })?;
463
464 let releases: Vec<GoRelease> =
465 response
466 .json()
467 .await
468 .map_err(|e| ToolchainError::RegistryError {
469 message: format!("Failed to parse Go versions JSON: {e}"),
470 })?;
471
472 if version_prefix == "latest" {
473 return releases
474 .first()
475 .map(|r| {
476 r.version
477 .strip_prefix("go")
478 .unwrap_or(&r.version)
479 .to_string()
480 })
481 .ok_or_else(|| ToolchainError::RegistryError {
482 message: "No Go releases found".to_string(),
483 });
484 }
485
486 let prefix_dot = format!("go{version_prefix}.");
488 let prefix_exact = format!("go{version_prefix}");
489 for release in &releases {
490 if (release.version.starts_with(&prefix_dot) || release.version == prefix_exact)
491 && release.stable
492 {
493 return Ok(release
494 .version
495 .strip_prefix("go")
496 .unwrap_or(&release.version)
497 .to_string());
498 }
499 }
500
501 for release in &releases {
502 if release.version.starts_with(&prefix_dot) || release.version == prefix_exact {
503 return Ok(release
504 .version
505 .strip_prefix("go")
506 .unwrap_or(&release.version)
507 .to_string());
508 }
509 }
510
511 Err(ToolchainError::RegistryError {
512 message: format!("No Go release found matching version '{version_prefix}'"),
513 })
514}
515
516#[derive(serde::Deserialize)]
517struct GoRelease {
518 version: String,
519 stable: bool,
520}
521
522async fn resolve_node(version: &str, arch: &str) -> Result<PrebuiltResolution> {
531 let node_arch = match arch {
532 "arm64" => "arm64",
533 _ => "x64",
534 };
535
536 let resolved = if version == "latest" || !version.contains('.') {
539 resolve_node_version_from_api(version).await?
540 } else {
541 version.to_string()
542 };
543
544 let filename = format!("node-v{resolved}-darwin-{node_arch}.tar.gz");
545 let url = format!("https://nodejs.org/dist/v{resolved}/{filename}");
546 let shasums = format!("https://nodejs.org/dist/v{resolved}/SHASUMS256.txt");
547 let sha256 = fetch_sha256_for(&shasums, &filename).await;
548 Ok(PrebuiltResolution {
549 version: resolved,
550 url,
551 sha256,
552 })
553}
554
555async fn resolve_node_version_from_api(version_prefix: &str) -> Result<String> {
557 let api_url = "https://nodejs.org/dist/index.json";
558 let response = reqwest::get(api_url)
559 .await
560 .map_err(|e| ToolchainError::RegistryError {
561 message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
562 })?;
563
564 let releases: Vec<NodeRelease> =
565 response
566 .json()
567 .await
568 .map_err(|e| ToolchainError::RegistryError {
569 message: format!("Failed to parse Node.js versions JSON: {e}"),
570 })?;
571
572 if version_prefix == "latest" {
573 return releases
574 .first()
575 .map(|r| {
576 r.version
577 .strip_prefix('v')
578 .unwrap_or(&r.version)
579 .to_string()
580 })
581 .ok_or_else(|| ToolchainError::RegistryError {
582 message: "No Node.js releases found".to_string(),
583 });
584 }
585
586 if version_prefix == "lts" {
591 return select_newest_node_lts(&releases).ok_or_else(|| ToolchainError::RegistryError {
592 message: "No Node.js LTS release found in dist index".to_string(),
593 });
594 }
595
596 let prefix = format!("v{version_prefix}");
598 for release in &releases {
599 if release.version.starts_with(&prefix)
600 && release
601 .version
602 .chars()
603 .nth(prefix.len())
604 .is_none_or(|c| c == '.')
605 {
606 return Ok(release
607 .version
608 .strip_prefix('v')
609 .unwrap_or(&release.version)
610 .to_string());
611 }
612 }
613
614 Err(ToolchainError::RegistryError {
615 message: format!("No Node.js release found matching version '{version_prefix}'"),
616 })
617}
618
619#[derive(serde::Deserialize)]
620struct NodeRelease {
621 version: String,
622 #[serde(default, deserialize_with = "deserialize_node_lts")]
626 lts: Option<String>,
627}
628
629fn deserialize_node_lts<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
633where
634 D: serde::Deserializer<'de>,
635{
636 use serde::Deserialize;
637 Ok(match serde_json::Value::deserialize(deserializer)? {
638 serde_json::Value::String(s) => Some(s),
639 _ => None,
640 })
641}
642
643fn parse_node_semver(version: &str) -> (u64, u64, u64) {
646 let v = version.strip_prefix('v').unwrap_or(version);
647 let mut parts = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
648 (
649 parts.next().unwrap_or(0),
650 parts.next().unwrap_or(0),
651 parts.next().unwrap_or(0),
652 )
653}
654
655fn select_newest_node_lts(releases: &[NodeRelease]) -> Option<String> {
659 releases
660 .iter()
661 .filter(|r| r.lts.is_some())
662 .max_by_key(|r| parse_node_semver(&r.version))
663 .map(|r| {
664 r.version
665 .strip_prefix('v')
666 .unwrap_or(&r.version)
667 .to_string()
668 })
669}
670
671async fn resolve_rust(version: &str, arch: &str) -> Result<PrebuiltResolution> {
680 let rust_target = match arch {
681 "arm64" => "aarch64-apple-darwin",
682 _ => "x86_64-apple-darwin",
683 };
684
685 let resolved = if version == "latest" {
686 resolve_rust_latest_version().await?
687 } else if version.matches('.').count() < 2 {
688 format!("{version}.0")
690 } else {
691 version.to_string()
692 };
693
694 let url = format!("https://static.rust-lang.org/dist/rust-{resolved}-{rust_target}.tar.gz");
695 let sha256 = fetch_sha256_token(&format!("{url}.sha256")).await;
696 Ok(PrebuiltResolution {
697 version: resolved,
698 url,
699 sha256,
700 })
701}
702
703async fn resolve_rust_latest_version() -> Result<String> {
705 let channel_url = "https://static.rust-lang.org/dist/channel-rust-stable.toml";
706 let response = reqwest::get(channel_url)
707 .await
708 .map_err(|e| ToolchainError::RegistryError {
709 message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
710 })?;
711
712 let body = response
713 .text()
714 .await
715 .map_err(|e| ToolchainError::RegistryError {
716 message: format!("Failed to read Rust stable channel response: {e}"),
717 })?;
718
719 let pkg_rust_pos = body
720 .find("[pkg.rust]")
721 .ok_or_else(|| ToolchainError::RegistryError {
722 message: "Rust stable channel TOML missing [pkg.rust] section".to_string(),
723 })?;
724
725 let after_pkg = &body[pkg_rust_pos..];
726 let version_prefix = "version = \"";
727 let ver_start =
728 after_pkg
729 .find(version_prefix)
730 .ok_or_else(|| ToolchainError::RegistryError {
731 message: "No version field found in [pkg.rust] section".to_string(),
732 })?
733 + version_prefix.len();
734
735 let ver_str: String = after_pkg[ver_start..]
736 .chars()
737 .take_while(|c| c.is_ascii_digit() || *c == '.')
738 .collect();
739
740 if ver_str.is_empty() {
741 return Err(ToolchainError::RegistryError {
742 message: "Failed to parse Rust version from stable channel".to_string(),
743 });
744 }
745
746 debug!("Resolved Rust latest stable version: {ver_str}");
747 Ok(ver_str)
748}
749
750async fn resolve_python(version: &str, arch: &str) -> Result<PrebuiltResolution> {
756 let python_target = match arch {
757 "arm64" => "aarch64-apple-darwin",
758 _ => "x86_64-apple-darwin",
759 };
760
761 resolve_python_from_github(version, python_target).await
762}
763
764async fn resolve_python_from_github(
767 version_prefix: &str,
768 target: &str,
769) -> Result<PrebuiltResolution> {
770 let api_url =
771 "https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=25";
772
773 let client = reqwest::Client::builder()
774 .user_agent("zlayer")
775 .build()
776 .map_err(|e| ToolchainError::RegistryError {
777 message: format!("Failed to build HTTP client: {e}"),
778 })?;
779
780 let response = client
781 .get(api_url)
782 .send()
783 .await
784 .map_err(|e| ToolchainError::RegistryError {
785 message: format!("Failed to fetch Python releases from GitHub: {e}"),
786 })?;
787
788 if !response.status().is_success() {
789 return Err(ToolchainError::RegistryError {
790 message: format!(
791 "GitHub API returned status {} fetching Python releases",
792 response.status()
793 ),
794 });
795 }
796
797 let releases: Vec<GitHubRelease> =
798 response
799 .json()
800 .await
801 .map_err(|e| ToolchainError::RegistryError {
802 message: format!("Failed to parse GitHub releases JSON: {e}"),
803 })?;
804
805 let target_suffix = format!("{target}-install_only_stripped.tar.gz");
806
807 if version_prefix == "latest" {
808 for release in &releases {
809 for asset in &release.assets {
810 if asset.name.starts_with("cpython-")
811 && asset.name.ends_with(&target_suffix)
812 && asset.name.contains("install_only")
813 {
814 let py_version = extract_python_version_from_asset(&asset.name);
815 if !py_version.is_empty() {
816 debug!("Resolved Python latest to {py_version}");
817 let sha256 =
818 sibling_sha256(&release.assets, &format!("{}.sha256", asset.name))
819 .await;
820 return Ok(PrebuiltResolution {
821 version: py_version,
822 url: asset.browser_download_url.clone(),
823 sha256,
824 });
825 }
826 }
827 }
828 }
829 return Err(ToolchainError::RegistryError {
830 message: format!(
831 "No Python release found for target '{target}' in recent GitHub releases"
832 ),
833 });
834 }
835
836 let exact_prefix = format!("cpython-{version_prefix}+");
837 let partial_prefix = format!("cpython-{version_prefix}.");
838
839 for release in &releases {
840 for asset in &release.assets {
841 if !asset.name.ends_with(&target_suffix) {
842 continue;
843 }
844 if asset.name.starts_with(&exact_prefix) || asset.name.starts_with(&partial_prefix) {
845 let py_version = extract_python_version_from_asset(&asset.name);
846 debug!("Resolved Python {version_prefix} to {py_version}");
847 let sha256 =
848 sibling_sha256(&release.assets, &format!("{}.sha256", asset.name)).await;
849 return Ok(PrebuiltResolution {
850 version: py_version,
851 url: asset.browser_download_url.clone(),
852 sha256,
853 });
854 }
855 }
856 }
857
858 Err(ToolchainError::RegistryError {
859 message: format!("No Python release found matching version '{version_prefix}'"),
860 })
861}
862
863fn extract_python_version_from_asset(asset_name: &str) -> String {
866 asset_name
867 .strip_prefix("cpython-")
868 .and_then(|s| s.split('+').next())
869 .unwrap_or("")
870 .to_string()
871}
872
873#[derive(serde::Deserialize)]
874struct GitHubRelease {
875 tag_name: Option<String>,
878 assets: Vec<GitHubAsset>,
879}
880
881#[derive(serde::Deserialize)]
882struct GitHubAsset {
883 name: String,
884 browser_download_url: String,
885}
886
887async fn resolve_deno(version: &str, arch: &str) -> Result<PrebuiltResolution> {
897 let deno_target = match arch {
898 "arm64" => "aarch64-apple-darwin",
899 _ => "x86_64-apple-darwin",
900 };
901
902 if version == "latest" || !version.contains('.') {
903 resolve_deno_from_github(version, deno_target).await
904 } else {
905 let url = format!(
906 "https://github.com/denoland/deno/releases/download/v{version}/deno-{deno_target}.zip"
907 );
908 Ok(PrebuiltResolution {
909 version: version.to_string(),
910 url,
911 sha256: None,
912 })
913 }
914}
915
916async fn resolve_deno_from_github(
918 version_prefix: &str,
919 target: &str,
920) -> Result<PrebuiltResolution> {
921 let api_url = "https://api.github.com/repos/denoland/deno/releases?per_page=25";
922
923 let client = reqwest::Client::builder()
924 .user_agent("zlayer")
925 .build()
926 .map_err(|e| ToolchainError::RegistryError {
927 message: format!("Failed to build HTTP client: {e}"),
928 })?;
929
930 let response = client
931 .get(api_url)
932 .send()
933 .await
934 .map_err(|e| ToolchainError::RegistryError {
935 message: format!("Failed to fetch Deno releases from GitHub: {e}"),
936 })?;
937
938 if !response.status().is_success() {
939 return Err(ToolchainError::RegistryError {
940 message: format!(
941 "GitHub API returned status {} fetching Deno releases",
942 response.status()
943 ),
944 });
945 }
946
947 let releases: Vec<GitHubRelease> =
948 response
949 .json()
950 .await
951 .map_err(|e| ToolchainError::RegistryError {
952 message: format!("Failed to parse GitHub releases JSON: {e}"),
953 })?;
954
955 let asset_name = format!("deno-{target}.zip");
956
957 if version_prefix == "latest" {
958 for release in &releases {
959 for asset in &release.assets {
960 if asset.name == asset_name {
961 let tag = release
962 .tag_name
963 .as_deref()
964 .unwrap_or("")
965 .strip_prefix('v')
966 .unwrap_or(release.tag_name.as_deref().unwrap_or(""));
967 if !tag.is_empty() {
968 debug!("Resolved Deno latest to {tag}");
969 let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
970 return Ok(PrebuiltResolution {
971 version: tag.to_string(),
972 url: asset.browser_download_url.clone(),
973 sha256,
974 });
975 }
976 }
977 }
978 }
979 return Err(ToolchainError::RegistryError {
980 message: format!(
981 "No Deno release found for target '{target}' in recent GitHub releases"
982 ),
983 });
984 }
985
986 let tag_prefix = format!("v{version_prefix}.");
987 for release in &releases {
988 let tag = release.tag_name.as_deref().unwrap_or("");
989 if tag.starts_with(&tag_prefix) {
990 for asset in &release.assets {
991 if asset.name == asset_name {
992 let ver = tag.strip_prefix('v').unwrap_or(tag);
993 debug!("Resolved Deno {version_prefix} to {ver} (partial)");
994 let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
995 return Ok(PrebuiltResolution {
996 version: ver.to_string(),
997 url: asset.browser_download_url.clone(),
998 sha256,
999 });
1000 }
1001 }
1002 }
1003 }
1004
1005 Err(ToolchainError::RegistryError {
1006 message: format!("No Deno release found matching version '{version_prefix}'"),
1007 })
1008}
1009
1010async fn deno_sibling_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1013 if let Some(sha) = sibling_sha256(assets, &format!("{asset_name}.sha256sum")).await {
1014 return Some(sha);
1015 }
1016 sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1017}
1018
1019async fn resolve_bun(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1028 let bun_arch = match arch {
1029 "arm64" => "aarch64",
1030 _ => "x64",
1031 };
1032
1033 if version == "latest" || !version.contains('.') {
1034 resolve_bun_from_github(version, bun_arch).await
1035 } else {
1036 let url = format!(
1037 "https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-darwin-{bun_arch}.zip"
1038 );
1039 Ok(PrebuiltResolution {
1040 version: version.to_string(),
1041 url,
1042 sha256: None,
1043 })
1044 }
1045}
1046
1047async fn resolve_bun_from_github(
1049 version_prefix: &str,
1050 bun_arch: &str,
1051) -> Result<PrebuiltResolution> {
1052 let api_url = "https://api.github.com/repos/oven-sh/bun/releases?per_page=25";
1053
1054 let client = reqwest::Client::builder()
1055 .user_agent("zlayer")
1056 .build()
1057 .map_err(|e| ToolchainError::RegistryError {
1058 message: format!("Failed to build HTTP client: {e}"),
1059 })?;
1060
1061 let response = client
1062 .get(api_url)
1063 .send()
1064 .await
1065 .map_err(|e| ToolchainError::RegistryError {
1066 message: format!("Failed to fetch Bun releases from GitHub: {e}"),
1067 })?;
1068
1069 if !response.status().is_success() {
1070 return Err(ToolchainError::RegistryError {
1071 message: format!(
1072 "GitHub API returned status {} fetching Bun releases",
1073 response.status()
1074 ),
1075 });
1076 }
1077
1078 let releases: Vec<GitHubRelease> =
1079 response
1080 .json()
1081 .await
1082 .map_err(|e| ToolchainError::RegistryError {
1083 message: format!("Failed to parse GitHub releases JSON: {e}"),
1084 })?;
1085
1086 let asset_name = format!("bun-darwin-{bun_arch}.zip");
1087
1088 if version_prefix == "latest" {
1089 for release in &releases {
1090 let tag = release.tag_name.as_deref().unwrap_or("");
1091 let ver = tag
1092 .strip_prefix("bun-v")
1093 .unwrap_or(tag.strip_prefix('v').unwrap_or(tag));
1094 if ver.is_empty() {
1095 continue;
1096 }
1097 for asset in &release.assets {
1098 if asset.name == asset_name {
1099 debug!("Resolved Bun latest to {ver}");
1100 let sha256 = bun_sha256(&release.assets, &asset_name).await;
1101 return Ok(PrebuiltResolution {
1102 version: ver.to_string(),
1103 url: asset.browser_download_url.clone(),
1104 sha256,
1105 });
1106 }
1107 }
1108 }
1109 return Err(ToolchainError::RegistryError {
1110 message: format!(
1111 "No Bun release found for arch '{bun_arch}' in recent GitHub releases"
1112 ),
1113 });
1114 }
1115
1116 let tag_prefix = format!("bun-v{version_prefix}.");
1117 for release in &releases {
1118 let tag = release.tag_name.as_deref().unwrap_or("");
1119 if tag.starts_with(&tag_prefix) {
1120 for asset in &release.assets {
1121 if asset.name == asset_name {
1122 let ver = tag.strip_prefix("bun-v").unwrap_or(tag);
1123 debug!("Resolved Bun {version_prefix} to {ver} (partial)");
1124 let sha256 = bun_sha256(&release.assets, &asset_name).await;
1125 return Ok(PrebuiltResolution {
1126 version: ver.to_string(),
1127 url: asset.browser_download_url.clone(),
1128 sha256,
1129 });
1130 }
1131 }
1132 }
1133 }
1134
1135 Err(ToolchainError::RegistryError {
1136 message: format!("No Bun release found matching version '{version_prefix}'"),
1137 })
1138}
1139
1140async fn bun_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1143 if let Some(shasums) = assets.iter().find(|a| a.name == "SHASUMS256.txt") {
1144 if let Some(sha) = fetch_sha256_for(&shasums.browser_download_url, asset_name).await {
1145 return Some(sha);
1146 }
1147 }
1148 sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1149}
1150
1151#[derive(serde::Deserialize)]
1158struct ZigDownloadInfo {
1159 tarball: String,
1160 #[serde(default)]
1161 shasum: String,
1162}
1163
1164async fn resolve_zig(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1166 let platform_key = match arch {
1167 "arm64" => "aarch64-macos",
1168 _ => "x86_64-macos",
1169 };
1170
1171 let index_url = "https://ziglang.org/download/index.json";
1172 let response = reqwest::get(index_url)
1173 .await
1174 .map_err(|e| ToolchainError::RegistryError {
1175 message: format!("Failed to fetch Zig download index from {index_url}: {e}"),
1176 })?;
1177
1178 let index: HashMap<String, serde_json::Value> =
1179 response
1180 .json()
1181 .await
1182 .map_err(|e| ToolchainError::RegistryError {
1183 message: format!("Failed to parse Zig download index JSON: {e}"),
1184 })?;
1185
1186 let resolved = if version == "latest" {
1187 let mut versions: Vec<&String> = index.keys().filter(|k| *k != "master").collect();
1188 versions.sort_by(|a, b| compare_version_strings(b, a));
1189 versions
1190 .first()
1191 .map(|v| (*v).clone())
1192 .ok_or_else(|| ToolchainError::RegistryError {
1193 message: "No stable Zig versions found in download index".to_string(),
1194 })?
1195 } else if index.contains_key(version) {
1196 version.to_string()
1197 } else {
1198 let prefix = format!("{version}.");
1199 let mut candidates: Vec<&String> = index
1200 .keys()
1201 .filter(|k| *k != "master" && k.starts_with(&prefix))
1202 .collect();
1203 candidates.sort_by(|a, b| compare_version_strings(b, a));
1204 candidates
1205 .first()
1206 .map(|v| (*v).clone())
1207 .ok_or_else(|| ToolchainError::RegistryError {
1208 message: format!("No Zig version found matching '{version}'"),
1209 })?
1210 };
1211
1212 let version_data = index
1213 .get(&resolved)
1214 .ok_or_else(|| ToolchainError::RegistryError {
1215 message: format!("Zig version '{resolved}' not found in download index"),
1216 })?;
1217
1218 let platform_data =
1219 version_data
1220 .get(platform_key)
1221 .ok_or_else(|| ToolchainError::RegistryError {
1222 message: format!(
1223 "No Zig download found for platform '{platform_key}' in version '{resolved}'"
1224 ),
1225 })?;
1226
1227 let info: ZigDownloadInfo = serde_json::from_value(platform_data.clone()).map_err(|e| {
1228 ToolchainError::RegistryError {
1229 message: format!(
1230 "Failed to parse Zig download info for {platform_key}/{resolved}: {e}"
1231 ),
1232 }
1233 })?;
1234
1235 debug!("Resolved Zig {version} to {resolved}: {}", info.tarball);
1236 let sha256 = is_hex_sha256(&info.shasum).then(|| info.shasum.to_ascii_lowercase());
1237 Ok(PrebuiltResolution {
1238 version: resolved,
1239 url: info.tarball,
1240 sha256,
1241 })
1242}
1243
1244fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
1246 let a_parts: Vec<&str> = a.split('.').collect();
1247 let b_parts: Vec<&str> = b.split('.').collect();
1248 for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
1249 let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
1250 (Ok(an), Ok(bn)) => an.cmp(&bn),
1251 _ => ap.cmp(bp),
1252 };
1253 if ord != std::cmp::Ordering::Equal {
1254 return ord;
1255 }
1256 }
1257 a_parts.len().cmp(&b_parts.len())
1258}
1259
1260#[derive(serde::Deserialize)]
1266struct AdoptiumAvailableReleases {
1267 most_recent_lts: u32,
1269}
1270
1271async fn resolve_java(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1276 let adoptium_arch = match arch {
1277 "arm64" => "aarch64",
1278 _ => "x64",
1279 };
1280
1281 let feature_version = if version == "latest" {
1282 resolve_java_latest_lts().await?
1283 } else {
1284 version.split('.').next().unwrap_or(version).to_string()
1286 };
1287
1288 let url = format!(
1289 "https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/mac/{adoptium_arch}/jdk/hotspot/normal/eclipse"
1290 );
1291
1292 Ok(PrebuiltResolution {
1293 version: feature_version,
1294 url,
1295 sha256: None,
1296 })
1297}
1298
1299async fn resolve_java_latest_lts() -> Result<String> {
1301 let api_url = "https://api.adoptium.net/v3/info/available_releases";
1302 let response = reqwest::get(api_url)
1303 .await
1304 .map_err(|e| ToolchainError::RegistryError {
1305 message: format!("Failed to fetch Adoptium available releases from {api_url}: {e}"),
1306 })?;
1307
1308 if !response.status().is_success() {
1309 return Err(ToolchainError::RegistryError {
1310 message: format!(
1311 "Adoptium API returned status {} fetching available releases",
1312 response.status()
1313 ),
1314 });
1315 }
1316
1317 let releases: AdoptiumAvailableReleases =
1318 response
1319 .json()
1320 .await
1321 .map_err(|e| ToolchainError::RegistryError {
1322 message: format!("Failed to parse Adoptium available releases JSON: {e}"),
1323 })?;
1324
1325 let version = releases.most_recent_lts.to_string();
1326 debug!("Resolved Java latest LTS to feature version {version}");
1327 Ok(version)
1328}
1329
1330async fn resolve_graalvm(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1339 let graalvm_arch = match arch {
1340 "arm64" => "aarch64",
1341 _ => "x64",
1342 };
1343
1344 if version == "latest" || !version.contains('.') {
1345 resolve_graalvm_from_github(version, graalvm_arch).await
1346 } else {
1347 let url = format!(
1348 "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
1349 jdk-{version}/graalvm-community-jdk-{version}_macos-{graalvm_arch}_bin.tar.gz"
1350 );
1351 Ok(PrebuiltResolution {
1352 version: version.to_string(),
1353 url,
1354 sha256: None,
1355 })
1356 }
1357}
1358
1359async fn resolve_graalvm_from_github(
1361 version_prefix: &str,
1362 graalvm_arch: &str,
1363) -> Result<PrebuiltResolution> {
1364 let api_url = "https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=25";
1365
1366 let client = reqwest::Client::builder()
1367 .user_agent("zlayer")
1368 .build()
1369 .map_err(|e| ToolchainError::RegistryError {
1370 message: format!("Failed to build HTTP client: {e}"),
1371 })?;
1372
1373 let response = client
1374 .get(api_url)
1375 .send()
1376 .await
1377 .map_err(|e| ToolchainError::RegistryError {
1378 message: format!("Failed to fetch GraalVM releases from GitHub: {e}"),
1379 })?;
1380
1381 if !response.status().is_success() {
1382 return Err(ToolchainError::RegistryError {
1383 message: format!(
1384 "GitHub API returned status {} fetching GraalVM releases",
1385 response.status()
1386 ),
1387 });
1388 }
1389
1390 let releases: Vec<GitHubRelease> =
1391 response
1392 .json()
1393 .await
1394 .map_err(|e| ToolchainError::RegistryError {
1395 message: format!("Failed to parse GitHub releases JSON: {e}"),
1396 })?;
1397
1398 if version_prefix == "latest" {
1399 for release in &releases {
1400 let tag = release.tag_name.as_deref().unwrap_or("");
1401 if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1402 if jdk_version.is_empty() {
1403 continue;
1404 }
1405 let filename =
1406 format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1407 let url = format!(
1408 "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1409 );
1410 debug!("Resolved GraalVM latest to {jdk_version}");
1411 let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1412 return Ok(PrebuiltResolution {
1413 version: jdk_version.to_string(),
1414 url,
1415 sha256,
1416 });
1417 }
1418 }
1419 return Err(ToolchainError::RegistryError {
1420 message: "No GraalVM CE release found in recent GitHub releases".to_string(),
1421 });
1422 }
1423
1424 let tag_prefix = format!("jdk-{version_prefix}.");
1425 for release in &releases {
1426 let tag = release.tag_name.as_deref().unwrap_or("");
1427 if tag.starts_with(&tag_prefix) {
1428 if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1429 let filename =
1430 format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1431 let url = format!(
1432 "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1433 );
1434 debug!("Resolved GraalVM {version_prefix} to {jdk_version} (partial)");
1435 let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1436 return Ok(PrebuiltResolution {
1437 version: jdk_version.to_string(),
1438 url,
1439 sha256,
1440 });
1441 }
1442 }
1443 }
1444
1445 Err(ToolchainError::RegistryError {
1446 message: format!("No GraalVM CE release found matching version '{version_prefix}'"),
1447 })
1448}
1449
1450fn is_hex_sha256(s: &str) -> bool {
1456 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
1457}
1458
1459async fn fetch_sha256_token(url: &str) -> Option<String> {
1464 let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1465 let token = text.split_whitespace().next()?;
1466 is_hex_sha256(token).then(|| token.to_ascii_lowercase())
1467}
1468
1469async fn fetch_sha256_for(url: &str, filename: &str) -> Option<String> {
1473 let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1474 for line in text.lines() {
1475 let mut cols = line.split_whitespace();
1476 if let (Some(hash), Some(name)) = (cols.next(), cols.next()) {
1477 let name = name.strip_prefix('*').unwrap_or(name);
1479 if name == filename && is_hex_sha256(hash) {
1480 return Some(hash.to_ascii_lowercase());
1481 }
1482 }
1483 }
1484 None
1485}
1486
1487async fn sibling_sha256(assets: &[GitHubAsset], sibling: &str) -> Option<String> {
1490 let asset = assets.iter().find(|a| a.name == sibling)?;
1491 fetch_sha256_token(&asset.browser_download_url).await
1492}
1493
1494#[allow(clippy::too_many_lines, clippy::match_same_arms)]
1501async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
1502 let archive_str = archive.display().to_string();
1503 let target_str = target_dir.display().to_string();
1504
1505 let output = match language {
1506 "go" | "node" => {
1507 tokio::process::Command::new("tar")
1509 .args([
1510 "xzf",
1511 &archive_str,
1512 "-C",
1513 &target_str,
1514 "--strip-components=1",
1515 ])
1516 .output()
1517 .await?
1518 }
1519 "rust" => {
1520 let extract_tmp = target_dir.join("_extract");
1523 tokio::fs::create_dir_all(&extract_tmp).await?;
1524 let extract_tmp_str = extract_tmp.display().to_string();
1525
1526 let tar_out = tokio::process::Command::new("tar")
1527 .args([
1528 "xzf",
1529 &archive_str,
1530 "-C",
1531 &extract_tmp_str,
1532 "--strip-components=1",
1533 ])
1534 .output()
1535 .await?;
1536
1537 if !tar_out.status.success() {
1538 let stderr = String::from_utf8_lossy(&tar_out.stderr);
1539 let _ = tokio::process::Command::new("chmod")
1540 .args(["-R", "u+w"])
1541 .arg(&extract_tmp)
1542 .status()
1543 .await;
1544 let _ = tokio::process::Command::new("rm")
1545 .args(["-rf"])
1546 .arg(&extract_tmp)
1547 .status()
1548 .await;
1549 return Err(ToolchainError::RegistryError {
1550 message: format!("Failed to extract Rust tarball: {stderr}"),
1551 });
1552 }
1553
1554 let install_sh = extract_tmp.join("install.sh");
1555 let install_out = tokio::process::Command::new("sh")
1556 .arg(install_sh.display().to_string())
1557 .arg(format!("--prefix={target_str}"))
1558 .arg("--disable-ldconfig")
1559 .output()
1560 .await?;
1561
1562 let _ = tokio::process::Command::new("chmod")
1563 .args(["-R", "u+w"])
1564 .arg(&extract_tmp)
1565 .status()
1566 .await;
1567 let _ = tokio::process::Command::new("rm")
1568 .args(["-rf"])
1569 .arg(&extract_tmp)
1570 .status()
1571 .await;
1572
1573 if !install_out.status.success() {
1574 let stderr = String::from_utf8_lossy(&install_out.stderr);
1575 return Err(ToolchainError::RegistryError {
1576 message: format!("Rust install.sh failed: {stderr}"),
1577 });
1578 }
1579
1580 return Ok(());
1581 }
1582 "deno" => {
1583 let out = tokio::process::Command::new("unzip")
1585 .args(["-o", &archive_str, "-d", &target_str])
1586 .output()
1587 .await?;
1588 if out.status.success() {
1589 let bin_dir = target_dir.join("bin");
1590 tokio::fs::create_dir_all(&bin_dir).await?;
1591 let deno_binary = target_dir.join("deno");
1592 if deno_binary.exists() {
1593 tokio::fs::rename(&deno_binary, bin_dir.join("deno")).await?;
1594 }
1595 }
1596 out
1597 }
1598 "zig" => {
1599 let out = tokio::process::Command::new("tar")
1602 .args([
1603 "xJf",
1604 &archive_str,
1605 "-C",
1606 &target_str,
1607 "--strip-components=1",
1608 ])
1609 .output()
1610 .await?;
1611 if out.status.success() {
1612 let bin_dir = target_dir.join("bin");
1613 tokio::fs::create_dir_all(&bin_dir).await?;
1614 let zig_binary = target_dir.join("zig");
1615 if zig_binary.exists() && !bin_dir.join("zig").exists() {
1616 #[cfg(unix)]
1621 tokio::fs::symlink(Path::new("../zig"), &bin_dir.join("zig")).await?;
1622 #[cfg(windows)]
1623 {
1624 tokio::fs::copy(&zig_binary, bin_dir.join("zig")).await?;
1625 }
1626 }
1627 }
1628 out
1629 }
1630 "java" | "graalvm" => {
1631 tokio::process::Command::new("tar")
1634 .args([
1635 "xzf",
1636 &archive_str,
1637 "-C",
1638 &target_str,
1639 "--strip-components=3",
1640 ])
1641 .output()
1642 .await?
1643 }
1644 "bun" => {
1645 let out = tokio::process::Command::new("unzip")
1647 .args(["-o", &archive_str, "-d", &target_str])
1648 .output()
1649 .await?;
1650 if out.status.success() {
1651 let bin_dir = target_dir.join("bin");
1652 tokio::fs::create_dir_all(&bin_dir).await?;
1653 if let Ok(mut entries) = tokio::fs::read_dir(target_dir).await {
1654 while let Ok(Some(entry)) = entries.next_entry().await {
1655 if entry.file_name().to_string_lossy().starts_with("bun-") {
1656 let bun_binary = entry.path().join("bun");
1657 if bun_binary.exists() {
1658 tokio::fs::rename(&bun_binary, bin_dir.join("bun")).await?;
1659 let _ = tokio::process::Command::new("chmod")
1660 .args(["-R", "u+w"])
1661 .arg(entry.path())
1662 .status()
1663 .await;
1664 let _ = tokio::process::Command::new("rm")
1665 .args(["-rf"])
1666 .arg(entry.path())
1667 .status()
1668 .await;
1669 }
1670 }
1671 }
1672 }
1673 }
1674 out
1675 }
1676 _ => {
1678 tokio::process::Command::new("tar")
1679 .args([
1680 "xzf",
1681 &archive_str,
1682 "-C",
1683 &target_str,
1684 "--strip-components=1",
1685 ])
1686 .output()
1687 .await?
1688 }
1689 };
1690
1691 if !output.status.success() {
1692 let stderr = String::from_utf8_lossy(&output.stderr);
1693 return Err(ToolchainError::RegistryError {
1694 message: format!("Failed to extract {language} toolchain: {stderr}"),
1695 });
1696 }
1697
1698 Ok(())
1699}
1700
1701#[cfg(test)]
1702mod tests {
1703 use super::*;
1704
1705 #[test]
1706 fn is_prebuilt_accepts_languages_and_aliases() {
1707 for f in [
1708 "go",
1709 "golang",
1710 "node",
1711 "nodejs",
1712 "rust",
1713 "python",
1714 "python3",
1715 "python@3.12",
1716 "deno",
1717 "bun",
1718 "zig",
1719 "java",
1720 "openjdk",
1721 "openjdk@17",
1722 "node@22",
1723 "graalvm",
1724 "graalvm-ce",
1725 "graalvm-community",
1726 ] {
1727 assert!(is_prebuilt_formula(f), "{f} should be a prebuilt formula");
1728 }
1729 }
1730
1731 #[test]
1732 fn is_prebuilt_rejects_non_languages_and_swift() {
1733 for f in ["swift", "git", "jq", "cmake", "ripgrep", "openssl@3"] {
1734 assert!(
1735 !is_prebuilt_formula(f),
1736 "{f} should NOT be a prebuilt formula"
1737 );
1738 }
1739 }
1740
1741 #[test]
1742 fn formula_split_maps_language_and_version() {
1743 assert_eq!(
1744 formula_language_version("python@3.12"),
1745 ("python".to_string(), "3.12".to_string())
1746 );
1747 assert_eq!(
1748 formula_language_version("node@22"),
1749 ("node".to_string(), "22".to_string())
1750 );
1751 assert_eq!(
1752 formula_language_version("go"),
1753 ("go".to_string(), "latest".to_string())
1754 );
1755 assert_eq!(
1756 formula_language_version("golang"),
1757 ("go".to_string(), "latest".to_string())
1758 );
1759 assert_eq!(
1760 formula_language_version("openjdk@17"),
1761 ("java".to_string(), "17".to_string())
1762 );
1763 assert_eq!(
1764 formula_language_version("python3"),
1765 ("python".to_string(), "latest".to_string())
1766 );
1767 assert_eq!(
1768 formula_language_version("graalvm-ce"),
1769 ("graalvm".to_string(), "latest".to_string())
1770 );
1771 assert_eq!(
1772 formula_language_version("nodejs"),
1773 ("node".to_string(), "latest".to_string())
1774 );
1775 }
1776
1777 #[test]
1778 fn node_lts_selection_picks_highest_lts_codename() {
1779 let json = r#"[
1782 {"version":"v26.0.0","lts":false},
1783 {"version":"v25.2.0","lts":false},
1784 {"version":"v24.18.0","lts":"Krypton"},
1785 {"version":"v22.20.0","lts":"Jod"},
1786 {"version":"v20.19.0","lts":"Iron"}
1787 ]"#;
1788 let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1789 assert_eq!(
1790 select_newest_node_lts(&releases).as_deref(),
1791 Some("24.18.0"),
1792 "LTS resolver must pick the newest line whose lts is a codename string"
1793 );
1794 }
1795
1796 #[test]
1797 fn node_lts_selection_is_none_when_no_lts_line() {
1798 let json = r#"[{"version":"v26.0.0","lts":false},{"version":"v25.2.0","lts":false}]"#;
1799 let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1800 assert_eq!(select_newest_node_lts(&releases), None);
1801 }
1802
1803 #[test]
1804 fn node_lts_token_is_a_prebuilt_node_formula() {
1805 assert!(is_prebuilt_formula("node@lts"));
1808 assert_eq!(
1809 formula_language_version("node@lts"),
1810 ("node".to_string(), "lts".to_string())
1811 );
1812 }
1813
1814 #[test]
1815 fn go_keg_layout_sets_goroot_and_bin() {
1816 let keg = Path::new("/cache/go-1.23.6-arm64");
1817 let (path_dirs, env) = keg_path_dirs_and_env("go", keg);
1818 assert_eq!(path_dirs, vec!["/cache/go-1.23.6-arm64/bin".to_string()]);
1819 assert_eq!(
1820 env.get("GOROOT").map(String::as_str),
1821 Some("/cache/go-1.23.6-arm64")
1822 );
1823 assert_eq!(
1824 env.get("GOFLAGS").map(String::as_str),
1825 Some("-buildvcs=false")
1826 );
1827 }
1828
1829 #[test]
1830 fn rust_keg_layout_sets_cargo_and_rustup_homes() {
1831 let keg = Path::new("/cache/rust-1.82.0-arm64");
1832 let (path_dirs, env) = keg_path_dirs_and_env("rust", keg);
1833 assert_eq!(
1834 path_dirs,
1835 vec![
1836 "/cache/rust-1.82.0-arm64/cargo/bin".to_string(),
1837 "/cache/rust-1.82.0-arm64/bin".to_string(),
1838 ]
1839 );
1840 assert_eq!(
1841 env.get("CARGO_HOME").map(String::as_str),
1842 Some("/cache/rust-1.82.0-arm64/cargo")
1843 );
1844 assert_eq!(
1845 env.get("RUSTUP_HOME").map(String::as_str),
1846 Some("/cache/rust-1.82.0-arm64/rustup")
1847 );
1848 }
1849
1850 #[test]
1851 fn graalvm_keg_layout_sets_both_homes() {
1852 let keg = Path::new("/cache/graalvm-21.0.5-arm64");
1853 let (_, env) = keg_path_dirs_and_env("graalvm", keg);
1854 assert_eq!(
1855 env.get("JAVA_HOME").map(String::as_str),
1856 Some("/cache/graalvm-21.0.5-arm64")
1857 );
1858 assert_eq!(
1859 env.get("GRAALVM_HOME").map(String::as_str),
1860 Some("/cache/graalvm-21.0.5-arm64")
1861 );
1862 }
1863
1864 #[test]
1865 fn node_keg_layout_sets_openssl_conf() {
1866 let keg = Path::new("/cache/node-22.1.0-arm64");
1867 let (path_dirs, env) = keg_path_dirs_and_env("node", keg);
1868 assert_eq!(path_dirs, vec!["/cache/node-22.1.0-arm64/bin".to_string()]);
1869 assert_eq!(
1873 env.get("OPENSSL_CONF").map(String::as_str),
1874 Some("/cache/node-22.1.0-arm64/etc/openssl_sandbox.cnf")
1875 );
1876 }
1877
1878 #[test]
1879 fn python_version_extracted_from_asset_name() {
1880 assert_eq!(
1881 extract_python_version_from_asset(
1882 "cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
1883 ),
1884 "3.12.8"
1885 );
1886 assert_eq!(extract_python_version_from_asset("cpython-"), "");
1887 assert_eq!(extract_python_version_from_asset("not-cpython"), "");
1888 }
1889
1890 #[test]
1891 fn version_strings_compare_numerically() {
1892 use std::cmp::Ordering;
1893 assert_eq!(
1894 compare_version_strings("0.14.0", "0.13.0"),
1895 Ordering::Greater
1896 );
1897 assert_eq!(
1898 compare_version_strings("1.0.0", "0.14.0"),
1899 Ordering::Greater
1900 );
1901 assert_eq!(
1902 compare_version_strings("0.14.1", "0.14.0"),
1903 Ordering::Greater
1904 );
1905 assert_eq!(compare_version_strings("0.14.0", "0.14.0"), Ordering::Equal);
1906 }
1907
1908 #[tokio::test]
1913 async fn resolve_deno_exact_url() {
1914 let r = resolve_deno("2.1.4", "arm64").await.unwrap();
1915 assert_eq!(r.version, "2.1.4");
1916 assert_eq!(
1917 r.url,
1918 "https://github.com/denoland/deno/releases/download/v2.1.4/deno-aarch64-apple-darwin.zip"
1919 );
1920 assert!(r.sha256.is_none());
1922 }
1923
1924 #[tokio::test]
1925 async fn resolve_bun_exact_url() {
1926 let r = resolve_bun("1.2.3", "arm64").await.unwrap();
1927 assert_eq!(r.version, "1.2.3");
1928 assert_eq!(
1929 r.url,
1930 "https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-darwin-aarch64.zip"
1931 );
1932 assert!(r.sha256.is_none());
1933 }
1934
1935 #[tokio::test]
1936 async fn resolve_graalvm_exact_url() {
1937 let r = resolve_graalvm("21.0.5", "arm64").await.unwrap();
1938 assert_eq!(r.version, "21.0.5");
1939 assert_eq!(
1940 r.url,
1941 "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
1942 jdk-21.0.5/graalvm-community-jdk-21.0.5_macos-aarch64_bin.tar.gz"
1943 );
1944 assert!(r.sha256.is_none());
1945 }
1946
1947 #[tokio::test]
1948 async fn resolve_java_exact_url_strips_to_major() {
1949 let r = resolve_java("21.0.5", "arm64").await.unwrap();
1950 assert_eq!(r.version, "21");
1951 assert_eq!(
1952 r.url,
1953 "https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse"
1954 );
1955 assert!(r.sha256.is_none());
1956 }
1957
1958 #[test]
1959 fn zig_download_info_parses_shasum() {
1960 let info: ZigDownloadInfo = serde_json::from_str(
1961 r#"{"tarball":"https://ziglang.org/x.tar.xz","shasum":"aa","size":"1"}"#,
1962 )
1963 .unwrap();
1964 assert_eq!(info.tarball, "https://ziglang.org/x.tar.xz");
1965 assert_eq!(info.shasum, "aa");
1966 }
1967
1968 #[test]
1969 fn hex_sha256_validation() {
1970 assert!(is_hex_sha256(
1971 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1972 ));
1973 assert!(!is_hex_sha256("tooshort"));
1974 assert!(!is_hex_sha256(
1975 "zz4d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1976 ));
1977 }
1978}