use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::pipeline::{InstallReport, ItemKind};
pub const LOCKFILE_NAME: &str = ".upskill-lock.json";
pub const CURRENT_SCHEMA: u32 = 2;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LockfileV2 {
pub schema: u32,
#[serde(default)]
pub items: Vec<LockedItem>,
#[serde(default)]
pub bundles: Vec<LockedBundle>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LockedItem {
pub kind: String,
pub name: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "ref")]
pub git_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hash: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct LockedBundle {
pub name: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "ref")]
pub git_ref: Option<String>,
#[serde(default)]
pub items: Vec<String>,
}
impl Default for LockfileV2 {
fn default() -> Self {
Self::new()
}
}
impl LockfileV2 {
pub fn new() -> Self {
Self {
schema: CURRENT_SCHEMA,
items: Vec::new(),
bundles: Vec::new(),
}
}
pub fn load(project_root: &Path) -> Result<Self> {
let path = lockfile_path(project_root);
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::new()),
Err(e) => return Err(e).with_context(|| format!("read {}", path.display())),
};
if let Ok(parsed) = serde_json::from_str::<Self>(&raw) {
if parsed.schema == CURRENT_SCHEMA {
return Ok(parsed);
}
if parsed.schema > CURRENT_SCHEMA {
anyhow::bail!(
"{}: schema {} is newer than this implementation supports \
(max {}); upgrade upskill",
path.display(),
parsed.schema,
CURRENT_SCHEMA
);
}
}
if let Ok(v1) = serde_json::from_str::<V1Lockfile>(&raw) {
let migrated = migrate_v1(&v1);
migrated
.save(project_root)
.with_context(|| format!("persist v0.1 → v0.2 migration of {}", path.display()))?;
return Ok(migrated);
}
anyhow::bail!(
"{}: unrecognised lockfile shape (neither v0.2 schema-2 nor v0.1)",
path.display()
);
}
pub fn upsert(&mut self, item: LockedItem) {
self.items
.retain(|existing| !(existing.kind == item.kind && existing.name == item.name));
self.items.push(item);
self.items.sort();
}
pub fn remove(&mut self, kind: &str, name: &str) {
self.items
.retain(|existing| !(existing.kind == kind && existing.name == name));
}
pub fn upsert_bundle(&mut self, bundle: LockedBundle) {
self.bundles.retain(|existing| existing.name != bundle.name);
self.bundles.push(bundle);
self.bundles.sort();
}
pub fn save(&self, project_root: &Path) -> Result<()> {
let path = lockfile_path(project_root);
let json = serde_json::to_string_pretty(self).context("serialize lockfile")?;
std::fs::write(&path, format!("{json}\n"))
.with_context(|| format!("write {}", path.display()))
}
}
fn lockfile_path(project_root: &Path) -> PathBuf {
project_root.join(LOCKFILE_NAME)
}
pub fn items_from_report(
report: &InstallReport,
source_label: &str,
git_ref: Option<&str>,
mut hash_for: impl FnMut(ItemKind, &str) -> Option<String>,
) -> Vec<LockedItem> {
use std::collections::BTreeSet;
let mut seen: BTreeSet<(ItemKind, String)> = BTreeSet::new();
let mut out = Vec::new();
for entry in &report.items {
if !seen.insert((entry.kind, entry.name.clone())) {
continue;
}
out.push(LockedItem {
kind: kind_label(entry.kind).to_string(),
name: entry.name.clone(),
source: source_label.to_string(),
git_ref: git_ref.map(str::to_string),
hash: hash_for(entry.kind, &entry.name),
});
}
out
}
fn kind_label(kind: ItemKind) -> &'static str {
match kind {
ItemKind::Rule => "rule",
ItemKind::Skill => "skill",
ItemKind::Agent => "agent",
}
}
#[derive(Debug, Deserialize)]
struct V1Lockfile {
skills: Vec<V1LockedSkill>,
}
#[derive(Debug, Deserialize)]
struct V1LockedSkill {
name: String,
source: String,
#[serde(default, rename = "ref")]
git_ref: Option<String>,
#[serde(default)]
hash: Option<String>,
}
fn migrate_v1(v1: &V1Lockfile) -> LockfileV2 {
let mut out = LockfileV2::new();
for s in &v1.skills {
out.upsert(LockedItem {
kind: "skill".to_string(),
name: s.name.clone(),
source: s.source.clone(),
git_ref: s.git_ref.clone(),
hash: s.hash.clone(),
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generate::Client;
use crate::pipeline::InstalledItem;
use std::path::PathBuf;
fn item(kind: &str, name: &str) -> LockedItem {
LockedItem {
kind: kind.to_string(),
name: name.to_string(),
source: "github:driftsys/skills".to_string(),
git_ref: Some("v1.0.0".to_string()),
hash: Some("sha256:deadbeef".to_string()),
}
}
#[test]
fn new_lockfile_has_current_schema() {
let lock = LockfileV2::new();
assert_eq!(lock.schema, CURRENT_SCHEMA);
assert!(lock.items.is_empty());
assert!(lock.bundles.is_empty());
}
#[test]
fn upsert_replaces_existing_item_with_same_kind_and_name() {
let mut lock = LockfileV2::new();
lock.upsert(item("skill", "code-review"));
let mut updated = item("skill", "code-review");
updated.git_ref = Some("v2.0.0".to_string());
lock.upsert(updated);
assert_eq!(lock.items.len(), 1);
assert_eq!(lock.items[0].git_ref, Some("v2.0.0".to_string()));
}
#[test]
fn upsert_keeps_distinct_kinds_separate() {
let mut lock = LockfileV2::new();
lock.upsert(item("rule", "shared-name"));
lock.upsert(item("skill", "shared-name"));
assert_eq!(lock.items.len(), 2);
}
#[test]
fn items_are_sorted_for_deterministic_output() {
let mut lock = LockfileV2::new();
lock.upsert(item("skill", "z"));
lock.upsert(item("skill", "a"));
let names: Vec<_> = lock.items.iter().map(|i| i.name.as_str()).collect();
assert_eq!(names, vec!["a", "z"]);
}
#[test]
fn remove_filters_by_kind_and_name() {
let mut lock = LockfileV2::new();
lock.upsert(item("rule", "shared-name"));
lock.upsert(item("skill", "shared-name"));
lock.remove("rule", "shared-name");
assert_eq!(lock.items.len(), 1);
assert_eq!(lock.items[0].kind, "skill");
}
#[test]
fn save_and_load_roundtrip() {
let tmp = tempfile::tempdir().unwrap();
let mut lock = LockfileV2::new();
lock.upsert(item("skill", "code-review"));
lock.save(tmp.path()).expect("save");
let loaded = LockfileV2::load(tmp.path()).expect("load");
assert_eq!(loaded, lock);
}
#[test]
fn load_missing_file_returns_empty_v2() {
let tmp = tempfile::tempdir().unwrap();
let lock = LockfileV2::load(tmp.path()).expect("load");
assert_eq!(lock, LockfileV2::new());
}
#[test]
fn load_rejects_newer_schema() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join(LOCKFILE_NAME),
r#"{"schema": 99, "items": [], "bundles": []}"#,
)
.unwrap();
let err = LockfileV2::load(tmp.path()).expect_err("must reject");
assert!(err.to_string().contains("schema 99"));
}
#[test]
fn load_migrates_v1_in_place() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(LOCKFILE_NAME);
std::fs::write(
&path,
r#"{
"skills": [
{"name": "code-review", "source": "github:driftsys/skills@v1.0",
"ref": "v1.0", "hash": "abc"}
]
}"#,
)
.unwrap();
let loaded = LockfileV2::load(tmp.path()).expect("load with migration");
assert_eq!(loaded.schema, CURRENT_SCHEMA);
assert_eq!(loaded.items.len(), 1);
let it = &loaded.items[0];
assert_eq!(it.kind, "skill");
assert_eq!(it.name, "code-review");
assert_eq!(it.source, "github:driftsys/skills@v1.0");
assert_eq!(it.git_ref, Some("v1.0".to_string()));
assert_eq!(it.hash, Some("abc".to_string()));
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(on_disk.contains("\"schema\""));
let reloaded = LockfileV2::load(tmp.path()).expect("reload");
assert_eq!(reloaded, loaded);
}
#[test]
fn load_rejects_unrecognised_shape() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(LOCKFILE_NAME), r#"{"random": "shape"}"#).unwrap();
let err = LockfileV2::load(tmp.path()).expect_err("must reject");
assert!(err.to_string().contains("unrecognised"));
}
#[test]
fn items_from_report_dedupes_per_kind_name() {
let report = InstallReport {
bundles: Vec::new(),
items: vec![
InstalledItem {
kind: ItemKind::Skill,
name: "code-review".into(),
client: Client::Claude,
output_path: PathBuf::from(".claude/skills/code-review/SKILL.md"),
source_hash: Some("sha256:abc".into()),
},
InstalledItem {
kind: ItemKind::Skill,
name: "code-review".into(),
client: Client::Copilot,
output_path: PathBuf::from(".github/skills/code-review/SKILL.md"),
source_hash: Some("sha256:abc".into()),
},
InstalledItem {
kind: ItemKind::Skill,
name: "code-review".into(),
client: Client::OpenCode,
output_path: PathBuf::from(".agents/skills/code-review/SKILL.md"),
source_hash: Some("sha256:abc".into()),
},
],
};
let items = items_from_report(&report, "local:./src", None, |_, _| {
Some("sha256:abc".into())
});
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, "skill");
assert_eq!(items[0].name, "code-review");
assert_eq!(items[0].source, "local:./src");
assert_eq!(items[0].hash, Some("sha256:abc".into()));
}
}