use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::Path;
use jiff::Timestamp;
use toml_edit::{DocumentMut, Item, TableLike};
use crate::error::{Error, MANIFEST, Result};
pub const FILE: &str = "skills.lock";
#[derive(Clone, Debug, PartialEq)]
pub struct Dist {
pub kind: String,
pub url: String,
pub reference: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct LockedSkill {
pub name: String,
pub from: Option<String>,
pub path: String,
pub tree_sha256: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Package {
pub name: String,
pub constraint: String,
pub git_ref: Option<bool>,
pub version: String,
pub repository: String,
pub dist: Dist,
pub dist_sha256: String,
pub released: Option<Timestamp>,
pub selected: Option<Vec<String>>,
pub requires: BTreeMap<String, String>,
pub skills: Vec<LockedSkill>,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Lock {
pub packages: Vec<Package>,
}
fn bad(what: impl std::fmt::Display) -> Error {
Error::new(MANIFEST, format!("{FILE}: {what}"))
}
impl Lock {
pub fn load(root: &Path) -> Result<Lock> {
match std::fs::read_to_string(root.join(FILE)) {
Ok(t) => parse(&t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Lock::default()),
Err(e) => Err(e.into()),
}
}
pub fn save(&self, root: &Path) -> Result<()> {
crate::project::write_atomic(&root.join(FILE), &self.render())
}
pub fn get(&self, name: &str) -> Option<&Package> {
self.packages.iter().find(|p| p.name == name)
}
pub fn upsert(&mut self, p: Package) {
self.packages.retain(|x| x.name != p.name);
self.packages.push(p);
self.packages.sort_by(|a, b| a.name.cmp(&b.name));
}
pub fn skills(&self) -> impl Iterator<Item = (&Package, &LockedSkill)> {
self.packages.iter().flat_map(|p| p.skills.iter().map(move |s| (p, s)))
}
pub fn render(&self) -> String {
let q = |s: &str| toml_edit::Value::from(s).to_string().trim().to_string();
let version = if self.packages.iter().any(|p| !p.requires.is_empty()) { 2 } else { 1 };
let mut out = format!("# Generated by skillcask. Do not edit.\nversion = {version}\n");
let mut packages = self.packages.clone();
packages.sort_by(|a, b| a.name.cmp(&b.name));
for p in &packages {
let _ = write!(out, "\n[[package]]\nname = {}\nconstraint = {}\n", q(&p.name), q(&p.constraint));
if let Some(r) = p.git_ref {
let _ = writeln!(out, "ref = {r}");
}
let _ = write!(out, "version = {}\nrepository = {}\n", q(&p.version), q(&p.repository));
let _ = write!(out, "dist = {{ type = {}, url = {}", q(&p.dist.kind), q(&p.dist.url));
if let Some(r) = &p.dist.reference {
let _ = write!(out, ", reference = {}", q(r));
}
let _ = writeln!(out, " }}\ndist-sha256 = {}", q(&p.dist_sha256));
if let Some(t) = p.released {
let _ = writeln!(out, "released = {}", t.round(jiff::Unit::Second).unwrap_or(t));
}
if let Some(sel) = &p.selected {
let _ = writeln!(out, "selected = [{}]", sel.iter().map(|n| q(n)).collect::<Vec<_>>().join(", "));
}
if !p.requires.is_empty() {
let pairs: Vec<String> = p.requires.iter().map(|(k, v)| format!("{} = {}", q(k), q(v))).collect();
let _ = writeln!(out, "requires = {{ {} }}", pairs.join(", "));
}
out.push_str("skills = [");
for (i, s) in p.skills.iter().enumerate() {
let sep = if i == 0 { "" } else { "," };
let _ = write!(out, "{sep}\n {{ name = {}, ", q(&s.name));
if let Some(f) = &s.from {
let _ = write!(out, "from = {}, ", q(f));
}
let _ = write!(out, "path = {}, tree-sha256 = {} }}", q(&s.path), q(&s.tree_sha256));
}
out.push_str(if p.skills.is_empty() { "]\n" } else { ",\n]\n" });
}
out
}
}
pub fn parse(text: &str) -> Result<Lock> {
if text.lines().any(|l| ["<<<<<<<", "=======", ">>>>>>>"].iter().any(|m| l.starts_with(m))) {
return Err(bad("unresolved merge conflict").hint(format!(
"resolve {} first, keep either side of {FILE} (e.g. git checkout --theirs {FILE}), then run skillcask install; it locks what that side lacks",
crate::manifest::FILE
)));
}
let doc: DocumentMut = text.parse().map_err(bad)?;
match doc.get("version").and_then(Item::as_integer) {
Some(1 | 2) => {}
v => return Err(bad(format!("unsupported lock version {v:?}"))),
}
let mut packages: Vec<Package> = Vec::new();
if let Some(arr) = doc.get("package").and_then(Item::as_array_of_tables) {
for t in arr.iter() {
let p = package(t)?;
if packages.iter().any(|x| x.name == p.name) {
return Err(bad(format!("invalid lock: package {} appears twice", p.name)));
}
packages.push(p);
}
}
Ok(Lock { packages })
}
fn package(t: &dyn TableLike) -> Result<Package> {
let s = |t: &dyn TableLike, k: &str| -> Result<String> {
t.get(k).and_then(Item::as_str).map(String::from).ok_or_else(|| bad(format!("missing {k}")))
};
let name = s(t, "name")?;
if !crate::manifest::valid_package_name(&name) {
return Err(bad(format!("invalid package name {name:?}")));
}
let ctx = |e: Error| bad(format!("{name}: {}", e.msg.trim_start_matches("skills.lock: ")));
let dist = t.get("dist").and_then(Item::as_table_like).ok_or_else(|| bad(format!("{name}: missing dist")))?;
let released = match t.get("released") {
None => None,
Some(i) => {
let raw = i.as_datetime().map(|d| d.to_string()).or_else(|| i.as_str().map(String::from));
Some(raw.and_then(|r| r.parse().ok()).ok_or_else(|| bad(format!("{name}: invalid released")))?)
}
};
let mut skills = Vec::new();
for v in t.get("skills").and_then(Item::as_array).ok_or_else(|| bad(format!("{name}: missing skills")))?.iter() {
let st = v.as_inline_table().ok_or_else(|| bad(format!("{name}: invalid skills entry")))?;
let skill = LockedSkill {
name: s(st, "name").map_err(ctx)?,
from: st.get("from").map(|i| i.as_str().map(String::from).ok_or_else(|| bad(format!("{name}: invalid from")))).transpose()?,
path: s(st, "path").map_err(ctx)?,
tree_sha256: s(st, "tree-sha256").map_err(ctx)?,
};
let safe_path =
skill.path.is_empty() || skill.path.split('/').all(|c| !c.is_empty() && c != "." && c != ".." && !c.contains(['\\', ':']));
if !crate::skill::valid_name(&skill.name)
|| skill.from.as_deref().is_some_and(|f| !crate::skill::valid_name(f) || f == skill.name)
|| !safe_path
{
return Err(bad(format!("{name}: invalid skill entry {:?} at {:?}", skill.name, skill.path)));
}
if !is_sha256(&skill.tree_sha256) {
return Err(bad(format!("invalid tree-sha256 of skill {} in {name}", skill.name)));
}
skills.push(skill);
}
let selected = match t.get("selected") {
None => None,
Some(i) => Some(
i.as_array()
.and_then(|a| a.iter().map(|v| v.as_str().filter(|n| crate::skill::valid_name(n)).map(String::from)).collect())
.ok_or_else(|| bad(format!("invalid selected in {name}")))?,
),
};
let mut requires = BTreeMap::new();
if let Some(i) = t.get("requires") {
let r = i.as_table_like().ok_or_else(|| bad(format!("invalid requires in {name}")))?;
for (k, v) in r.iter() {
let v = v.as_str().ok_or_else(|| bad(format!("invalid requires in {name}")))?;
crate::deps::check(k, v).map_err(|e| bad(format!("invalid requires in {name}: {e}")))?;
requires.insert(k.to_string(), v.to_string());
}
}
let git_ref = t.get("ref").map(|i| i.as_bool().ok_or_else(|| bad(format!("invalid ref in {name}")))).transpose()?;
let dist = Dist { kind: s(dist, "type").map_err(ctx)?, url: s(dist, "url").map_err(ctx)?, reference: s(dist, "reference").ok() };
if dist.kind != "zip" && dist.kind != crate::git::KIND {
return Err(bad(format!("invalid dist type {:?} in {name}, only \"zip\" or \"git\"", dist.kind)));
}
if !dist.url.starts_with("https://") {
return Err(bad(format!("invalid dist url in {name}: only https:// is allowed")));
}
let dist_sha256 = s(t, "dist-sha256").map_err(ctx)?;
if !is_sha256(&dist_sha256) {
return Err(bad(format!("invalid dist-sha256 in {name}")));
}
Ok(Package {
constraint: s(t, "constraint").map_err(ctx)?,
git_ref,
version: s(t, "version").map_err(ctx)?,
repository: s(t, "repository").map_err(ctx)?,
dist,
dist_sha256,
released,
selected,
requires,
skills,
name,
})
}
fn is_sha256(h: &str) -> bool {
h.len() == 64 && h.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trip_is_byte_stable() {
let mut lock = Lock::default();
for name in ["z/last", "acme/agents-md"] {
let last = name == "z/last";
lock.upsert(Package {
name: name.into(),
constraint: "^1.0".into(),
git_ref: last.then_some(false),
version: "1.0.2".into(),
repository: "https://git.example.com/packages.json".into(),
dist: Dist { kind: "zip".into(), url: "https://x/a \"q\".zip".into(), reference: Some("9f2c1e".into()) },
dist_sha256: "ab".repeat(32),
released: Some("2026-10-01T09:12:00Z".parse().unwrap()),
selected: last.then(|| vec!["agents-md".into(), "orig".into()]),
requires: BTreeMap::new(),
skills: vec![
LockedSkill { name: "agents-md".into(), from: None, path: "".into(), tree_sha256: "cd".repeat(32) },
LockedSkill { name: "two".into(), from: Some("orig".into()), path: "skills/two".into(), tree_sha256: "ef".repeat(32) },
],
});
}
let text = lock.render();
assert!(text.contains("released = 2026-10-01T09:12:00Z\n"));
assert!(text.contains("{ name = \"two\", from = \"orig\", path"));
assert!(parse(&text.replace("from = \"orig\"", "from = \"../x\"")).is_err());
assert!(text.find("acme/agents-md").unwrap() < text.find("z/last").unwrap());
let back = parse(&text).unwrap();
assert_eq!(back, lock);
assert_eq!(back.render(), text);
assert_eq!(parse("version = 1\n").unwrap(), Lock::default());
assert!(parse("version = 3\n").is_err());
let evil = |n: &str, p: &str| text.replace("name = \"agents-md\", path = \"\"", &format!("name = \"{n}\", path = \"{p}\""));
assert!(parse(&evil("../../x", "")).is_err());
assert!(parse(&evil("ok", "../..")).is_err());
assert!(parse(&evil("ok", "a//b")).is_err());
assert!(parse(&evil("ok", "skills/ok")).is_ok());
}
#[test]
fn new_keys_are_optional_and_render_only_when_set() {
let mut lock = parse(&sample()).unwrap();
assert_eq!(lock.render(), sample(), "a lock without the new keys renders byte-identical");
let p = &mut lock.packages[0];
(p.git_ref, p.selected) = (Some(true), Some(vec!["one".into()]));
let text = lock.render();
assert!(text.contains("constraint = \"^1.0\"\nref = true\nversion") && text.contains("selected = [\"one\"]\nskills = ["), "{text}");
assert_eq!(parse(&text).unwrap(), lock);
}
#[test]
fn requires_switches_to_version_2() {
let mut lock = parse(&sample()).unwrap();
lock.packages[0].requires = BTreeMap::from([("github:o/r/pr".into(), "main".into()), ("acme/x".into(), "^1.0".into())]);
let text = lock.render();
assert!(text.contains("version = 2\n"), "{text}");
assert!(text.contains("requires = { \"acme/x\" = \"^1.0\", \"github:o/r/pr\" = \"main\" }\nskills = ["), "{text}");
assert_eq!(parse(&text).unwrap(), lock);
for bad in ["\"acme\" = \"^1\"", "\"acme/x\" = \"main\"", "\"acme/x\" = 1"] {
let e = parse(&text.replace("\"acme/x\" = \"^1.0\"", bad)).unwrap_err();
assert!(e.msg.contains("invalid requires in a/b"), "{bad}: {}", e.msg);
}
}
#[test]
fn merge_conflict_markers_fail_with_a_hint() {
let ok = sample();
let at = ok.find("\n[[package]]").unwrap();
let text = format!("{}\n<<<<<<< HEAD\n{}=======\n>>>>>>> other\n", &ok[..at], &ok[at..]);
let e = parse(&text).unwrap_err();
assert!(e.msg.contains("merge conflict") && e.code == MANIFEST, "{}", e.msg);
assert!(e.hint.unwrap().contains("skillcask install"));
assert!(parse(&ok.replace("version = 1\n", "version = 1 # <<<<<<< only at line start\n")).is_ok());
}
fn sample() -> String {
let h = "ab".repeat(32);
format!(
"# Generated by skillcask. Do not edit.\nversion = 1\n\n[[package]]\nname = \"a/b\"\nconstraint = \"^1.0\"\nversion = \"1.0.0\"\nrepository = \"https://r/packages.json\"\ndist = {{ type = \"zip\", url = \"https://r/a.zip\" }}\ndist-sha256 = \"{h}\"\nskills = [\n {{ name = \"one\", path = \"\", tree-sha256 = \"{h}\" }},\n]\n"
)
}
#[test]
fn invalid_values_are_rejected() {
let ok = sample();
let h = "ab".repeat(32);
for (from, to) in [
(format!("dist-sha256 = \"{h}\""), "dist-sha256 = \"AB\"".to_string()),
(format!("dist-sha256 = \"{h}\""), format!("dist-sha256 = \"{}\"", h.to_uppercase())),
(format!("tree-sha256 = \"{h}\""), "tree-sha256 = \"x\"".to_string()),
("url = \"https://".into(), "url = \"http://".into()),
("type = \"zip\"".into(), "type = \"tar\"".into()),
("name = \"one\", ".into(), "name = \"one\", from = \"one\", ".into()),
("version = \"1.0.0\"".into(), "ref = \"yes\"\nversion = \"1.0.0\"".into()),
("skills = [".into(), "selected = [\"Bad\"]\nskills = [".into()),
] {
let text = ok.replace(&from, &to);
assert_ne!(text, ok, "{from}");
let e = parse(&text).unwrap_err();
assert!(e.msg.starts_with("skills.lock: ") && e.msg.contains("invalid") && e.code == MANIFEST, "{to}: {}", e.msg);
}
let twice = format!("{ok}{}", &ok[ok.find("\n[[package]]").unwrap()..]);
assert!(parse(&twice).unwrap_err().msg.contains("invalid lock: package a/b appears twice"));
}
}