1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::graph_model::{GraphSummary, MarketplaceMeta};
5
6#[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 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 #[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 #[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 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 #[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 #[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 #[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}