use std::collections::BTreeMap;
use std::sync::LazyLock;
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::manifest::{AgentId, PackageType};
pub const LOCKFILE_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RegistrySource {
OfficialMcp,
Smithery,
Glama,
Github,
Git,
Pakx,
}
pub const REGISTRY_SOURCES: [RegistrySource; 6] = [
RegistrySource::OfficialMcp,
RegistrySource::Smithery,
RegistrySource::Glama,
RegistrySource::Github,
RegistrySource::Git,
RegistrySource::Pakx,
];
impl RegistrySource {
pub const fn as_tag(self) -> &'static str {
match self {
Self::OfficialMcp => "official-mcp",
Self::Smithery => "smithery",
Self::Glama => "glama",
Self::Github => "github",
Self::Git => "git",
Self::Pakx => "pakx",
}
}
}
static INTEGRITY_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^sha256-[A-Za-z0-9+/]{43}=$").expect("static regex compiles"));
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
#[serde(transparent)]
pub struct Integrity(String);
impl Integrity {
pub fn parse(s: impl Into<String>) -> Result<Self, String> {
let s = s.into();
if INTEGRITY_RE.is_match(&s) {
Ok(Self(s))
} else {
Err(format!(
"invalid integrity {s:?}: must be `sha256-<43 base64 chars>=`"
))
}
}
pub const fn as_str(&self) -> &str {
self.0.as_str()
}
}
impl<'de> Deserialize<'de> for Integrity {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
Self::parse(s).map_err(serde::de::Error::custom)
}
}
static ENTRY_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(skills|mcp|subagents|prompts|commands|hooks)/[^@\s]+@[^\s]+$")
.expect("static regex compiles")
});
pub fn is_valid_entry_key(key: &str) -> bool {
ENTRY_KEY_RE.is_match(key)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct LockEntry {
pub name: String,
#[serde(rename = "type")]
pub kind: PackageType,
pub version: String,
pub resolved_from: String,
pub registry: RegistrySource,
pub integrity: Integrity,
#[serde(default)]
pub agents: Vec<AgentId>,
#[serde(default)]
pub dependencies: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Lockfile {
pub lockfile_version: u32,
pub manifest_hash: Integrity,
pub entries: BTreeMap<String, LockEntry>,
}
#[cfg(test)]
mod tests {
use super::{RegistrySource, REGISTRY_SOURCES};
#[test]
fn as_tag_matches_serde_kebab_case() {
for src in REGISTRY_SOURCES {
let via_serde = serde_json::to_string(&src).expect("serialize variant");
let trimmed = via_serde.trim_matches('"');
assert_eq!(trimmed, src.as_tag(), "as_tag must match serde for {src:?}");
}
}
#[test]
fn as_tag_returns_documented_strings() {
assert_eq!(RegistrySource::OfficialMcp.as_tag(), "official-mcp");
assert_eq!(RegistrySource::Smithery.as_tag(), "smithery");
assert_eq!(RegistrySource::Glama.as_tag(), "glama");
assert_eq!(RegistrySource::Github.as_tag(), "github");
assert_eq!(RegistrySource::Git.as_tag(), "git");
assert_eq!(RegistrySource::Pakx.as_tag(), "pakx");
}
}