use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{Result, ToolchainError};
pub const MANIFEST_FILE: &str = "toolchain.json";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolchainSource {
SourceBuild {
url: String,
#[serde(default)]
sha256: String,
},
Prebuilt {
url: String,
#[serde(default)]
sha256: String,
},
Registry {
reference: String,
#[serde(default)]
digest: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolchainManifest {
pub tool: String,
pub version: String,
pub arch: String,
pub platform: String,
pub path_dirs: Vec<String>,
pub env: HashMap<String, String>,
pub source: ToolchainSource,
pub build_deps: Vec<String>,
pub provisioned_at: String,
}
impl ToolchainManifest {
pub async fn write_to_toolchain(&self, toolchain: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
message: format!(
"failed to serialize toolchain manifest for {}: {e}",
self.tool
),
})?;
tokio::fs::write(toolchain.join(MANIFEST_FILE), json).await?;
Ok(())
}
pub async fn read_from_toolchain(toolchain: &Path) -> Result<Option<Self>> {
let path = toolchain.join(MANIFEST_FILE);
match tokio::fs::read(&path).await {
Ok(bytes) => {
let manifest: Self =
serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
message: format!("corrupt toolchain manifest at {}: {e}", path.display()),
})?;
Ok(Some(manifest))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ToolchainError::IoError(e)),
}
}
pub async fn load_or_synthesize(toolchain: &Path) -> Result<Self> {
if let Some(manifest) = Self::read_from_toolchain(toolchain).await? {
return Ok(manifest);
}
Ok(Self::synthesize_from_toolchain(toolchain).await)
}
pub async fn synthesize_from_toolchain(toolchain: &Path) -> Self {
let mut path_dirs = Vec::new();
let bin = toolchain.join("bin");
if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
path_dirs.push(bin.display().to_string());
}
let mut env = HashMap::new();
let git_exec = toolchain.join("libexec/git-core");
if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
}
let (tool, version, arch) = parse_toolchain_dir_name(toolchain);
Self {
tool,
version,
arch,
platform: "macos".to_string(),
path_dirs,
env,
source: ToolchainSource::SourceBuild {
url: String::new(),
sha256: String::new(),
},
build_deps: Vec::new(),
provisioned_at: String::new(),
}
}
}
fn parse_toolchain_dir_name(toolchain: &Path) -> (String, String, String) {
let name = toolchain
.file_name()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let parts: Vec<&str> = name.rsplitn(3, '-').collect(); match parts.as_slice() {
[arch, version, tool] => (
(*tool).to_string(),
(*version).to_string(),
(*arch).to_string(),
),
_ => (name, String::new(), String::new()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn round_trips_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp.path();
let mut env = HashMap::new();
env.insert(
"GIT_EXEC_PATH".to_string(),
"/x/libexec/git-core".to_string(),
);
let manifest = ToolchainManifest {
tool: "git".to_string(),
version: "2.55.0".to_string(),
arch: "arm64".to_string(),
platform: "macos".to_string(),
path_dirs: vec!["/x/bin".to_string()],
env,
source: ToolchainSource::SourceBuild {
url: "https://example/git.tar.xz".to_string(),
sha256: "deadbeef".to_string(),
},
build_deps: vec![],
provisioned_at: "2026-06-30T00:00:00Z".to_string(),
};
manifest.write_to_toolchain(toolchain).await.unwrap();
let read = ToolchainManifest::read_from_toolchain(toolchain)
.await
.unwrap()
.unwrap();
assert_eq!(read.tool, "git");
assert_eq!(read.version, "2.55.0");
assert_eq!(
read.env.get("GIT_EXEC_PATH").map(String::as_str),
Some("/x/libexec/git-core")
);
assert!(matches!(
read.source,
ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
));
}
#[tokio::test]
async fn missing_manifest_reads_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(ToolchainManifest::read_from_toolchain(tmp.path())
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn synthesizes_git_layout_when_manifest_absent() {
let tmp = tempfile::tempdir().unwrap();
let toolchain = tmp.path().join("git-2.55.0-arm64");
tokio::fs::create_dir_all(toolchain.join("bin"))
.await
.unwrap();
tokio::fs::create_dir_all(toolchain.join("libexec/git-core"))
.await
.unwrap();
let manifest = ToolchainManifest::load_or_synthesize(&toolchain)
.await
.unwrap();
assert_eq!(manifest.tool, "git");
assert_eq!(manifest.version, "2.55.0");
assert_eq!(manifest.arch, "arm64");
assert_eq!(
manifest.path_dirs,
vec![toolchain.join("bin").display().to_string()]
);
assert_eq!(
manifest.env.get("GIT_EXEC_PATH"),
Some(&toolchain.join("libexec/git-core").display().to_string())
);
assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
}
#[test]
fn parses_toolchain_dir_name_with_dashed_tool() {
let (tool, version, arch) =
parse_toolchain_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
assert_eq!(tool, "openssl");
assert_eq!(version, "3.4.0");
assert_eq!(arch, "arm64");
}
#[test]
fn registry_source_round_trips_through_serde() {
let manifest = ToolchainManifest {
tool: "jq".to_string(),
version: "1.8.1".to_string(),
arch: "arm64".to_string(),
platform: "macos".to_string(),
path_dirs: vec!["/x/bin".to_string()],
env: HashMap::new(),
source: ToolchainSource::Registry {
reference: "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
.to_string(),
digest: "sha256:deadbeef".to_string(),
},
build_deps: vec![],
provisioned_at: "2026-07-06T00:00:00Z".to_string(),
};
let json = serde_json::to_string_pretty(&manifest).unwrap();
assert!(
json.contains("\"registry\""),
"externally tagged as registry: {json}"
);
let read: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
assert_eq!(read.source, manifest.source);
}
#[test]
fn old_source_build_manifest_still_parses() {
let json = r#"{
"tool": "git",
"version": "2.55.0",
"arch": "arm64",
"platform": "macos",
"path_dirs": ["/x/bin"],
"env": {},
"source": {
"source_build": {
"url": "https://example/git.tar.xz",
"sha256": "deadbeef"
}
},
"build_deps": [],
"provisioned_at": "2026-06-30T00:00:00Z"
}"#;
let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
assert!(matches!(
manifest.source,
ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
));
}
#[test]
fn registry_manifest_parses_to_registry_variant() {
let json = r#"{
"tool": "jq",
"version": "1.8.1",
"arch": "arm64",
"platform": "macos",
"path_dirs": ["/x/bin"],
"env": {},
"source": {
"registry": {
"reference": "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
}
},
"build_deps": [],
"provisioned_at": "2026-07-06T00:00:00Z"
}"#;
let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
assert!(matches!(
manifest.source,
ToolchainSource::Registry { ref reference, ref digest }
if reference.ends_with("jq-1.8.1-macos-arm64") && digest.is_empty()
));
}
}