use std::collections::HashMap;
use serde::{Deserialize, Serialize};
pub const DEFAULT_PACKAGE_INDEX_URL: &str = "https://packages.zlayer.dev";
pub const PACKAGE_INDEX_URL_ENV: &str = "ZLAYER_PACKAGE_INDEX_URL";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageIndexConfig {
pub base_url: String,
}
impl Default for PackageIndexConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_PACKAGE_INDEX_URL.to_string(),
}
}
}
impl PackageIndexConfig {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
let base_url = base_url.into().trim_end_matches('/').to_string();
let base_url = if base_url.is_empty() {
DEFAULT_PACKAGE_INDEX_URL.to_string()
} else {
base_url
};
Self { base_url }
}
#[must_use]
pub fn from_env() -> Self {
match std::env::var(PACKAGE_INDEX_URL_ENV) {
Ok(v) if !v.trim().is_empty() => Self::new(v.trim()),
_ => Self::default(),
}
}
#[must_use]
fn base(&self) -> &str {
self.base_url.trim_end_matches('/')
}
#[must_use]
pub fn linux_request_url(&self) -> String {
format!("{}/linux/request", self.base())
}
#[must_use]
pub fn choco_hint_url(&self) -> String {
format!("{}/choco-hint", self.base())
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaData {
#[serde(default)]
pub versions: FormulaVersions,
#[serde(default)]
pub urls: FormulaUrls,
#[serde(default)]
pub bottle: FormulaBottle,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub build_dependencies: Vec<String>,
#[serde(default)]
pub uses_from_macos: Vec<UsesFromMacos>,
#[serde(default)]
pub ruby_source_path: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaVersions {
#[serde(default)]
pub stable: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaUrls {
#[serde(default)]
pub stable: Option<FormulaUrlStable>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaUrlStable {
#[serde(default)]
pub url: String,
#[serde(default)]
pub checksum: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottle {
#[serde(default)]
pub stable: Option<FormulaBottleStable>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottleStable {
#[serde(default)]
pub files: HashMap<String, FormulaBottleFile>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottleFile {
#[serde(default)]
pub url: String,
#[serde(default)]
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum UsesFromMacos {
Name(String),
Conditional(HashMap<String, serde_json::Value>),
}
impl UsesFromMacos {
#[must_use]
pub fn name(&self) -> Option<&str> {
match self {
Self::Name(name) => Some(name.as_str()),
Self::Conditional(map) => map.keys().next().map(String::as_str),
}
}
}
impl FormulaData {
#[must_use]
pub fn stable_version(&self) -> Option<&str> {
self.versions.stable.as_deref().filter(|v| !v.is_empty())
}
#[must_use]
pub fn stable_url(&self) -> Option<&str> {
self.urls
.stable
.as_ref()
.map(|u| u.url.as_str())
.filter(|u| !u.is_empty())
}
#[must_use]
pub fn stable_checksum(&self) -> Option<String> {
self.urls
.stable
.as_ref()
.map(|u| u.checksum.trim())
.filter(|c| !c.is_empty())
.map(|c| c.strip_prefix("sha256:").unwrap_or(c).to_string())
}
#[must_use]
pub fn bottle_file(&self, tag: &str) -> Option<&FormulaBottleFile> {
self.bottle.stable.as_ref().and_then(|b| b.files.get(tag))
}
#[must_use]
pub fn macos_provided(&self) -> Vec<String> {
self.uses_from_macos
.iter()
.filter_map(|u| u.name().map(String::from))
.collect()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChocoData {
#[serde(default, alias = "Version")]
pub version: String,
#[serde(default, alias = "Url", alias = "url", alias = "nupkg_url")]
pub url: String,
#[serde(default, alias = "Sha256")]
pub sha256: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
const GIT_JSON: &str = r#"{
"versions": {"stable": "2.55.0"},
"urls": {"stable": {"url": "https://example/git-2.55.0.tar.xz", "checksum": "sha256:abc123"}},
"bottle": {"stable": {"files": {
"arm64_sonoma": {"url": "https://ghcr.io/git", "sha256": "deadbeef"}
}}},
"dependencies": ["pcre2", "gettext"],
"build_dependencies": ["gettext", "pkgconf"],
"uses_from_macos": ["curl", "expat", {"llvm": ["build"]}],
"ruby_source_path": "Formula/g/git.rb"
}"#;
#[test]
fn parses_all_fields_including_checksum_and_bottle() {
let f: FormulaData = serde_json::from_str(GIT_JSON).unwrap();
assert_eq!(f.stable_version(), Some("2.55.0"));
assert_eq!(f.stable_url(), Some("https://example/git-2.55.0.tar.xz"));
assert_eq!(f.stable_checksum().as_deref(), Some("abc123"));
assert_eq!(f.dependencies, vec!["pcre2", "gettext"]);
assert_eq!(f.build_dependencies, vec!["gettext", "pkgconf"]);
assert_eq!(f.ruby_source_path.as_deref(), Some("Formula/g/git.rb"));
assert_eq!(f.macos_provided(), vec!["curl", "expat", "llvm"]);
let bottle = f.bottle_file("arm64_sonoma").unwrap();
assert_eq!(bottle.url, "https://ghcr.io/git");
assert_eq!(bottle.sha256, "deadbeef");
assert!(f.bottle_file("missing").is_none());
}
#[test]
fn missing_fields_default_cleanly() {
let f: FormulaData = serde_json::from_str("{}").unwrap();
assert_eq!(f.stable_version(), None);
assert_eq!(f.stable_url(), None);
assert_eq!(f.stable_checksum(), None);
assert!(f.dependencies.is_empty());
assert!(f.macos_provided().is_empty());
assert!(f.ruby_source_path.is_none());
}
#[test]
fn empty_version_url_and_checksum_are_absent() {
let f: FormulaData = serde_json::from_str(
r#"{"versions":{"stable":""},"urls":{"stable":{"url":"","checksum":""}}}"#,
)
.unwrap();
assert_eq!(f.stable_version(), None);
assert_eq!(f.stable_url(), None);
assert_eq!(f.stable_checksum(), None);
}
#[test]
fn checksum_without_prefix_passes_through() {
let f: FormulaData =
serde_json::from_str(r#"{"urls":{"stable":{"url":"u","checksum":"beef"}}}"#).unwrap();
assert_eq!(f.stable_checksum().as_deref(), Some("beef"));
}
#[test]
fn config_from_env_defaults() {
assert_eq!(
PackageIndexConfig::new("https://x.dev/").base_url,
"https://x.dev"
);
assert_eq!(
PackageIndexConfig::default().base_url,
DEFAULT_PACKAGE_INDEX_URL
);
}
#[test]
fn derived_endpoint_urls() {
let cfg = PackageIndexConfig::new("https://packages.example.dev/");
assert_eq!(
cfg.linux_request_url(),
"https://packages.example.dev/linux/request"
);
assert_eq!(
cfg.choco_hint_url(),
"https://packages.example.dev/choco-hint"
);
assert_eq!(
PackageIndexConfig::default().linux_request_url(),
format!("{DEFAULT_PACKAGE_INDEX_URL}/linux/request")
);
}
#[test]
fn choco_parses_odata_casing() {
let c: ChocoData =
serde_json::from_str(r#"{"Version":"1.2.3","Url":"https://x/pkg.nupkg"}"#).unwrap();
assert_eq!(c.version, "1.2.3");
assert_eq!(c.url, "https://x/pkg.nupkg");
assert!(c.sha256.is_none());
}
}