use std::path::Path;
use sha2::{Digest, Sha256};
use crate::error::{Result, ToolchainError};
pub use zlayer_types::toolchain_lock::{LockedTool, ToolchainLockfile, TOOLCHAIN_LOCK_SCHEMA};
pub const LOCKFILE_NAME: &str = "zlayer-toolchains.lock";
pub trait ToolchainLockfileExt: Sized {
#[must_use]
fn new() -> Self;
fn load(path: &Path) -> Result<Option<Self>>;
fn save(&self, path: &Path) -> Result<()>;
fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool>;
fn upsert(&mut self, entry: LockedTool);
}
impl ToolchainLockfileExt for ToolchainLockfile {
fn new() -> Self {
Self {
schema: TOOLCHAIN_LOCK_SCHEMA,
generated_at: chrono::Utc::now().to_rfc3339(),
tools: Vec::new(),
}
}
fn load(path: &Path) -> Result<Option<Self>> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(ToolchainError::IoError(e)),
};
let lock: Self = toml::from_str(&text).map_err(|e| ToolchainError::CacheError {
message: format!("failed to parse lockfile {}: {e}", path.display()),
})?;
if lock.schema != TOOLCHAIN_LOCK_SCHEMA {
return Err(ToolchainError::CacheError {
message: format!(
"lockfile {} has schema {} but this build supports schema {TOOLCHAIN_LOCK_SCHEMA}",
path.display(),
lock.schema
),
});
}
Ok(Some(lock))
}
fn save(&self, path: &Path) -> Result<()> {
let mut out = self.clone();
out.schema = TOOLCHAIN_LOCK_SCHEMA;
out.generated_at = chrono::Utc::now().to_rfc3339();
out.tools
.sort_by(|a, b| (&a.tool, &a.platform, &a.arch).cmp(&(&b.tool, &b.platform, &b.arch)));
let text = toml::to_string_pretty(&out).map_err(|e| ToolchainError::CacheError {
message: format!("failed to serialize lockfile: {e}"),
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, text)?;
Ok(())
}
fn lookup(&self, tool: &str, platform: &str, arch: &str) -> Option<&LockedTool> {
self.tools
.iter()
.find(|t| t.tool == tool && t.platform == platform && t.arch == arch)
}
fn upsert(&mut self, entry: LockedTool) {
if let Some(slot) = self
.tools
.iter_mut()
.find(|t| t.tool == entry.tool && t.platform == entry.platform && t.arch == entry.arch)
{
*slot = entry;
} else {
self.tools.push(entry);
}
}
}
pub fn compute_sha256(path: &Path) -> Result<String> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
fn tool(name: &str, platform: &str, arch: &str, version: &str) -> LockedTool {
LockedTool {
tool: name.to_string(),
platform: platform.to_string(),
arch: arch.to_string(),
version: version.to_string(),
url: format!("https://example/{name}-{version}"),
sha256: "abc123".to_string(),
resolved_at: "2026-07-01T00:00:00Z".to_string(),
}
}
#[test]
fn round_trips_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
let mut lock = ToolchainLockfile::new();
lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
lock.upsert(tool("node@lts", "macos", "arm64", "22.1.0"));
lock.save(&path).unwrap();
let read = ToolchainLockfile::load(&path).unwrap().unwrap();
assert_eq!(read.schema, TOOLCHAIN_LOCK_SCHEMA);
assert_eq!(read.tools.len(), 2);
assert_eq!(
read.lookup("git", "macos", "arm64")
.map(|t| t.version.as_str()),
Some("2.55.0")
);
assert!(read.lookup("git", "macos", "x86_64").is_none());
}
#[test]
fn absent_file_loads_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
assert!(ToolchainLockfile::load(&path).unwrap().is_none());
}
#[test]
fn schema_mismatch_is_a_loud_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
std::fs::write(&path, "schema = 999\ngenerated_at = \"x\"\n").unwrap();
let err = ToolchainLockfile::load(&path).unwrap_err();
assert!(matches!(err, ToolchainError::CacheError { .. }));
}
#[test]
fn corrupt_toml_is_a_loud_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
std::fs::write(&path, "not = = valid toml").unwrap();
assert!(matches!(
ToolchainLockfile::load(&path).unwrap_err(),
ToolchainError::CacheError { .. }
));
}
#[test]
fn upsert_replaces_matching_key() {
let mut lock = ToolchainLockfile::new();
lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
lock.upsert(tool("git", "macos", "arm64", "2.56.0"));
assert_eq!(lock.tools.len(), 1);
assert_eq!(
lock.lookup("git", "macos", "arm64")
.map(|t| t.version.as_str()),
Some("2.56.0")
);
lock.upsert(tool("git", "macos", "x86_64", "2.56.0"));
assert_eq!(lock.tools.len(), 2);
}
#[test]
fn save_sorts_tools_by_key() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
let mut lock = ToolchainLockfile::new();
lock.upsert(tool("zig", "macos", "arm64", "0.13.0"));
lock.upsert(tool("git", "macos", "x86_64", "2.55.0"));
lock.upsert(tool("git", "macos", "arm64", "2.55.0"));
lock.save(&path).unwrap();
let read = ToolchainLockfile::load(&path).unwrap().unwrap();
let keys: Vec<_> = read
.tools
.iter()
.map(|t| (t.tool.as_str(), t.platform.as_str(), t.arch.as_str()))
.collect();
assert_eq!(
keys,
vec![
("git", "macos", "arm64"),
("git", "macos", "x86_64"),
("zig", "macos", "arm64"),
]
);
}
#[test]
fn compute_sha256_matches_known_vector() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("f.bin");
std::fs::write(&path, b"hello world").unwrap();
assert_eq!(
compute_sha256(&path).unwrap(),
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
}