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