Skip to main content

lean_ctx/core/context_package/
registry.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::path::{Path, PathBuf};
5
6use super::content::PackageContent;
7use super::manifest::PackageManifest;
8
9const INDEX_FILE: &str = "package-index.json";
10const PACKAGES_DIR: &str = "packages";
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PackageIndex {
14    pub schema_version: u32,
15    pub updated_at: DateTime<Utc>,
16    pub entries: Vec<PackageEntry>,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PackageEntry {
21    pub name: String,
22    pub version: String,
23    pub description: String,
24    pub installed_at: DateTime<Utc>,
25    pub layers: Vec<String>,
26    pub sha256: String,
27    pub byte_size: u64,
28    #[serde(default)]
29    pub tags: Vec<String>,
30    #[serde(default)]
31    pub auto_load: bool,
32}
33
34impl PackageIndex {
35    fn new() -> Self {
36        Self {
37            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
38            updated_at: Utc::now(),
39            entries: Vec::new(),
40        }
41    }
42}
43
44pub struct LocalRegistry {
45    root: PathBuf,
46}
47
48impl LocalRegistry {
49    pub fn open() -> Result<Self, String> {
50        let data_dir = crate::core::data_dir::lean_ctx_data_dir()?;
51        let root = data_dir.join(PACKAGES_DIR);
52        std::fs::create_dir_all(&root).map_err(|e| format!("create packages dir: {e}"))?;
53        Ok(Self { root })
54    }
55
56    pub fn open_at(root: &Path) -> Result<Self, String> {
57        std::fs::create_dir_all(root).map_err(|e| format!("create packages dir: {e}"))?;
58        Ok(Self {
59            root: root.to_path_buf(),
60        })
61    }
62
63    pub fn root(&self) -> &Path {
64        &self.root
65    }
66
67    pub fn install(
68        &self,
69        manifest: &PackageManifest,
70        content: &PackageContent,
71    ) -> Result<PathBuf, String> {
72        let pkg_dir = self.package_dir(&manifest.name, &manifest.version);
73        std::fs::create_dir_all(&pkg_dir).map_err(|e| format!("create package dir: {e}"))?;
74
75        let manifest_json = serde_json::to_string_pretty(manifest).map_err(|e| e.to_string())?;
76        atomic_write(&pkg_dir.join("manifest.json"), manifest_json.as_bytes())?;
77
78        let content_json = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
79        atomic_write(&pkg_dir.join("content.json"), content_json.as_bytes())?;
80
81        let mut index = self.load_index()?;
82        index
83            .entries
84            .retain(|e| !(e.name == manifest.name && e.version == manifest.version));
85        index.entries.push(PackageEntry {
86            name: manifest.name.clone(),
87            version: manifest.version.clone(),
88            description: manifest.description.clone(),
89            installed_at: Utc::now(),
90            layers: manifest
91                .layers
92                .iter()
93                .map(|l| l.as_str().to_string())
94                .collect(),
95            sha256: manifest.integrity.sha256.clone(),
96            byte_size: manifest.integrity.byte_size,
97            tags: manifest.tags.clone(),
98            auto_load: false,
99        });
100        index.updated_at = Utc::now();
101        self.save_index(&index)?;
102
103        Ok(pkg_dir)
104    }
105
106    pub fn remove(&self, name: &str, version: Option<&str>) -> Result<u32, String> {
107        let mut index = self.load_index()?;
108        let before = index.entries.len();
109
110        let to_remove: Vec<(String, String)> = index
111            .entries
112            .iter()
113            .filter(|e| e.name == name && version.is_none_or(|v| e.version == v))
114            .map(|e| (e.name.clone(), e.version.clone()))
115            .collect();
116
117        for (n, v) in &to_remove {
118            let dir = self.package_dir(n, v);
119            if dir.exists() {
120                let _ = std::fs::remove_dir_all(&dir);
121            }
122        }
123
124        index.entries.retain(|e| {
125            !to_remove
126                .iter()
127                .any(|(n, v)| e.name == *n && e.version == *v)
128        });
129
130        let removed = (before - index.entries.len()) as u32;
131        if removed > 0 {
132            index.updated_at = Utc::now();
133            self.save_index(&index)?;
134        }
135
136        Ok(removed)
137    }
138
139    pub fn list(&self) -> Result<Vec<PackageEntry>, String> {
140        let index = self.load_index()?;
141        Ok(index.entries)
142    }
143
144    pub fn get(&self, name: &str, version: Option<&str>) -> Result<Option<PackageEntry>, String> {
145        let index = self.load_index()?;
146        Ok(index
147            .entries
148            .into_iter()
149            .find(|e| e.name == name && version.is_none_or(|v| e.version == v)))
150    }
151
152    pub fn load_package(
153        &self,
154        name: &str,
155        version: &str,
156    ) -> Result<(PackageManifest, PackageContent), String> {
157        let pkg_dir = self.package_dir(name, version);
158        if !pkg_dir.exists() {
159            return Err(format!("package {name}@{version} not found"));
160        }
161
162        let manifest_json = std::fs::read_to_string(pkg_dir.join("manifest.json"))
163            .map_err(|e| format!("read manifest: {e}"))?;
164        let content_json = std::fs::read_to_string(pkg_dir.join("content.json"))
165            .map_err(|e| format!("read content: {e}"))?;
166
167        let manifest: PackageManifest =
168            serde_json::from_str(&manifest_json).map_err(|e| format!("parse manifest: {e}"))?;
169        let content: PackageContent =
170            serde_json::from_str(&content_json).map_err(|e| format!("parse content: {e}"))?;
171
172        verify_integrity(&manifest, &content)?;
173
174        Ok((manifest, content))
175    }
176
177    pub fn set_auto_load(&self, name: &str, version: &str, auto_load: bool) -> Result<(), String> {
178        let mut index = self.load_index()?;
179        if let Some(entry) = index
180            .entries
181            .iter_mut()
182            .find(|e| e.name == name && e.version == version)
183        {
184            entry.auto_load = auto_load;
185            index.updated_at = Utc::now();
186            self.save_index(&index)?;
187        } else {
188            return Err(format!("package {name}@{version} not found in index"));
189        }
190        Ok(())
191    }
192
193    pub fn auto_load_packages(&self) -> Result<Vec<PackageEntry>, String> {
194        let index = self.load_index()?;
195        Ok(index.entries.into_iter().filter(|e| e.auto_load).collect())
196    }
197
198    pub fn export_to_file(&self, name: &str, version: &str, output: &Path) -> Result<u64, String> {
199        let (manifest, content) = self.load_package(name, version)?;
200
201        let bundle = ExportBundle { manifest, content };
202        let json = serde_json::to_string_pretty(&bundle).map_err(|e| e.to_string())?;
203        let bytes = json.as_bytes();
204
205        atomic_write(output, bytes)?;
206        Ok(bytes.len() as u64)
207    }
208
209    pub fn import_from_file(&self, path: &Path) -> Result<PackageManifest, String> {
210        let json = std::fs::read_to_string(path).map_err(|e| format!("read package file: {e}"))?;
211        let bundle: ExportBundle =
212            serde_json::from_str(&json).map_err(|e| format!("parse package: {e}"))?;
213
214        bundle.manifest.validate().map_err(|errs| errs.join("; "))?;
215
216        verify_integrity(&bundle.manifest, &bundle.content)?;
217
218        self.install(&bundle.manifest, &bundle.content)?;
219        Ok(bundle.manifest)
220    }
221
222    fn package_dir(&self, name: &str, version: &str) -> PathBuf {
223        self.root.join(format!("{name}-{version}"))
224    }
225
226    fn load_index(&self) -> Result<PackageIndex, String> {
227        let path = self.root.join(INDEX_FILE);
228        if !path.exists() {
229            return Ok(PackageIndex::new());
230        }
231        let json = std::fs::read_to_string(&path).map_err(|e| format!("read index: {e}"))?;
232        serde_json::from_str(&json).map_err(|e| format!("parse index: {e}"))
233    }
234
235    fn save_index(&self, index: &PackageIndex) -> Result<(), String> {
236        let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
237        atomic_write(&self.root.join(INDEX_FILE), json.as_bytes())
238    }
239}
240
241#[derive(Debug, Serialize, Deserialize)]
242struct ExportBundle {
243    manifest: PackageManifest,
244    content: PackageContent,
245}
246
247fn verify_integrity(manifest: &PackageManifest, content: &PackageContent) -> Result<(), String> {
248    let canonical = serde_json::to_string(content).map_err(|e| e.to_string())?;
249    let mut hasher = Sha256::new();
250    hasher.update(canonical.as_bytes());
251    let actual_hash = format!("{:x}", hasher.finalize());
252
253    if actual_hash != manifest.integrity.content_hash {
254        return Err(format!(
255            "integrity check failed: content_hash mismatch (expected {}, got {actual_hash})",
256            manifest.integrity.content_hash
257        ));
258    }
259    Ok(())
260}
261
262fn atomic_write(path: &Path, data: &[u8]) -> Result<(), String> {
263    let parent = path.parent().ok_or_else(|| "invalid path".to_string())?;
264    let tmp = parent.join(format!(
265        ".{}.tmp",
266        path.file_name().and_then(|s| s.to_str()).unwrap_or("pkg")
267    ));
268    std::fs::write(&tmp, data).map_err(|e| format!("write tmp: {e}"))?;
269    std::fs::rename(&tmp, path).map_err(|e| format!("rename: {e}"))?;
270    Ok(())
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::core::context_package::manifest::{CompatibilitySpec, PackageStats};
277
278    #[test]
279    fn registry_round_trip() {
280        let dir = tempfile::tempdir().unwrap();
281        let reg = LocalRegistry::open_at(dir.path()).unwrap();
282
283        assert!(reg.list().unwrap().is_empty());
284
285        let manifest = PackageManifest {
286            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
287            name: "test-pkg".into(),
288            version: "1.0.0".into(),
289            description: "test".into(),
290            author: None,
291            created_at: Utc::now(),
292            updated_at: None,
293            layers: vec![super::super::manifest::PackageLayer::Knowledge],
294            dependencies: vec![],
295            tags: vec!["rust".into()],
296            integrity: super::super::manifest::PackageIntegrity {
297                sha256: "a".repeat(64),
298                content_hash: {
299                    let c = PackageContent::default();
300                    let j = serde_json::to_string(&c).unwrap();
301                    let mut h = Sha256::new();
302                    h.update(j.as_bytes());
303                    format!("{:x}", h.finalize())
304                },
305                byte_size: 2,
306            },
307            provenance: super::super::manifest::PackageProvenance {
308                tool: "lean-ctx".into(),
309                tool_version: "0.0.0".into(),
310                project_hash: None,
311                source_session_id: None,
312            },
313            compatibility: CompatibilitySpec::default(),
314            stats: PackageStats::default(),
315        };
316
317        let content = PackageContent::default();
318
319        reg.install(&manifest, &content).unwrap();
320        let list = reg.list().unwrap();
321        assert_eq!(list.len(), 1);
322        assert_eq!(list[0].name, "test-pkg");
323
324        let (loaded_m, _loaded_c) = reg.load_package("test-pkg", "1.0.0").unwrap();
325        assert_eq!(loaded_m.name, "test-pkg");
326
327        let removed = reg.remove("test-pkg", None).unwrap();
328        assert_eq!(removed, 1);
329        assert!(reg.list().unwrap().is_empty());
330    }
331
332    #[test]
333    fn export_import_round_trip() {
334        let dir = tempfile::tempdir().unwrap();
335        let reg = LocalRegistry::open_at(dir.path()).unwrap();
336
337        let content = PackageContent::default();
338        let content_json = serde_json::to_string(&content).unwrap();
339        let mut h = Sha256::new();
340        h.update(content_json.as_bytes());
341        let content_hash = format!("{:x}", h.finalize());
342
343        let manifest = PackageManifest {
344            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V1_SCHEMA_VERSION,
345            name: "export-test".into(),
346            version: "2.0.0".into(),
347            description: "round trip test".into(),
348            author: Some("test".into()),
349            created_at: Utc::now(),
350            updated_at: None,
351            layers: vec![super::super::manifest::PackageLayer::Knowledge],
352            dependencies: vec![],
353            tags: vec![],
354            integrity: super::super::manifest::PackageIntegrity {
355                sha256: "b".repeat(64),
356                content_hash,
357                byte_size: content_json.len() as u64,
358            },
359            provenance: super::super::manifest::PackageProvenance {
360                tool: "lean-ctx".into(),
361                tool_version: "0.0.0".into(),
362                project_hash: None,
363                source_session_id: None,
364            },
365            compatibility: CompatibilitySpec::default(),
366            stats: PackageStats::default(),
367        };
368
369        reg.install(&manifest, &content).unwrap();
370
371        let export_path = dir.path().join("test.lctxpkg");
372        let bytes = reg
373            .export_to_file("export-test", "2.0.0", &export_path)
374            .unwrap();
375        assert!(bytes > 0);
376
377        let reg2 = LocalRegistry::open_at(&dir.path().join("other")).unwrap();
378        let imported = reg2.import_from_file(&export_path).unwrap();
379        assert_eq!(imported.name, "export-test");
380        assert_eq!(reg2.list().unwrap().len(), 1);
381    }
382}