use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
pub const CURRENT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockFile {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default, rename = "plugins")]
pub plugins: Vec<LockEntry>,
}
fn default_version() -> u32 {
CURRENT_VERSION
}
impl Default for LockFile {
fn default() -> Self {
Self {
version: CURRENT_VERSION,
plugins: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LockEntry {
pub name: String,
pub url: String,
pub commit: String,
}
impl LockFile {
pub fn load(path: &Path) -> Self {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Self::default(),
Err(e) => {
eprintln!(
"\u{26a0} lockfile: failed to read {}: {} (treating as empty)",
path.display(),
e
);
return Self::default();
}
};
let lock = toml::from_str::<LockFile>(&content).unwrap_or_else(|e| {
eprintln!(
"\u{26a0} lockfile: failed to parse {}: {} (treating as empty)",
path.display(),
e
);
Self::default()
});
if lock.version != CURRENT_VERSION {
eprintln!(
"\u{26a0} lockfile: unsupported version {} in {} (expected {}; treating as empty)",
lock.version,
path.display(),
CURRENT_VERSION
);
return Self::default();
}
lock
}
pub fn save(&mut self, path: &Path) -> Result<()> {
self.plugins.sort_by(|a, b| a.name.cmp(&b.name));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create_dir_all {}", parent.display()))?;
}
let header = "# rvpm.lock — generated by rvpm. Commit this alongside config.toml for reproducibility.\n# Do not edit by hand; run `rvpm sync` or `rvpm update` to refresh.\n\n";
let body = toml::to_string_pretty(self).context("serialize lockfile")?;
let content = format!("{}{}", header, body);
let parent = path.parent().unwrap_or(Path::new("."));
let tmp = tempfile::Builder::new()
.prefix(".rvpm-lock-")
.suffix(".tmp")
.tempfile_in(parent)
.with_context(|| format!("create tempfile in {}", parent.display()))?;
std::fs::write(tmp.path(), content.as_bytes())
.with_context(|| format!("write tempfile {}", tmp.path().display()))?;
tmp.persist(path)
.map_err(|e| anyhow::anyhow!("rename tempfile to {}: {}", path.display(), e))?;
Ok(())
}
pub fn find(&self, name: &str) -> Option<&LockEntry> {
self.plugins.iter().find(|e| e.name == name)
}
pub fn upsert(&mut self, entry: LockEntry) {
if let Some(slot) = self.plugins.iter_mut().find(|e| e.name == entry.name) {
*slot = entry;
} else {
self.plugins.push(entry);
}
}
pub fn retain_by_names(&mut self, names: &std::collections::HashSet<String>) -> Vec<String> {
let mut dropped: Vec<String> = self
.plugins
.iter()
.filter(|e| !names.contains(&e.name))
.map(|e| e.name.clone())
.collect();
dropped.sort();
self.plugins.retain(|e| names.contains(&e.name));
dropped
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn mk(name: &str, commit: &str) -> LockEntry {
LockEntry {
name: name.to_string(),
url: format!("owner/{}", name),
commit: commit.to_string(),
}
}
#[test]
fn test_load_missing_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.lock");
let lock = LockFile::load(&path);
assert_eq!(lock.version, CURRENT_VERSION);
assert!(lock.plugins.is_empty());
}
#[test]
fn test_load_malformed_returns_default() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.lock");
std::fs::write(&path, "this is not valid toml = = =").unwrap();
let lock = LockFile::load(&path);
assert!(lock.plugins.is_empty());
}
#[test]
fn test_save_then_load_roundtrip() {
let dir = tempdir().unwrap();
let path = dir.path().join("rvpm.lock");
let mut lock = LockFile::default();
lock.plugins.push(mk("a", "111"));
lock.plugins.push(mk("b", "222"));
lock.save(&path).unwrap();
let loaded = LockFile::load(&path);
assert_eq!(loaded.version, CURRENT_VERSION);
assert_eq!(loaded.plugins.len(), 2);
assert_eq!(loaded.plugins[0].name, "a");
assert_eq!(loaded.plugins[1].name, "b");
}
#[test]
fn test_save_sorts_by_name() {
let dir = tempdir().unwrap();
let path = dir.path().join("rvpm.lock");
let mut lock = LockFile::default();
lock.plugins.push(mk("zeta", "z"));
lock.plugins.push(mk("alpha", "a"));
lock.plugins.push(mk("mid", "m"));
lock.save(&path).unwrap();
let loaded = LockFile::load(&path);
let names: Vec<_> = loaded.plugins.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["alpha", "mid", "zeta"]);
}
#[test]
fn test_save_contains_header_comment() {
let dir = tempdir().unwrap();
let path = dir.path().join("rvpm.lock");
LockFile::default().save(&path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.starts_with("# rvpm.lock"),
"must have banner comment for users"
);
assert!(
content.contains("rvpm sync"),
"comment should point users at the command to regenerate"
);
}
#[test]
fn test_upsert_inserts_new_entry() {
let mut lock = LockFile::default();
lock.upsert(mk("a", "111"));
assert_eq!(lock.plugins.len(), 1);
assert_eq!(lock.plugins[0].commit, "111");
}
#[test]
fn test_upsert_replaces_existing_entry() {
let mut lock = LockFile::default();
lock.upsert(mk("a", "111"));
lock.upsert(mk("a", "222"));
assert_eq!(lock.plugins.len(), 1);
assert_eq!(lock.plugins[0].commit, "222");
}
#[test]
fn test_find_returns_matching_entry() {
let mut lock = LockFile::default();
lock.upsert(mk("a", "111"));
lock.upsert(mk("b", "222"));
assert_eq!(lock.find("a").map(|e| e.commit.as_str()), Some("111"));
assert_eq!(lock.find("missing"), None);
}
#[test]
fn test_retain_by_names_drops_orphans_and_returns_dropped() {
let mut lock = LockFile::default();
lock.upsert(mk("a", "1"));
lock.upsert(mk("b", "2"));
lock.upsert(mk("c", "3"));
let mut keep = std::collections::HashSet::new();
keep.insert("a".to_string());
keep.insert("c".to_string());
let dropped = lock.retain_by_names(&keep);
assert_eq!(dropped, vec!["b".to_string()]);
let kept: Vec<_> = lock.plugins.iter().map(|e| e.name.as_str()).collect();
assert!(kept.contains(&"a"));
assert!(kept.contains(&"c"));
assert_eq!(kept.len(), 2);
}
#[test]
fn test_load_rejects_future_schema_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("future.lock");
std::fs::write(
&path,
r#"version = 99
[[plugins]]
name = "x"
url = "o/x"
commit = "abc"
"#,
)
.unwrap();
let lock = LockFile::load(&path);
assert!(
lock.plugins.is_empty(),
"unsupported version must fall back to empty lockfile"
);
assert_eq!(
lock.version, CURRENT_VERSION,
"default always reports current schema version"
);
}
#[test]
fn test_load_accepts_minimal_schema_without_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("legacy.lock");
std::fs::write(
&path,
r#"[[plugins]]
name = "only"
url = "x/y"
commit = "abc"
"#,
)
.unwrap();
let lock = LockFile::load(&path);
assert_eq!(lock.plugins.len(), 1);
assert_eq!(lock.version, CURRENT_VERSION);
}
}