1use std::{
36 collections::HashSet,
37 fs,
38 path::{Path, PathBuf},
39};
40
41use serde::{Deserialize, Serialize};
42
43use crate::PkgError;
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Lockfile {
58 pub version: u32,
63
64 #[serde(rename = "pkg", default, skip_serializing_if = "Vec::is_empty")]
68 pub pkg: Vec<LockedPkg>,
69}
70
71impl Default for Lockfile {
72 fn default() -> Self {
73 Self {
74 version: 1,
75 pkg: Vec::new(),
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct LockedPkg {
91 pub name: String,
93
94 pub source: String,
99
100 #[serde(skip_serializing_if = "Option::is_none")]
104 pub tag: Option<String>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
111 pub rev: Option<String>,
112
113 #[serde(skip_serializing_if = "Option::is_none")]
117 pub branch: Option<String>,
118
119 pub sha: String,
124
125 #[serde(with = "entry_serde")]
130 pub entry: PathBuf,
131}
132
133mod entry_serde {
138 use std::path::{Path, PathBuf};
139
140 use serde::{Deserialize, Deserializer, Serializer};
141
142 pub fn serialize<S: Serializer>(path: &Path, serializer: S) -> Result<S::Ok, S::Error> {
143 let s = path.to_string_lossy().replace('\\', "/");
145 serializer.serialize_str(&s)
146 }
147
148 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PathBuf, D::Error> {
149 let s = String::deserialize(deserializer)?;
150 Ok(PathBuf::from(s))
151 }
152}
153
154impl Lockfile {
157 pub fn read(path: impl AsRef<Path>) -> Result<Self, PkgError> {
168 let path = path.as_ref();
169
170 let content = fs::read_to_string(path).map_err(|e| {
171 if e.kind() == std::io::ErrorKind::NotFound {
172 PkgError::MissingLockfile {
173 path: path.to_path_buf(),
174 }
175 } else {
176 PkgError::Io { source: e }
177 }
178 })?;
179
180 let lockfile: Self =
181 toml::from_str(&content).map_err(|source| PkgError::LockfileParse { source })?;
182
183 let mut seen: HashSet<&str> = HashSet::with_capacity(lockfile.pkg.len());
185 for pkg in &lockfile.pkg {
186 if !seen.insert(pkg.name.as_str()) {
187 return Err(PkgError::SameNameConflict {
188 name: pkg.name.clone(),
189 });
190 }
191 }
192
193 Ok(lockfile)
194 }
195
196 pub fn write(&self, path: impl AsRef<Path>) -> Result<(), PkgError> {
212 let mut sorted_pkg = self.pkg.clone();
214 sorted_pkg.sort_by(|a, b| a.name.cmp(&b.name));
215
216 let to_serialize = Self {
217 version: self.version,
218 pkg: sorted_pkg,
219 };
220
221 let content = toml::to_string_pretty(&to_serialize)?;
223
224 fs::write(path, content)?;
226
227 Ok(())
228 }
229}
230
231#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::io::Write as _;
237
238 fn write_temp(content: &str) -> tempfile::NamedTempFile {
240 let mut f = tempfile::NamedTempFile::new().unwrap();
241 f.write_all(content.as_bytes()).unwrap();
242 f
243 }
244
245 fn pkg_tag(name: &str, sha_char: char) -> LockedPkg {
247 LockedPkg {
248 name: name.to_owned(),
249 source: format!("git+https://github.com/x/{name}"),
250 tag: Some("v1.0.0".to_owned()),
251 rev: None,
252 branch: None,
253 sha: sha_char.to_string().repeat(40),
254 entry: PathBuf::from("src"),
255 }
256 }
257
258 #[test]
261 fn read_empty_lockfile() {
262 let toml = "version = 1\n";
263 let f = write_temp(toml);
264 let lf = Lockfile::read(f.path()).unwrap();
265 assert_eq!(lf.version, 1);
266 assert!(lf.pkg.is_empty());
267 }
268
269 #[test]
272 fn read_single_pkg() {
273 let toml = r#"
274version = 1
275
276[[pkg]]
277name = "foo"
278source = "git+https://github.com/x/foo"
279tag = "v1.2.0"
280sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
281entry = "src"
282"#;
283 let f = write_temp(toml);
284 let lf = Lockfile::read(f.path()).unwrap();
285
286 assert_eq!(lf.version, 1);
287 assert_eq!(lf.pkg.len(), 1);
288
289 let pkg = &lf.pkg[0];
290 assert_eq!(pkg.name, "foo");
291 assert_eq!(pkg.source, "git+https://github.com/x/foo");
292 assert_eq!(pkg.tag.as_deref(), Some("v1.2.0"));
293 assert!(pkg.rev.is_none());
294 assert!(pkg.branch.is_none());
295 assert_eq!(pkg.sha, "a".repeat(40));
296 assert_eq!(pkg.entry, PathBuf::from("src"));
297 }
298
299 #[test]
302 fn read_multiple_pkgs() {
303 let toml = r#"
304version = 1
305
306[[pkg]]
307name = "foo"
308source = "git+https://github.com/x/foo"
309tag = "v1.2.0"
310sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
311entry = "src"
312
313[[pkg]]
314name = "bar"
315source = "git+https://github.com/y/bar"
316rev = "deadbeef"
317sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
318entry = "lua"
319
320[[pkg]]
321name = "baz"
322source = "git+https://github.com/z/baz"
323branch = "main"
324sha = "cccccccccccccccccccccccccccccccccccccccc"
325entry = "."
326"#;
327 let f = write_temp(toml);
328 let lf = Lockfile::read(f.path()).unwrap();
329
330 assert_eq!(lf.pkg.len(), 3);
331
332 assert_eq!(lf.pkg[0].name, "foo");
334 assert_eq!(lf.pkg[0].tag.as_deref(), Some("v1.2.0"));
335
336 assert_eq!(lf.pkg[1].name, "bar");
337 assert_eq!(lf.pkg[1].rev.as_deref(), Some("deadbeef"));
338
339 assert_eq!(lf.pkg[2].name, "baz");
340 assert_eq!(lf.pkg[2].branch.as_deref(), Some("main"));
341 assert_eq!(lf.pkg[2].entry, PathBuf::from("."));
342 }
343
344 #[test]
347 fn round_trip_write_then_read() {
348 let original = Lockfile {
350 version: 1,
351 pkg: vec![
352 LockedPkg {
353 name: "alib".to_owned(),
354 source: "git+https://github.com/a/alib".to_owned(),
355 tag: None,
356 rev: Some("abc123".to_owned()),
357 branch: None,
358 sha: "a".repeat(40),
359 entry: PathBuf::from("lua"),
360 },
361 LockedPkg {
362 name: "zlib".to_owned(),
363 source: "git+https://github.com/z/zlib".to_owned(),
364 tag: Some("v1.0.0".to_owned()),
365 rev: None,
366 branch: None,
367 sha: "z".repeat(40),
368 entry: PathBuf::from("src"),
369 },
370 ],
371 };
372
373 let f = tempfile::NamedTempFile::new().unwrap();
374 original.write(f.path()).unwrap();
375 let loaded = Lockfile::read(f.path()).unwrap();
376
377 assert_eq!(original, loaded);
378 }
379
380 #[test]
383 fn write_sorts_by_name() {
384 let lf = Lockfile {
386 version: 1,
387 pkg: vec![
388 pkg_tag("zeta", 'z'),
389 pkg_tag("alpha", 'a'),
390 pkg_tag("mu", 'm'),
391 ],
392 };
393
394 let f = tempfile::NamedTempFile::new().unwrap();
395 lf.write(f.path()).unwrap();
396 let loaded = Lockfile::read(f.path()).unwrap();
397
398 assert_eq!(loaded.pkg[0].name, "alpha");
399 assert_eq!(loaded.pkg[1].name, "mu");
400 assert_eq!(loaded.pkg[2].name, "zeta");
401 }
402
403 #[test]
406 fn missing_file_returns_missing_lockfile_error() {
407 let path = PathBuf::from("/nonexistent/dir/mlua-pkg.lock");
408 let err = Lockfile::read(&path).unwrap_err();
409 assert!(
410 matches!(err, PkgError::MissingLockfile { .. }),
411 "expected MissingLockfile, got: {err}"
412 );
413 }
414
415 #[test]
418 fn invalid_toml_returns_lockfile_parse_error() {
419 let f = write_temp("this is not = [ valid toml");
420 let err = Lockfile::read(f.path()).unwrap_err();
421 assert!(
422 matches!(err, PkgError::LockfileParse { .. }),
423 "expected LockfileParse, got: {err}"
424 );
425 }
426
427 #[test]
430 fn duplicate_name_returns_same_name_conflict() {
431 let toml = r#"
432version = 1
433
434[[pkg]]
435name = "foo"
436source = "git+https://github.com/x/foo"
437sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
438entry = "src"
439
440[[pkg]]
441name = "foo"
442source = "git+https://github.com/y/foo"
443sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
444entry = "lib"
445"#;
446 let f = write_temp(toml);
447 let err = Lockfile::read(f.path()).unwrap_err();
448 assert!(
449 matches!(&err, PkgError::SameNameConflict { name } if name == "foo"),
450 "expected SameNameConflict for 'foo', got: {err}"
451 );
452 }
453
454 #[test]
457 fn default_lockfile_is_version_1_empty() {
458 let lf = Lockfile::default();
459 assert_eq!(lf.version, 1);
460 assert!(lf.pkg.is_empty());
461 }
462}