zlayer-types 0.13.0

Shared wire types for the ZLayer platform — API DTOs, OCI image references, and related serde types.
Documentation
//! Wire types for the ZLayer package index (`packages.zlayer.dev`).
//!
//! These are **pure serde** shapes: they carry no HTTP client and do no I/O. The
//! index client (which fetches `/formula/:name` and `/choco/:name`, follows
//! redirects, and fires HMAC-signed refresh hints) lives in `zlayer-toolchain`
//! so that this crate stays dependency-light (no `reqwest`, no `tokio`).
//!
//! [`FormulaData`] deserializes the subset of a Homebrew formula JSON that the
//! provisioning + integrity pipeline consumes: `versions.stable`,
//! `urls.stable.{url,checksum}` (the source-tarball sha256, sometimes
//! `sha256:`-prefixed), and `bottle.stable.files.<tag>.{url,sha256}` (the
//! per-platform prebuilt bottle digests), plus the dependency graph the macOS
//! source builder walks. Every field is permissive (`#[serde(default)]`) so a
//! partial or evolving index payload never fails to parse.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Default base URL for the ZLayer package index.
pub const DEFAULT_PACKAGE_INDEX_URL: &str = "https://packages.zlayer.dev";

/// Environment variable overriding the package-index base URL.
pub const PACKAGE_INDEX_URL_ENV: &str = "ZLAYER_PACKAGE_INDEX_URL";

/// Configuration for the package-index client.
///
/// Pure data — the HTTP client that consumes it lives in `zlayer-toolchain`.
/// Construct with [`PackageIndexConfig::from_env`] to honor
/// [`PACKAGE_INDEX_URL_ENV`], falling back to [`DEFAULT_PACKAGE_INDEX_URL`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PackageIndexConfig {
    /// Base URL of the index (no trailing slash), e.g. `https://packages.zlayer.dev`.
    pub base_url: String,
}

impl Default for PackageIndexConfig {
    fn default() -> Self {
        Self {
            base_url: DEFAULT_PACKAGE_INDEX_URL.to_string(),
        }
    }
}

impl PackageIndexConfig {
    /// Build a config from an explicit base URL (trailing slashes trimmed).
    #[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 }
    }

    /// Build a config from [`PACKAGE_INDEX_URL_ENV`], defaulting to
    /// [`DEFAULT_PACKAGE_INDEX_URL`] when the variable is unset or empty.
    #[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(),
        }
    }

    /// The base URL with any trailing slash trimmed.
    ///
    /// [`PackageIndexConfig::new`] already normalizes this, but
    /// [`Default`]/deserialized instances may retain one, so the URL helpers
    /// trim defensively.
    #[must_use]
    fn base(&self) -> &str {
        self.base_url.trim_end_matches('/')
    }

    /// URL of the Linux "unfulfilled request" endpoint (`{base_url}/linux/request`).
    ///
    /// The builder's cross-search harvest POSTs here (HMAC-signed) when a Linux
    /// package name maps to no Homebrew/Chocolatey equivalent, so the index can
    /// backfill the mapping for a future native build.
    #[must_use]
    pub fn linux_request_url(&self) -> String {
        format!("{}/linux/request", self.base())
    }

    /// URL of the Chocolatey cache-warm hint endpoint (`{base_url}/choco-hint`).
    ///
    /// The Windows image resolver POSTs here (HMAC-signed) to nudge the index to
    /// keep a distro/shard's Chocolatey mappings warm.
    #[must_use]
    pub fn choco_hint_url(&self) -> String {
        format!("{}/choco-hint", self.base())
    }
}

/// A parsed Homebrew formula (the subset served verbatim by `/formula/:name`).
///
/// The version is never hardcoded — a formula bump is picked up automatically.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaData {
    /// Released versions (we use `stable`).
    #[serde(default)]
    pub versions: FormulaVersions,
    /// Source/bottle URLs (we use `stable.{url,checksum}`).
    #[serde(default)]
    pub urls: FormulaUrls,
    /// Prebuilt bottle coordinates (per-platform `{url,sha256}`).
    #[serde(default)]
    pub bottle: FormulaBottle,
    /// Runtime dependencies (other formulae the tool links / runs against).
    #[serde(default)]
    pub dependencies: Vec<String>,
    /// Build-only dependencies (autoconf, pkgconf, cmake, ...).
    #[serde(default)]
    pub build_dependencies: Vec<String>,
    /// Dependencies satisfied by macOS itself (curl, expat, zlib, ...). Each
    /// entry is either a bare name or a `{name: [conditions]}` object.
    #[serde(default)]
    pub uses_from_macos: Vec<UsesFromMacos>,
    /// Path of the formula's Ruby definition within homebrew-core
    /// (e.g. `Formula/g/git.rb`).
    #[serde(default)]
    pub ruby_source_path: Option<String>,
}

/// Versions block (`versions.stable`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaVersions {
    /// Stable release version string (e.g. `2.55.0`).
    #[serde(default)]
    pub stable: Option<String>,
}

/// URLs block (`urls.stable`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaUrls {
    /// Stable source-tarball coordinates.
    #[serde(default)]
    pub stable: Option<FormulaUrlStable>,
}

/// The stable source URL entry (`urls.stable`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaUrlStable {
    /// The source tarball URL.
    #[serde(default)]
    pub url: String,
    /// The source tarball sha256 (sometimes `sha256:`-prefixed upstream).
    #[serde(default)]
    pub checksum: String,
}

/// Bottle block (`bottle.stable`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottle {
    /// Stable bottle coordinates.
    #[serde(default)]
    pub stable: Option<FormulaBottleStable>,
}

/// The stable bottle entry, keyed by platform tag (`arm64_sonoma`, ...).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottleStable {
    /// Per-platform bottle files (`bottle.stable.files.<tag>`).
    #[serde(default)]
    pub files: HashMap<String, FormulaBottleFile>,
}

/// A single per-platform bottle file (`{url, sha256}`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FormulaBottleFile {
    /// The bottle download URL (a GHCR blob).
    #[serde(default)]
    pub url: String,
    /// The bottle sha256 (bare hex).
    #[serde(default)]
    pub sha256: String,
}

/// An entry in `uses_from_macos`: either a bare formula name, or a
/// `{name: [conditions]}` object (e.g. `{"llvm": ["build"]}`).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum UsesFromMacos {
    /// A bare dependency name provided by macOS.
    Name(String),
    /// A conditional dependency: the single key is the formula name.
    Conditional(HashMap<String, serde_json::Value>),
}

impl UsesFromMacos {
    /// The dependency name, regardless of the entry shape.
    #[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 {
    /// The stable version, if present and non-empty.
    #[must_use]
    pub fn stable_version(&self) -> Option<&str> {
        self.versions.stable.as_deref().filter(|v| !v.is_empty())
    }

    /// The stable source tarball URL, if present and non-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())
    }

    /// The stable source tarball sha256 (bare hex, `sha256:` prefix stripped),
    /// if present and non-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())
    }

    /// The prebuilt bottle file for a platform `tag` (e.g. `arm64_sonoma`).
    #[must_use]
    pub fn bottle_file(&self, tag: &str) -> Option<&FormulaBottleFile> {
        self.bottle.stable.as_ref().and_then(|b| b.files.get(tag))
    }

    /// The set of dependency names macOS itself provides (so a source build
    /// need not resolve them as kegs).
    #[must_use]
    pub fn macos_provided(&self) -> Vec<String> {
        self.uses_from_macos
            .iter()
            .filter_map(|u| u.name().map(String::from))
            .collect()
    }
}

/// A parsed Chocolatey `OData` package entry (the subset served by `/choco/:name`).
///
/// Choco publishes a `.nupkg` URL and a `Version`, but **no reliable sha256**
/// (the hash is computed on download), so [`ChocoData::sha256`] is `Option` and
/// typically `None`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ChocoData {
    /// The package version (`Version` in `OData`).
    #[serde(default, alias = "Version")]
    pub version: String,
    /// The `.nupkg` download URL.
    #[serde(default, alias = "Url", alias = "url", alias = "nupkg_url")]
    pub url: String,
    /// The package sha256, when the index happens to carry one (usually absent).
    #[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"));
        // `sha256:` prefix is stripped.
        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() {
        // Explicit constructor trims trailing slashes.
        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"
        );
        // Default base yields the production endpoints.
        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());
    }
}