use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
pub(crate) const LOCKFILE_REL_PATH: &str = ".lean-ctx/ctxpkg.lock";
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct Lockfile {
#[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
pub packages: Vec<LockedPackage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct LockedPackage {
pub name: String,
pub version: String,
pub artifact_sha256: String,
pub registry: String,
}
pub(crate) fn lockfile_path(project_root: &Path) -> PathBuf {
project_root.join(LOCKFILE_REL_PATH)
}
pub(crate) fn load(project_root: &Path) -> Result<Lockfile, String> {
let path = lockfile_path(project_root);
if !path.exists() {
return Ok(Lockfile::default());
}
let text = std::fs::read_to_string(&path).map_err(|e| format!("read ctxpkg.lock: {e}"))?;
toml::from_str(&text).map_err(|e| format!("parse ctxpkg.lock: {e}"))
}
pub(crate) fn upsert(project_root: &Path, entry: LockedPackage) -> Result<(), String> {
let mut lock = load(project_root)?;
lock.packages.retain(|p| p.name != entry.name);
lock.packages.push(entry);
lock.packages.sort_by(|a, b| a.name.cmp(&b.name));
save(project_root, &lock)
}
fn save(project_root: &Path, lock: &Lockfile) -> Result<(), String> {
let path = lockfile_path(project_root);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| format!("create .lean-ctx: {e}"))?;
}
let header = "# ctxpkg.lock — generated by `lean-ctx pack install`; commit this file.\n";
let body = toml::to_string_pretty(lock).map_err(|e| format!("render ctxpkg.lock: {e}"))?;
std::fs::write(&path, format!("{header}{body}")).map_err(|e| format!("write ctxpkg.lock: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn tmp_root() -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"ctxlock-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("mkdir");
dir
}
#[test]
fn upsert_roundtrip_sorted_and_replacing() {
let root = tmp_root();
upsert(
&root,
LockedPackage {
name: "@zeta/pkg".into(),
version: "1.0.0".into(),
artifact_sha256: "aa".into(),
registry: "https://ctxpkg.com/api".into(),
},
)
.expect("upsert 1");
upsert(
&root,
LockedPackage {
name: "@acme/pkg".into(),
version: "2.0.0".into(),
artifact_sha256: "bb".into(),
registry: "https://ctxpkg.com/api".into(),
},
)
.expect("upsert 2");
upsert(
&root,
LockedPackage {
name: "@zeta/pkg".into(),
version: "1.1.0".into(),
artifact_sha256: "cc".into(),
registry: "https://ctxpkg.com/api".into(),
},
)
.expect("upsert 3");
let lock = load(&root).expect("load");
assert_eq!(lock.packages.len(), 2);
assert_eq!(lock.packages[0].name, "@acme/pkg"); assert_eq!(lock.packages[1].version, "1.1.0"); std::fs::remove_dir_all(&root).ok();
}
#[test]
fn missing_lockfile_loads_empty() {
let root = tmp_root();
assert!(load(&root).expect("load").packages.is_empty());
std::fs::remove_dir_all(&root).ok();
}
}