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 KegSource {
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}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct KegManifest {
59 pub tool: String,
61 pub version: String,
63 pub arch: String,
65 pub platform: String,
67 pub path_dirs: Vec<String>,
69 pub env: HashMap<String, String>,
71 pub source: KegSource,
73 pub build_deps: Vec<String>,
76 pub provisioned_at: String,
78}
79
80impl KegManifest {
81 pub async fn write_to_keg(&self, keg: &Path) -> Result<()> {
88 let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
89 message: format!("failed to serialize keg manifest for {}: {e}", self.tool),
90 })?;
91 tokio::fs::write(keg.join(MANIFEST_FILE), json).await?;
92 Ok(())
93 }
94
95 pub async fn read_from_keg(keg: &Path) -> Result<Option<Self>> {
103 let path = keg.join(MANIFEST_FILE);
104 match tokio::fs::read(&path).await {
105 Ok(bytes) => {
106 let manifest: Self =
107 serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
108 message: format!("corrupt keg manifest at {}: {e}", path.display()),
109 })?;
110 Ok(Some(manifest))
111 }
112 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
113 Err(e) => Err(ToolchainError::IoError(e)),
114 }
115 }
116
117 pub async fn load_or_synthesize(keg: &Path) -> Result<Self> {
129 if let Some(manifest) = Self::read_from_keg(keg).await? {
130 return Ok(manifest);
131 }
132 Ok(Self::synthesize_from_keg(keg).await)
133 }
134
135 pub async fn synthesize_from_keg(keg: &Path) -> Self {
138 let mut path_dirs = Vec::new();
139 let bin = keg.join("bin");
140 if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
141 path_dirs.push(bin.display().to_string());
142 }
143
144 let mut env = HashMap::new();
145 let git_exec = keg.join("libexec/git-core");
146 if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
147 env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
148 }
149
150 let (tool, version, arch) = parse_keg_dir_name(keg);
152
153 Self {
154 tool,
155 version,
156 arch,
157 platform: "macos".to_string(),
158 path_dirs,
159 env,
160 source: KegSource::SourceBuild {
161 url: String::new(),
162 sha256: String::new(),
163 },
164 build_deps: Vec::new(),
165 provisioned_at: String::new(),
166 }
167 }
168}
169
170fn parse_keg_dir_name(keg: &Path) -> (String, String, String) {
175 let name = keg
176 .file_name()
177 .and_then(|s| s.to_str())
178 .unwrap_or_default()
179 .to_string();
180 let parts: Vec<&str> = name.rsplitn(3, '-').collect(); match parts.as_slice() {
182 [arch, version, tool] => (
183 (*tool).to_string(),
184 (*version).to_string(),
185 (*arch).to_string(),
186 ),
187 _ => (name, String::new(), String::new()),
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[tokio::test]
196 async fn round_trips_through_disk() {
197 let tmp = tempfile::tempdir().unwrap();
198 let keg = tmp.path();
199 let mut env = HashMap::new();
200 env.insert(
201 "GIT_EXEC_PATH".to_string(),
202 "/x/libexec/git-core".to_string(),
203 );
204 let manifest = KegManifest {
205 tool: "git".to_string(),
206 version: "2.55.0".to_string(),
207 arch: "arm64".to_string(),
208 platform: "macos".to_string(),
209 path_dirs: vec!["/x/bin".to_string()],
210 env,
211 source: KegSource::SourceBuild {
212 url: "https://example/git.tar.xz".to_string(),
213 sha256: "deadbeef".to_string(),
214 },
215 build_deps: vec![],
216 provisioned_at: "2026-06-30T00:00:00Z".to_string(),
217 };
218 manifest.write_to_keg(keg).await.unwrap();
219
220 let read = KegManifest::read_from_keg(keg).await.unwrap().unwrap();
221 assert_eq!(read.tool, "git");
222 assert_eq!(read.version, "2.55.0");
223 assert_eq!(
224 read.env.get("GIT_EXEC_PATH").map(String::as_str),
225 Some("/x/libexec/git-core")
226 );
227 assert!(matches!(
228 read.source,
229 KegSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
230 ));
231 }
232
233 #[tokio::test]
234 async fn missing_manifest_reads_none() {
235 let tmp = tempfile::tempdir().unwrap();
236 assert!(KegManifest::read_from_keg(tmp.path())
237 .await
238 .unwrap()
239 .is_none());
240 }
241
242 #[tokio::test]
243 async fn synthesizes_git_layout_when_manifest_absent() {
244 let tmp = tempfile::tempdir().unwrap();
245 let keg = tmp.path().join("git-2.55.0-arm64");
246 tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
247 tokio::fs::create_dir_all(keg.join("libexec/git-core"))
248 .await
249 .unwrap();
250
251 let manifest = KegManifest::load_or_synthesize(&keg).await.unwrap();
252 assert_eq!(manifest.tool, "git");
253 assert_eq!(manifest.version, "2.55.0");
254 assert_eq!(manifest.arch, "arm64");
255 assert_eq!(
256 manifest.path_dirs,
257 vec![keg.join("bin").display().to_string()]
258 );
259 assert_eq!(
260 manifest.env.get("GIT_EXEC_PATH"),
261 Some(&keg.join("libexec/git-core").display().to_string())
262 );
263 assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
264 assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
265 }
266
267 #[test]
268 fn parses_keg_dir_name_with_dashed_tool() {
269 let (tool, version, arch) = parse_keg_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
270 assert_eq!(tool, "openssl");
271 assert_eq!(version, "3.4.0");
272 assert_eq!(arch, "arm64");
273 }
274}