Skip to main content

nika_engine/registry/
lockfile.rs

1//! Lockfile parsing for exact package versions
2//!
3//! Reads `nika.lock` to resolve exact package versions instead of using "latest".
4//! This ensures reproducible builds and avoids version drift.
5//!
6//! # Format
7//!
8//! ```yaml
9//! packages:
10//!   - name: "@workflows/seo-audit"
11//!     version: "1.2.0"
12//!     checksum: "sha256:abc123..."
13//!
14//!   - name: "@agents/researcher"
15//!     version: "2.0.0"
16//!     checksum: "sha256:def456..."
17//! ```
18
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24/// Errors that can occur during lockfile operations.
25#[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/// A single locked package entry.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct LockEntry {
43    /// Full package name (e.g., "@workflows/seo-audit")
44    pub name: String,
45
46    /// Exact version (e.g., "1.2.0")
47    pub version: String,
48
49    /// Package checksum for integrity verification
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub checksum: Option<String>,
52}
53
54/// The lockfile containing all locked package versions.
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Lockfile {
57    /// List of locked packages
58    pub packages: Vec<LockEntry>,
59}
60
61impl Lockfile {
62    /// Create an empty lockfile.
63    pub fn new() -> Self {
64        Self {
65            packages: Vec::new(),
66        }
67    }
68
69    /// Load lockfile from the current directory or a specified path.
70    ///
71    /// Returns an empty lockfile if `nika.lock` doesn't exist.
72    ///
73    /// # Examples
74    ///
75    /// ```no_run
76    /// use nika::registry::lockfile::Lockfile;
77    ///
78    /// let lockfile = Lockfile::load(None).unwrap();
79    /// if let Some(version) = lockfile.find_version("@workflows/seo-audit") {
80    ///     println!("Locked version: {}", version);
81    /// }
82    /// ```
83    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 empty lockfile if file doesn't exist
92            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    /// Find the locked version for a given package name.
102    ///
103    /// Returns `None` if the package is not in the lockfile.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use nika::registry::lockfile::Lockfile;
109    ///
110    /// let mut lockfile = Lockfile::new();
111    /// // Assuming lockfile is populated...
112    /// if let Some(version) = lockfile.find_version("@workflows/seo-audit") {
113    ///     println!("Version: {}", version);
114    /// }
115    /// ```
116    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    /// Add or update a package entry in the lockfile.
124    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    /// Remove a package from the lockfile.
138    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    /// Save the lockfile to disk atomically.
148    ///
149    /// Uses temp+rename pattern from util::fs to ensure durability.
150    /// This prevents corruption if the process crashes during write.
151    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        // SECURITY: Atomic write prevents corruption on crash
162        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        // Loading a non-existent file should return an empty lockfile
260        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}