lean_ctx/core/context_package/
manifest.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use super::graph_model::{GraphSummary, MarketplaceMeta};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct PackageManifest {
8 pub schema_version: u32,
9 #[serde(default, skip_serializing_if = "Option::is_none")]
10 pub conformance_level: Option<u32>,
11 pub name: String,
12 pub version: String,
13 pub description: String,
14 #[serde(default)]
15 pub author: Option<String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub scope: Option<String>,
18 pub created_at: DateTime<Utc>,
19 #[serde(default)]
20 pub updated_at: Option<DateTime<Utc>>,
21 pub layers: Vec<PackageLayer>,
22 #[serde(default)]
23 pub dependencies: Vec<PackageDependency>,
24 #[serde(default)]
25 pub tags: Vec<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub visibility: Option<String>,
30 pub integrity: PackageIntegrity,
31 pub provenance: PackageProvenance,
32 #[serde(default)]
33 pub compatibility: CompatibilitySpec,
34 #[serde(default)]
35 pub stats: PackageStats,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 pub signature: Option<PackageSignature>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub graph_summary: Option<GraphSummary>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub marketplace: Option<MarketplaceMeta>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct PackageSignature {
46 pub algorithm: String,
47 pub public_key: String,
48 pub value: String,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum PackageLayer {
54 Knowledge,
55 Graph,
56 Session,
57 Patterns,
58 Gotchas,
59}
60
61impl PackageLayer {
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 Self::Knowledge => "knowledge",
65 Self::Graph => "graph",
66 Self::Session => "session",
67 Self::Patterns => "patterns",
68 Self::Gotchas => "gotchas",
69 }
70 }
71
72 pub fn filename(&self) -> &'static str {
73 match self {
74 Self::Knowledge => "knowledge.json",
75 Self::Graph => "graph.json",
76 Self::Session => "session.json",
77 Self::Patterns => "patterns.json",
78 Self::Gotchas => "gotchas.json",
79 }
80 }
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PackageDependency {
85 pub name: String,
86 pub version_req: String,
87 #[serde(default)]
88 pub optional: bool,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct PackageIntegrity {
93 pub sha256: String,
94 pub content_hash: String,
95 pub byte_size: u64,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct PackageProvenance {
100 pub tool: String,
101 pub tool_version: String,
102 pub project_hash: Option<String>,
103 #[serde(default)]
104 pub source_session_id: Option<String>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
108pub struct CompatibilitySpec {
109 #[serde(default)]
110 pub min_lean_ctx_version: Option<String>,
111 #[serde(default)]
112 pub target_languages: Vec<String>,
113 #[serde(default)]
114 pub target_frameworks: Vec<String>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
118pub struct PackageStats {
119 pub knowledge_facts: u32,
120 pub graph_nodes: u32,
121 pub graph_edges: u32,
122 pub pattern_count: u32,
123 pub gotcha_count: u32,
124 pub compression_ratio: f64,
125}
126
127impl PackageManifest {
128 pub fn is_v2(&self) -> bool {
129 self.schema_version >= crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION
130 }
131
132 pub fn validate(&self) -> Result<(), Vec<String>> {
133 let mut errors = Vec::new();
134
135 let v1 = crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION;
136 let v2 = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
137 if self.schema_version != v1 && self.schema_version != v2 {
138 errors.push(format!(
139 "unsupported schema_version {} (expected {v1} or {v2})",
140 self.schema_version,
141 ));
142 }
143 if self.name.is_empty() {
144 errors.push("name must not be empty".into());
145 }
146 if self.name.len() > 128 {
147 errors.push("name must be <= 128 characters".into());
148 }
149 if !self.name.chars().all(|c| {
150 c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '@' || c == '/'
151 }) {
152 errors.push("name must only contain [a-zA-Z0-9._@/-]".into());
153 }
154 if self.version.is_empty() {
155 errors.push("version must not be empty".into());
156 }
157 if self.version.len() > 64 {
158 errors.push("version must be <= 64 characters".into());
159 }
160 if !self
161 .version
162 .chars()
163 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
164 {
165 errors.push("version must only contain [a-zA-Z0-9._+-]".into());
166 }
167 if self.version.starts_with('.') {
168 errors.push("version must not start with '.'".into());
169 }
170 if self.layers.is_empty() && !self.is_v2() {
171 errors.push("at least one layer is required".into());
172 }
173 let mut seen_layers = std::collections::HashSet::new();
174 for layer in &self.layers {
175 if !seen_layers.insert(layer.as_str()) {
176 errors.push(format!("duplicate layer: {}", layer.as_str()));
177 }
178 }
179 if self.integrity.sha256.len() != 64
180 || !self.integrity.sha256.chars().all(|c| c.is_ascii_hexdigit())
181 {
182 errors.push("integrity.sha256 must be a 64-char hex string".into());
183 }
184 if self.integrity.content_hash.len() != 64
185 || !self
186 .integrity
187 .content_hash
188 .chars()
189 .all(|c| c.is_ascii_hexdigit())
190 {
191 errors.push("integrity.content_hash must be a 64-char hex string".into());
192 }
193 if self.integrity.byte_size == 0 {
194 errors.push("integrity.byte_size must be > 0".into());
195 }
196
197 if errors.is_empty() {
198 Ok(())
199 } else {
200 Err(errors)
201 }
202 }
203
204 pub fn has_layer(&self, layer: PackageLayer) -> bool {
205 self.layers.contains(&layer)
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn minimal_manifest() -> PackageManifest {
214 PackageManifest {
215 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
216 conformance_level: None,
217 name: "test-pkg".into(),
218 version: "0.1.0".into(),
219 description: "A test package".into(),
220 author: None,
221 scope: None,
222 created_at: Utc::now(),
223 updated_at: None,
224 layers: vec![PackageLayer::Knowledge],
225 dependencies: vec![],
226 tags: vec![],
227 visibility: None,
228 integrity: PackageIntegrity {
229 sha256: "a".repeat(64),
230 content_hash: "b".repeat(64),
231 byte_size: 100,
232 },
233 provenance: PackageProvenance {
234 tool: "lean-ctx".into(),
235 tool_version: env!("CARGO_PKG_VERSION").into(),
236 project_hash: None,
237 source_session_id: None,
238 },
239 compatibility: CompatibilitySpec::default(),
240 stats: PackageStats::default(),
241 signature: None,
242 graph_summary: None,
243 marketplace: None,
244 }
245 }
246
247 #[test]
248 fn valid_manifest_passes() {
249 assert!(minimal_manifest().validate().is_ok());
250 }
251
252 #[test]
253 fn empty_name_fails() {
254 let mut m = minimal_manifest();
255 assert!(m.validate().is_ok());
256 m.name = String::new();
257 assert!(m.validate().is_err());
258 }
259
260 #[test]
261 fn duplicate_layers_fails() {
262 let mut m = minimal_manifest();
263 m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
264 assert!(m.validate().is_err());
265 }
266
267 #[test]
268 fn non_hex_sha256_fails() {
269 let mut m = minimal_manifest();
270 m.integrity.sha256 = "z".repeat(64);
271 assert!(m.validate().is_err());
272 }
273
274 #[test]
275 fn invalid_name_chars_fails() {
276 let mut m = minimal_manifest();
277 m.name = "my package!".into();
278 let errs = m.validate().unwrap_err();
279 assert!(errs.iter().any(|e| e.contains("only contain")));
280 }
281
282 #[test]
283 fn v2_schema_version_validates() {
284 let mut m = minimal_manifest();
285 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
286 assert!(m.validate().is_ok());
287 }
288
289 #[test]
290 fn scoped_name_validates() {
291 let mut m = minimal_manifest();
292 m.name = "@company/auth-service".into();
293 assert!(m.validate().is_ok());
294 }
295
296 #[test]
297 fn is_v2_flag() {
298 let mut m = minimal_manifest();
299 assert!(!m.is_v2());
300 m.schema_version = crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION;
301 assert!(m.is_v2());
302 }
303
304 #[test]
305 fn unsupported_schema_version_fails() {
306 let mut m = minimal_manifest();
307 m.schema_version = 99;
308 let errs = m.validate().unwrap_err();
309 assert!(errs
310 .iter()
311 .any(|e| e.contains("unsupported schema_version")));
312 }
313
314 #[test]
315 fn v2_manifest_serde_roundtrip() {
316 use super::super::graph_model::{GraphSummary, MarketplaceMeta};
317
318 let mut m = minimal_manifest();
319 m.schema_version = 2;
320 m.conformance_level = Some(2);
321 m.scope = Some("@company".into());
322 m.graph_summary = Some(GraphSummary {
323 node_count: 42,
324 edge_count: 100,
325 node_types: vec!["fact".into(), "gotcha".into()],
326 activation_mean: Some(0.75),
327 freshness: Some(Utc::now()),
328 });
329 m.marketplace = Some(MarketplaceMeta {
330 categories: vec!["security".into()],
331 badges: vec!["verified".into()],
332 license: Some("MIT".into()),
333 });
334
335 let json = serde_json::to_string(&m).unwrap();
336 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
337
338 assert_eq!(decoded.schema_version, 2);
339 assert_eq!(decoded.conformance_level, Some(2));
340 assert_eq!(decoded.scope.as_deref(), Some("@company"));
341 let gs = decoded.graph_summary.unwrap();
342 assert_eq!(gs.node_count, 42);
343 assert_eq!(gs.edge_count, 100);
344 let mp = decoded.marketplace.unwrap();
345 assert_eq!(mp.categories, vec!["security"]);
346 assert_eq!(mp.license.as_deref(), Some("MIT"));
347 }
348
349 #[test]
350 fn v1_manifest_missing_v2_fields_deserializes() {
351 let json = serde_json::to_string(&minimal_manifest()).unwrap();
352 let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
353 assert!(decoded.conformance_level.is_none());
354 assert!(decoded.scope.is_none());
355 assert!(decoded.graph_summary.is_none());
356 assert!(decoded.marketplace.is_none());
357 }
358
359 #[test]
360 fn nested_scope_validates() {
361 let mut m = minimal_manifest();
362 m.name = "@org/team/service".into();
363 assert!(m.validate().is_ok());
364 }
365}