Skip to main content

lean_ctx/core/context_package/
lockfile.rs

1//! `ctxpkg.lock` — pins remote installs per project (GL #406).
2//!
3//! Lives at `.lean-ctx/ctxpkg.lock` in the project root, TOML, sorted by
4//! scoped name so diffs stay minimal and reviews stay sane. Records what was
5//! installed, from where, and the artifact hash — enough to re-fetch and
6//! re-verify the exact bytes later.
7
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12pub const LOCKFILE_REL_PATH: &str = ".lean-ctx/ctxpkg.lock";
13
14#[derive(Debug, Default, Serialize, Deserialize)]
15pub struct Lockfile {
16    #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")]
17    pub packages: Vec<LockedPackage>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct LockedPackage {
22    /// Scoped name, e.g. `@acme/auth-context`.
23    pub name: String,
24    pub version: String,
25    /// SHA-256 of the downloaded artifact bytes (registry + local verified).
26    pub artifact_sha256: String,
27    /// Registry base URL the artifact came from.
28    pub registry: String,
29}
30
31pub fn lockfile_path(project_root: &Path) -> PathBuf {
32    project_root.join(LOCKFILE_REL_PATH)
33}
34
35pub fn load(project_root: &Path) -> Result<Lockfile, String> {
36    let path = lockfile_path(project_root);
37    if !path.exists() {
38        return Ok(Lockfile::default());
39    }
40    let text = std::fs::read_to_string(&path).map_err(|e| format!("read ctxpkg.lock: {e}"))?;
41    toml::from_str(&text).map_err(|e| format!("parse ctxpkg.lock: {e}"))
42}
43
44/// Insert or replace the entry for `entry.name`, keeping the file sorted.
45pub fn upsert(project_root: &Path, entry: LockedPackage) -> Result<(), String> {
46    let mut lock = load(project_root)?;
47    lock.packages.retain(|p| p.name != entry.name);
48    lock.packages.push(entry);
49    lock.packages.sort_by(|a, b| a.name.cmp(&b.name));
50    save(project_root, &lock)
51}
52
53fn save(project_root: &Path, lock: &Lockfile) -> Result<(), String> {
54    let path = lockfile_path(project_root);
55    if let Some(dir) = path.parent() {
56        std::fs::create_dir_all(dir).map_err(|e| format!("create .lean-ctx: {e}"))?;
57    }
58    let header = "# ctxpkg.lock — generated by `lean-ctx pack install`; commit this file.\n";
59    let body = toml::to_string_pretty(lock).map_err(|e| format!("render ctxpkg.lock: {e}"))?;
60    std::fs::write(&path, format!("{header}{body}")).map_err(|e| format!("write ctxpkg.lock: {e}"))
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    fn tmp_root() -> PathBuf {
68        let dir = std::env::temp_dir().join(format!(
69            "ctxlock-{}-{:?}",
70            std::process::id(),
71            std::thread::current().id()
72        ));
73        std::fs::create_dir_all(&dir).expect("mkdir");
74        dir
75    }
76
77    #[test]
78    fn upsert_roundtrip_sorted_and_replacing() {
79        let root = tmp_root();
80        upsert(
81            &root,
82            LockedPackage {
83                name: "@zeta/pkg".into(),
84                version: "1.0.0".into(),
85                artifact_sha256: "aa".into(),
86                registry: "https://ctxpkg.com/api".into(),
87            },
88        )
89        .expect("upsert 1");
90        upsert(
91            &root,
92            LockedPackage {
93                name: "@acme/pkg".into(),
94                version: "2.0.0".into(),
95                artifact_sha256: "bb".into(),
96                registry: "https://ctxpkg.com/api".into(),
97            },
98        )
99        .expect("upsert 2");
100        // Replace the @zeta entry with a newer version.
101        upsert(
102            &root,
103            LockedPackage {
104                name: "@zeta/pkg".into(),
105                version: "1.1.0".into(),
106                artifact_sha256: "cc".into(),
107                registry: "https://ctxpkg.com/api".into(),
108            },
109        )
110        .expect("upsert 3");
111
112        let lock = load(&root).expect("load");
113        assert_eq!(lock.packages.len(), 2);
114        assert_eq!(lock.packages[0].name, "@acme/pkg"); // sorted
115        assert_eq!(lock.packages[1].version, "1.1.0"); // replaced
116        std::fs::remove_dir_all(&root).ok();
117    }
118
119    #[test]
120    fn missing_lockfile_loads_empty() {
121        let root = tmp_root();
122        assert!(load(&root).expect("load").packages.is_empty());
123        std::fs::remove_dir_all(&root).ok();
124    }
125}