1use crate::error::PackageError;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::path::Path;
7
8#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct LockFile {
11 #[serde(default = "default_version")]
13 pub version: u32,
14 #[serde(default)]
16 pub packages: Vec<LockedPackage>,
17}
18
19fn default_version() -> u32 {
20 1
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct LockedPackage {
26 pub name: String,
28 pub version: String,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub git: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub rev: Option<String>,
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub path: Option<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
41 pub dependencies: Vec<String>,
42}
43
44impl LockedPackage {
45 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 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 pub fn is_path(&self) -> bool {
77 self.path.is_some()
78 }
79
80 pub fn is_git(&self) -> bool {
82 self.git.is_some()
83 }
84}
85
86impl LockFile {
87 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 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 pub fn is_empty(&self) -> bool {
115 self.packages.is_empty()
116 }
117
118 pub fn find(&self, name: &str) -> Option<&LockedPackage> {
120 self.packages.iter().find(|p| p.name == name)
121 }
122
123 pub fn package_map(&self) -> HashMap<&str, &LockedPackage> {
125 self.packages.iter().map(|p| (p.name.as_str(), p)).collect()
126 }
127
128 pub fn matches_dependencies(&self, deps: &HashMap<String, crate::DependencySpec>) -> bool {
130 use crate::DependencySpec;
131
132 for (name, spec) in deps {
134 match self.find(name) {
135 Some(locked) => match spec {
136 DependencySpec::Git(g) => {
137 if locked.git.as_ref() != Some(&g.git) {
139 return false;
140 }
141 }
142 DependencySpec::Path(p) => {
143 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 pub fn in_dependency_order(&self) -> Vec<&LockedPackage> {
157 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 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}