1use std::collections::HashMap;
16use std::path::Path;
17
18use serde::{Deserialize, Serialize};
19
20use crate::error::{Result, ToolchainError};
21
22pub const MANIFEST_FILE: &str = "toolchain.json";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ToolchainSource {
29 SourceBuild {
32 url: String,
34 #[serde(default)]
37 sha256: String,
38 },
39 Prebuilt {
42 url: String,
44 #[serde(default)]
48 sha256: String,
49 },
50 Registry {
57 reference: String,
60 #[serde(default)]
62 digest: String,
63 },
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ToolchainManifest {
73 pub tool: String,
75 pub version: String,
77 pub arch: String,
79 pub platform: String,
81 pub path_dirs: Vec<String>,
83 pub env: HashMap<String, String>,
85 pub source: ToolchainSource,
87 pub build_deps: Vec<String>,
90 pub provisioned_at: String,
92}
93
94impl ToolchainManifest {
95 pub async fn write_to_toolchain(&self, toolchain: &Path) -> Result<()> {
102 let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
103 message: format!(
104 "failed to serialize toolchain manifest for {}: {e}",
105 self.tool
106 ),
107 })?;
108 tokio::fs::write(toolchain.join(MANIFEST_FILE), json).await?;
109 Ok(())
110 }
111
112 pub async fn read_from_toolchain(toolchain: &Path) -> Result<Option<Self>> {
120 let path = toolchain.join(MANIFEST_FILE);
121 match tokio::fs::read(&path).await {
122 Ok(bytes) => {
123 let manifest: Self =
124 serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
125 message: format!("corrupt toolchain manifest at {}: {e}", path.display()),
126 })?;
127 Ok(Some(manifest))
128 }
129 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
130 Err(e) => Err(ToolchainError::IoError(e)),
131 }
132 }
133
134 pub async fn load_or_synthesize(toolchain: &Path) -> Result<Self> {
146 if let Some(manifest) = Self::read_from_toolchain(toolchain).await? {
147 return Ok(manifest);
148 }
149 Ok(Self::synthesize_from_toolchain(toolchain).await)
150 }
151
152 pub async fn synthesize_from_toolchain(toolchain: &Path) -> Self {
155 let mut path_dirs = Vec::new();
156 let bin = toolchain.join("bin");
157 if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
158 path_dirs.push(bin.display().to_string());
159 }
160
161 let mut env = HashMap::new();
162 let git_exec = toolchain.join("libexec/git-core");
163 if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
164 env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
165 }
166
167 let (tool, version, arch) = parse_toolchain_dir_name(toolchain);
169
170 Self {
171 tool,
172 version,
173 arch,
174 platform: "macos".to_string(),
175 path_dirs,
176 env,
177 source: ToolchainSource::SourceBuild {
178 url: String::new(),
179 sha256: String::new(),
180 },
181 build_deps: Vec::new(),
182 provisioned_at: String::new(),
183 }
184 }
185}
186
187fn parse_toolchain_dir_name(toolchain: &Path) -> (String, String, String) {
192 let name = toolchain
193 .file_name()
194 .and_then(|s| s.to_str())
195 .unwrap_or_default()
196 .to_string();
197 let parts: Vec<&str> = name.rsplitn(3, '-').collect(); match parts.as_slice() {
199 [arch, version, tool] => (
200 (*tool).to_string(),
201 (*version).to_string(),
202 (*arch).to_string(),
203 ),
204 _ => (name, String::new(), String::new()),
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 #[tokio::test]
213 async fn round_trips_through_disk() {
214 let tmp = tempfile::tempdir().unwrap();
215 let toolchain = tmp.path();
216 let mut env = HashMap::new();
217 env.insert(
218 "GIT_EXEC_PATH".to_string(),
219 "/x/libexec/git-core".to_string(),
220 );
221 let manifest = ToolchainManifest {
222 tool: "git".to_string(),
223 version: "2.55.0".to_string(),
224 arch: "arm64".to_string(),
225 platform: "macos".to_string(),
226 path_dirs: vec!["/x/bin".to_string()],
227 env,
228 source: ToolchainSource::SourceBuild {
229 url: "https://example/git.tar.xz".to_string(),
230 sha256: "deadbeef".to_string(),
231 },
232 build_deps: vec![],
233 provisioned_at: "2026-06-30T00:00:00Z".to_string(),
234 };
235 manifest.write_to_toolchain(toolchain).await.unwrap();
236
237 let read = ToolchainManifest::read_from_toolchain(toolchain)
238 .await
239 .unwrap()
240 .unwrap();
241 assert_eq!(read.tool, "git");
242 assert_eq!(read.version, "2.55.0");
243 assert_eq!(
244 read.env.get("GIT_EXEC_PATH").map(String::as_str),
245 Some("/x/libexec/git-core")
246 );
247 assert!(matches!(
248 read.source,
249 ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
250 ));
251 }
252
253 #[tokio::test]
254 async fn missing_manifest_reads_none() {
255 let tmp = tempfile::tempdir().unwrap();
256 assert!(ToolchainManifest::read_from_toolchain(tmp.path())
257 .await
258 .unwrap()
259 .is_none());
260 }
261
262 #[tokio::test]
263 async fn synthesizes_git_layout_when_manifest_absent() {
264 let tmp = tempfile::tempdir().unwrap();
265 let toolchain = tmp.path().join("git-2.55.0-arm64");
266 tokio::fs::create_dir_all(toolchain.join("bin"))
267 .await
268 .unwrap();
269 tokio::fs::create_dir_all(toolchain.join("libexec/git-core"))
270 .await
271 .unwrap();
272
273 let manifest = ToolchainManifest::load_or_synthesize(&toolchain)
274 .await
275 .unwrap();
276 assert_eq!(manifest.tool, "git");
277 assert_eq!(manifest.version, "2.55.0");
278 assert_eq!(manifest.arch, "arm64");
279 assert_eq!(
280 manifest.path_dirs,
281 vec![toolchain.join("bin").display().to_string()]
282 );
283 assert_eq!(
284 manifest.env.get("GIT_EXEC_PATH"),
285 Some(&toolchain.join("libexec/git-core").display().to_string())
286 );
287 assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
288 assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
289 }
290
291 #[test]
292 fn parses_toolchain_dir_name_with_dashed_tool() {
293 let (tool, version, arch) =
294 parse_toolchain_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
295 assert_eq!(tool, "openssl");
296 assert_eq!(version, "3.4.0");
297 assert_eq!(arch, "arm64");
298 }
299
300 #[test]
301 fn registry_source_round_trips_through_serde() {
302 let manifest = ToolchainManifest {
303 tool: "jq".to_string(),
304 version: "1.8.1".to_string(),
305 arch: "arm64".to_string(),
306 platform: "macos".to_string(),
307 path_dirs: vec!["/x/bin".to_string()],
308 env: HashMap::new(),
309 source: ToolchainSource::Registry {
310 reference: "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
311 .to_string(),
312 digest: "sha256:deadbeef".to_string(),
313 },
314 build_deps: vec![],
315 provisioned_at: "2026-07-06T00:00:00Z".to_string(),
316 };
317 let json = serde_json::to_string_pretty(&manifest).unwrap();
318 assert!(
319 json.contains("\"registry\""),
320 "externally tagged as registry: {json}"
321 );
322
323 let read: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
324 assert_eq!(read.source, manifest.source);
325 }
326
327 #[test]
328 fn old_source_build_manifest_still_parses() {
329 let json = r#"{
330 "tool": "git",
331 "version": "2.55.0",
332 "arch": "arm64",
333 "platform": "macos",
334 "path_dirs": ["/x/bin"],
335 "env": {},
336 "source": {
337 "source_build": {
338 "url": "https://example/git.tar.xz",
339 "sha256": "deadbeef"
340 }
341 },
342 "build_deps": [],
343 "provisioned_at": "2026-06-30T00:00:00Z"
344 }"#;
345 let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
346 assert!(matches!(
347 manifest.source,
348 ToolchainSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
349 ));
350 }
351
352 #[test]
353 fn registry_manifest_parses_to_registry_variant() {
354 let json = r#"{
355 "tool": "jq",
356 "version": "1.8.1",
357 "arch": "arm64",
358 "platform": "macos",
359 "path_dirs": ["/x/bin"],
360 "env": {},
361 "source": {
362 "registry": {
363 "reference": "ghcr.io/blackleafdigital/zlayer/toolchains:jq-1.8.1-macos-arm64"
364 }
365 },
366 "build_deps": [],
367 "provisioned_at": "2026-07-06T00:00:00Z"
368 }"#;
369 let manifest: ToolchainManifest = serde_json::from_slice(json.as_bytes()).unwrap();
370 assert!(matches!(
371 manifest.source,
372 ToolchainSource::Registry { ref reference, ref digest }
373 if reference.ends_with("jq-1.8.1-macos-arm64") && digest.is_empty()
374 ));
375 }
376}