skillcask 0.9.4

A lockfile-first package manager for AI agent skills (SKILL.md), backed by Composer repositories
//! `skills.lock`: written by hand-rolled formatting so the layout is stable and merge-friendly.

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::{Context, 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,
    /// Upstream name when installed under another one.
    pub from: Option<String>,
    /// Directory of the skill inside the package, `/`-separated, `""` for the root.
    pub path: String,
    pub tree_sha256: String,
}

#[derive(Clone, Debug, PartialEq)]
pub struct Package {
    pub name: String,
    pub constraint: String,
    /// GitHub only: `constraint` is a ref (true) or a version over tags (false). `None`: not
    /// GitHub, or a lock written before the key existed.
    pub git_ref: Option<bool>,
    pub version: String,
    pub repository: String,
    pub dist: Dist,
    pub dist_sha256: String,
    pub released: Option<Timestamp>,
    /// `skills = [...]` of the entry; `None` means all skills (also in locks older than the key).
    pub selected: Option<Vec<String>>,
    /// `requires:` of the installed skills, `vendor/name` or `github:…` to constraint (D16).
    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 {
    /// A missing lock is an empty one.
    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).ctx(FILE),
        }
    }

    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();
        // Version 1 readers would drop dependencies as entries missing from the manifest.
        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| t.get(k).and_then(Item::as_str).map(String::from).ok_or_else(|| format!("missing {k}"));
    let name = s(t, "name").map_err(bad)?;
    if !crate::manifest::valid_package_name(&name) {
        return Err(bad(format!("invalid package name {name:?}")));
    }
    let ctx = |e: String| bad(format!("{name}: {e}"));
    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)?,
        };
        // The lock is committed and may come from an untrusted change; both end up in paths.
        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"));
    }

    #[test]
    fn a_missing_field_names_the_package_once() {
        let once = |m: &str| m.starts_with("skills.lock: a/b: missing ") && m.matches("skills.lock: ").count() == 1;
        let e = parse(&sample().replace("version = \"1.0.0\"\n", "")).unwrap_err();
        assert!(e.code == MANIFEST && once(&e.msg), "{}", e.msg);
        let e = parse(&sample().replace("name = \"one\", ", "")).unwrap_err();
        assert!(once(&e.msg), "{}", e.msg);
        let e = parse(&sample().replace("name = \"a/b\"\n", "")).unwrap_err();
        assert!(e.msg.starts_with("skills.lock: missing ") && !e.msg.contains("a/b"), "{}", e.msg);
    }
}