use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const LOCK_FILE: &str = "ai.lock";
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Lockfile {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub id: String,
#[serde(default)]
pub skills: BTreeMap<String, LockedSkill>,
}
pub fn generate_id(root: &Path) -> String {
let seed = format!(
"{:?}|{:?}|{}",
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH),
root,
std::process::id()
);
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in seed.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
format!("spm-{:08x}", h & 0xffff_ffff)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LockedSkill {
pub git: String,
pub reference: String,
pub commit: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub path: Option<String>,
pub store: String,
}
impl Lockfile {
pub fn path_in(dir: &Path) -> PathBuf {
dir.join(LOCK_FILE)
}
pub fn load_or_default(dir: &Path) -> Result<Self> {
let p = Self::path_in(dir);
if !p.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&p)?;
let lock: Self =
serde_json::from_str(&text).with_context(|| format!("parsing {}", p.display()))?;
lock.validate()
.with_context(|| format!("in {}", p.display()))?;
Ok(lock)
}
fn validate(&self) -> Result<()> {
if !self.id.is_empty() {
validate_project_id(&self.id)?;
}
for (name, l) in &self.skills {
validate_commit(&l.commit).with_context(|| format!("skill `{name}`"))?;
validate_store_key(&l.store).with_context(|| format!("skill `{name}`"))?;
let expected = store_key(&l.git, &l.commit);
if l.store != expected {
bail!(
"skill `{name}`: store key `{}` does not match `{expected}` derived from git+commit",
l.store
);
}
}
Ok(())
}
pub fn save(&self, dir: &Path) -> Result<()> {
let p = Self::path_in(dir);
let text = serde_json::to_string_pretty(self)?;
std::fs::write(&p, text + "\n").with_context(|| format!("writing {}", p.display()))
}
}
pub fn validate_project_id(id: &str) -> Result<()> {
let ok = !id.is_empty()
&& id != "."
&& id != ".."
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
if !ok {
bail!("invalid project id `{id}`: expected `[A-Za-z0-9_-]+`");
}
Ok(())
}
pub fn validate_commit(commit: &str) -> Result<()> {
let ok = commit.len() == 40
&& commit
.chars()
.all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c));
if !ok {
bail!("invalid commit `{commit}`: expected a full 40-character lowercase hex SHA");
}
Ok(())
}
pub fn validate_store_key(key: &str) -> Result<()> {
let bad = key.is_empty()
|| key.contains('/')
|| key.contains('\\')
|| key.contains('\0')
|| key.split('@').any(|seg| seg == "." || seg == "..");
if bad {
bail!("invalid store key `{key}`: must be a single path component");
}
Ok(())
}
pub fn store_key(url: &str, sha: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in url.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
let hint: String = url
.trim_end_matches('/')
.rsplit(['/', ':'])
.next()
.unwrap_or(url)
.trim_end_matches(".git")
.chars()
.filter(char::is_ascii_alphanumeric)
.take(32)
.collect();
format!("{hint}-{h:016x}@{sha}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_or_default_accepts_missing_id() {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"spm-lockfile-test-noid-{}-{nanos}",
std::process::id(),
));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(LOCK_FILE), r#"{"skills":{}}"#).unwrap();
let lock = Lockfile::load_or_default(&dir).unwrap();
assert!(lock.id.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn validate_commit_rejects_short_or_uppercase_or_non_hex() {
assert!(validate_commit(&"a".repeat(40)).is_ok());
assert!(validate_commit("abc123").is_err(), "too short");
assert!(
validate_commit(&"A".repeat(40)).is_err(),
"uppercase must be rejected (normalize before validating)"
);
assert!(
validate_commit(&"g".repeat(40)).is_err(),
"non-hex chars must be rejected"
);
}
#[test]
fn validate_rejects_store_key_mismatch() {
let sha = "b".repeat(40);
let mut skills = BTreeMap::new();
skills.insert(
"greet".to_string(),
LockedSkill {
git: "https://example.com/repo.git".into(),
reference: "branch:main".into(),
commit: sha.clone(),
path: None,
store: "totally-not-the-derived-key".into(),
},
);
let lock = Lockfile {
id: String::new(),
skills,
};
let err = lock.validate().unwrap_err();
assert!(format!("{err}").contains("does not match"), "{err}");
}
#[test]
fn store_key_is_bounded_safe_and_stable() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let long = format!(
"file://{}/skill",
"C_Users_runner_AppData_Local_Temp".repeat(20)
);
let key = store_key(&long, sha);
validate_store_key(&key).expect("generated key must pass validation");
assert!(key.len() <= 90, "key too long: {} ({})", key.len(), key);
assert!(key.ends_with(&format!("@{sha}")));
assert_eq!(key, store_key(&long, sha));
assert_ne!(
store_key("https://github.com/a/repo", sha),
store_key("https://github.com/b/repo", sha)
);
}
}