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    /// Registry visibility: `private` hides the package from catalog/search;
27    /// installs then need a namespace token (GL #524). `None` = public.
28    #[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!(
310            errs.iter()
311                .any(|e| e.contains("unsupported schema_version"))
312        );
313    }
314
315    #[test]
316    fn v2_manifest_serde_roundtrip() {
317        use super::super::graph_model::{GraphSummary, MarketplaceMeta};
318
319        let mut m = minimal_manifest();
320        m.schema_version = 2;
321        m.conformance_level = Some(2);
322        m.scope = Some("@company".into());
323        m.graph_summary = Some(GraphSummary {
324            node_count: 42,
325            edge_count: 100,
326            node_types: vec!["fact".into(), "gotcha".into()],
327            activation_mean: Some(0.75),
328            freshness: Some(Utc::now()),
329        });
330        m.marketplace = Some(MarketplaceMeta {
331            categories: vec!["security".into()],
332            badges: vec!["verified".into()],
333            license: Some("MIT".into()),
334        });
335
336        let json = serde_json::to_string(&m).unwrap();
337        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
338
339        assert_eq!(decoded.schema_version, 2);
340        assert_eq!(decoded.conformance_level, Some(2));
341        assert_eq!(decoded.scope.as_deref(), Some("@company"));
342        let gs = decoded.graph_summary.unwrap();
343        assert_eq!(gs.node_count, 42);
344        assert_eq!(gs.edge_count, 100);
345        let mp = decoded.marketplace.unwrap();
346        assert_eq!(mp.categories, vec!["security"]);
347        assert_eq!(mp.license.as_deref(), Some("MIT"));
348    }
349
350    #[test]
351    fn v1_manifest_missing_v2_fields_deserializes() {
352        let json = serde_json::to_string(&minimal_manifest()).unwrap();
353        let decoded: PackageManifest = serde_json::from_str(&json).unwrap();
354        assert!(decoded.conformance_level.is_none());
355        assert!(decoded.scope.is_none());
356        assert!(decoded.graph_summary.is_none());
357        assert!(decoded.marketplace.is_none());
358    }
359
360    #[test]
361    fn nested_scope_validates() {
362        let mut m = minimal_manifest();
363        m.name = "@org/team/service".into();
364        assert!(m.validate().is_ok());
365    }
366}