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(crate) fn is_context(&self) -> bool {
35 matches!(self, Self::Context)
36 }
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(remote = "Self")]
44pub struct PackageManifest {
45 pub schema_version: u32,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub conformance_level: Option<u32>,
48 #[serde(default, skip_serializing_if = "PackageKind::is_context")]
51 pub kind: PackageKind,
52 pub name: String,
53 pub version: String,
54 pub description: String,
55 #[serde(default)]
56 pub author: Option<String>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub scope: Option<String>,
59 pub created_at: DateTime<Utc>,
60 #[serde(default)]
61 pub updated_at: Option<DateTime<Utc>>,
62 pub layers: Vec<PackageLayer>,
63 #[serde(default)]
64 pub dependencies: Vec<PackageDependency>,
65 #[serde(default)]
66 pub tags: Vec<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub visibility: Option<String>,
71 pub integrity: PackageIntegrity,
72 pub provenance: PackageProvenance,
73 #[serde(default)]
74 pub compatibility: CompatibilitySpec,
75 #[serde(default)]
76 pub stats: PackageStats,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub signature: Option<PackageSignature>,
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub graph_summary: Option<GraphSummary>,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub marketplace: Option<MarketplaceMeta>,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub redistributable: Option<bool>,
90}
91
92impl Serialize for PackageManifest {
93 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
94 PackageManifest::serialize(self, serializer)
95 }
96}
97
98impl<'de> Deserialize<'de> for PackageManifest {
99 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
100 use serde::de::Error;
101 let mut value = serde_json::Value::deserialize(deserializer)?;
102 normalize_dependencies(&mut value).map_err(D::Error::custom)?;
103 PackageManifest::deserialize(value).map_err(D::Error::custom)
104 }
105}
106
107fn normalize_dependencies(manifest: &mut serde_json::Value) -> Result<(), String> {
112 let Some(object) = manifest.as_object_mut() else {
113 return Ok(());
114 };
115 let mut entries = match object.remove("dependencies") {
116 None | Some(serde_json::Value::Null) => Vec::new(),
117 Some(value) => dependency_entries(value, false, "dependencies")?,
118 };
119 if let Some(value) = object
120 .remove("optional_dependencies")
121 .filter(|value| !value.is_null())
122 {
123 entries.extend(dependency_entries(value, true, "optional_dependencies")?);
124 }
125 object.insert("dependencies".into(), serde_json::Value::Array(entries));
126 Ok(())
127}
128
129fn dependency_entries(
130 value: serde_json::Value,
131 optional: bool,
132 member: &str,
133) -> Result<Vec<serde_json::Value>, String> {
134 match value {
135 serde_json::Value::Array(mut items) => {
136 if optional {
137 for item in items
138 .iter_mut()
139 .filter_map(serde_json::Value::as_object_mut)
140 {
141 item.insert("optional".into(), serde_json::Value::Bool(true));
142 }
143 }
144 Ok(items)
145 }
146 serde_json::Value::Object(map) => map
147 .into_iter()
148 .map(|(name, range)| match range {
149 serde_json::Value::String(range) => Ok(serde_json::json!({
150 "name": name,
151 "version_req": range,
152 "optional": optional,
153 })),
154 other => Err(format!(
155 "{member}.{name} must be a SemVer range string, got {other}"
156 )),
157 })
158 .collect(),
159 other => Err(format!(
160 "{member} must be a list or a map of name to SemVer range, got {other}"
161 )),
162 }
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct PackageSignature {
167 pub algorithm: String,
168 pub public_key: String,
169 pub value: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub manifest_value: Option<String>,
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum PackageLayer {
180 Knowledge,
181 Graph,
182 Session,
183 Patterns,
184 Gotchas,
185 Checkpoint,
186}
187
188impl PackageLayer {
189 pub fn as_str(&self) -> &'static str {
190 match self {
191 Self::Knowledge => "knowledge",
192 Self::Graph => "graph",
193 Self::Session => "session",
194 Self::Patterns => "patterns",
195 Self::Gotchas => "gotchas",
196 Self::Checkpoint => "checkpoint",
197 }
198 }
199}
200
201#[derive(Debug, Clone, Default, Serialize, Deserialize)]
202pub struct PackageDependency {
203 pub name: String,
204 pub version_req: String,
205 #[serde(default)]
206 pub optional: bool,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PackageIntegrity {
211 pub sha256: String,
212 pub content_hash: String,
213 pub byte_size: u64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct PackageProvenance {
218 pub tool: String,
219 pub tool_version: String,
220 pub project_hash: Option<String>,
221 #[serde(default)]
222 pub source_session_id: Option<String>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, Default)]
226pub struct CompatibilitySpec {
227 #[serde(default)]
228 pub min_lean_ctx_version: Option<String>,
229 #[serde(default)]
230 pub target_languages: Vec<String>,
231 #[serde(default)]
232 pub target_frameworks: Vec<String>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize, Default)]
236pub struct PackageStats {
237 pub knowledge_facts: u32,
238 pub graph_nodes: u32,
239 pub graph_edges: u32,
240 pub pattern_count: u32,
241 pub gotcha_count: u32,
242 pub compression_ratio: f64,
243}
244
245impl PackageManifest {
246 pub(crate) fn is_v2(&self) -> bool {
247 self.schema_version >= crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
248 }
249
250 pub(crate) fn validate(&self) -> Result<(), Vec<String>> {
251 let mut errors = Vec::new();
252
253 let v1 = crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION;
254 let v2 = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
255 if self.schema_version != v1 && self.schema_version != v2 {
256 errors.push(format!(
257 "unsupported schema_version {} (expected {v1} or {v2})",
258 self.schema_version,
259 ));
260 }
261 if self.name.is_empty() {
262 errors.push("name must not be empty".into());
263 }
264 if self.name.len() > 128 {
265 errors.push("name must be <= 128 characters".into());
266 }
267 if !self.name.chars().all(|c| {
268 c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@' || c == '/'
269 }) {
270 errors.push("name must only contain [a-zA-Z0-9._@/-]".into());
271 }
272 let name_scope = self
275 .name
276 .strip_prefix('@')
277 .and_then(|scoped| scoped.split_once('/'))
278 .map(|(scope, _)| scope);
279 for scope in [name_scope, self.scope.as_deref()].into_iter().flatten() {
280 if scope.chars().any(char::is_uppercase) {
281 errors.push(
282 "scope must be lowercase (uppercase scopes are refused, not normalized)".into(),
283 );
284 }
285 }
286 if self.version.is_empty() {
287 errors.push("version must not be empty".into());
288 }
289 if self.version.len() > 64 {
290 errors.push("version must be <= 64 characters".into());
291 }
292 if !self
293 .version
294 .chars()
295 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
296 {
297 errors.push("version must only contain [a-zA-Z0-9._+-]".into());
298 }
299 if self.version.starts_with('.') {
300 errors.push("version must not start with '.'".into());
301 }
302 if self.layers.is_empty() && !self.is_v2() {
303 errors.push("at least one layer is required".into());
304 }
305 if !self.kind.is_context() && !self.is_v2() {
309 errors.push(format!(
310 "kind = {} requires schema_version {} (v2)",
311 self.kind.as_str(),
312 crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
313 ));
314 }
315 if self.layers.contains(&PackageLayer::Checkpoint)
316 && (!self.is_v2() || !self.kind.is_context())
317 {
318 errors.push("checkpoint layer requires schema_version 2 and kind=context".into());
319 }
320 let mut seen_layers = std::collections::HashSet::new();
321 for layer in &self.layers {
322 if !seen_layers.insert(layer.as_str()) {
323 errors.push(format!("duplicate layer: {}", layer.as_str()));
324 }
325 }
326 if !is_lower_hex(&self.integrity.sha256, 64) {
327 errors.push("integrity.sha256 must be a 64-char lowercase hex string".into());
328 }
329 if !is_lower_hex(&self.integrity.content_hash, 64) {
330 errors.push("integrity.content_hash must be a 64-char lowercase hex string".into());
331 }
332 if self.integrity.byte_size == 0 {
333 errors.push("integrity.byte_size must be > 0".into());
334 }
335
336 if errors.is_empty() {
337 Ok(())
338 } else {
339 Err(errors)
340 }
341 }
342
343 pub(crate) fn has_layer(&self, layer: PackageLayer) -> bool {
344 self.layers.contains(&layer)
345 }
346}
347
348pub(crate) fn is_lower_hex(value: &str, len: usize) -> bool {
350 value.len() == len
351 && value
352 .bytes()
353 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 fn minimal_manifest() -> PackageManifest {
361 PackageManifest {
362 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
363 conformance_level: None,
364 kind: PackageKind::default(),
365 name: "test-pkg".into(),
366 version: "0.1.0".into(),
367 description: "A test package".into(),
368 author: None,
369 scope: None,
370 created_at: Utc::now(),
371 updated_at: None,
372 layers: vec![PackageLayer::Knowledge],
373 dependencies: vec![],
374 tags: vec![],
375 visibility: None,
376 integrity: PackageIntegrity {
377 sha256: "a".repeat(64),
378 content_hash: "b".repeat(64),
379 byte_size: 100,
380 },
381 provenance: PackageProvenance {
382 tool: "lean-ctx".into(),
383 tool_version: env!("CARGO_PKG_VERSION").into(),
384 project_hash: None,
385 source_session_id: None,
386 },
387 compatibility: CompatibilitySpec::default(),
388 stats: PackageStats::default(),
389 signature: None,
390 graph_summary: None,
391 marketplace: None,
392 redistributable: None,
393 }
394 }
395
396 #[test]
397 fn valid_manifest_passes() {
398 assert!(minimal_manifest().validate().is_ok());
399 }
400
401 #[test]
402 fn empty_name_fails() {
403 let mut m = minimal_manifest();
404 assert!(m.validate().is_ok());
405 m.name = String::new();
406 assert!(m.validate().is_err());
407 }
408
409 #[test]
410 fn duplicate_layers_fails() {
411 let mut m = minimal_manifest();
412 m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
413 assert!(m.validate().is_err());
414 }
415
416 #[test]
417 fn non_hex_sha256_fails() {
418 let mut m = minimal_manifest();
419 m.integrity.sha256 = "z".repeat(64);
420 assert!(m.validate().is_err());
421 }
422
423 #[test]
424 fn invalid_name_chars_fails() {
425 let mut m = minimal_manifest();
426 m.name = "my package!".into();
427 let errs = m.validate().unwrap_err();
428 assert!(errs.iter().any(|e| e.contains("only contain")));
429 }
430
431 #[test]
432 fn v2_schema_version_validates() {
433 let mut m = minimal_manifest();
434 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
435 assert!(m.validate().is_ok());
436 }
437
438 #[test]
439 fn scoped_name_validates() {
440 let mut m = minimal_manifest();
441 m.name = "@company/auth-service".into();
442 assert!(m.validate().is_ok());
443 }
444
445 #[test]
446 fn is_v2_flag() {
447 let mut m = minimal_manifest();
448 assert!(!m.is_v2());
449 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
450 assert!(m.is_v2());
451 }
452
453 #[test]
454 fn unsupported_schema_version_fails() {
455 let mut m = minimal_manifest();
456 m.schema_version = 99;
457 let errs = m.validate().unwrap_err();
458 assert!(
459 errs.iter()
460 .any(|e| e.contains("unsupported schema_version"))
461 );
462 }
463
464 #[test]
465 fn v2_manifest_serde_roundtrip() {
466 use super::super::graph_model::{GraphSummary, MarketplaceMeta};
467
468 let mut m = minimal_manifest();
469 m.schema_version = 2;
470 m.conformance_level = Some(2);
471 m.scope = Some("@company".into());
472 m.graph_summary = Some(GraphSummary {
473 node_count: 42,
474 edge_count: 100,
475 node_types: vec!["fact".into(), "gotcha".into()],
476 activation_mean: Some(0.75),
477 freshness: Some(Utc::now()),
478 });
479 m.marketplace = Some(MarketplaceMeta {
480 categories: vec!["security".into()],
481 badges: vec!["verified".into()],
482 license: Some("MIT".into()),
483 });
484
485 let json = serde_json::to_string(&m).unwrap();
486 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
487
488 assert_eq!(decoded.schema_version, 2);
489 assert_eq!(decoded.conformance_level, Some(2));
490 assert_eq!(decoded.scope.as_deref(), Some("@company"));
491 let gs = decoded.graph_summary.unwrap();
492 assert_eq!(gs.node_count, 42);
493 assert_eq!(gs.edge_count, 100);
494 let mp = decoded.marketplace.unwrap();
495 assert_eq!(mp.categories, vec!["security"]);
496 assert_eq!(mp.license.as_deref(), Some("MIT"));
497 }
498
499 #[test]
500 fn v1_manifest_missing_v2_fields_deserializes() {
501 let json = serde_json::to_string(&minimal_manifest()).unwrap();
502 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
503 assert!(decoded.conformance_level.is_none());
504 assert!(decoded.scope.is_none());
505 assert!(decoded.graph_summary.is_none());
506 assert!(decoded.marketplace.is_none());
507 }
508
509 #[test]
513 fn spec_dependency_maps_read_into_the_written_list_form() {
514 let mut value = serde_json::to_value(minimal_manifest()).unwrap();
515 value["dependencies"] = serde_json::json!({"@acme/base": "^1.2"});
516 value["optional_dependencies"] = serde_json::json!({"@acme/extra": "~0.3"});
517 let manifest: PackageManifest = serde_json::from_value(value).unwrap();
518 let read: Vec<_> = manifest
519 .dependencies
520 .iter()
521 .map(|d| (d.name.as_str(), d.version_req.as_str(), d.optional))
522 .collect();
523 assert_eq!(
524 read,
525 [("@acme/base", "^1.2", false), ("@acme/extra", "~0.3", true)]
526 );
527 let written = serde_json::to_value(&manifest).unwrap();
528 assert!(written["dependencies"].is_array());
529 assert!(written.get("optional_dependencies").is_none());
530
531 let mut bad = serde_json::to_value(minimal_manifest()).unwrap();
532 bad["dependencies"] = serde_json::json!({"@acme/base": 1});
533 assert!(serde_json::from_value::<PackageManifest>(bad).is_err());
534 }
535
536 #[test]
537 fn nested_scope_validates() {
538 let mut m = minimal_manifest();
539 m.name = "@org/team/service".into();
540 assert!(m.validate().is_ok());
541 }
542
543 #[test]
547 fn uppercase_scopes_are_refused_not_normalized() {
548 let lowercase = "scope must be lowercase (uppercase scopes are refused, not normalized)";
549 for (name, scope) in [("@Acme/tool", None), ("@acme/tool", Some("@Acme"))] {
550 let mut m = minimal_manifest();
551 m.name = name.into();
552 m.scope = scope.map(Into::into);
553 assert_eq!(m.validate().unwrap_err(), vec![lowercase.to_string()]);
554 assert_eq!(m.name, name, "not normalized");
555 }
556 let mut m = minimal_manifest();
557 m.name = "@acme/Tool".into();
558 m.scope = Some("@acme".into());
559 assert!(m.validate().is_ok());
560 }
561
562 #[test]
567 fn default_kind_is_omitted_from_serialization() {
568 let json = serde_json::to_string(&minimal_manifest()).unwrap();
569 assert!(!json.contains("\"kind\""), "got: {json}");
570 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
571 assert_eq!(decoded.kind, PackageKind::Context);
572 }
573
574 #[test]
575 fn non_default_kind_round_trips() {
576 let mut m = minimal_manifest();
577 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
578 m.kind = PackageKind::Addon;
579 let json = serde_json::to_string(&m).unwrap();
580 assert!(json.contains("\"kind\":\"addon\""), "got: {json}");
581 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
582 assert_eq!(decoded.kind, PackageKind::Addon);
583 assert!(decoded.validate().is_ok());
584 }
585
586 #[test]
588 fn non_context_kind_requires_v2_schema() {
589 let mut m = minimal_manifest();
590 m.kind = PackageKind::Skills;
591 let errs = m.validate().unwrap_err();
592 assert!(
593 errs.iter().any(|e| e.contains("requires schema_version")),
594 "got: {errs:?}"
595 );
596 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
597 assert!(m.validate().is_ok());
598 }
599
600 #[test]
603 fn kind_field_is_forward_and_backward_tolerant() {
604 let mut m = minimal_manifest();
605 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
606 m.kind = PackageKind::Grammar;
607 let with_kind = serde_json::to_string(&m).unwrap();
608 let decoded: PackageManifest = serde_json::from_str(&with_kind).unwrap();
609 assert_eq!(decoded.kind, PackageKind::Grammar);
610
611 let legacy = serde_json::to_string(&minimal_manifest()).unwrap();
612 let decoded: PackageManifest = serde_json::from_str(&legacy).unwrap();
613 assert_eq!(decoded.kind, PackageKind::Context);
614 }
615}