Skip to main content

lean_ctx/core/context_package/
manifest.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct PackageManifest {
6    pub schema_version: u32,
7    pub name: String,
8    pub version: String,
9    pub description: String,
10    #[serde(default)]
11    pub author: Option<String>,
12    pub created_at: DateTime<Utc>,
13    #[serde(default)]
14    pub updated_at: Option<DateTime<Utc>>,
15    pub layers: Vec<PackageLayer>,
16    #[serde(default)]
17    pub dependencies: Vec<PackageDependency>,
18    #[serde(default)]
19    pub tags: Vec<String>,
20    pub integrity: PackageIntegrity,
21    pub provenance: PackageProvenance,
22    #[serde(default)]
23    pub compatibility: CompatibilitySpec,
24    #[serde(default)]
25    pub stats: PackageStats,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum PackageLayer {
31    Knowledge,
32    Graph,
33    Session,
34    Patterns,
35    Gotchas,
36}
37
38impl PackageLayer {
39    pub fn as_str(&self) -> &'static str {
40        match self {
41            Self::Knowledge => "knowledge",
42            Self::Graph => "graph",
43            Self::Session => "session",
44            Self::Patterns => "patterns",
45            Self::Gotchas => "gotchas",
46        }
47    }
48
49    pub fn filename(&self) -> &'static str {
50        match self {
51            Self::Knowledge => "knowledge.json",
52            Self::Graph => "graph.json",
53            Self::Session => "session.json",
54            Self::Patterns => "patterns.json",
55            Self::Gotchas => "gotchas.json",
56        }
57    }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PackageDependency {
62    pub name: String,
63    pub version_req: String,
64    #[serde(default)]
65    pub optional: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct PackageIntegrity {
70    pub sha256: String,
71    pub content_hash: String,
72    pub byte_size: u64,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct PackageProvenance {
77    pub tool: String,
78    pub tool_version: String,
79    pub project_hash: Option<String>,
80    #[serde(default)]
81    pub source_session_id: Option<String>,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize, Default)]
85pub struct CompatibilitySpec {
86    #[serde(default)]
87    pub min_lean_ctx_version: Option<String>,
88    #[serde(default)]
89    pub target_languages: Vec<String>,
90    #[serde(default)]
91    pub target_frameworks: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct PackageStats {
96    pub knowledge_facts: u32,
97    pub graph_nodes: u32,
98    pub graph_edges: u32,
99    pub pattern_count: u32,
100    pub gotcha_count: u32,
101    pub compression_ratio: f64,
102}
103
104impl PackageManifest {
105    pub fn validate(&self) -> Result<(), Vec<String>> {
106        let mut errors = Vec::new();
107
108        if self.schema_version != crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION {
109            errors.push(format!(
110                "unsupported schema_version {} (expected {})",
111                self.schema_version,
112                crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
113            ));
114        }
115        if self.name.is_empty() {
116            errors.push("name must not be empty".into());
117        }
118        if self.name.len() > 128 {
119            errors.push("name must be <= 128 characters".into());
120        }
121        if !self
122            .name
123            .chars()
124            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
125        {
126            errors.push("name must only contain [a-zA-Z0-9._-]".into());
127        }
128        if self.version.is_empty() {
129            errors.push("version must not be empty".into());
130        }
131        if self.version.len() > 64 {
132            errors.push("version must be <= 64 characters".into());
133        }
134        if !self
135            .version
136            .chars()
137            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
138        {
139            errors.push("version must only contain [a-zA-Z0-9._+-]".into());
140        }
141        if self.version.starts_with('.') {
142            errors.push("version must not start with '.'".into());
143        }
144        if self.layers.is_empty() {
145            errors.push("at least one layer is required".into());
146        }
147        let mut seen_layers = std::collections::HashSet::new();
148        for layer in &self.layers {
149            if !seen_layers.insert(layer.as_str()) {
150                errors.push(format!("duplicate layer: {}", layer.as_str()));
151            }
152        }
153        if self.integrity.sha256.len() != 64
154            || !self.integrity.sha256.chars().all(|c| c.is_ascii_hexdigit())
155        {
156            errors.push("integrity.sha256 must be a 64-char hex string".into());
157        }
158        if self.integrity.content_hash.len() != 64
159            || !self
160                .integrity
161                .content_hash
162                .chars()
163                .all(|c| c.is_ascii_hexdigit())
164        {
165            errors.push("integrity.content_hash must be a 64-char hex string".into());
166        }
167        if self.integrity.byte_size == 0 {
168            errors.push("integrity.byte_size must be > 0".into());
169        }
170
171        if errors.is_empty() {
172            Ok(())
173        } else {
174            Err(errors)
175        }
176    }
177
178    pub fn has_layer(&self, layer: PackageLayer) -> bool {
179        self.layers.contains(&layer)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn minimal_manifest() -> PackageManifest {
188        PackageManifest {
189            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
190            name: "test-pkg".into(),
191            version: "0.1.0".into(),
192            description: "A test package".into(),
193            author: None,
194            created_at: Utc::now(),
195            updated_at: None,
196            layers: vec![PackageLayer::Knowledge],
197            dependencies: vec![],
198            tags: vec![],
199            integrity: PackageIntegrity {
200                sha256: "a".repeat(64),
201                content_hash: "b".repeat(64),
202                byte_size: 100,
203            },
204            provenance: PackageProvenance {
205                tool: "lean-ctx".into(),
206                tool_version: env!("CARGO_PKG_VERSION").into(),
207                project_hash: None,
208                source_session_id: None,
209            },
210            compatibility: CompatibilitySpec::default(),
211            stats: PackageStats::default(),
212        }
213    }
214
215    #[test]
216    fn valid_manifest_passes() {
217        assert!(minimal_manifest().validate().is_ok());
218    }
219
220    #[test]
221    fn empty_name_fails() {
222        let mut m = minimal_manifest();
223        assert!(m.validate().is_ok());
224        m.name = String::new();
225        assert!(m.validate().is_err());
226    }
227
228    #[test]
229    fn duplicate_layers_fails() {
230        let mut m = minimal_manifest();
231        m.layers = vec![PackageLayer::Knowledge, PackageLayer::Knowledge];
232        assert!(m.validate().is_err());
233    }
234
235    #[test]
236    fn non_hex_sha256_fails() {
237        let mut m = minimal_manifest();
238        m.integrity.sha256 = "z".repeat(64);
239        assert!(m.validate().is_err());
240    }
241
242    #[test]
243    fn invalid_name_chars_fails() {
244        let mut m = minimal_manifest();
245        m.name = "my package!".into();
246        let errs = m.validate().unwrap_err();
247        assert!(errs.iter().any(|e| e.contains("only contain")));
248    }
249}