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 path: String,
pub tree_sha256: String,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Package {
pub name: String,
pub constraint: String,
pub version: String,
pub repository: String,
pub dist: Dist,
pub dist_sha256: String,
pub released: Option<Timestamp>,
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<()> {
std::fs::write(root.join(FILE), self.render())?;
Ok(())
}
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 mut out = String::from("# Generated by skillcask. Do not edit.\nversion = 1\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 = {}\nversion = {}\nrepository = {}\n",
q(&p.name),
q(&p.constraint),
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));
}
out.push_str("skills = [");
for (i, s) in p.skills.iter().enumerate() {
let sep = if i == 0 { "" } else { "," };
let _ = write!(out, "{sep}\n {{ name = {}, path = {}, tree-sha256 = {} }}", q(&s.name), 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> {
let doc: DocumentMut = text.parse().map_err(bad)?;
match doc.get("version").and_then(Item::as_integer) {
Some(1) => {}
v => return Err(bad(format!("unsupported lock version {v:?}"))),
}
let mut packages = Vec::new();
if let Some(arr) = doc.get("package").and_then(Item::as_array_of_tables) {
for t in arr.iter() {
packages.push(package(t)?);
}
}
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)?,
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) || !safe_path {
return Err(bad(format!("{name}: invalid skill entry {:?} at {:?}", skill.name, skill.path)));
}
skills.push(skill);
}
Ok(Package {
constraint: s(t, "constraint").map_err(ctx)?,
version: s(t, "version").map_err(ctx)?,
repository: s(t, "repository").map_err(ctx)?,
dist: Dist { kind: s(dist, "type").map_err(ctx)?, url: s(dist, "url").map_err(ctx)?, reference: s(dist, "reference").ok() },
dist_sha256: s(t, "dist-sha256").map_err(ctx)?,
released,
skills,
name,
})
}
#[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"] {
lock.upsert(Package {
name: name.into(),
constraint: "^1.0".into(),
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()),
skills: vec![
LockedSkill { name: "agents-md".into(), path: "".into(), tree_sha256: "cd".repeat(32) },
LockedSkill { name: "two".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.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 = 2\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());
}
}