1use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20pub const DEFAULT_PACKAGE_INDEX_URL: &str = "https://packages.zlayer.dev";
22
23pub const PACKAGE_INDEX_URL_ENV: &str = "ZLAYER_PACKAGE_INDEX_URL";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct PackageIndexConfig {
33 pub base_url: String,
35}
36
37impl Default for PackageIndexConfig {
38 fn default() -> Self {
39 Self {
40 base_url: DEFAULT_PACKAGE_INDEX_URL.to_string(),
41 }
42 }
43}
44
45impl PackageIndexConfig {
46 #[must_use]
48 pub fn new(base_url: impl Into<String>) -> Self {
49 let base_url = base_url.into().trim_end_matches('/').to_string();
50 let base_url = if base_url.is_empty() {
51 DEFAULT_PACKAGE_INDEX_URL.to_string()
52 } else {
53 base_url
54 };
55 Self { base_url }
56 }
57
58 #[must_use]
61 pub fn from_env() -> Self {
62 match std::env::var(PACKAGE_INDEX_URL_ENV) {
63 Ok(v) if !v.trim().is_empty() => Self::new(v.trim()),
64 _ => Self::default(),
65 }
66 }
67
68 #[must_use]
74 fn base(&self) -> &str {
75 self.base_url.trim_end_matches('/')
76 }
77
78 #[must_use]
84 pub fn linux_request_url(&self) -> String {
85 format!("{}/linux/request", self.base())
86 }
87
88 #[must_use]
93 pub fn choco_hint_url(&self) -> String {
94 format!("{}/choco-hint", self.base())
95 }
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
102pub struct FormulaData {
103 #[serde(default)]
105 pub versions: FormulaVersions,
106 #[serde(default)]
108 pub urls: FormulaUrls,
109 #[serde(default)]
111 pub bottle: FormulaBottle,
112 #[serde(default)]
114 pub dependencies: Vec<String>,
115 #[serde(default)]
117 pub build_dependencies: Vec<String>,
118 #[serde(default)]
121 pub uses_from_macos: Vec<UsesFromMacos>,
122 #[serde(default)]
125 pub ruby_source_path: Option<String>,
126}
127
128#[derive(Debug, Clone, Default, Serialize, Deserialize)]
130pub struct FormulaVersions {
131 #[serde(default)]
133 pub stable: Option<String>,
134}
135
136#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct FormulaUrls {
139 #[serde(default)]
141 pub stable: Option<FormulaUrlStable>,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
146pub struct FormulaUrlStable {
147 #[serde(default)]
149 pub url: String,
150 #[serde(default)]
152 pub checksum: String,
153}
154
155#[derive(Debug, Clone, Default, Serialize, Deserialize)]
157pub struct FormulaBottle {
158 #[serde(default)]
160 pub stable: Option<FormulaBottleStable>,
161}
162
163#[derive(Debug, Clone, Default, Serialize, Deserialize)]
165pub struct FormulaBottleStable {
166 #[serde(default)]
168 pub files: HashMap<String, FormulaBottleFile>,
169}
170
171#[derive(Debug, Clone, Default, Serialize, Deserialize)]
173pub struct FormulaBottleFile {
174 #[serde(default)]
176 pub url: String,
177 #[serde(default)]
179 pub sha256: String,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
185#[serde(untagged)]
186pub enum UsesFromMacos {
187 Name(String),
189 Conditional(HashMap<String, serde_json::Value>),
191}
192
193impl UsesFromMacos {
194 #[must_use]
196 pub fn name(&self) -> Option<&str> {
197 match self {
198 Self::Name(name) => Some(name.as_str()),
199 Self::Conditional(map) => map.keys().next().map(String::as_str),
200 }
201 }
202}
203
204impl FormulaData {
205 #[must_use]
207 pub fn stable_version(&self) -> Option<&str> {
208 self.versions.stable.as_deref().filter(|v| !v.is_empty())
209 }
210
211 #[must_use]
213 pub fn stable_url(&self) -> Option<&str> {
214 self.urls
215 .stable
216 .as_ref()
217 .map(|u| u.url.as_str())
218 .filter(|u| !u.is_empty())
219 }
220
221 #[must_use]
224 pub fn stable_checksum(&self) -> Option<String> {
225 self.urls
226 .stable
227 .as_ref()
228 .map(|u| u.checksum.trim())
229 .filter(|c| !c.is_empty())
230 .map(|c| c.strip_prefix("sha256:").unwrap_or(c).to_string())
231 }
232
233 #[must_use]
235 pub fn bottle_file(&self, tag: &str) -> Option<&FormulaBottleFile> {
236 self.bottle.stable.as_ref().and_then(|b| b.files.get(tag))
237 }
238
239 #[must_use]
242 pub fn macos_provided(&self) -> Vec<String> {
243 self.uses_from_macos
244 .iter()
245 .filter_map(|u| u.name().map(String::from))
246 .collect()
247 }
248}
249
250#[derive(Debug, Clone, Default, Serialize, Deserialize)]
256pub struct ChocoData {
257 #[serde(default, alias = "Version")]
259 pub version: String,
260 #[serde(default, alias = "Url", alias = "url", alias = "nupkg_url")]
262 pub url: String,
263 #[serde(default, alias = "Sha256")]
265 pub sha256: Option<String>,
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const GIT_JSON: &str = r#"{
273 "versions": {"stable": "2.55.0"},
274 "urls": {"stable": {"url": "https://example/git-2.55.0.tar.xz", "checksum": "sha256:abc123"}},
275 "bottle": {"stable": {"files": {
276 "arm64_sonoma": {"url": "https://ghcr.io/git", "sha256": "deadbeef"}
277 }}},
278 "dependencies": ["pcre2", "gettext"],
279 "build_dependencies": ["gettext", "pkgconf"],
280 "uses_from_macos": ["curl", "expat", {"llvm": ["build"]}],
281 "ruby_source_path": "Formula/g/git.rb"
282 }"#;
283
284 #[test]
285 fn parses_all_fields_including_checksum_and_bottle() {
286 let f: FormulaData = serde_json::from_str(GIT_JSON).unwrap();
287 assert_eq!(f.stable_version(), Some("2.55.0"));
288 assert_eq!(f.stable_url(), Some("https://example/git-2.55.0.tar.xz"));
289 assert_eq!(f.stable_checksum().as_deref(), Some("abc123"));
291 assert_eq!(f.dependencies, vec!["pcre2", "gettext"]);
292 assert_eq!(f.build_dependencies, vec!["gettext", "pkgconf"]);
293 assert_eq!(f.ruby_source_path.as_deref(), Some("Formula/g/git.rb"));
294 assert_eq!(f.macos_provided(), vec!["curl", "expat", "llvm"]);
295 let bottle = f.bottle_file("arm64_sonoma").unwrap();
296 assert_eq!(bottle.url, "https://ghcr.io/git");
297 assert_eq!(bottle.sha256, "deadbeef");
298 assert!(f.bottle_file("missing").is_none());
299 }
300
301 #[test]
302 fn missing_fields_default_cleanly() {
303 let f: FormulaData = serde_json::from_str("{}").unwrap();
304 assert_eq!(f.stable_version(), None);
305 assert_eq!(f.stable_url(), None);
306 assert_eq!(f.stable_checksum(), None);
307 assert!(f.dependencies.is_empty());
308 assert!(f.macos_provided().is_empty());
309 assert!(f.ruby_source_path.is_none());
310 }
311
312 #[test]
313 fn empty_version_url_and_checksum_are_absent() {
314 let f: FormulaData = serde_json::from_str(
315 r#"{"versions":{"stable":""},"urls":{"stable":{"url":"","checksum":""}}}"#,
316 )
317 .unwrap();
318 assert_eq!(f.stable_version(), None);
319 assert_eq!(f.stable_url(), None);
320 assert_eq!(f.stable_checksum(), None);
321 }
322
323 #[test]
324 fn checksum_without_prefix_passes_through() {
325 let f: FormulaData =
326 serde_json::from_str(r#"{"urls":{"stable":{"url":"u","checksum":"beef"}}}"#).unwrap();
327 assert_eq!(f.stable_checksum().as_deref(), Some("beef"));
328 }
329
330 #[test]
331 fn config_from_env_defaults() {
332 assert_eq!(
334 PackageIndexConfig::new("https://x.dev/").base_url,
335 "https://x.dev"
336 );
337 assert_eq!(
338 PackageIndexConfig::default().base_url,
339 DEFAULT_PACKAGE_INDEX_URL
340 );
341 }
342
343 #[test]
344 fn derived_endpoint_urls() {
345 let cfg = PackageIndexConfig::new("https://packages.example.dev/");
346 assert_eq!(
347 cfg.linux_request_url(),
348 "https://packages.example.dev/linux/request"
349 );
350 assert_eq!(
351 cfg.choco_hint_url(),
352 "https://packages.example.dev/choco-hint"
353 );
354 assert_eq!(
356 PackageIndexConfig::default().linux_request_url(),
357 format!("{DEFAULT_PACKAGE_INDEX_URL}/linux/request")
358 );
359 }
360
361 #[test]
362 fn choco_parses_odata_casing() {
363 let c: ChocoData =
364 serde_json::from_str(r#"{"Version":"1.2.3","Url":"https://x/pkg.nupkg"}"#).unwrap();
365 assert_eq!(c.version, "1.2.3");
366 assert_eq!(c.url, "https://x/pkg.nupkg");
367 assert!(c.sha256.is_none());
368 }
369}