Skip to main content

lean_ctx/core/context_package/
manifest.rs

1use 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}