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.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}