use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::error::{BuildError, Result};
pub const LOCKFILE_NAME: &str = "zlayer-bottles.lock";
pub const CURRENT_SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BottleLockfile {
pub schema: u32,
pub generated_at: String,
#[serde(default, rename = "bottle")]
pub bottles: Vec<LockedBottle>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedBottle {
pub formula: String,
pub version: String,
#[serde(default)]
pub deps: Vec<String>,
pub urls: HashMap<String, String>,
}
impl BottleLockfile {
#[must_use]
pub fn new() -> Self {
Self {
schema: CURRENT_SCHEMA,
generated_at: now_iso8601(),
bottles: Vec::new(),
}
}
pub async fn load(path: &Path) -> Result<Option<Self>> {
let text = match tokio::fs::read_to_string(path).await {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(BuildError::IoError(std::io::Error::other(format!(
"failed to read {}: {e}",
path.display()
))));
}
};
let parsed: Self = toml::from_str(&text).map_err(|e| BuildError::RegistryError {
message: format!("failed to parse {}: {e}", path.display()),
})?;
if parsed.schema != CURRENT_SCHEMA {
return Err(BuildError::RegistryError {
message: format!(
"{} schema {} not supported (expected {}); regenerate with --update-bottles",
path.display(),
parsed.schema,
CURRENT_SCHEMA,
),
});
}
debug!(
"Loaded {} pinned bottles from {}",
parsed.bottles.len(),
path.display()
);
Ok(Some(parsed))
}
pub async fn save(&self, path: &Path) -> Result<()> {
let mut sorted = self.clone();
sorted.bottles.sort_by(|a, b| a.formula.cmp(&b.formula));
let text = toml::to_string_pretty(&sorted).map_err(|e| BuildError::RegistryError {
message: format!("failed to serialize lockfile: {e}"),
})?;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("failed to create {}: {e}", parent.display()),
})?;
let tmp = path.with_extension("lock.tmp");
tokio::fs::write(&tmp, text)
.await
.map_err(|e| BuildError::RegistryError {
message: format!("failed to write {}: {e}", tmp.display()),
})?;
tokio::fs::rename(&tmp, path)
.await
.map_err(|e| BuildError::RegistryError {
message: format!(
"failed to rename {} -> {}: {e}",
tmp.display(),
path.display()
),
})?;
debug!(
"Wrote {} pinned bottles to {}",
sorted.bottles.len(),
path.display()
);
Ok(())
}
#[must_use]
pub fn get(&self, formula: &str) -> Option<&LockedBottle> {
self.bottles.iter().find(|b| b.formula == formula)
}
pub fn upsert(&mut self, entry: LockedBottle) {
if let Some(slot) = self.bottles.iter_mut().find(|b| b.formula == entry.formula) {
*slot = entry;
} else {
self.bottles.push(entry);
}
}
}
impl Default for BottleLockfile {
fn default() -> Self {
Self::new()
}
}
#[must_use]
pub fn lockfile_path_for(spec_path: &Path) -> PathBuf {
let dir = if spec_path.is_dir() {
spec_path.to_path_buf()
} else {
spec_path
.parent()
.map_or_else(|| PathBuf::from("."), Path::to_path_buf)
};
dir.join(LOCKFILE_NAME)
}
fn now_iso8601() -> String {
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
pub fn warn_lockfile_ignored(path: &Path, reason: &str) {
warn!("Ignoring lockfile at {}: {}", path.display(), reason);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roundtrip_empty() {
let lock = BottleLockfile::new();
let text = toml::to_string_pretty(&lock).unwrap();
let parsed: BottleLockfile = toml::from_str(&text).unwrap();
assert_eq!(parsed.schema, CURRENT_SCHEMA);
assert!(parsed.bottles.is_empty());
}
#[test]
fn roundtrip_with_entries() {
let mut lock = BottleLockfile::new();
let mut urls = HashMap::new();
urls.insert(
"arm64_sequoia".to_string(),
"https://example/openssl@3".to_string(),
);
urls.insert(
"all".to_string(),
"https://example/openssl@3-all".to_string(),
);
lock.upsert(LockedBottle {
formula: "openssl@3".to_string(),
version: "3.6.2".to_string(),
deps: vec!["ca-certificates".to_string()],
urls,
});
let text = toml::to_string_pretty(&lock).unwrap();
assert!(text.contains("openssl@3"));
let parsed: BottleLockfile = toml::from_str(&text).unwrap();
assert_eq!(parsed.bottles.len(), 1);
assert_eq!(parsed.bottles[0].deps, vec!["ca-certificates".to_string()]);
}
#[test]
fn lockfile_path_sibling_to_file() {
let p = lockfile_path_for(Path::new("/tmp/myapp/Dockerfile"));
assert_eq!(p, PathBuf::from("/tmp/myapp/zlayer-bottles.lock"));
}
#[test]
fn lockfile_path_for_directory() {
let p = lockfile_path_for(Path::new("/tmp"));
assert_eq!(p, PathBuf::from("/tmp/zlayer-bottles.lock"));
}
}