nika_engine/registry/
lockfile.rs1use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24#[derive(Error, Debug)]
26pub enum LockfileError {
27 #[error("IO error: {0}")]
28 IoError(#[from] std::io::Error),
29
30 #[error("YAML parse error: {0}")]
31 YamlParseError(String),
32
33 #[error("YAML serialize error: {0}")]
34 YamlSerializeError(String),
35
36 #[error("Lockfile not found at: {0}")]
37 NotFound(String),
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct LockEntry {
43 pub name: String,
45
46 pub version: String,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub checksum: Option<String>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Lockfile {
57 pub packages: Vec<LockEntry>,
59}
60
61impl Lockfile {
62 pub fn new() -> Self {
64 Self {
65 packages: Vec::new(),
66 }
67 }
68
69 pub fn load(path: Option<&Path>) -> Result<Self, LockfileError> {
84 let lockfile_path = if let Some(p) = path {
85 p.to_path_buf()
86 } else {
87 PathBuf::from("nika.lock")
88 };
89
90 if !lockfile_path.exists() {
91 return Ok(Self::new());
93 }
94
95 let content = std::fs::read_to_string(&lockfile_path)?;
96 let lockfile: Lockfile = crate::serde_yaml::from_str(&content)
97 .map_err(|e| LockfileError::YamlParseError(e.to_string()))?;
98 Ok(lockfile)
99 }
100
101 pub fn find_version(&self, name: &str) -> Option<&str> {
117 self.packages
118 .iter()
119 .find(|p| p.name == name)
120 .map(|p| p.version.as_str())
121 }
122
123 pub fn upsert(&mut self, name: String, version: String, checksum: Option<String>) {
125 if let Some(entry) = self.packages.iter_mut().find(|p| p.name == name) {
126 entry.version = version;
127 entry.checksum = checksum;
128 } else {
129 self.packages.push(LockEntry {
130 name,
131 version,
132 checksum,
133 });
134 }
135 }
136
137 pub fn remove(&mut self, name: &str) -> bool {
139 if let Some(pos) = self.packages.iter().position(|p| p.name == name) {
140 self.packages.remove(pos);
141 true
142 } else {
143 false
144 }
145 }
146
147 pub fn save(&self, path: Option<&Path>) -> Result<(), LockfileError> {
152 let lockfile_path = if let Some(p) = path {
153 p.to_path_buf()
154 } else {
155 PathBuf::from("nika.lock")
156 };
157
158 let content = crate::serde_yaml::to_string(&self)
159 .map_err(|e| LockfileError::YamlSerializeError(e.to_string()))?;
160
161 crate::util::fs::atomic_write(&lockfile_path, content.as_bytes())?;
163 Ok(())
164 }
165}
166
167impl Default for Lockfile {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173#[cfg(test)]
174mod tests {
175 use super::*;
176
177 #[test]
178 fn test_lockfile_new() {
179 let lockfile = Lockfile::new();
180 assert!(lockfile.packages.is_empty());
181 }
182
183 #[test]
184 fn test_find_version() {
185 let mut lockfile = Lockfile::new();
186 lockfile.packages.push(LockEntry {
187 name: "@workflows/seo-audit".to_string(),
188 version: "1.2.0".to_string(),
189 checksum: Some("sha256:abc123".to_string()),
190 });
191 lockfile.packages.push(LockEntry {
192 name: "@agents/researcher".to_string(),
193 version: "2.0.0".to_string(),
194 checksum: None,
195 });
196
197 assert_eq!(lockfile.find_version("@workflows/seo-audit"), Some("1.2.0"));
198 assert_eq!(lockfile.find_version("@agents/researcher"), Some("2.0.0"));
199 assert_eq!(lockfile.find_version("@workflows/missing"), None);
200 }
201
202 #[test]
203 fn test_upsert_new() {
204 let mut lockfile = Lockfile::new();
205 lockfile.upsert(
206 "@workflows/test".to_string(),
207 "1.0.0".to_string(),
208 Some("sha256:test".to_string()),
209 );
210
211 assert_eq!(lockfile.packages.len(), 1);
212 assert_eq!(lockfile.packages[0].name, "@workflows/test");
213 assert_eq!(lockfile.packages[0].version, "1.0.0");
214 assert_eq!(
215 lockfile.packages[0].checksum,
216 Some("sha256:test".to_string())
217 );
218 }
219
220 #[test]
221 fn test_upsert_existing() {
222 let mut lockfile = Lockfile::new();
223 lockfile.packages.push(LockEntry {
224 name: "@workflows/test".to_string(),
225 version: "1.0.0".to_string(),
226 checksum: None,
227 });
228
229 lockfile.upsert(
230 "@workflows/test".to_string(),
231 "2.0.0".to_string(),
232 Some("sha256:new".to_string()),
233 );
234
235 assert_eq!(lockfile.packages.len(), 1);
236 assert_eq!(lockfile.packages[0].version, "2.0.0");
237 assert_eq!(
238 lockfile.packages[0].checksum,
239 Some("sha256:new".to_string())
240 );
241 }
242
243 #[test]
244 fn test_remove() {
245 let mut lockfile = Lockfile::new();
246 lockfile.packages.push(LockEntry {
247 name: "@workflows/test".to_string(),
248 version: "1.0.0".to_string(),
249 checksum: None,
250 });
251
252 assert!(lockfile.remove("@workflows/test"));
253 assert_eq!(lockfile.packages.len(), 0);
254 assert!(!lockfile.remove("@workflows/missing"));
255 }
256
257 #[test]
258 fn test_load_missing_file() {
259 let result = Lockfile::load(Some(Path::new("/tmp/nonexistent-nika.lock")));
261 assert!(result.is_ok());
262 assert!(result.unwrap().packages.is_empty());
263 }
264}