Skip to main content

pakx_core/lockfile/
schema.rs

1//! Strongly-typed representation of `agents.lock`.
2//!
3//! The lockfile pins every resolved dep to a content hash + source URL +
4//! version, with transitive deps as forward references to other entries.
5//! JSON storage chosen over YAML for determinism (no key-order ambiguity)
6//! and tooling support.
7
8use std::collections::BTreeMap;
9use std::sync::LazyLock;
10
11use regex::Regex;
12use serde::{Deserialize, Serialize};
13
14use crate::manifest::{AgentId, PackageType};
15
16/// Current on-disk lockfile schema version. Bump on incompatible changes.
17pub const LOCKFILE_VERSION: u32 = 1;
18
19/// Source registry that produced an entry. `git` and `github` are direct
20/// fetches (no intermediary index); the others are federated public APIs
21/// queried by the registry-client in v0.1.
22#[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    /// The pakx-registry backend (registry.pakx.dev) — first-party
31    /// federated source for packages published through the CLI.
32    Pakx,
33}
34
35/// All registry-source variants in canonical order.
36pub 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    /// Stable kebab-case tag. Matches the serde representation (so a
47    /// round-trip through `serde_json` / `serde_yaml_ng` produces the
48    /// same string), but available without serializing. Used by the CLI
49    /// for human + JSON output and is part of the documented JSON
50    /// contract — only add new variants, never rename existing ones.
51    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
63// ---------------------------------------------------------------------------
64// Integrity (SRI-style sha256)
65// ---------------------------------------------------------------------------
66
67static INTEGRITY_RE: LazyLock<Regex> =
68    LazyLock::new(|| Regex::new(r"^sha256-[A-Za-z0-9+/]{43}=$").expect("static regex compiles"));
69
70/// SRI integrity string: `sha256-<base64>` (RFC 6920). The 44 char body is
71/// 32 raw bytes = a sha256 digest. Newtype keeps malformed values out of
72/// other lockfile fields.
73#[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
104// ---------------------------------------------------------------------------
105// Entry key
106// ---------------------------------------------------------------------------
107
108static 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
113/// Validate a flat lockfile entry key.
114///
115/// Format: `<type>/<canonical-id>@<version>` — for example
116/// `skills/anthropics/pdf@1.2.0` or `mcp/smithery/github-mcp@0.5.1`.
117pub fn is_valid_entry_key(key: &str) -> bool {
118    ENTRY_KEY_RE.is_match(key)
119}
120
121// ---------------------------------------------------------------------------
122// Lock entry + lockfile
123// ---------------------------------------------------------------------------
124
125/// One resolved package pinned into the lockfile.
126#[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    /// Fully resolved fetch location (URL, git+ref, registry URI).
134    pub resolved_from: String,
135    pub registry: RegistrySource,
136    pub integrity: Integrity,
137    /// Agent ids this entry was installed into.
138    #[serde(default)]
139    pub agents: Vec<AgentId>,
140    /// Transitive lockfile-entry keys this entry depends on.
141    #[serde(default)]
142    pub dependencies: Vec<String>,
143}
144
145/// On-disk `agents.lock`. `BTreeMap` gives deterministic key order for free.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields, rename_all = "camelCase")]
148pub struct Lockfile {
149    pub lockfile_version: u32,
150    /// sha256 of the canonicalised manifest at lock time. Drift signal for
151    /// `pakx doctor`.
152    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    /// `RegistrySource::as_tag` is the single source of truth for the
161    /// kebab-case representation used by the CLI's human + JSON output.
162    /// It MUST match the serde representation so a round-trip through
163    /// JSON / YAML produces the same string — locking that in here.
164    #[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}