zlayer-toolchain 0.14.1

Runtime toolchain provisioning (macOS Homebrew bottle resolver/installer) for ZLayer
Documentation
//! The unified keg manifest (`toolchain.json`).
//!
//! Every provisioned keg — whether built from source ([`crate::source_build`])
//! or fetched as a self-contained prebuilt ([`crate::prebuilt`]) — carries a
//! `toolchain.json` next to its `.ready` marker. The manifest is the single
//! source of truth for how to *run* the tool: which directories to prepend to
//! `PATH` and which environment variables to set. The resolver
//! ([`crate::probe_ready_toolchain`], handle construction) reads the manifest
//! generically, so no tool is special-cased in the handle path.
//!
//! Backward compatibility: a keg laid down before manifests existed (an old
//! `git-<ver>-<arch>` keg with only `.ready`) has its manifest *synthesized*
//! from the on-disk layout — see [`KegManifest::synthesize_from_keg`].

use std::collections::HashMap;
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::{Result, ToolchainError};

/// Name of the per-keg manifest file, written next to `.ready`.
pub const MANIFEST_FILE: &str = "toolchain.json";

/// How a keg was provisioned. Recorded in the manifest for provenance.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum KegSource {
    /// Built from the Homebrew formula's `urls.stable.url` source tarball with
    /// the host Command Line Tools, at an absolute cache prefix.
    SourceBuild {
        /// The source tarball URL the keg was built from.
        url: String,
        /// The sha256 (bare hex) of the source tarball, verified on download.
        /// Empty for a pre-integrity keg.
        #[serde(default)]
        sha256: String,
    },
    /// Fetched as a self-contained, relocation-free prebuilt vendor archive
    /// (the language toolchains: go/node/rust/...).
    Prebuilt {
        /// The vendor download URL the keg was extracted from.
        url: String,
        /// The sha256 (bare hex) of the downloaded archive, verified on download
        /// when an upstream/lockfile digest was available, else the digest
        /// computed over the bytes. Empty for a pre-integrity keg.
        #[serde(default)]
        sha256: String,
    },
}

/// Per-keg manifest: identity + how to run the provisioned tool.
///
/// `path_dirs` and `env` use **absolute** keg-rooted paths, so a
/// [`crate::ToolchainHandle`] is a direct projection of the manifest — the
/// resolver never has to know which tool it is dealing with.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KegManifest {
    /// Homebrew formula / tool name (e.g. `git`, `jq`, `go`).
    pub tool: String,
    /// Resolved stable version (e.g. `2.55.0`).
    pub version: String,
    /// Host architecture token (`arm64` / `x86_64`).
    pub arch: String,
    /// Target platform (`macos`).
    pub platform: String,
    /// Absolute directories to prepend to `PATH` (the keg's `bin`).
    pub path_dirs: Vec<String>,
    /// Extra environment variables to set when running the tool.
    pub env: HashMap<String, String>,
    /// How this keg was provisioned.
    pub source: KegSource,
    /// Build dependencies resolved (as sibling kegs) to produce this keg.
    /// Empty for prebuilts.
    pub build_deps: Vec<String>,
    /// RFC 3339 timestamp of when the keg was provisioned.
    pub provisioned_at: String,
}

impl KegManifest {
    /// Serialize and write the manifest into `keg/toolchain.json`.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] if serialization fails, or an I/O
    /// error if the file cannot be written.
    pub async fn write_to_keg(&self, keg: &Path) -> Result<()> {
        let json = serde_json::to_string_pretty(self).map_err(|e| ToolchainError::CacheError {
            message: format!("failed to serialize keg manifest for {}: {e}", self.tool),
        })?;
        tokio::fs::write(keg.join(MANIFEST_FILE), json).await?;
        Ok(())
    }

    /// Read `keg/toolchain.json` if present, returning `None` when it is absent
    /// (a pre-manifest keg) and an error only on a corrupt manifest.
    ///
    /// # Errors
    ///
    /// Returns [`ToolchainError::CacheError`] if the file exists but cannot be
    /// parsed.
    pub async fn read_from_keg(keg: &Path) -> Result<Option<Self>> {
        let path = keg.join(MANIFEST_FILE);
        match tokio::fs::read(&path).await {
            Ok(bytes) => {
                let manifest: Self =
                    serde_json::from_slice(&bytes).map_err(|e| ToolchainError::CacheError {
                        message: format!("corrupt keg manifest at {}: {e}", path.display()),
                    })?;
                Ok(Some(manifest))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(ToolchainError::IoError(e)),
        }
    }

    /// Load a keg's manifest, synthesizing one from the on-disk layout if the
    /// keg predates manifests (an old `git` keg with only `.ready`).
    ///
    /// Synthesis rules (cover every keg shape that existed before manifests):
    /// - `path_dirs` = `[<keg>/bin]` when that directory exists,
    /// - `env` gets `GIT_EXEC_PATH=<keg>/libexec/git-core` when that directory
    ///   exists (the source-built `git` keg layout), otherwise stays empty.
    ///
    /// # Errors
    ///
    /// Returns an error only when an existing manifest is corrupt.
    pub async fn load_or_synthesize(keg: &Path) -> Result<Self> {
        if let Some(manifest) = Self::read_from_keg(keg).await? {
            return Ok(manifest);
        }
        Ok(Self::synthesize_from_keg(keg).await)
    }

    /// Build a manifest purely from a keg's on-disk layout (no `toolchain.json`).
    /// Used for backward compatibility with pre-manifest kegs.
    pub async fn synthesize_from_keg(keg: &Path) -> Self {
        let mut path_dirs = Vec::new();
        let bin = keg.join("bin");
        if tokio::fs::try_exists(&bin).await.unwrap_or(false) {
            path_dirs.push(bin.display().to_string());
        }

        let mut env = HashMap::new();
        let git_exec = keg.join("libexec/git-core");
        if tokio::fs::try_exists(&git_exec).await.unwrap_or(false) {
            env.insert("GIT_EXEC_PATH".to_string(), git_exec.display().to_string());
        }

        // Best-effort identity from the directory name `<tool>-<version>-<arch>`.
        let (tool, version, arch) = parse_keg_dir_name(keg);

        Self {
            tool,
            version,
            arch,
            platform: "macos".to_string(),
            path_dirs,
            env,
            source: KegSource::SourceBuild {
                url: String::new(),
                sha256: String::new(),
            },
            build_deps: Vec::new(),
            provisioned_at: String::new(),
        }
    }
}

/// Parse a `<tool>-<version>-<arch>` keg directory name into its parts,
/// tolerating tools whose name itself contains `-`. Returns best-effort
/// `(tool, version, arch)`; unknown parts fall back to the whole dir name /
/// empty strings.
fn parse_keg_dir_name(keg: &Path) -> (String, String, String) {
    let name = keg
        .file_name()
        .and_then(|s| s.to_str())
        .unwrap_or_default()
        .to_string();
    let parts: Vec<&str> = name.rsplitn(3, '-').collect(); // [arch, version, tool...]
    match parts.as_slice() {
        [arch, version, tool] => (
            (*tool).to_string(),
            (*version).to_string(),
            (*arch).to_string(),
        ),
        _ => (name, String::new(), String::new()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn round_trips_through_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let keg = tmp.path();
        let mut env = HashMap::new();
        env.insert(
            "GIT_EXEC_PATH".to_string(),
            "/x/libexec/git-core".to_string(),
        );
        let manifest = KegManifest {
            tool: "git".to_string(),
            version: "2.55.0".to_string(),
            arch: "arm64".to_string(),
            platform: "macos".to_string(),
            path_dirs: vec!["/x/bin".to_string()],
            env,
            source: KegSource::SourceBuild {
                url: "https://example/git.tar.xz".to_string(),
                sha256: "deadbeef".to_string(),
            },
            build_deps: vec![],
            provisioned_at: "2026-06-30T00:00:00Z".to_string(),
        };
        manifest.write_to_keg(keg).await.unwrap();

        let read = KegManifest::read_from_keg(keg).await.unwrap().unwrap();
        assert_eq!(read.tool, "git");
        assert_eq!(read.version, "2.55.0");
        assert_eq!(
            read.env.get("GIT_EXEC_PATH").map(String::as_str),
            Some("/x/libexec/git-core")
        );
        assert!(matches!(
            read.source,
            KegSource::SourceBuild { ref sha256, .. } if sha256 == "deadbeef"
        ));
    }

    #[tokio::test]
    async fn missing_manifest_reads_none() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(KegManifest::read_from_keg(tmp.path())
            .await
            .unwrap()
            .is_none());
    }

    #[tokio::test]
    async fn synthesizes_git_layout_when_manifest_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let keg = tmp.path().join("git-2.55.0-arm64");
        tokio::fs::create_dir_all(keg.join("bin")).await.unwrap();
        tokio::fs::create_dir_all(keg.join("libexec/git-core"))
            .await
            .unwrap();

        let manifest = KegManifest::load_or_synthesize(&keg).await.unwrap();
        assert_eq!(manifest.tool, "git");
        assert_eq!(manifest.version, "2.55.0");
        assert_eq!(manifest.arch, "arm64");
        assert_eq!(
            manifest.path_dirs,
            vec![keg.join("bin").display().to_string()]
        );
        assert_eq!(
            manifest.env.get("GIT_EXEC_PATH"),
            Some(&keg.join("libexec/git-core").display().to_string())
        );
        assert!(!manifest.env.contains_key("GIT_CONFIG_SYSTEM"));
        assert!(!manifest.env.contains_key("DYLD_FALLBACK_LIBRARY_PATH"));
    }

    #[test]
    fn parses_keg_dir_name_with_dashed_tool() {
        let (tool, version, arch) = parse_keg_dir_name(Path::new("/cache/openssl-3.4.0-arm64"));
        assert_eq!(tool, "openssl");
        assert_eq!(version, "3.4.0");
        assert_eq!(arch, "arm64");
    }
}