Skip to main content

sage_package/
lock.rs

1//! Lock file (grove.lock) management.
2
3use crate::error::PackageError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8/// The lock file format for grove.lock.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct LockFile {
11    /// Schema version for future compatibility.
12    #[serde(default = "default_version")]
13    pub version: u32,
14    /// All locked packages (including transitive dependencies).
15    #[serde(default)]
16    pub packages: Vec<LockedPackage>,
17}
18
19fn default_version() -> u32 {
20    1
21}
22
23/// A locked package entry.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LockedPackage {
26    /// Package name (as declared in grove.toml).
27    pub name: String,
28    /// Package version from its grove.toml.
29    pub version: String,
30    /// Git repository URL (None for path dependencies).
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub git: Option<String>,
33    /// Pinned full SHA (None for path dependencies).
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub rev: Option<String>,
36    /// Local path (for path dependencies).
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub path: Option<String>,
39    /// List of package names this depends on (for ordering).
40    #[serde(default, skip_serializing_if = "Vec::is_empty")]
41    pub dependencies: Vec<String>,
42}
43
44impl LockedPackage {
45    /// Create a new locked package for a git dependency.
46    pub fn git(
47        name: String,
48        version: String,
49        git: String,
50        rev: String,
51        dependencies: Vec<String>,
52    ) -> Self {
53        Self {
54            name,
55            version,
56            git: Some(git),
57            rev: Some(rev),
58            path: None,
59            dependencies,
60        }
61    }
62
63    /// Create a new locked package for a path dependency.
64    pub fn path(name: String, version: String, path: String, dependencies: Vec<String>) -> Self {
65        Self {
66            name,
67            version,
68            git: None,
69            rev: None,
70            path: Some(path),
71            dependencies,
72        }
73    }
74
75    /// Check if this is a path dependency.
76    pub fn is_path(&self) -> bool {
77        self.path.is_some()
78    }
79
80    /// Check if this is a git dependency.
81    pub fn is_git(&self) -> bool {
82        self.git.is_some()
83    }
84}
85
86impl LockFile {
87    /// Load a lock file from disk.
88    pub fn load(path: &Path) -> Result<Self, PackageError> {
89        let contents = std::fs::read_to_string(path).map_err(|e| PackageError::IoError {
90            message: format!("failed to read {}", path.display()),
91            source: e,
92        })?;
93
94        toml::from_str(&contents).map_err(|e| PackageError::InvalidLockFile { source: e })
95    }
96
97    /// Save the lock file to disk.
98    pub fn save(&self, path: &Path) -> Result<(), PackageError> {
99        let header = "# This file is auto-generated by Grove. Do not edit manually.\n\n";
100        let contents = toml::to_string_pretty(self).map_err(|e| PackageError::IoError {
101            message: format!("failed to serialize lock file: {e}"),
102            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e),
103        })?;
104
105        std::fs::write(path, format!("{header}{contents}")).map_err(|e| PackageError::IoError {
106            message: format!("failed to write {}", path.display()),
107            source: e,
108        })?;
109
110        Ok(())
111    }
112
113    /// Check if the lock file is empty.
114    pub fn is_empty(&self) -> bool {
115        self.packages.is_empty()
116    }
117
118    /// Find a package by name.
119    pub fn find(&self, name: &str) -> Option<&LockedPackage> {
120        self.packages.iter().find(|p| p.name == name)
121    }
122
123    /// Build a map of package name to locked package.
124    pub fn package_map(&self) -> HashMap<&str, &LockedPackage> {
125        self.packages.iter().map(|p| (p.name.as_str(), p)).collect()
126    }
127
128    /// Check if the lock file matches the given dependencies.
129    pub fn matches_dependencies(&self, deps: &HashMap<String, crate::DependencySpec>) -> bool {
130        use crate::DependencySpec;
131
132        // All deps must be in lock file with matching source
133        for (name, spec) in deps {
134            match self.find(name) {
135                Some(locked) => match spec {
136                    DependencySpec::Git(g) => {
137                        // Git dep must match git URL
138                        if locked.git.as_ref() != Some(&g.git) {
139                            return false;
140                        }
141                    }
142                    DependencySpec::Path(p) => {
143                        // Path dep must match path
144                        if locked.path.as_ref() != Some(&p.path) {
145                            return false;
146                        }
147                    }
148                },
149                None => return false,
150            }
151        }
152        true
153    }
154
155    /// Return packages in dependency order (dependencies first).
156    pub fn in_dependency_order(&self) -> Vec<&LockedPackage> {
157        // Simple topological sort
158        let mut result = Vec::new();
159        let mut visited = std::collections::HashSet::new();
160        let pkg_map: HashMap<&str, &LockedPackage> = self.package_map();
161
162        fn visit<'a>(
163            pkg: &'a LockedPackage,
164            pkg_map: &HashMap<&str, &'a LockedPackage>,
165            visited: &mut std::collections::HashSet<&'a str>,
166            result: &mut Vec<&'a LockedPackage>,
167        ) {
168            if visited.contains(pkg.name.as_str()) {
169                return;
170            }
171            visited.insert(&pkg.name);
172
173            for dep_name in &pkg.dependencies {
174                if let Some(dep) = pkg_map.get(dep_name.as_str()) {
175                    visit(dep, pkg_map, visited, result);
176                }
177            }
178            result.push(pkg);
179        }
180
181        for pkg in &self.packages {
182            visit(pkg, &pkg_map, &mut visited, &mut result);
183        }
184
185        result
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn serialize_lock_file() {
195        let lock = LockFile {
196            version: 1,
197            packages: vec![
198                LockedPackage::git(
199                    "foo".to_string(),
200                    "1.0.0".to_string(),
201                    "https://github.com/example/foo".to_string(),
202                    "abc123def456".to_string(),
203                    vec![],
204                ),
205                LockedPackage::git(
206                    "bar".to_string(),
207                    "2.0.0".to_string(),
208                    "https://github.com/example/bar".to_string(),
209                    "789xyz".to_string(),
210                    vec!["foo".to_string()],
211                ),
212            ],
213        };
214
215        let serialized = toml::to_string_pretty(&lock).unwrap();
216        assert!(serialized.contains("name = \"foo\""));
217        assert!(serialized.contains("name = \"bar\""));
218        assert!(serialized.contains("dependencies = [\"foo\"]"));
219    }
220
221    #[test]
222    fn serialize_path_dependency() {
223        let lock = LockFile {
224            version: 1,
225            packages: vec![LockedPackage::path(
226                "local-lib".to_string(),
227                "0.1.0".to_string(),
228                "../my-local-lib".to_string(),
229                vec![],
230            )],
231        };
232
233        let serialized = toml::to_string_pretty(&lock).unwrap();
234        assert!(serialized.contains("name = \"local-lib\""));
235        assert!(serialized.contains("path = \"../my-local-lib\""));
236        assert!(!serialized.contains("git ="));
237        assert!(!serialized.contains("rev ="));
238    }
239
240    #[test]
241    fn deserialize_lock_file() {
242        let toml_str = r#"
243version = 1
244
245[[packages]]
246name = "foo"
247version = "1.0.0"
248git = "https://github.com/example/foo"
249rev = "abc123"
250
251[[packages]]
252name = "bar"
253version = "2.0.0"
254git = "https://github.com/example/bar"
255rev = "def456"
256dependencies = ["foo"]
257"#;
258
259        let lock: LockFile = toml::from_str(toml_str).unwrap();
260        assert_eq!(lock.packages.len(), 2);
261        assert_eq!(lock.packages[0].name, "foo");
262        assert_eq!(lock.packages[1].dependencies, vec!["foo"]);
263    }
264
265    #[test]
266    fn deserialize_path_dependency() {
267        let toml_str = r#"
268version = 1
269
270[[packages]]
271name = "local"
272version = "0.1.0"
273path = "../local-lib"
274"#;
275
276        let lock: LockFile = toml::from_str(toml_str).unwrap();
277        assert_eq!(lock.packages.len(), 1);
278        assert!(lock.packages[0].is_path());
279        assert_eq!(lock.packages[0].path, Some("../local-lib".to_string()));
280    }
281
282    #[test]
283    fn find_package() {
284        let lock = LockFile {
285            version: 1,
286            packages: vec![LockedPackage::git(
287                "test".to_string(),
288                "1.0.0".to_string(),
289                "https://example.com/test".to_string(),
290                "abc".to_string(),
291                vec![],
292            )],
293        };
294
295        assert!(lock.find("test").is_some());
296        assert!(lock.find("nonexistent").is_none());
297    }
298
299    #[test]
300    fn dependency_order() {
301        let lock = LockFile {
302            version: 1,
303            packages: vec![
304                LockedPackage::git(
305                    "c".to_string(),
306                    "1.0.0".to_string(),
307                    "https://example.com/c".to_string(),
308                    "ccc".to_string(),
309                    vec!["a".to_string(), "b".to_string()],
310                ),
311                LockedPackage::git(
312                    "a".to_string(),
313                    "1.0.0".to_string(),
314                    "https://example.com/a".to_string(),
315                    "aaa".to_string(),
316                    vec![],
317                ),
318                LockedPackage::git(
319                    "b".to_string(),
320                    "1.0.0".to_string(),
321                    "https://example.com/b".to_string(),
322                    "bbb".to_string(),
323                    vec!["a".to_string()],
324                ),
325            ],
326        };
327
328        let ordered = lock.in_dependency_order();
329        let names: Vec<&str> = ordered.iter().map(|p| p.name.as_str()).collect();
330
331        // a must come before b and c, b must come before c
332        let a_pos = names.iter().position(|&n| n == "a").unwrap();
333        let b_pos = names.iter().position(|&n| n == "b").unwrap();
334        let c_pos = names.iter().position(|&n| n == "c").unwrap();
335
336        assert!(a_pos < b_pos);
337        assert!(a_pos < c_pos);
338        assert!(b_pos < c_pos);
339    }
340
341    #[test]
342    fn locked_package_helpers() {
343        let git_pkg = LockedPackage::git(
344            "foo".to_string(),
345            "1.0.0".to_string(),
346            "https://example.com".to_string(),
347            "abc123".to_string(),
348            vec![],
349        );
350        assert!(git_pkg.is_git());
351        assert!(!git_pkg.is_path());
352
353        let path_pkg = LockedPackage::path(
354            "bar".to_string(),
355            "0.1.0".to_string(),
356            "../bar".to_string(),
357            vec![],
358        );
359        assert!(path_pkg.is_path());
360        assert!(!path_pkg.is_git());
361    }
362}