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    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.version.len() > 64 {
135            errors.push("version must be <= 64 characters".into());
136        }
137        if !self
138            .version
139            .chars()
140            .all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '+')
141        {
142            errors.push("version must only contain [a-zA-Z0-9._+-]".into());
143        }
144        if self.version.starts_with('.') {
145            errors.push("version must not start with '.'".into());
146        }
147        if self.layers.is_empty() {
148            errors.push("at least one layer is required".into());
149        }
150        if self.integrity.sha256.len() != 64 {
151            errors.push("integrity.sha256 must be a 64-char hex string".into());
152        }
153
154        if errors.is_empty() {
155            Ok(())
156        } else {
157            Err(errors)
158        }
159    }
160
161    pub fn has_layer(&self, layer: PackageLayer) -> bool {
162        self.layers.contains(&layer)
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    fn minimal_manifest() -> PackageManifest {
171        PackageManifest {
172            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
173            name: "test-pkg".into(),
174            version: "0.1.0".into(),
175            description: "A test package".into(),
176            author: None,
177            created_at: Utc::now(),
178            updated_at: None,
179            layers: vec![PackageLayer::Knowledge],
180            dependencies: vec![],
181            tags: vec![],
182            integrity: PackageIntegrity {
183                sha256: "a".repeat(64),
184                content_hash: "b".repeat(64),
185                byte_size: 100,
186            },
187            provenance: PackageProvenance {
188                tool: "lean-ctx".into(),
189                tool_version: env!("CARGO_PKG_VERSION").into(),
190                project_hash: None,
191                source_session_id: None,
192            },
193            compatibility: CompatibilitySpec::default(),
194            stats: PackageStats::default(),
195        }
196    }
197
198    #[test]
199    fn valid_manifest_passes() {
200        assert!(minimal_manifest().validate().is_ok());
201    }
202
203    #[test]
204    fn empty_name_fails() {
205        let mut m = minimal_manifest();
206        m.name = String::new();
207        assert!(minimal_manifest().validate().is_ok());
208        assert!(m.validate().is_err());
209    }
210
211    #[test]
212    fn invalid_name_chars_fails() {
213        let mut m = minimal_manifest();
214        m.name = "my package!".into();
215        let errs = m.validate().unwrap_err();
216        assert!(errs.iter().any(|e| e.contains("only contain")));
217    }
218}