1use std::collections::HashMap;
31use std::path::{Path, PathBuf};
32
33use tracing::{debug, info, warn};
34
35use crate::error::{Result, ToolchainError};
36use crate::manifest::{ToolchainManifest, ToolchainSource};
37use crate::registry::ToolchainArtifactId;
38
39const MACOS_OS_TOKEN: &str = "macos";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct PrebuiltResolution {
58 pub version: String,
60 pub url: String,
62 pub sha256: Option<String>,
64}
65
66pub(crate) async fn resolve_prebuilt(
73 formula: &str,
74 vendor_arch: &str,
75) -> Result<PrebuiltResolution> {
76 let (language, version_token) = formula_language_version(formula);
77 resolve_prebuilt_url(&language, &version_token, vendor_arch).await
78}
79
80#[must_use]
87#[allow(clippy::module_name_repetitions)]
88pub fn is_prebuilt_formula(formula: &str) -> bool {
89 let (language, _) = formula_language_version(formula);
90 matches!(
91 language.as_str(),
92 "go" | "node" | "rust" | "python" | "deno" | "bun" | "zig" | "java" | "graalvm"
93 )
94}
95
96#[allow(clippy::module_name_repetitions)]
115pub async fn ensure_prebuilt(
116 formula: &str,
117 cache_dir: &Path,
118 lockfile: Option<&crate::ToolchainLockfile>,
119) -> Result<PathBuf> {
120 use crate::ToolchainLockfileExt;
121 let (language, version_token) = formula_language_version(formula);
122
123 let (resolved_version, url, expected_sha) =
127 if let Some(locked) = lockfile.and_then(|l| l.lookup(formula, "macos", arch_token())) {
128 (
129 locked.version.clone(),
130 locked.url.clone(),
131 Some(locked.sha256.clone()),
132 )
133 } else {
134 let resolution = resolve_prebuilt_url(&language, &version_token, host_arch()).await?;
135 (resolution.version, resolution.url, resolution.sha256)
136 };
137
138 let toolchain = cache_dir.join(format!("{formula}-{resolved_version}-{}", arch_token()));
139 let ready_marker = toolchain.join(".ready");
140
141 if tokio::fs::try_exists(&ready_marker).await.unwrap_or(false) {
143 return Ok(toolchain);
144 }
145
146 let id = ToolchainArtifactId {
152 tool: formula.to_string(),
153 version: resolved_version.clone(),
154 os: MACOS_OS_TOKEN.to_string(),
155 arch: arch_token().to_string(),
156 };
157 if crate::pull_first(&id, &toolchain).await?.is_some() {
158 return Ok(toolchain);
159 }
160
161 match fetch_prebuilt_fresh(
163 formula,
164 &language,
165 &resolved_version,
166 &url,
167 expected_sha.as_deref(),
168 &toolchain,
169 )
170 .await
171 {
172 Ok(()) => {
173 crate::finish_built(&id, &toolchain, None, true).await;
174 Ok(toolchain)
175 }
176 Err(e) => {
177 crate::record_failed(&id, &e).await;
178 Err(e)
179 }
180 }
181}
182
183async fn fetch_prebuilt_fresh(
189 formula: &str,
190 language: &str,
191 resolved_version: &str,
192 url: &str,
193 expected_sha: Option<&str>,
194 toolchain: &Path,
195) -> Result<()> {
196 let _ = tokio::fs::remove_dir_all(toolchain).await;
198 tokio::fs::create_dir_all(toolchain).await?;
199
200 let scratch = toolchain.join(".download");
201 tokio::fs::create_dir_all(&scratch).await?;
202
203 info!(
204 formula,
205 language,
206 version = %resolved_version,
207 url = %url,
208 "fetching prebuilt toolchain archive"
209 );
210
211 let archive = scratch.join("archive");
214 let computed_sha = crate::package_index::download_verified(url, &archive, expected_sha).await?;
215
216 extract_toolchain(language, &archive, toolchain).await?;
218
219 if language == "rust" {
223 tokio::fs::create_dir_all(toolchain.join("cargo/bin")).await?;
224 tokio::fs::create_dir_all(toolchain.join("rustup")).await?;
225 }
226
227 if language == "node" {
234 tokio::fs::create_dir_all(toolchain.join("etc")).await?;
235 tokio::fs::write(toolchain.join("etc/openssl_sandbox.cnf"), b"").await?;
236 }
237
238 let manifest = build_manifest(language, resolved_version, url, &computed_sha, toolchain).await;
240 manifest.write_to_toolchain(toolchain).await?;
241
242 if let Err(e) = tokio::fs::remove_dir_all(&scratch).await {
243 warn!(error = %e, "failed to clean prebuilt download scratch dir (non-fatal)");
244 }
245 tokio::fs::write(toolchain.join(".ready"), b"").await?;
246
247 Ok(())
248}
249
250fn formula_language_version(formula: &str) -> (String, String) {
261 let (name, version) = match formula.split_once('@') {
262 Some((n, v)) if !v.is_empty() => (n, v.to_string()),
263 _ => (formula, "latest".to_string()),
264 };
265 (normalize_language(name), version)
266}
267
268fn normalize_language(name: &str) -> String {
274 let lower = name.to_ascii_lowercase();
275 if lower.contains("graalvm") {
276 return "graalvm".to_string();
277 }
278 let canonical = match lower.as_str() {
279 "go" | "golang" => "go",
280 "node" | "nodejs" => "node",
281 "rust" => "rust",
282 "python" | "python3" => "python",
283 "deno" => "deno",
284 "bun" => "bun",
285 "zig" => "zig",
286 "java" | "openjdk" => "java",
287 other => other,
288 };
289 canonical.to_string()
290}
291
292fn toolchain_path_dirs_and_env(
309 language: &str,
310 toolchain: &Path,
311) -> (Vec<String>, HashMap<String, String>) {
312 let toolchain_str = toolchain.display().to_string();
313 let bin = toolchain.join("bin").display().to_string();
314 let mut env = HashMap::new();
315
316 let path_dirs = match language {
317 "go" => {
318 env.insert("GOROOT".to_string(), toolchain_str);
319 env.insert("GOFLAGS".to_string(), "-buildvcs=false".to_string());
320 vec![bin]
321 }
322 "rust" => {
323 env.insert(
324 "CARGO_HOME".to_string(),
325 toolchain.join("cargo").display().to_string(),
326 );
327 env.insert(
328 "RUSTUP_HOME".to_string(),
329 toolchain.join("rustup").display().to_string(),
330 );
331 vec![toolchain.join("cargo/bin").display().to_string(), bin]
332 }
333 "java" => {
334 env.insert("JAVA_HOME".to_string(), toolchain_str);
335 vec![bin]
336 }
337 "graalvm" => {
338 env.insert("JAVA_HOME".to_string(), toolchain_str.clone());
339 env.insert("GRAALVM_HOME".to_string(), toolchain_str);
340 vec![bin]
341 }
342 "node" => {
343 env.insert(
347 "OPENSSL_CONF".to_string(),
348 toolchain
349 .join("etc/openssl_sandbox.cnf")
350 .display()
351 .to_string(),
352 );
353 vec![bin]
354 }
355 _ => vec![bin],
357 };
358
359 (path_dirs, env)
360}
361
362async fn build_manifest(
365 language: &str,
366 resolved_version: &str,
367 url: &str,
368 sha256: &str,
369 toolchain: &Path,
370) -> ToolchainManifest {
371 let (candidates, env) = toolchain_path_dirs_and_env(language, toolchain);
372
373 let mut path_dirs = Vec::with_capacity(candidates.len());
374 for dir in candidates {
375 if tokio::fs::try_exists(&dir).await.unwrap_or(false) {
376 path_dirs.push(dir);
377 }
378 }
379
380 ToolchainManifest {
381 tool: language.to_string(),
382 version: resolved_version.to_string(),
383 arch: arch_token().to_string(),
384 platform: "macos".to_string(),
385 path_dirs,
386 env,
387 source: ToolchainSource::Prebuilt {
388 url: url.to_string(),
389 sha256: sha256.to_string(),
390 },
391 build_deps: Vec::new(),
392 provisioned_at: chrono::Utc::now().to_rfc3339(),
393 }
394}
395
396fn arch_token() -> &'static str {
403 match std::env::consts::ARCH {
404 "aarch64" => "arm64",
405 other => other,
406 }
407}
408
409fn host_arch() -> &'static str {
411 if cfg!(target_arch = "aarch64") {
412 "arm64"
413 } else {
414 "amd64"
415 }
416}
417
418async fn resolve_prebuilt_url(
424 language: &str,
425 version: &str,
426 arch: &str,
427) -> Result<PrebuiltResolution> {
428 match language {
429 "go" => resolve_go(version, arch).await,
430 "node" => resolve_node(version, arch).await,
431 "rust" => resolve_rust(version, arch).await,
432 "python" => resolve_python(version, arch).await,
433 "deno" => resolve_deno(version, arch).await,
434 "bun" => resolve_bun(version, arch).await,
435 "zig" => resolve_zig(version, arch).await,
436 "java" => resolve_java(version, arch).await,
437 "graalvm" => resolve_graalvm(version, arch).await,
438 other => Err(ToolchainError::RegistryError {
439 message: format!(
440 "no prebuilt toolchain provisioner for '{other}'. \
441 Supported: go, node, rust, python, deno, bun, zig, java, graalvm."
442 ),
443 }),
444 }
445}
446
447async fn resolve_go(version: &str, arch: &str) -> Result<PrebuiltResolution> {
457 let resolved = if version == "latest" {
458 resolve_go_version_from_api(version).await?
459 } else if version.matches('.').count() < 2 {
460 resolve_go_version_from_api(version)
463 .await
464 .unwrap_or_else(|_| format!("{version}.0"))
465 } else {
466 version.to_string()
467 };
468
469 let filename = format!("go{resolved}.darwin-{arch}.tar.gz");
470 let url = format!("https://go.dev/dl/{filename}");
471 let sha256 = fetch_go_sha256(&filename).await;
472 Ok(PrebuiltResolution {
473 version: resolved,
474 url,
475 sha256,
476 })
477}
478
479#[derive(serde::Deserialize)]
482struct GoFile {
483 #[serde(default)]
484 filename: String,
485 #[serde(default)]
486 sha256: String,
487}
488
489#[derive(serde::Deserialize)]
491struct GoReleaseFiles {
492 #[serde(default)]
493 files: Vec<GoFile>,
494}
495
496async fn fetch_go_sha256(filename: &str) -> Option<String> {
500 let releases: Vec<GoReleaseFiles> = reqwest::get("https://go.dev/dl/?mode=json")
501 .await
502 .ok()?
503 .json()
504 .await
505 .ok()?;
506 for release in &releases {
507 for file in &release.files {
508 if file.filename == filename && is_hex_sha256(&file.sha256) {
509 return Some(file.sha256.to_ascii_lowercase());
510 }
511 }
512 }
513 None
514}
515
516async fn resolve_go_version_from_api(version_prefix: &str) -> Result<String> {
518 let api_url = "https://go.dev/dl/?mode=json";
519 let response = reqwest::get(api_url)
520 .await
521 .map_err(|e| ToolchainError::RegistryError {
522 message: format!("Failed to fetch Go versions from {api_url}: {e}"),
523 })?;
524
525 let releases: Vec<GoRelease> =
526 response
527 .json()
528 .await
529 .map_err(|e| ToolchainError::RegistryError {
530 message: format!("Failed to parse Go versions JSON: {e}"),
531 })?;
532
533 if version_prefix == "latest" {
534 return releases
535 .first()
536 .map(|r| {
537 r.version
538 .strip_prefix("go")
539 .unwrap_or(&r.version)
540 .to_string()
541 })
542 .ok_or_else(|| ToolchainError::RegistryError {
543 message: "No Go releases found".to_string(),
544 });
545 }
546
547 let prefix_dot = format!("go{version_prefix}.");
549 let prefix_exact = format!("go{version_prefix}");
550 for release in &releases {
551 if (release.version.starts_with(&prefix_dot) || release.version == prefix_exact)
552 && release.stable
553 {
554 return Ok(release
555 .version
556 .strip_prefix("go")
557 .unwrap_or(&release.version)
558 .to_string());
559 }
560 }
561
562 for release in &releases {
563 if release.version.starts_with(&prefix_dot) || release.version == prefix_exact {
564 return Ok(release
565 .version
566 .strip_prefix("go")
567 .unwrap_or(&release.version)
568 .to_string());
569 }
570 }
571
572 Err(ToolchainError::RegistryError {
573 message: format!("No Go release found matching version '{version_prefix}'"),
574 })
575}
576
577#[derive(serde::Deserialize)]
578struct GoRelease {
579 version: String,
580 stable: bool,
581}
582
583async fn resolve_node(version: &str, arch: &str) -> Result<PrebuiltResolution> {
592 let node_arch = match arch {
593 "arm64" => "arm64",
594 _ => "x64",
595 };
596
597 let resolved = if version == "latest" || !version.contains('.') {
600 resolve_node_version_from_api(version).await?
601 } else {
602 version.to_string()
603 };
604
605 let filename = format!("node-v{resolved}-darwin-{node_arch}.tar.gz");
606 let url = format!("https://nodejs.org/dist/v{resolved}/{filename}");
607 let shasums = format!("https://nodejs.org/dist/v{resolved}/SHASUMS256.txt");
608 let sha256 = fetch_sha256_for(&shasums, &filename).await;
609 Ok(PrebuiltResolution {
610 version: resolved,
611 url,
612 sha256,
613 })
614}
615
616async fn resolve_node_version_from_api(version_prefix: &str) -> Result<String> {
618 let api_url = "https://nodejs.org/dist/index.json";
619 let response = reqwest::get(api_url)
620 .await
621 .map_err(|e| ToolchainError::RegistryError {
622 message: format!("Failed to fetch Node.js versions from {api_url}: {e}"),
623 })?;
624
625 let releases: Vec<NodeRelease> =
626 response
627 .json()
628 .await
629 .map_err(|e| ToolchainError::RegistryError {
630 message: format!("Failed to parse Node.js versions JSON: {e}"),
631 })?;
632
633 if version_prefix == "latest" {
634 return releases
635 .first()
636 .map(|r| {
637 r.version
638 .strip_prefix('v')
639 .unwrap_or(&r.version)
640 .to_string()
641 })
642 .ok_or_else(|| ToolchainError::RegistryError {
643 message: "No Node.js releases found".to_string(),
644 });
645 }
646
647 if version_prefix == "lts" {
652 return select_newest_node_lts(&releases).ok_or_else(|| ToolchainError::RegistryError {
653 message: "No Node.js LTS release found in dist index".to_string(),
654 });
655 }
656
657 let prefix = format!("v{version_prefix}");
659 for release in &releases {
660 if release.version.starts_with(&prefix)
661 && release
662 .version
663 .chars()
664 .nth(prefix.len())
665 .is_none_or(|c| c == '.')
666 {
667 return Ok(release
668 .version
669 .strip_prefix('v')
670 .unwrap_or(&release.version)
671 .to_string());
672 }
673 }
674
675 Err(ToolchainError::RegistryError {
676 message: format!("No Node.js release found matching version '{version_prefix}'"),
677 })
678}
679
680#[derive(serde::Deserialize)]
681struct NodeRelease {
682 version: String,
683 #[serde(default, deserialize_with = "deserialize_node_lts")]
687 lts: Option<String>,
688}
689
690fn deserialize_node_lts<'de, D>(deserializer: D) -> std::result::Result<Option<String>, D::Error>
694where
695 D: serde::Deserializer<'de>,
696{
697 use serde::Deserialize;
698 Ok(match serde_json::Value::deserialize(deserializer)? {
699 serde_json::Value::String(s) => Some(s),
700 _ => None,
701 })
702}
703
704fn parse_node_semver(version: &str) -> (u64, u64, u64) {
707 let v = version.strip_prefix('v').unwrap_or(version);
708 let mut parts = v.split('.').map(|p| p.parse::<u64>().unwrap_or(0));
709 (
710 parts.next().unwrap_or(0),
711 parts.next().unwrap_or(0),
712 parts.next().unwrap_or(0),
713 )
714}
715
716fn select_newest_node_lts(releases: &[NodeRelease]) -> Option<String> {
720 releases
721 .iter()
722 .filter(|r| r.lts.is_some())
723 .max_by_key(|r| parse_node_semver(&r.version))
724 .map(|r| {
725 r.version
726 .strip_prefix('v')
727 .unwrap_or(&r.version)
728 .to_string()
729 })
730}
731
732async fn resolve_rust(version: &str, arch: &str) -> Result<PrebuiltResolution> {
741 let rust_target = match arch {
742 "arm64" => "aarch64-apple-darwin",
743 _ => "x86_64-apple-darwin",
744 };
745
746 let resolved = if version == "latest" {
747 resolve_rust_latest_version().await?
748 } else if version.matches('.').count() < 2 {
749 format!("{version}.0")
751 } else {
752 version.to_string()
753 };
754
755 let url = format!("https://static.rust-lang.org/dist/rust-{resolved}-{rust_target}.tar.gz");
756 let sha256 = fetch_sha256_token(&format!("{url}.sha256")).await;
757 Ok(PrebuiltResolution {
758 version: resolved,
759 url,
760 sha256,
761 })
762}
763
764async fn resolve_rust_latest_version() -> Result<String> {
766 let channel_url = "https://static.rust-lang.org/dist/channel-rust-stable.toml";
767 let response = reqwest::get(channel_url)
768 .await
769 .map_err(|e| ToolchainError::RegistryError {
770 message: format!("Failed to fetch Rust stable channel from {channel_url}: {e}"),
771 })?;
772
773 let body = response
774 .text()
775 .await
776 .map_err(|e| ToolchainError::RegistryError {
777 message: format!("Failed to read Rust stable channel response: {e}"),
778 })?;
779
780 let pkg_rust_pos = body
781 .find("[pkg.rust]")
782 .ok_or_else(|| ToolchainError::RegistryError {
783 message: "Rust stable channel TOML missing [pkg.rust] section".to_string(),
784 })?;
785
786 let after_pkg = &body[pkg_rust_pos..];
787 let version_prefix = "version = \"";
788 let ver_start =
789 after_pkg
790 .find(version_prefix)
791 .ok_or_else(|| ToolchainError::RegistryError {
792 message: "No version field found in [pkg.rust] section".to_string(),
793 })?
794 + version_prefix.len();
795
796 let ver_str: String = after_pkg[ver_start..]
797 .chars()
798 .take_while(|c| c.is_ascii_digit() || *c == '.')
799 .collect();
800
801 if ver_str.is_empty() {
802 return Err(ToolchainError::RegistryError {
803 message: "Failed to parse Rust version from stable channel".to_string(),
804 });
805 }
806
807 debug!("Resolved Rust latest stable version: {ver_str}");
808 Ok(ver_str)
809}
810
811async fn resolve_python(version: &str, arch: &str) -> Result<PrebuiltResolution> {
817 let python_target = match arch {
818 "arm64" => "aarch64-apple-darwin",
819 _ => "x86_64-apple-darwin",
820 };
821
822 resolve_python_from_github(version, python_target).await
823}
824
825async fn resolve_python_from_github(
828 version_prefix: &str,
829 target: &str,
830) -> Result<PrebuiltResolution> {
831 let api_url =
832 "https://api.github.com/repos/astral-sh/python-build-standalone/releases?per_page=25";
833
834 let client = reqwest::Client::builder()
835 .user_agent("zlayer")
836 .build()
837 .map_err(|e| ToolchainError::RegistryError {
838 message: format!("Failed to build HTTP client: {e}"),
839 })?;
840
841 let response = client
842 .get(api_url)
843 .send()
844 .await
845 .map_err(|e| ToolchainError::RegistryError {
846 message: format!("Failed to fetch Python releases from GitHub: {e}"),
847 })?;
848
849 if !response.status().is_success() {
850 return Err(ToolchainError::RegistryError {
851 message: format!(
852 "GitHub API returned status {} fetching Python releases",
853 response.status()
854 ),
855 });
856 }
857
858 let releases: Vec<GitHubRelease> =
859 response
860 .json()
861 .await
862 .map_err(|e| ToolchainError::RegistryError {
863 message: format!("Failed to parse GitHub releases JSON: {e}"),
864 })?;
865
866 let target_suffix = format!("{target}-install_only_stripped.tar.gz");
867
868 if version_prefix == "latest" {
869 for release in &releases {
870 for asset in &release.assets {
871 if asset.name.starts_with("cpython-")
872 && asset.name.ends_with(&target_suffix)
873 && asset.name.contains("install_only")
874 {
875 let py_version = extract_python_version_from_asset(&asset.name);
876 if !py_version.is_empty() {
877 debug!("Resolved Python latest to {py_version}");
878 let sha256 =
879 sibling_sha256(&release.assets, &format!("{}.sha256", asset.name))
880 .await;
881 return Ok(PrebuiltResolution {
882 version: py_version,
883 url: asset.browser_download_url.clone(),
884 sha256,
885 });
886 }
887 }
888 }
889 }
890 return Err(ToolchainError::RegistryError {
891 message: format!(
892 "No Python release found for target '{target}' in recent GitHub releases"
893 ),
894 });
895 }
896
897 let exact_prefix = format!("cpython-{version_prefix}+");
898 let partial_prefix = format!("cpython-{version_prefix}.");
899
900 for release in &releases {
901 for asset in &release.assets {
902 if !asset.name.ends_with(&target_suffix) {
903 continue;
904 }
905 if asset.name.starts_with(&exact_prefix) || asset.name.starts_with(&partial_prefix) {
906 let py_version = extract_python_version_from_asset(&asset.name);
907 debug!("Resolved Python {version_prefix} to {py_version}");
908 let sha256 =
909 sibling_sha256(&release.assets, &format!("{}.sha256", asset.name)).await;
910 return Ok(PrebuiltResolution {
911 version: py_version,
912 url: asset.browser_download_url.clone(),
913 sha256,
914 });
915 }
916 }
917 }
918
919 Err(ToolchainError::RegistryError {
920 message: format!("No Python release found matching version '{version_prefix}'"),
921 })
922}
923
924fn extract_python_version_from_asset(asset_name: &str) -> String {
927 asset_name
928 .strip_prefix("cpython-")
929 .and_then(|s| s.split('+').next())
930 .unwrap_or("")
931 .to_string()
932}
933
934#[derive(serde::Deserialize)]
935struct GitHubRelease {
936 tag_name: Option<String>,
939 assets: Vec<GitHubAsset>,
940}
941
942#[derive(serde::Deserialize)]
943struct GitHubAsset {
944 name: String,
945 browser_download_url: String,
946}
947
948async fn resolve_deno(version: &str, arch: &str) -> Result<PrebuiltResolution> {
958 let deno_target = match arch {
959 "arm64" => "aarch64-apple-darwin",
960 _ => "x86_64-apple-darwin",
961 };
962
963 if version == "latest" || !version.contains('.') {
964 resolve_deno_from_github(version, deno_target).await
965 } else {
966 let url = format!(
967 "https://github.com/denoland/deno/releases/download/v{version}/deno-{deno_target}.zip"
968 );
969 Ok(PrebuiltResolution {
970 version: version.to_string(),
971 url,
972 sha256: None,
973 })
974 }
975}
976
977async fn resolve_deno_from_github(
979 version_prefix: &str,
980 target: &str,
981) -> Result<PrebuiltResolution> {
982 let api_url = "https://api.github.com/repos/denoland/deno/releases?per_page=25";
983
984 let client = reqwest::Client::builder()
985 .user_agent("zlayer")
986 .build()
987 .map_err(|e| ToolchainError::RegistryError {
988 message: format!("Failed to build HTTP client: {e}"),
989 })?;
990
991 let response = client
992 .get(api_url)
993 .send()
994 .await
995 .map_err(|e| ToolchainError::RegistryError {
996 message: format!("Failed to fetch Deno releases from GitHub: {e}"),
997 })?;
998
999 if !response.status().is_success() {
1000 return Err(ToolchainError::RegistryError {
1001 message: format!(
1002 "GitHub API returned status {} fetching Deno releases",
1003 response.status()
1004 ),
1005 });
1006 }
1007
1008 let releases: Vec<GitHubRelease> =
1009 response
1010 .json()
1011 .await
1012 .map_err(|e| ToolchainError::RegistryError {
1013 message: format!("Failed to parse GitHub releases JSON: {e}"),
1014 })?;
1015
1016 let asset_name = format!("deno-{target}.zip");
1017
1018 if version_prefix == "latest" {
1019 for release in &releases {
1020 for asset in &release.assets {
1021 if asset.name == asset_name {
1022 let tag = release
1023 .tag_name
1024 .as_deref()
1025 .unwrap_or("")
1026 .strip_prefix('v')
1027 .unwrap_or(release.tag_name.as_deref().unwrap_or(""));
1028 if !tag.is_empty() {
1029 debug!("Resolved Deno latest to {tag}");
1030 let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
1031 return Ok(PrebuiltResolution {
1032 version: tag.to_string(),
1033 url: asset.browser_download_url.clone(),
1034 sha256,
1035 });
1036 }
1037 }
1038 }
1039 }
1040 return Err(ToolchainError::RegistryError {
1041 message: format!(
1042 "No Deno release found for target '{target}' in recent GitHub releases"
1043 ),
1044 });
1045 }
1046
1047 let tag_prefix = format!("v{version_prefix}.");
1048 for release in &releases {
1049 let tag = release.tag_name.as_deref().unwrap_or("");
1050 if tag.starts_with(&tag_prefix) {
1051 for asset in &release.assets {
1052 if asset.name == asset_name {
1053 let ver = tag.strip_prefix('v').unwrap_or(tag);
1054 debug!("Resolved Deno {version_prefix} to {ver} (partial)");
1055 let sha256 = deno_sibling_sha256(&release.assets, &asset_name).await;
1056 return Ok(PrebuiltResolution {
1057 version: ver.to_string(),
1058 url: asset.browser_download_url.clone(),
1059 sha256,
1060 });
1061 }
1062 }
1063 }
1064 }
1065
1066 Err(ToolchainError::RegistryError {
1067 message: format!("No Deno release found matching version '{version_prefix}'"),
1068 })
1069}
1070
1071async fn deno_sibling_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1074 if let Some(sha) = sibling_sha256(assets, &format!("{asset_name}.sha256sum")).await {
1075 return Some(sha);
1076 }
1077 sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1078}
1079
1080async fn resolve_bun(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1089 let bun_arch = match arch {
1090 "arm64" => "aarch64",
1091 _ => "x64",
1092 };
1093
1094 if version == "latest" || !version.contains('.') {
1095 resolve_bun_from_github(version, bun_arch).await
1096 } else {
1097 let url = format!(
1098 "https://github.com/oven-sh/bun/releases/download/bun-v{version}/bun-darwin-{bun_arch}.zip"
1099 );
1100 Ok(PrebuiltResolution {
1101 version: version.to_string(),
1102 url,
1103 sha256: None,
1104 })
1105 }
1106}
1107
1108async fn resolve_bun_from_github(
1110 version_prefix: &str,
1111 bun_arch: &str,
1112) -> Result<PrebuiltResolution> {
1113 let api_url = "https://api.github.com/repos/oven-sh/bun/releases?per_page=25";
1114
1115 let client = reqwest::Client::builder()
1116 .user_agent("zlayer")
1117 .build()
1118 .map_err(|e| ToolchainError::RegistryError {
1119 message: format!("Failed to build HTTP client: {e}"),
1120 })?;
1121
1122 let response = client
1123 .get(api_url)
1124 .send()
1125 .await
1126 .map_err(|e| ToolchainError::RegistryError {
1127 message: format!("Failed to fetch Bun releases from GitHub: {e}"),
1128 })?;
1129
1130 if !response.status().is_success() {
1131 return Err(ToolchainError::RegistryError {
1132 message: format!(
1133 "GitHub API returned status {} fetching Bun releases",
1134 response.status()
1135 ),
1136 });
1137 }
1138
1139 let releases: Vec<GitHubRelease> =
1140 response
1141 .json()
1142 .await
1143 .map_err(|e| ToolchainError::RegistryError {
1144 message: format!("Failed to parse GitHub releases JSON: {e}"),
1145 })?;
1146
1147 let asset_name = format!("bun-darwin-{bun_arch}.zip");
1148
1149 if version_prefix == "latest" {
1150 for release in &releases {
1151 let tag = release.tag_name.as_deref().unwrap_or("");
1152 let ver = tag
1153 .strip_prefix("bun-v")
1154 .unwrap_or(tag.strip_prefix('v').unwrap_or(tag));
1155 if ver.is_empty() {
1156 continue;
1157 }
1158 for asset in &release.assets {
1159 if asset.name == asset_name {
1160 debug!("Resolved Bun latest to {ver}");
1161 let sha256 = bun_sha256(&release.assets, &asset_name).await;
1162 return Ok(PrebuiltResolution {
1163 version: ver.to_string(),
1164 url: asset.browser_download_url.clone(),
1165 sha256,
1166 });
1167 }
1168 }
1169 }
1170 return Err(ToolchainError::RegistryError {
1171 message: format!(
1172 "No Bun release found for arch '{bun_arch}' in recent GitHub releases"
1173 ),
1174 });
1175 }
1176
1177 let tag_prefix = format!("bun-v{version_prefix}.");
1178 for release in &releases {
1179 let tag = release.tag_name.as_deref().unwrap_or("");
1180 if tag.starts_with(&tag_prefix) {
1181 for asset in &release.assets {
1182 if asset.name == asset_name {
1183 let ver = tag.strip_prefix("bun-v").unwrap_or(tag);
1184 debug!("Resolved Bun {version_prefix} to {ver} (partial)");
1185 let sha256 = bun_sha256(&release.assets, &asset_name).await;
1186 return Ok(PrebuiltResolution {
1187 version: ver.to_string(),
1188 url: asset.browser_download_url.clone(),
1189 sha256,
1190 });
1191 }
1192 }
1193 }
1194 }
1195
1196 Err(ToolchainError::RegistryError {
1197 message: format!("No Bun release found matching version '{version_prefix}'"),
1198 })
1199}
1200
1201async fn bun_sha256(assets: &[GitHubAsset], asset_name: &str) -> Option<String> {
1204 if let Some(shasums) = assets.iter().find(|a| a.name == "SHASUMS256.txt") {
1205 if let Some(sha) = fetch_sha256_for(&shasums.browser_download_url, asset_name).await {
1206 return Some(sha);
1207 }
1208 }
1209 sibling_sha256(assets, &format!("{asset_name}.sha256")).await
1210}
1211
1212#[derive(serde::Deserialize)]
1219struct ZigDownloadInfo {
1220 tarball: String,
1221 #[serde(default)]
1222 shasum: String,
1223}
1224
1225async fn resolve_zig(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1227 let platform_key = match arch {
1228 "arm64" => "aarch64-macos",
1229 _ => "x86_64-macos",
1230 };
1231
1232 let index_url = "https://ziglang.org/download/index.json";
1233 let response = reqwest::get(index_url)
1234 .await
1235 .map_err(|e| ToolchainError::RegistryError {
1236 message: format!("Failed to fetch Zig download index from {index_url}: {e}"),
1237 })?;
1238
1239 let index: HashMap<String, serde_json::Value> =
1240 response
1241 .json()
1242 .await
1243 .map_err(|e| ToolchainError::RegistryError {
1244 message: format!("Failed to parse Zig download index JSON: {e}"),
1245 })?;
1246
1247 let resolved = if version == "latest" {
1248 let mut versions: Vec<&String> = index.keys().filter(|k| *k != "master").collect();
1249 versions.sort_by(|a, b| compare_version_strings(b, a));
1250 versions
1251 .first()
1252 .map(|v| (*v).clone())
1253 .ok_or_else(|| ToolchainError::RegistryError {
1254 message: "No stable Zig versions found in download index".to_string(),
1255 })?
1256 } else if index.contains_key(version) {
1257 version.to_string()
1258 } else {
1259 let prefix = format!("{version}.");
1260 let mut candidates: Vec<&String> = index
1261 .keys()
1262 .filter(|k| *k != "master" && k.starts_with(&prefix))
1263 .collect();
1264 candidates.sort_by(|a, b| compare_version_strings(b, a));
1265 candidates
1266 .first()
1267 .map(|v| (*v).clone())
1268 .ok_or_else(|| ToolchainError::RegistryError {
1269 message: format!("No Zig version found matching '{version}'"),
1270 })?
1271 };
1272
1273 let version_data = index
1274 .get(&resolved)
1275 .ok_or_else(|| ToolchainError::RegistryError {
1276 message: format!("Zig version '{resolved}' not found in download index"),
1277 })?;
1278
1279 let platform_data =
1280 version_data
1281 .get(platform_key)
1282 .ok_or_else(|| ToolchainError::RegistryError {
1283 message: format!(
1284 "No Zig download found for platform '{platform_key}' in version '{resolved}'"
1285 ),
1286 })?;
1287
1288 let info: ZigDownloadInfo = serde_json::from_value(platform_data.clone()).map_err(|e| {
1289 ToolchainError::RegistryError {
1290 message: format!(
1291 "Failed to parse Zig download info for {platform_key}/{resolved}: {e}"
1292 ),
1293 }
1294 })?;
1295
1296 debug!("Resolved Zig {version} to {resolved}: {}", info.tarball);
1297 let sha256 = is_hex_sha256(&info.shasum).then(|| info.shasum.to_ascii_lowercase());
1298 Ok(PrebuiltResolution {
1299 version: resolved,
1300 url: info.tarball,
1301 sha256,
1302 })
1303}
1304
1305fn compare_version_strings(a: &str, b: &str) -> std::cmp::Ordering {
1307 let a_parts: Vec<&str> = a.split('.').collect();
1308 let b_parts: Vec<&str> = b.split('.').collect();
1309 for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
1310 let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
1311 (Ok(an), Ok(bn)) => an.cmp(&bn),
1312 _ => ap.cmp(bp),
1313 };
1314 if ord != std::cmp::Ordering::Equal {
1315 return ord;
1316 }
1317 }
1318 a_parts.len().cmp(&b_parts.len())
1319}
1320
1321#[derive(serde::Deserialize)]
1327struct AdoptiumAvailableReleases {
1328 most_recent_lts: u32,
1330}
1331
1332async fn resolve_java(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1337 let adoptium_arch = match arch {
1338 "arm64" => "aarch64",
1339 _ => "x64",
1340 };
1341
1342 let feature_version = if version == "latest" {
1343 resolve_java_latest_lts().await?
1344 } else {
1345 version.split('.').next().unwrap_or(version).to_string()
1347 };
1348
1349 let url = format!(
1350 "https://api.adoptium.net/v3/binary/latest/{feature_version}/ga/mac/{adoptium_arch}/jdk/hotspot/normal/eclipse"
1351 );
1352
1353 Ok(PrebuiltResolution {
1354 version: feature_version,
1355 url,
1356 sha256: None,
1357 })
1358}
1359
1360async fn resolve_java_latest_lts() -> Result<String> {
1362 let api_url = "https://api.adoptium.net/v3/info/available_releases";
1363 let response = reqwest::get(api_url)
1364 .await
1365 .map_err(|e| ToolchainError::RegistryError {
1366 message: format!("Failed to fetch Adoptium available releases from {api_url}: {e}"),
1367 })?;
1368
1369 if !response.status().is_success() {
1370 return Err(ToolchainError::RegistryError {
1371 message: format!(
1372 "Adoptium API returned status {} fetching available releases",
1373 response.status()
1374 ),
1375 });
1376 }
1377
1378 let releases: AdoptiumAvailableReleases =
1379 response
1380 .json()
1381 .await
1382 .map_err(|e| ToolchainError::RegistryError {
1383 message: format!("Failed to parse Adoptium available releases JSON: {e}"),
1384 })?;
1385
1386 let version = releases.most_recent_lts.to_string();
1387 debug!("Resolved Java latest LTS to feature version {version}");
1388 Ok(version)
1389}
1390
1391async fn resolve_graalvm(version: &str, arch: &str) -> Result<PrebuiltResolution> {
1400 let graalvm_arch = match arch {
1401 "arm64" => "aarch64",
1402 _ => "x64",
1403 };
1404
1405 if version == "latest" || !version.contains('.') {
1406 resolve_graalvm_from_github(version, graalvm_arch).await
1407 } else {
1408 let url = format!(
1409 "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
1410 jdk-{version}/graalvm-community-jdk-{version}_macos-{graalvm_arch}_bin.tar.gz"
1411 );
1412 Ok(PrebuiltResolution {
1413 version: version.to_string(),
1414 url,
1415 sha256: None,
1416 })
1417 }
1418}
1419
1420async fn resolve_graalvm_from_github(
1422 version_prefix: &str,
1423 graalvm_arch: &str,
1424) -> Result<PrebuiltResolution> {
1425 let api_url = "https://api.github.com/repos/graalvm/graalvm-ce-builds/releases?per_page=25";
1426
1427 let client = reqwest::Client::builder()
1428 .user_agent("zlayer")
1429 .build()
1430 .map_err(|e| ToolchainError::RegistryError {
1431 message: format!("Failed to build HTTP client: {e}"),
1432 })?;
1433
1434 let response = client
1435 .get(api_url)
1436 .send()
1437 .await
1438 .map_err(|e| ToolchainError::RegistryError {
1439 message: format!("Failed to fetch GraalVM releases from GitHub: {e}"),
1440 })?;
1441
1442 if !response.status().is_success() {
1443 return Err(ToolchainError::RegistryError {
1444 message: format!(
1445 "GitHub API returned status {} fetching GraalVM releases",
1446 response.status()
1447 ),
1448 });
1449 }
1450
1451 let releases: Vec<GitHubRelease> =
1452 response
1453 .json()
1454 .await
1455 .map_err(|e| ToolchainError::RegistryError {
1456 message: format!("Failed to parse GitHub releases JSON: {e}"),
1457 })?;
1458
1459 if version_prefix == "latest" {
1460 for release in &releases {
1461 let tag = release.tag_name.as_deref().unwrap_or("");
1462 if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1463 if jdk_version.is_empty() {
1464 continue;
1465 }
1466 let filename =
1467 format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1468 let url = format!(
1469 "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1470 );
1471 debug!("Resolved GraalVM latest to {jdk_version}");
1472 let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1473 return Ok(PrebuiltResolution {
1474 version: jdk_version.to_string(),
1475 url,
1476 sha256,
1477 });
1478 }
1479 }
1480 return Err(ToolchainError::RegistryError {
1481 message: "No GraalVM CE release found in recent GitHub releases".to_string(),
1482 });
1483 }
1484
1485 let tag_prefix = format!("jdk-{version_prefix}.");
1486 for release in &releases {
1487 let tag = release.tag_name.as_deref().unwrap_or("");
1488 if tag.starts_with(&tag_prefix) {
1489 if let Some(jdk_version) = tag.strip_prefix("jdk-") {
1490 let filename =
1491 format!("graalvm-community-jdk-{jdk_version}_macos-{graalvm_arch}_bin.tar.gz");
1492 let url = format!(
1493 "https://github.com/graalvm/graalvm-ce-builds/releases/download/{tag}/{filename}"
1494 );
1495 debug!("Resolved GraalVM {version_prefix} to {jdk_version} (partial)");
1496 let sha256 = sibling_sha256(&release.assets, &format!("{filename}.sha256")).await;
1497 return Ok(PrebuiltResolution {
1498 version: jdk_version.to_string(),
1499 url,
1500 sha256,
1501 });
1502 }
1503 }
1504 }
1505
1506 Err(ToolchainError::RegistryError {
1507 message: format!("No GraalVM CE release found matching version '{version_prefix}'"),
1508 })
1509}
1510
1511fn is_hex_sha256(s: &str) -> bool {
1517 s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit())
1518}
1519
1520async fn fetch_sha256_token(url: &str) -> Option<String> {
1525 let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1526 let token = text.split_whitespace().next()?;
1527 is_hex_sha256(token).then(|| token.to_ascii_lowercase())
1528}
1529
1530async fn fetch_sha256_for(url: &str, filename: &str) -> Option<String> {
1534 let text = reqwest::get(url).await.ok()?.text().await.ok()?;
1535 for line in text.lines() {
1536 let mut cols = line.split_whitespace();
1537 if let (Some(hash), Some(name)) = (cols.next(), cols.next()) {
1538 let name = name.strip_prefix('*').unwrap_or(name);
1540 if name == filename && is_hex_sha256(hash) {
1541 return Some(hash.to_ascii_lowercase());
1542 }
1543 }
1544 }
1545 None
1546}
1547
1548async fn sibling_sha256(assets: &[GitHubAsset], sibling: &str) -> Option<String> {
1551 let asset = assets.iter().find(|a| a.name == sibling)?;
1552 fetch_sha256_token(&asset.browser_download_url).await
1553}
1554
1555#[allow(clippy::too_many_lines, clippy::match_same_arms)]
1562async fn extract_toolchain(language: &str, archive: &Path, target_dir: &Path) -> Result<()> {
1563 let archive_str = archive.display().to_string();
1564 let target_str = target_dir.display().to_string();
1565
1566 let output = match language {
1567 "go" | "node" => {
1568 tokio::process::Command::new("tar")
1570 .args([
1571 "xzf",
1572 &archive_str,
1573 "-C",
1574 &target_str,
1575 "--strip-components=1",
1576 ])
1577 .output()
1578 .await?
1579 }
1580 "rust" => {
1581 let extract_tmp = target_dir.join("_extract");
1584 tokio::fs::create_dir_all(&extract_tmp).await?;
1585 let extract_tmp_str = extract_tmp.display().to_string();
1586
1587 let tar_out = tokio::process::Command::new("tar")
1588 .args([
1589 "xzf",
1590 &archive_str,
1591 "-C",
1592 &extract_tmp_str,
1593 "--strip-components=1",
1594 ])
1595 .output()
1596 .await?;
1597
1598 if !tar_out.status.success() {
1599 let stderr = String::from_utf8_lossy(&tar_out.stderr);
1600 let _ = tokio::process::Command::new("chmod")
1601 .args(["-R", "u+w"])
1602 .arg(&extract_tmp)
1603 .status()
1604 .await;
1605 let _ = tokio::process::Command::new("rm")
1606 .args(["-rf"])
1607 .arg(&extract_tmp)
1608 .status()
1609 .await;
1610 return Err(ToolchainError::RegistryError {
1611 message: format!("Failed to extract Rust tarball: {stderr}"),
1612 });
1613 }
1614
1615 let install_sh = extract_tmp.join("install.sh");
1616 let install_out = tokio::process::Command::new("sh")
1617 .arg(install_sh.display().to_string())
1618 .arg(format!("--prefix={target_str}"))
1619 .arg("--disable-ldconfig")
1620 .output()
1621 .await?;
1622
1623 let _ = tokio::process::Command::new("chmod")
1624 .args(["-R", "u+w"])
1625 .arg(&extract_tmp)
1626 .status()
1627 .await;
1628 let _ = tokio::process::Command::new("rm")
1629 .args(["-rf"])
1630 .arg(&extract_tmp)
1631 .status()
1632 .await;
1633
1634 if !install_out.status.success() {
1635 let stderr = String::from_utf8_lossy(&install_out.stderr);
1636 return Err(ToolchainError::RegistryError {
1637 message: format!("Rust install.sh failed: {stderr}"),
1638 });
1639 }
1640
1641 return Ok(());
1642 }
1643 "deno" => {
1644 let out = tokio::process::Command::new("unzip")
1646 .args(["-o", &archive_str, "-d", &target_str])
1647 .output()
1648 .await?;
1649 if out.status.success() {
1650 let bin_dir = target_dir.join("bin");
1651 tokio::fs::create_dir_all(&bin_dir).await?;
1652 let deno_binary = target_dir.join("deno");
1653 if deno_binary.exists() {
1654 tokio::fs::rename(&deno_binary, bin_dir.join("deno")).await?;
1655 }
1656 }
1657 out
1658 }
1659 "zig" => {
1660 let out = tokio::process::Command::new("tar")
1663 .args([
1664 "xJf",
1665 &archive_str,
1666 "-C",
1667 &target_str,
1668 "--strip-components=1",
1669 ])
1670 .output()
1671 .await?;
1672 if out.status.success() {
1673 let bin_dir = target_dir.join("bin");
1674 tokio::fs::create_dir_all(&bin_dir).await?;
1675 let zig_binary = target_dir.join("zig");
1676 if zig_binary.exists() && !bin_dir.join("zig").exists() {
1677 #[cfg(unix)]
1682 tokio::fs::symlink(Path::new("../zig"), &bin_dir.join("zig")).await?;
1683 #[cfg(windows)]
1684 {
1685 tokio::fs::copy(&zig_binary, bin_dir.join("zig")).await?;
1686 }
1687 }
1688 }
1689 out
1690 }
1691 "java" | "graalvm" => {
1692 tokio::process::Command::new("tar")
1695 .args([
1696 "xzf",
1697 &archive_str,
1698 "-C",
1699 &target_str,
1700 "--strip-components=3",
1701 ])
1702 .output()
1703 .await?
1704 }
1705 "bun" => {
1706 let out = tokio::process::Command::new("unzip")
1708 .args(["-o", &archive_str, "-d", &target_str])
1709 .output()
1710 .await?;
1711 if out.status.success() {
1712 let bin_dir = target_dir.join("bin");
1713 tokio::fs::create_dir_all(&bin_dir).await?;
1714 if let Ok(mut entries) = tokio::fs::read_dir(target_dir).await {
1715 while let Ok(Some(entry)) = entries.next_entry().await {
1716 if entry.file_name().to_string_lossy().starts_with("bun-") {
1717 let bun_binary = entry.path().join("bun");
1718 if bun_binary.exists() {
1719 tokio::fs::rename(&bun_binary, bin_dir.join("bun")).await?;
1720 let _ = tokio::process::Command::new("chmod")
1721 .args(["-R", "u+w"])
1722 .arg(entry.path())
1723 .status()
1724 .await;
1725 let _ = tokio::process::Command::new("rm")
1726 .args(["-rf"])
1727 .arg(entry.path())
1728 .status()
1729 .await;
1730 }
1731 }
1732 }
1733 }
1734 }
1735 out
1736 }
1737 _ => {
1739 tokio::process::Command::new("tar")
1740 .args([
1741 "xzf",
1742 &archive_str,
1743 "-C",
1744 &target_str,
1745 "--strip-components=1",
1746 ])
1747 .output()
1748 .await?
1749 }
1750 };
1751
1752 if !output.status.success() {
1753 let stderr = String::from_utf8_lossy(&output.stderr);
1754 return Err(ToolchainError::RegistryError {
1755 message: format!("Failed to extract {language} toolchain: {stderr}"),
1756 });
1757 }
1758
1759 Ok(())
1760}
1761
1762#[cfg(test)]
1763mod tests {
1764 use super::*;
1765
1766 #[test]
1767 fn is_prebuilt_accepts_languages_and_aliases() {
1768 for f in [
1769 "go",
1770 "golang",
1771 "node",
1772 "nodejs",
1773 "rust",
1774 "python",
1775 "python3",
1776 "python@3.12",
1777 "deno",
1778 "bun",
1779 "zig",
1780 "java",
1781 "openjdk",
1782 "openjdk@17",
1783 "node@22",
1784 "graalvm",
1785 "graalvm-ce",
1786 "graalvm-community",
1787 ] {
1788 assert!(is_prebuilt_formula(f), "{f} should be a prebuilt formula");
1789 }
1790 }
1791
1792 #[test]
1793 fn is_prebuilt_rejects_non_languages_and_swift() {
1794 for f in ["swift", "git", "jq", "cmake", "ripgrep", "openssl@3"] {
1795 assert!(
1796 !is_prebuilt_formula(f),
1797 "{f} should NOT be a prebuilt formula"
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn formula_split_maps_language_and_version() {
1804 assert_eq!(
1805 formula_language_version("python@3.12"),
1806 ("python".to_string(), "3.12".to_string())
1807 );
1808 assert_eq!(
1809 formula_language_version("node@22"),
1810 ("node".to_string(), "22".to_string())
1811 );
1812 assert_eq!(
1813 formula_language_version("go"),
1814 ("go".to_string(), "latest".to_string())
1815 );
1816 assert_eq!(
1817 formula_language_version("golang"),
1818 ("go".to_string(), "latest".to_string())
1819 );
1820 assert_eq!(
1821 formula_language_version("openjdk@17"),
1822 ("java".to_string(), "17".to_string())
1823 );
1824 assert_eq!(
1825 formula_language_version("python3"),
1826 ("python".to_string(), "latest".to_string())
1827 );
1828 assert_eq!(
1829 formula_language_version("graalvm-ce"),
1830 ("graalvm".to_string(), "latest".to_string())
1831 );
1832 assert_eq!(
1833 formula_language_version("nodejs"),
1834 ("node".to_string(), "latest".to_string())
1835 );
1836 }
1837
1838 #[test]
1839 fn node_lts_selection_picks_highest_lts_codename() {
1840 let json = r#"[
1843 {"version":"v26.0.0","lts":false},
1844 {"version":"v25.2.0","lts":false},
1845 {"version":"v24.18.0","lts":"Krypton"},
1846 {"version":"v22.20.0","lts":"Jod"},
1847 {"version":"v20.19.0","lts":"Iron"}
1848 ]"#;
1849 let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1850 assert_eq!(
1851 select_newest_node_lts(&releases).as_deref(),
1852 Some("24.18.0"),
1853 "LTS resolver must pick the newest line whose lts is a codename string"
1854 );
1855 }
1856
1857 #[test]
1858 fn node_lts_selection_is_none_when_no_lts_line() {
1859 let json = r#"[{"version":"v26.0.0","lts":false},{"version":"v25.2.0","lts":false}]"#;
1860 let releases: Vec<NodeRelease> = serde_json::from_str(json).unwrap();
1861 assert_eq!(select_newest_node_lts(&releases), None);
1862 }
1863
1864 #[test]
1865 fn node_lts_token_is_a_prebuilt_node_formula() {
1866 assert!(is_prebuilt_formula("node@lts"));
1869 assert_eq!(
1870 formula_language_version("node@lts"),
1871 ("node".to_string(), "lts".to_string())
1872 );
1873 }
1874
1875 #[test]
1876 fn go_toolchain_layout_sets_goroot_and_bin() {
1877 let toolchain = Path::new("/cache/go-1.23.6-arm64");
1878 let (path_dirs, env) = toolchain_path_dirs_and_env("go", toolchain);
1879 assert_eq!(path_dirs, vec!["/cache/go-1.23.6-arm64/bin".to_string()]);
1880 assert_eq!(
1881 env.get("GOROOT").map(String::as_str),
1882 Some("/cache/go-1.23.6-arm64")
1883 );
1884 assert_eq!(
1885 env.get("GOFLAGS").map(String::as_str),
1886 Some("-buildvcs=false")
1887 );
1888 }
1889
1890 #[test]
1891 fn rust_toolchain_layout_sets_cargo_and_rustup_homes() {
1892 let toolchain = Path::new("/cache/rust-1.82.0-arm64");
1893 let (path_dirs, env) = toolchain_path_dirs_and_env("rust", toolchain);
1894 assert_eq!(
1895 path_dirs,
1896 vec![
1897 "/cache/rust-1.82.0-arm64/cargo/bin".to_string(),
1898 "/cache/rust-1.82.0-arm64/bin".to_string(),
1899 ]
1900 );
1901 assert_eq!(
1902 env.get("CARGO_HOME").map(String::as_str),
1903 Some("/cache/rust-1.82.0-arm64/cargo")
1904 );
1905 assert_eq!(
1906 env.get("RUSTUP_HOME").map(String::as_str),
1907 Some("/cache/rust-1.82.0-arm64/rustup")
1908 );
1909 }
1910
1911 #[test]
1912 fn graalvm_toolchain_layout_sets_both_homes() {
1913 let toolchain = Path::new("/cache/graalvm-21.0.5-arm64");
1914 let (_, env) = toolchain_path_dirs_and_env("graalvm", toolchain);
1915 assert_eq!(
1916 env.get("JAVA_HOME").map(String::as_str),
1917 Some("/cache/graalvm-21.0.5-arm64")
1918 );
1919 assert_eq!(
1920 env.get("GRAALVM_HOME").map(String::as_str),
1921 Some("/cache/graalvm-21.0.5-arm64")
1922 );
1923 }
1924
1925 #[test]
1926 fn node_toolchain_layout_sets_openssl_conf() {
1927 let toolchain = Path::new("/cache/node-22.1.0-arm64");
1928 let (path_dirs, env) = toolchain_path_dirs_and_env("node", toolchain);
1929 assert_eq!(path_dirs, vec!["/cache/node-22.1.0-arm64/bin".to_string()]);
1930 assert_eq!(
1934 env.get("OPENSSL_CONF").map(String::as_str),
1935 Some("/cache/node-22.1.0-arm64/etc/openssl_sandbox.cnf")
1936 );
1937 }
1938
1939 #[test]
1940 fn python_version_extracted_from_asset_name() {
1941 assert_eq!(
1942 extract_python_version_from_asset(
1943 "cpython-3.12.8+20250106-aarch64-apple-darwin-install_only_stripped.tar.gz"
1944 ),
1945 "3.12.8"
1946 );
1947 assert_eq!(extract_python_version_from_asset("cpython-"), "");
1948 assert_eq!(extract_python_version_from_asset("not-cpython"), "");
1949 }
1950
1951 #[test]
1952 fn version_strings_compare_numerically() {
1953 use std::cmp::Ordering;
1954 assert_eq!(
1955 compare_version_strings("0.14.0", "0.13.0"),
1956 Ordering::Greater
1957 );
1958 assert_eq!(
1959 compare_version_strings("1.0.0", "0.14.0"),
1960 Ordering::Greater
1961 );
1962 assert_eq!(
1963 compare_version_strings("0.14.1", "0.14.0"),
1964 Ordering::Greater
1965 );
1966 assert_eq!(compare_version_strings("0.14.0", "0.14.0"), Ordering::Equal);
1967 }
1968
1969 #[tokio::test]
1974 async fn resolve_deno_exact_url() {
1975 let r = resolve_deno("2.1.4", "arm64").await.unwrap();
1976 assert_eq!(r.version, "2.1.4");
1977 assert_eq!(
1978 r.url,
1979 "https://github.com/denoland/deno/releases/download/v2.1.4/deno-aarch64-apple-darwin.zip"
1980 );
1981 assert!(r.sha256.is_none());
1983 }
1984
1985 #[tokio::test]
1986 async fn resolve_bun_exact_url() {
1987 let r = resolve_bun("1.2.3", "arm64").await.unwrap();
1988 assert_eq!(r.version, "1.2.3");
1989 assert_eq!(
1990 r.url,
1991 "https://github.com/oven-sh/bun/releases/download/bun-v1.2.3/bun-darwin-aarch64.zip"
1992 );
1993 assert!(r.sha256.is_none());
1994 }
1995
1996 #[tokio::test]
1997 async fn resolve_graalvm_exact_url() {
1998 let r = resolve_graalvm("21.0.5", "arm64").await.unwrap();
1999 assert_eq!(r.version, "21.0.5");
2000 assert_eq!(
2001 r.url,
2002 "https://github.com/graalvm/graalvm-ce-builds/releases/download/\
2003 jdk-21.0.5/graalvm-community-jdk-21.0.5_macos-aarch64_bin.tar.gz"
2004 );
2005 assert!(r.sha256.is_none());
2006 }
2007
2008 #[tokio::test]
2009 async fn resolve_java_exact_url_strips_to_major() {
2010 let r = resolve_java("21.0.5", "arm64").await.unwrap();
2011 assert_eq!(r.version, "21");
2012 assert_eq!(
2013 r.url,
2014 "https://api.adoptium.net/v3/binary/latest/21/ga/mac/aarch64/jdk/hotspot/normal/eclipse"
2015 );
2016 assert!(r.sha256.is_none());
2017 }
2018
2019 #[test]
2020 fn zig_download_info_parses_shasum() {
2021 let info: ZigDownloadInfo = serde_json::from_str(
2022 r#"{"tarball":"https://ziglang.org/x.tar.xz","shasum":"aa","size":"1"}"#,
2023 )
2024 .unwrap();
2025 assert_eq!(info.tarball, "https://ziglang.org/x.tar.xz");
2026 assert_eq!(info.shasum, "aa");
2027 }
2028
2029 #[test]
2030 fn hex_sha256_validation() {
2031 assert!(is_hex_sha256(
2032 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
2033 ));
2034 assert!(!is_hex_sha256("tooshort"));
2035 assert!(!is_hex_sha256(
2036 "zz4d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
2037 ));
2038 }
2039}