Skip to main content

lean_ctx/core/context_package/
manifest.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::graph_model::{GraphSummary, MarketplaceMeta};
5
6/// What a package delivers (unified distribution, GH #724). One taxonomy for
7/// everything an agent can install: knowledge (`context`), workflow content
8/// (`skills`), MCP servers (`addon`) and tree-sitter grammars (`grammar`).
9///
10/// Serialization is additive-only: the field is omitted for the default
11/// `context`, so every pre-existing v1/v2 package stays **byte-identical**
12/// and old readers (which tolerate unknown fields) keep working.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum PackageKind {
16    #[default]
17    Context,
18    Skills,
19    Addon,
20    Grammar,
21}
22
23impl PackageKind {
24    pub fn as_str(&self) -> &'static str {
25        match self {
26            Self::Context => "context",
27            Self::Skills => "skills",
28            Self::Addon => "addon",
29            Self::Grammar => "grammar",
30        }
31    }
32
33    /// True for the default kind (used to omit the field when serializing).
34    pub fn is_context(&self) -> bool {
35        matches!(self, Self::Context)
36    }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PackageManifest {
41    pub schema_version: u32,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub conformance_level: Option<u32>,
44    /// What this package delivers (GH #724). Defaults to `context`; omitted
45    /// from serialized manifests for that default (byte-compat guarantee).
46    #[serde(default, skip_serializing_if = "PackageKind::is_context")]
47    pub kind: PackageKind,
48    pub name: String,
49    pub version: String,
50    pub description: String,
51    #[serde(default)]
52    pub author: Option<String>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub scope: Option<String>,
55    pub created_at: DateTime<Utc>,
56    #[serde(default)]
57    pub updated_at: Option<DateTime<Utc>>,
58    pub layers: Vec<PackageLayer>,
59    #[serde(default)]
60    pub dependencies: Vec<PackageDependency>,
61    #[serde(default)]
62    pub tags: Vec<String>,
63    /// Registry visibility: `private` hides the package from catalog/search;
64    /// installs then need a namespace token (GL #524). `None` = public.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub visibility: Option<String>,
67    pub integrity: PackageIntegrity,
68    pub provenance: PackageProvenance,
69    #[serde(default)]
70    pub compatibility: CompatibilitySpec,
71    #[serde(default)]
72    pub stats: PackageStats,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub signature: Option<PackageSignature>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub graph_summary: Option<GraphSummary>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub marketplace: Option<MarketplaceMeta>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct PackageSignature {
83    pub algorithm: String,
84    pub public_key: String,
85    pub value: String,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum PackageLayer {
91    Knowledge,
92    Graph,
93    Session,
94    Patterns,
95    Gotchas,
96}
97
98impl PackageLayer {
99    pub fn as_str(&self) -> &'static str {
100        match self {
101            Self::Knowledge => "knowledge",
102            Self::Graph => "graph",
103            Self::Session => "session",
104            Self::Patterns => "patterns",
105            Self::Gotchas => "gotchas",
106        }
107    }
108
109    pub fn filename(&self) -> &'static str {
110        match self {
111            Self::Knowledge => "knowledge.json",
112            Self::Graph => "graph.json",
113            Self::Session => "session.json",
114            Self::Patterns => "patterns.json",
115            Self::Gotchas => "gotchas.json",
116        }
117    }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct PackageDependency {
122    pub name: String,
123    pub version_req: String,
124    #[serde(default)]
125    pub optional: bool,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct PackageIntegrity {
130    pub sha256: String,
131    pub content_hash: String,
132    pub byte_size: u64,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct PackageProvenance {
137    pub tool: String,
138    pub tool_version: String,
139    pub project_hash: Option<String>,
140    #[serde(default)]
141    pub source_session_id: Option<String>,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize, Default)]
145pub struct CompatibilitySpec {
146    #[serde(default)]
147    pub min_lean_ctx_version: Option<String>,
148    #[serde(default)]
149    pub target_languages: Vec<String>,
150    #[serde(default)]
151    pub target_frameworks: Vec<String>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default)]
155pub struct PackageStats {
156    pub knowledge_facts: u32,
157    pub graph_nodes: u32,
158    pub graph_edges: u32,
159    pub pattern_count: u32,
160    pub gotcha_count: u32,
161    pub compression_ratio: f64,
162}
163
164impl PackageManifest {
165    pub fn is_v2(&self) -> bool {
166        self.schema_version >= crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
167    }
168
169    pub fn validate(&self) -> Result<(), Vec<String>> {
170        let mut errors = Vec::new();
171
172        let v1 = crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION;
173        let v2 = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
174        if self.schema_version != v1 && self.schema_version != v2 {
175            errors.push(format!(
176                "unsupported schema_version {} (expected {v1} or {v2})",
177                self.schema_version,
178            ));
179        }
180        if self.name.is_empty() {
181            errors.push("name must not be empty".into());
182        }
183        if self.name.len() > 128 {
184            errors.push("name must be <= 128 characters".into());
185        }
186        if !self.name.chars().all(|c| {
187            c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@' || c == '/'
188        }) {
189            errors.push("name must only contain [a-zA-Z0-9._@/-]".into());
190        }
191        if self.version.is_empty() {
192            errors.push("version must not be empty".into());
193        }
194        if self.version.len() > 64 {
195            errors.push("version must be <= 64 characters".into());
196        }
197        if !self
198            .version
199            .chars()
200            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
201        {
202            errors.push("version must only contain [a-zA-Z0-9._+-]".into());
203        }
204        if self.version.starts_with('.') {
205            errors.push("version must not start with '.'".into());
206        }
207        if self.layers.is_empty() && !self.is_v2() {
208            errors.push("at least one layer is required".into());
209        }
210        // Unified distribution (GH #724): non-context kinds are a v2-era
211        // concept — a v1 package claiming to be an addon/skills/grammar pack
212        // is malformed, not merely old.
213        if !self.kind.is_context() && !self.is_v2() {
214            errors.push(format!(
215                "kind = {} requires schema_version {} (v2)",
216                self.kind.as_str(),
217                crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
218            ));
219        }
220        let mut seen_layers = std::collections::HashSet::new();
221        for layer in &self.layers {
222            if !seen_layers.insert(layer.as_str()) {
223                errors.push(format!("duplicate layer: {}", layer.as_str()));
224            }
225        }
226        if self.integrity.sha256.len() != 64
227            || !self.integrity.sha256.chars().all(|c| c.is_ascii_hexdigit())
228        {
229            errors.push("integrity.sha256 must be a 64-char hex string".into());
230        }
231        if self.integrity.content_hash.len() != 64
232            || !self
233                .integrity
234                .content_hash
235                .chars()
236                .all(|c| c.is_ascii_hexdigit())
237        {
238            errors.push("integrity.content_hash must be a 64-char hex string".into());
239        }
240        if self.integrity.byte_size == 0 {
241            errors.push("integrity.byte_size must be > 0".into());
242        }
243
244        if errors.is_empty() {
245            Ok(())
246        } else {
247            Err(errors)
248        }
249    }
250
251    pub fn has_layer(&self, layer: PackageLayer) -> bool {
252        self.layers.contains(&layer)
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn minimal_manifest() -> PackageManifest {
261        PackageManifest {
262            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
263            conformance_level: None,
264            kind: PackageKind::default(),
265            name: "test-pkg".into(),
266            version: "0.1.0".into(),
267            description: "A test package".into(),
268            author: None,
269            scope: None,
270            created_at: Utc::now(),
271            updated_at: None,
272            layers: vec![PackageLayer::Knowledge],
273            dependencies: vec![],
274            tags: vec![],
275            visibility: None,
276            integrity: PackageIntegrity {
277                sha256: "a".repeat(64),
278                content_hash: "b".repeat(64),
279                byte_size: 100,
280            },
281            provenance: PackageProvenance {
282                tool: "lean-ctx".into(),
283                tool_version: env!("CARGO_PKG_VERSION").into(),
284                project_hash: None,
285                source_session_id: None,
286            },
287            compatibility: CompatibilitySpec::default(),
288            stats: PackageStats::default(),
289            signature: None,
290            graph_summary: None,
291            marketplace: None,
292        }
293    }
294
295    #[test]
296    fn valid_manifest_passes() {
297        assert!(minimal_manifest().validate().is_ok());
298    }
299
300    #[test]
301    fn empty_name_fails() {
302        let mut m = minimal_manifest();
303        assert!(m.validate().is_ok());
304        m.name = String::new();
305        assert!(m.validate().is_err());
306    }
307
308    #[test]
309    fn duplicate_layers_fails() {
310        let mut m = minimal_manifest();
311        m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
312        assert!(m.validate().is_err());
313    }
314
315    #[test]
316    fn non_hex_sha256_fails() {
317        let mut m = minimal_manifest();
318        m.integrity.sha256 = "z".repeat(64);
319        assert!(m.validate().is_err());
320    }
321
322    #[test]
323    fn invalid_name_chars_fails() {
324        let mut m = minimal_manifest();
325        m.name = "my package!".into();
326        let errs = m.validate().unwrap_err();
327        assert!(errs.iter().any(|e| e.contains("only contain")));
328    }
329
330    #[test]
331    fn v2_schema_version_validates() {
332        let mut m = minimal_manifest();
333        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
334        assert!(m.validate().is_ok());
335    }
336
337    #[test]
338    fn scoped_name_validates() {
339        let mut m = minimal_manifest();
340        m.name = "@company/auth-service".into();
341        assert!(m.validate().is_ok());
342    }
343
344    #[test]
345    fn is_v2_flag() {
346        let mut m = minimal_manifest();
347        assert!(!m.is_v2());
348        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
349        assert!(m.is_v2());
350    }
351
352    #[test]
353    fn unsupported_schema_version_fails() {
354        let mut m = minimal_manifest();
355        m.schema_version = 99;
356        let errs = m.validate().unwrap_err();
357        assert!(
358            errs.iter()
359                .any(|e| e.contains("unsupported schema_version"))
360        );
361    }
362
363    #[test]
364    fn v2_manifest_serde_roundtrip() {
365        use super::super::graph_model::{GraphSummary, MarketplaceMeta};
366
367        let mut m = minimal_manifest();
368        m.schema_version = 2;
369        m.conformance_level = Some(2);
370        m.scope = Some("@company".into());
371        m.graph_summary = Some(GraphSummary {
372            node_count: 42,
373            edge_count: 100,
374            node_types: vec!["fact".into(), "gotcha".into()],
375            activation_mean: Some(0.75),
376            freshness: Some(Utc::now()),
377        });
378        m.marketplace = Some(MarketplaceMeta {
379            categories: vec!["security".into()],
380            badges: vec!["verified".into()],
381            license: Some("MIT".into()),
382        });
383
384        let json = serde_json::to_string(&m).unwrap();
385        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
386
387        assert_eq!(decoded.schema_version, 2);
388        assert_eq!(decoded.conformance_level, Some(2));
389        assert_eq!(decoded.scope.as_deref(), Some("@company"));
390        let gs = decoded.graph_summary.unwrap();
391        assert_eq!(gs.node_count, 42);
392        assert_eq!(gs.edge_count, 100);
393        let mp = decoded.marketplace.unwrap();
394        assert_eq!(mp.categories, vec!["security"]);
395        assert_eq!(mp.license.as_deref(), Some("MIT"));
396    }
397
398    #[test]
399    fn v1_manifest_missing_v2_fields_deserializes() {
400        let json = serde_json::to_string(&minimal_manifest()).unwrap();
401        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
402        assert!(decoded.conformance_level.is_none());
403        assert!(decoded.scope.is_none());
404        assert!(decoded.graph_summary.is_none());
405        assert!(decoded.marketplace.is_none());
406    }
407
408    #[test]
409    fn nested_scope_validates() {
410        let mut m = minimal_manifest();
411        m.name = "@org/team/service".into();
412        assert!(m.validate().is_ok());
413    }
414
415    // ── kind taxonomy (GH #724, unified distribution) ──
416
417    /// Byte-compat guarantee: the default `context` kind is omitted when
418    /// serializing, so pre-#724 packages and fresh ones are byte-identical.
419    #[test]
420    fn default_kind_is_omitted_from_serialization() {
421        let json = serde_json::to_string(&minimal_manifest()).unwrap();
422        assert!(!json.contains("\"kind\""), "got: {json}");
423        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
424        assert_eq!(decoded.kind, PackageKind::Context);
425    }
426
427    #[test]
428    fn non_default_kind_round_trips() {
429        let mut m = minimal_manifest();
430        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
431        m.kind = PackageKind::Addon;
432        let json = serde_json::to_string(&m).unwrap();
433        assert!(json.contains("\"kind\":\"addon\""), "got: {json}");
434        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
435        assert_eq!(decoded.kind, PackageKind::Addon);
436        assert!(decoded.validate().is_ok());
437    }
438
439    /// A v1 manifest claiming a non-context kind is malformed, not merely old.
440    #[test]
441    fn non_context_kind_requires_v2_schema() {
442        let mut m = minimal_manifest();
443        m.kind = PackageKind::Skills;
444        let errs = m.validate().unwrap_err();
445        assert!(
446            errs.iter().any(|e| e.contains("requires schema_version")),
447            "got: {errs:?}"
448        );
449        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
450        assert!(m.validate().is_ok());
451    }
452
453    /// Old readers must not choke on a manifest that carries the new field —
454    /// and new readers must parse legacy manifests without it (serde default).
455    #[test]
456    fn kind_field_is_forward_and_backward_tolerant() {
457        let mut m = minimal_manifest();
458        m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
459        m.kind = PackageKind::Grammar;
460        let with_kind = serde_json::to_string(&m).unwrap();
461        let decoded: PackageManifest = serde_json::from_str(&with_kind).unwrap();
462        assert_eq!(decoded.kind, PackageKind::Grammar);
463
464        let legacy = serde_json::to_string(&minimal_manifest()).unwrap();
465        let decoded: PackageManifest = serde_json::from_str(&legacy).unwrap();
466        assert_eq!(decoded.kind, PackageKind::Context);
467    }
468}