use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::graph_model::{GraphSummary, MarketplaceMeta};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackageKind {
#[default]
Context,
Skills,
Addon,
Grammar,
}
impl PackageKind {
pub fn as_str(&self) -> &'static str {
match self {
Self::Context => "context",
Self::Skills => "skills",
Self::Addon => "addon",
Self::Grammar => "grammar",
}
}
pub(crate) fn is_context(&self) -> bool {
matches!(self, Self::Context)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(remote = "Self")]
pub struct PackageManifest {
pub schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conformance_level: Option<u32>,
#[serde(default, skip_serializing_if = "PackageKind::is_context")]
pub kind: PackageKind,
pub name: String,
pub version: String,
pub description: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub updated_at: Option<DateTime<Utc>>,
pub layers: Vec<PackageLayer>,
#[serde(default)]
pub dependencies: Vec<PackageDependency>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visibility: Option<String>,
pub integrity: PackageIntegrity,
pub provenance: PackageProvenance,
#[serde(default)]
pub compatibility: CompatibilitySpec,
#[serde(default)]
pub stats: PackageStats,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<PackageSignature>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_summary: Option<GraphSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub marketplace: Option<MarketplaceMeta>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redistributable: Option<bool>,
}
impl Serialize for PackageManifest {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
PackageManifest::serialize(self, serializer)
}
}
impl<'de> Deserialize<'de> for PackageManifest {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error;
let mut value = serde_json::Value::deserialize(deserializer)?;
normalize_dependencies(&mut value).map_err(D::Error::custom)?;
PackageManifest::deserialize(value).map_err(D::Error::custom)
}
}
fn normalize_dependencies(manifest: &mut serde_json::Value) -> Result<(), String> {
let Some(object) = manifest.as_object_mut() else {
return Ok(());
};
let mut entries = match object.remove("dependencies") {
None | Some(serde_json::Value::Null) => Vec::new(),
Some(value) => dependency_entries(value, false, "dependencies")?,
};
if let Some(value) = object
.remove("optional_dependencies")
.filter(|value| !value.is_null())
{
entries.extend(dependency_entries(value, true, "optional_dependencies")?);
}
object.insert("dependencies".into(), serde_json::Value::Array(entries));
Ok(())
}
fn dependency_entries(
value: serde_json::Value,
optional: bool,
member: &str,
) -> Result<Vec<serde_json::Value>, String> {
match value {
serde_json::Value::Array(mut items) => {
if optional {
for item in items
.iter_mut()
.filter_map(serde_json::Value::as_object_mut)
{
item.insert("optional".into(), serde_json::Value::Bool(true));
}
}
Ok(items)
}
serde_json::Value::Object(map) => map
.into_iter()
.map(|(name, range)| match range {
serde_json::Value::String(range) => Ok(serde_json::json!({
"name": name,
"version_req": range,
"optional": optional,
})),
other => Err(format!(
"{member}.{name} must be a SemVer range string, got {other}"
)),
})
.collect(),
other => Err(format!(
"{member} must be a list or a map of name to SemVer range, got {other}"
)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageSignature {
pub algorithm: String,
pub public_key: String,
pub value: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest_value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PackageLayer {
Knowledge,
Graph,
Session,
Patterns,
Gotchas,
Checkpoint,
}
impl PackageLayer {
pub fn as_str(&self) -> &'static str {
match self {
Self::Knowledge => "knowledge",
Self::Graph => "graph",
Self::Session => "session",
Self::Patterns => "patterns",
Self::Gotchas => "gotchas",
Self::Checkpoint => "checkpoint",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PackageDependency {
pub name: String,
pub version_req: String,
#[serde(default)]
pub optional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageIntegrity {
pub sha256: String,
pub content_hash: String,
pub byte_size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageProvenance {
pub tool: String,
pub tool_version: String,
pub project_hash: Option<String>,
#[serde(default)]
pub source_session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CompatibilitySpec {
#[serde(default)]
pub min_lean_ctx_version: Option<String>,
#[serde(default)]
pub target_languages: Vec<String>,
#[serde(default)]
pub target_frameworks: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PackageStats {
pub knowledge_facts: u32,
pub graph_nodes: u32,
pub graph_edges: u32,
pub pattern_count: u32,
pub gotcha_count: u32,
pub compression_ratio: f64,
}
impl PackageManifest {
pub(crate) fn is_v2(&self) -> bool {
self.schema_version >= crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
}
pub(crate) fn validate(&self) -> Result<(), Vec<String>> {
let mut errors = Vec::new();
let v1 = crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION;
let v2 = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
if self.schema_version != v1 && self.schema_version != v2 {
errors.push(format!(
"unsupported schema_version {} (expected {v1} or {v2})",
self.schema_version,
));
}
if self.name.is_empty() {
errors.push("name must not be empty".into());
}
if self.name.len() > 128 {
errors.push("name must be <= 128 characters".into());
}
if !self.name.chars().all(|c| {
c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@' || c == '/'
}) {
errors.push("name must only contain [a-zA-Z0-9._@/-]".into());
}
let name_scope = self
.name
.strip_prefix('@')
.and_then(|scoped| scoped.split_once('/'))
.map(|(scope, _)| scope);
for scope in [name_scope, self.scope.as_deref()].into_iter().flatten() {
if scope.chars().any(char::is_uppercase) {
errors.push(
"scope must be lowercase (uppercase scopes are refused, not normalized)".into(),
);
}
}
if self.version.is_empty() {
errors.push("version must not be empty".into());
}
if self.version.len() > 64 {
errors.push("version must be <= 64 characters".into());
}
if !self
.version
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
{
errors.push("version must only contain [a-zA-Z0-9._+-]".into());
}
if self.version.starts_with('.') {
errors.push("version must not start with '.'".into());
}
if self.layers.is_empty() && !self.is_v2() {
errors.push("at least one layer is required".into());
}
if !self.kind.is_context() && !self.is_v2() {
errors.push(format!(
"kind = {} requires schema_version {} (v2)",
self.kind.as_str(),
crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
));
}
if self.layers.contains(&PackageLayer::Checkpoint)
&& (!self.is_v2() || !self.kind.is_context())
{
errors.push("checkpoint layer requires schema_version 2 and kind=context".into());
}
let mut seen_layers = std::collections::HashSet::new();
for layer in &self.layers {
if !seen_layers.insert(layer.as_str()) {
errors.push(format!("duplicate layer: {}", layer.as_str()));
}
}
if !is_lower_hex(&self.integrity.sha256, 64) {
errors.push("integrity.sha256 must be a 64-char lowercase hex string".into());
}
if !is_lower_hex(&self.integrity.content_hash, 64) {
errors.push("integrity.content_hash must be a 64-char lowercase hex string".into());
}
if self.integrity.byte_size == 0 {
errors.push("integrity.byte_size must be > 0".into());
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub(crate) fn has_layer(&self, layer: PackageLayer) -> bool {
self.layers.contains(&layer)
}
}
pub(crate) fn is_lower_hex(value: &str, len: usize) -> bool {
value.len() == len
&& value
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_manifest() -> PackageManifest {
PackageManifest {
schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
conformance_level: None,
kind: PackageKind::default(),
name: "test-pkg".into(),
version: "0.1.0".into(),
description: "A test package".into(),
author: None,
scope: None,
created_at: Utc::now(),
updated_at: None,
layers: vec![PackageLayer::Knowledge],
dependencies: vec![],
tags: vec![],
visibility: None,
integrity: PackageIntegrity {
sha256: "a".repeat(64),
content_hash: "b".repeat(64),
byte_size: 100,
},
provenance: PackageProvenance {
tool: "lean-ctx".into(),
tool_version: env!("CARGO_PKG_VERSION").into(),
project_hash: None,
source_session_id: None,
},
compatibility: CompatibilitySpec::default(),
stats: PackageStats::default(),
signature: None,
graph_summary: None,
marketplace: None,
redistributable: None,
}
}
#[test]
fn valid_manifest_passes() {
assert!(minimal_manifest().validate().is_ok());
}
#[test]
fn empty_name_fails() {
let mut m = minimal_manifest();
assert!(m.validate().is_ok());
m.name = String::new();
assert!(m.validate().is_err());
}
#[test]
fn duplicate_layers_fails() {
let mut m = minimal_manifest();
m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
assert!(m.validate().is_err());
}
#[test]
fn non_hex_sha256_fails() {
let mut m = minimal_manifest();
m.integrity.sha256 = "z".repeat(64);
assert!(m.validate().is_err());
}
#[test]
fn invalid_name_chars_fails() {
let mut m = minimal_manifest();
m.name = "my package!".into();
let errs = m.validate().unwrap_err();
assert!(errs.iter().any(|e| e.contains("only contain")));
}
#[test]
fn v2_schema_version_validates() {
let mut m = minimal_manifest();
m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
assert!(m.validate().is_ok());
}
#[test]
fn scoped_name_validates() {
let mut m = minimal_manifest();
m.name = "@company/auth-service".into();
assert!(m.validate().is_ok());
}
#[test]
fn is_v2_flag() {
let mut m = minimal_manifest();
assert!(!m.is_v2());
m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
assert!(m.is_v2());
}
#[test]
fn unsupported_schema_version_fails() {
let mut m = minimal_manifest();
m.schema_version = 99;
let errs = m.validate().unwrap_err();
assert!(
errs.iter()
.any(|e| e.contains("unsupported schema_version"))
);
}
#[test]
fn v2_manifest_serde_roundtrip() {
use super::super::graph_model::{GraphSummary, MarketplaceMeta};
let mut m = minimal_manifest();
m.schema_version = 2;
m.conformance_level = Some(2);
m.scope = Some("@company".into());
m.graph_summary = Some(GraphSummary {
node_count: 42,
edge_count: 100,
node_types: vec!["fact".into(), "gotcha".into()],
activation_mean: Some(0.75),
freshness: Some(Utc::now()),
});
m.marketplace = Some(MarketplaceMeta {
categories: vec!["security".into()],
badges: vec!["verified".into()],
license: Some("MIT".into()),
});
let json = serde_json::to_string(&m).unwrap();
let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.schema_version, 2);
assert_eq!(decoded.conformance_level, Some(2));
assert_eq!(decoded.scope.as_deref(), Some("@company"));
let gs = decoded.graph_summary.unwrap();
assert_eq!(gs.node_count, 42);
assert_eq!(gs.edge_count, 100);
let mp = decoded.marketplace.unwrap();
assert_eq!(mp.categories, vec!["security"]);
assert_eq!(mp.license.as_deref(), Some("MIT"));
}
#[test]
fn v1_manifest_missing_v2_fields_deserializes() {
let json = serde_json::to_string(&minimal_manifest()).unwrap();
let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
assert!(decoded.conformance_level.is_none());
assert!(decoded.scope.is_none());
assert!(decoded.graph_summary.is_none());
assert!(decoded.marketplace.is_none());
}
#[test]
fn spec_dependency_maps_read_into_the_written_list_form() {
let mut value = serde_json::to_value(minimal_manifest()).unwrap();
value["dependencies"] = serde_json::json!({"@acme/base": "^1.2"});
value["optional_dependencies"] = serde_json::json!({"@acme/extra": "~0.3"});
let manifest: PackageManifest = serde_json::from_value(value).unwrap();
let read: Vec<_> = manifest
.dependencies
.iter()
.map(|d| (d.name.as_str(), d.version_req.as_str(), d.optional))
.collect();
assert_eq!(
read,
[("@acme/base", "^1.2", false), ("@acme/extra", "~0.3", true)]
);
let written = serde_json::to_value(&manifest).unwrap();
assert!(written["dependencies"].is_array());
assert!(written.get("optional_dependencies").is_none());
let mut bad = serde_json::to_value(minimal_manifest()).unwrap();
bad["dependencies"] = serde_json::json!({"@acme/base": 1});
assert!(serde_json::from_value::<PackageManifest>(bad).is_err());
}
#[test]
fn nested_scope_validates() {
let mut m = minimal_manifest();
m.name = "@org/team/service".into();
assert!(m.validate().is_ok());
}
#[test]
fn uppercase_scopes_are_refused_not_normalized() {
let lowercase = "scope must be lowercase (uppercase scopes are refused, not normalized)";
for (name, scope) in [("@Acme/tool", None), ("@acme/tool", Some("@Acme"))] {
let mut m = minimal_manifest();
m.name = name.into();
m.scope = scope.map(Into::into);
assert_eq!(m.validate().unwrap_err(), vec![lowercase.to_string()]);
assert_eq!(m.name, name, "not normalized");
}
let mut m = minimal_manifest();
m.name = "@acme/Tool".into();
m.scope = Some("@acme".into());
assert!(m.validate().is_ok());
}
#[test]
fn default_kind_is_omitted_from_serialization() {
let json = serde_json::to_string(&minimal_manifest()).unwrap();
assert!(!json.contains("\"kind\""), "got: {json}");
let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.kind, PackageKind::Context);
}
#[test]
fn non_default_kind_round_trips() {
let mut m = minimal_manifest();
m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
m.kind = PackageKind::Addon;
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"kind\":\"addon\""), "got: {json}");
let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.kind, PackageKind::Addon);
assert!(decoded.validate().is_ok());
}
#[test]
fn non_context_kind_requires_v2_schema() {
let mut m = minimal_manifest();
m.kind = PackageKind::Skills;
let errs = m.validate().unwrap_err();
assert!(
errs.iter().any(|e| e.contains("requires schema_version")),
"got: {errs:?}"
);
m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
assert!(m.validate().is_ok());
}
#[test]
fn kind_field_is_forward_and_backward_tolerant() {
let mut m = minimal_manifest();
m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
m.kind = PackageKind::Grammar;
let with_kind = serde_json::to_string(&m).unwrap();
let decoded: PackageManifest = serde_json::from_str(&with_kind).unwrap();
assert_eq!(decoded.kind, PackageKind::Grammar);
let legacy = serde_json::to_string(&minimal_manifest()).unwrap();
let decoded: PackageManifest = serde_json::from_str(&legacy).unwrap();
assert_eq!(decoded.kind, PackageKind::Context);
}
}