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 = 1;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Lockfile {
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 Lockfile {
fn default() -> Self {
Self::new()
}
}
impl Lockfile {
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())),
};
let parsed: Self =
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))?;
if parsed.schema > CURRENT_SCHEMA {
anyhow::bail!(
"{}: schema {} is newer than this implementation supports \
(max {}); upgrade upskill",
path.display(),
parsed.schema,
CURRENT_SCHEMA
);
}
if parsed.schema != CURRENT_SCHEMA {
anyhow::bail!(
"{}: unsupported schema {} (expected {})",
path.display(),
parsed.schema,
CURRENT_SCHEMA
);
}
Ok(parsed)
}
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",
}
}
#[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 = Lockfile::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 = Lockfile::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 = Lockfile::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 = Lockfile::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 = Lockfile::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 = Lockfile::new();
lock.upsert(item("skill", "code-review"));
lock.save(tmp.path()).expect("save");
let loaded = Lockfile::load(tmp.path()).expect("load");
assert_eq!(loaded, lock);
}
#[test]
fn load_missing_file_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let lock = Lockfile::load(tmp.path()).expect("load");
assert_eq!(lock, Lockfile::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 = Lockfile::load(tmp.path()).expect_err("must reject");
assert!(err.to_string().contains("schema 99"));
}
#[test]
fn load_rejects_unrecognised_shape() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(LOCKFILE_NAME), r#"{"random": "shape"}"#).unwrap();
Lockfile::load(tmp.path()).expect_err("must reject");
}
#[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()));
}
}