pakx_core/lockfile/
schema.rs1use std::collections::BTreeMap;
9use std::sync::LazyLock;
10
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13
14use crate::manifest::{AgentId, PackageType};
15
16pub const LOCKFILE_VERSION: u32 = 1;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum RegistrySource {
25 OfficialMcp,
26 Smithery,
27 Glama,
28 Github,
29 Git,
30 Pakx,
33}
34
35pub const REGISTRY_SOURCES: [RegistrySource; 6] = [
37 RegistrySource::OfficialMcp,
38 RegistrySource::Smithery,
39 RegistrySource::Glama,
40 RegistrySource::Github,
41 RegistrySource::Git,
42 RegistrySource::Pakx,
43];
44
45impl RegistrySource {
46 pub const fn as_tag(self) -> &'static str {
52 match self {
53 Self::OfficialMcp => "official-mcp",
54 Self::Smithery => "smithery",
55 Self::Glama => "glama",
56 Self::Github => "github",
57 Self::Git => "git",
58 Self::Pakx => "pakx",
59 }
60 }
61}
62
63static INTEGRITY_RE: LazyLock<Regex> =
68 LazyLock::new(|| Regex::new(r"^sha256-[A-Za-z0-9+/]{43}=$").expect("static regex compiles"));
69
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
74#[serde(transparent)]
75pub struct Integrity(String);
76
77impl Integrity {
78 pub fn parse(s: impl Into<String>) -> Result<Self, String> {
79 let s = s.into();
80 if INTEGRITY_RE.is_match(&s) {
81 Ok(Self(s))
82 } else {
83 Err(format!(
84 "invalid integrity {s:?}: must be `sha256-<43 base64 chars>=`"
85 ))
86 }
87 }
88
89 pub const fn as_str(&self) -> &str {
90 self.0.as_str()
91 }
92}
93
94impl<'de> Deserialize<'de> for Integrity {
95 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96 where
97 D: serde::Deserializer<'de>,
98 {
99 let s = String::deserialize(deserializer)?;
100 Self::parse(s).map_err(serde::de::Error::custom)
101 }
102}
103
104static ENTRY_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
109 Regex::new(r"^(skills|mcp|subagents|prompts|commands|hooks)/[^@\s]+@[^\s]+$")
110 .expect("static regex compiles")
111});
112
113pub fn is_valid_entry_key(key: &str) -> bool {
118 ENTRY_KEY_RE.is_match(key)
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(deny_unknown_fields, rename_all = "camelCase")]
128pub struct LockEntry {
129 pub name: String,
130 #[serde(rename = "type")]
131 pub kind: PackageType,
132 pub version: String,
133 pub resolved_from: String,
135 pub registry: RegistrySource,
136 pub integrity: Integrity,
137 #[serde(default)]
139 pub agents: Vec<AgentId>,
140 #[serde(default)]
142 pub dependencies: Vec<String>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields, rename_all = "camelCase")]
148pub struct Lockfile {
149 pub lockfile_version: u32,
150 pub manifest_hash: Integrity,
153 pub entries: BTreeMap<String, LockEntry>,
154}
155
156#[cfg(test)]
157mod tests {
158 use super::{RegistrySource, REGISTRY_SOURCES};
159
160 #[test]
165 fn as_tag_matches_serde_kebab_case() {
166 for src in REGISTRY_SOURCES {
167 let via_serde = serde_json::to_string(&src).expect("serialize variant");
168 let trimmed = via_serde.trim_matches('"');
169 assert_eq!(trimmed, src.as_tag(), "as_tag must match serde for {src:?}");
170 }
171 }
172
173 #[test]
174 fn as_tag_returns_documented_strings() {
175 assert_eq!(RegistrySource::OfficialMcp.as_tag(), "official-mcp");
176 assert_eq!(RegistrySource::Smithery.as_tag(), "smithery");
177 assert_eq!(RegistrySource::Glama.as_tag(), "glama");
178 assert_eq!(RegistrySource::Github.as_tag(), "github");
179 assert_eq!(RegistrySource::Git.as_tag(), "git");
180 assert_eq!(RegistrySource::Pakx.as_tag(), "pakx");
181 }
182}