lean_ctx/core/context_package/
manifest.rs1use 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}