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 KegSource {
SourceBuild {
url: String,
#[serde(default)]
sha256: String,
},
Prebuilt {
url: String,
#[serde(default)]
sha256: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KegManifest {
pub tool: String,
pub version: String,
pub arch: String,
pub platform: String,
pub path_dirs: Vec<String>,
pub env: HashMap<String, String>,
pub source: KegSource,
pub build_deps: Vec<String>,
pub provisioned_at: String,
}
impl KegManifest {
pub async fn write_to_keg(&self, keg: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
message: format!("failed to serialize keg manifest for {}: {e}", self.tool),
})?;
tokio::fs::write(keg.join(MANIFEST_FILE), json).await?;
Ok(())
}
pub async fn read_from_keg(keg: &Path) -> Result<Option<Self>> {
let path = keg.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 keg 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(keg: &Path) -> Result<Self> {
if let Some(manifest) = Self::read_from_keg(keg).await? {
return Ok(manifest);
}
Ok(Self::synthesize_from_keg(keg).await)
}
pub async fn synthesize_from_keg(keg: &Path) -> Self {
let mut path_dirs = Vec::new();
let bin = keg.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 = keg.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_keg_dir_name(keg);
Self {
tool,
version,
arch,
platform: "macos".to_string(),
path_dirs,
env,
source: KegSource::SourceBuild {
url: String::new(),
sha256: String::new(),
},
build_deps: Vec::new(),
provisioned_at: String::new(),
}
}
}
fn parse_keg_dir_name(keg: &Path) -> (String, String, String) {
let name = keg
.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 keg = tmp.path();
let mut env = HashMap::new();
env.insert(
"GIT_EXEC_PATH".to_string(),
"/x/libexec/git-core".to_string(),
);
let manifest = KegManifest {
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: KegSource::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_keg(keg).await.unwrap();
let read = KegManifest::read_from_keg(keg).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,
KegSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
));
}
#[tokio::test]
async fn missing_manifest_reads_none() {
let tmp = tempfile::tempdir().unwrap();
assert!(KegManifest::read_from_keg(tmp.path())
.await
.unwrap()
.is_none());
}
#[tokio::test]
async fn synthesizes_git_layout_when_manifest_absent() {
let tmp = tempfile::tempdir().unwrap();
let keg = tmp.path().join("git-2.55.0-arm64");
tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
tokio::fs::create_dir_all(keg.join("libexec/git-core"))
.await
.unwrap();
let manifest = KegManifest::load_or_synthesize(&keg).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![keg.join("bin").display().to_string()]
);
assert_eq!(
manifest.env.get("GIT_EXEC_PATH"),
Some(&keg.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_keg_dir_name_with_dashed_tool() {
let (tool, version, arch) = parse_keg_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
assert_eq!(tool, "openssl");
assert_eq!(version, "3.4.0");
assert_eq!(arch, "arm64");
}
}