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 Artifacts,
35 Patterns,
36 Gotchas,
37}
38
39impl PackageLayer {
40 pub fn as_str(&self) -> &'static str {
41 match self {
42 Self::Knowledge => "knowledge",
43 Self::Graph => "graph",
44 Self::Session => "session",
45 Self::Artifacts => "artifacts",
46 Self::Patterns => "patterns",
47 Self::Gotchas => "gotchas",
48 }
49 }
50
51 pub fn filename(&self) -> &'static str {
52 match self {
53 Self::Knowledge => "knowledge.json",
54 Self::Graph => "graph.json",
55 Self::Session => "session.json",
56 Self::Artifacts => "artifacts.json",
57 Self::Patterns => "patterns.json",
58 Self::Gotchas => "gotchas.json",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PackageDependency {
65 pub name: String,
66 pub version_req: String,
67 #[serde(default)]
68 pub optional: bool,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct PackageIntegrity {
73 pub sha256: String,
74 pub content_hash: String,
75 pub byte_size: u64,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct PackageProvenance {
80 pub tool: String,
81 pub tool_version: String,
82 pub project_hash: Option<String>,
83 #[serde(default)]
84 pub source_session_id: Option<String>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct CompatibilitySpec {
89 #[serde(default)]
90 pub min_lean_ctx_version: Option<String>,
91 #[serde(default)]
92 pub target_languages: Vec<String>,
93 #[serde(default)]
94 pub target_frameworks: Vec<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize, Default)]
98pub struct PackageStats {
99 pub knowledge_facts: u32,
100 pub graph_nodes: u32,
101 pub graph_edges: u32,
102 pub pattern_count: u32,
103 pub gotcha_count: u32,
104 pub compression_ratio: f64,
105}
106
107impl PackageManifest {
108 pub fn validate(&self) -> Result<(), Vec<String>> {
109 let mut errors = Vec::new();
110
111 if self.schema_version != crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION {
112 errors.push(format!(
113 "unsupported schema_version {} (expected {})",
114 self.schema_version,
115 crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION
116 ));
117 }
118 if self.name.is_empty() {
119 errors.push("name must not be empty".into());
120 }
121 if self.name.len() > 128 {
122 errors.push("name must be <= 128 characters".into());
123 }
124 if !self
125 .name
126 .chars()
127 .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.')
128 {
129 errors.push("name must only contain [a-zA-Z0-9._-]".into());
130 }
131 if self.version.is_empty() {
132 errors.push("version must not be empty".into());
133 }
134 if self.layers.is_empty() {
135 errors.push("at least one layer is required".into());
136 }
137 if self.integrity.sha256.len() != 64 {
138 errors.push("integrity.sha256 must be a 64-char hex string".into());
139 }
140
141 if errors.is_empty() {
142 Ok(())
143 } else {
144 Err(errors)
145 }
146 }
147
148 pub fn has_layer(&self, layer: PackageLayer) -> bool {
149 self.layers.contains(&layer)
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn minimal_manifest() -> PackageManifest {
158 PackageManifest {
159 schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
160 name: "test-pkg".into(),
161 version: "0.1.0".into(),
162 description: "A test package".into(),
163 author: None,
164 created_at: Utc::now(),
165 updated_at: None,
166 layers: vec![PackageLayer::Knowledge],
167 dependencies: vec![],
168 tags: vec![],
169 integrity: PackageIntegrity {
170 sha256: "a".repeat(64),
171 content_hash: "b".repeat(64),
172 byte_size: 100,
173 },
174 provenance: PackageProvenance {
175 tool: "lean-ctx".into(),
176 tool_version: env!("CARGO_PKG_VERSION").into(),
177 project_hash: None,
178 source_session_id: None,
179 },
180 compatibility: CompatibilitySpec::default(),
181 stats: PackageStats::default(),
182 }
183 }
184
185 #[test]
186 fn valid_manifest_passes() {
187 assert!(minimal_manifest().validate().is_ok());
188 }
189
190 #[test]
191 fn empty_name_fails() {
192 let mut m = minimal_manifest();
193 m.name = String::new();
194 assert!(minimal_manifest().validate().is_ok());
195 assert!(m.validate().is_err());
196 }
197
198 #[test]
199 fn invalid_name_chars_fails() {
200 let mut m = minimal_manifest();
201 m.name = "my package!".into();
202 let errs = m.validate().unwrap_err();
203 assert!(errs.iter().any(|e| e.contains("only contain")));
204 }
205}