lean_ctx/core/context_package/
lockfile.rs1use 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 pub name: String,
24 pub version: String,
25 pub artifact_sha256: String,
27 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
44pub 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 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"); assert_eq!(lock.packages[1].version, "1.1.0"); 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}