use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::Result;
pub const LOCKFILE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lockfile {
pub version: u32,
#[serde(default, rename = "artifact")]
pub artifacts: Vec<LockEntry>,
}
impl Default for Lockfile {
fn default() -> Self {
Self {
version: LOCKFILE_VERSION,
artifacts: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
pub handle: String,
pub version: String,
pub target: String,
pub content_hash: String,
pub files: Vec<String>,
}
impl Lockfile {
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(toml::from_str(&text)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(&self, path: &Path) -> Result<()> {
let mut sorted = self.clone();
sorted
.artifacts
.sort_by(|a, b| (&a.handle, &a.target).cmp(&(&b.handle, &b.target)));
let text = toml::to_string_pretty(&sorted)?;
std::fs::write(path, text)?;
Ok(())
}
pub fn upsert(&mut self, mut entry: LockEntry) {
entry.files.sort();
match self
.artifacts
.iter_mut()
.find(|e| e.handle == entry.handle && e.target == entry.target)
{
Some(existing) => *existing = entry,
None => self.artifacts.push(entry),
}
}
pub fn remove(&mut self, handle: &str, target: &str) -> bool {
let before = self.artifacts.len();
self.artifacts
.retain(|e| !(e.handle == handle && e.target == target));
before != self.artifacts.len()
}
pub fn get(&self, handle: &str, target: &str) -> Option<&LockEntry> {
self.artifacts
.iter()
.find(|e| e.handle == handle && e.target == target)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(handle: &str, target: &str, version: &str) -> LockEntry {
LockEntry {
handle: handle.into(),
version: version.into(),
target: target.into(),
content_hash: "h".into(),
files: vec!["b".into(), "a".into()],
}
}
#[test]
fn upsert_replaces_and_sorts_files() {
let mut lf = Lockfile::default();
lf.upsert(entry("@a/x", "claude", "v1"));
lf.upsert(entry("@a/x", "claude", "v2"));
assert_eq!(lf.artifacts.len(), 1);
assert_eq!(lf.artifacts[0].version, "v2");
assert_eq!(lf.artifacts[0].files, vec!["a", "b"]);
}
#[test]
fn remove_only_drops_the_matching_target() {
let mut lf = Lockfile::default();
lf.upsert(entry("@a/x", "claude", "v1"));
lf.upsert(entry("@a/x", "cursor", "v1"));
assert!(lf.remove("@a/x", "claude"));
assert_eq!(lf.artifacts.len(), 1);
assert_eq!(lf.artifacts[0].target, "cursor");
assert!(!lf.remove("@a/x", "claude"), "second remove is a no-op");
}
#[test]
fn roundtrips_through_toml() {
let mut lf = Lockfile::default();
lf.upsert(entry("@a/x", "claude", "v1"));
let text = toml::to_string_pretty(&lf).unwrap();
let back: Lockfile = toml::from_str(&text).unwrap();
assert_eq!(back.version, LOCKFILE_VERSION);
assert_eq!(back.artifacts, lf.artifacts);
}
}