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 #[serde(
136 default,
137 with = "opt_entry_serde",
138 skip_serializing_if = "Option::is_none"
139 )]
140 pub patch_dir: Option<PathBuf>,
141
142 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub patch_base: Option<String>,
149}
150
151impl LockedPkg {
152 pub fn require_dir(&self, root: &Path) -> PathBuf {
159 join_entry(root, &self.entry)
160 }
161}
162
163pub fn join_entry(root: &Path, entry: &Path) -> PathBuf {
165 let trivial = entry.as_os_str().is_empty() || entry == Path::new(".");
166 if trivial {
167 root.to_path_buf()
168 } else {
169 root.join(entry)
170 }
171}
172
173mod opt_entry_serde {
174 use std::path::{Path, PathBuf};
175
176 use serde::{Deserialize, Deserializer, Serializer};
177
178 pub fn serialize<S: Serializer>(
179 path: &Option<PathBuf>,
180 serializer: S,
181 ) -> Result<S::Ok, S::Error> {
182 match path {
183 Some(p) => super::entry_serde::serialize(p, serializer),
184 None => serializer.serialize_none(),
185 }
186 }
187
188 pub fn deserialize<'de, D: Deserializer<'de>>(
189 deserializer: D,
190 ) -> Result<Option<PathBuf>, D::Error> {
191 let s: Option<String> = Option::deserialize(deserializer)?;
192 Ok(s.map(|s| PathBuf::from(Path::new(&s))))
193 }
194}
195
196mod entry_serde {
201 use std::path::{Path, PathBuf};
202
203 use serde::{Deserialize, Deserializer, Serializer};
204
205 pub fn serialize<S: Serializer>(path: &Path, serializer: S) -> Result<S::Ok, S::Error> {
206 let s = path.to_string_lossy().replace('\\', "/");
208 serializer.serialize_str(&s)
209 }
210
211 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PathBuf, D::Error> {
212 let s = String::deserialize(deserializer)?;
213 Ok(PathBuf::from(s))
214 }
215}
216
217impl Lockfile {
220 pub fn read(path: impl AsRef<Path>) -> Result<Self, PkgError> {
231 let path = path.as_ref();
232
233 let content = fs::read_to_string(path).map_err(|e| {
234 if e.kind() == std::io::ErrorKind::NotFound {
235 PkgError::MissingLockfile {
236 path: path.to_path_buf(),
237 }
238 } else {
239 PkgError::Io { source: e }
240 }
241 })?;
242
243 let lockfile: Self =
244 toml::from_str(&content).map_err(|source| PkgError::LockfileParse { source })?;
245
246 let mut seen: HashSet<&str> = HashSet::with_capacity(lockfile.pkg.len());
248 for pkg in &lockfile.pkg {
249 if !seen.insert(pkg.name.as_str()) {
250 return Err(PkgError::SameNameConflict {
251 name: pkg.name.clone(),
252 });
253 }
254 }
255
256 Ok(lockfile)
257 }
258
259 pub fn write(&self, path: impl AsRef<Path>) -> Result<(), PkgError> {
275 let mut sorted_pkg = self.pkg.clone();
277 sorted_pkg.sort_by(|a, b| a.name.cmp(&b.name));
278
279 let to_serialize = Self {
280 version: self.version,
281 pkg: sorted_pkg,
282 };
283
284 let content = toml::to_string_pretty(&to_serialize)?;
286
287 fs::write(path, content)?;
289
290 Ok(())
291 }
292}
293
294#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::io::Write as _;
300
301 fn write_temp(content: &str) -> tempfile::NamedTempFile {
303 let mut f = tempfile::NamedTempFile::new().unwrap();
304 f.write_all(content.as_bytes()).unwrap();
305 f
306 }
307
308 fn pkg_tag(name: &str, sha_char: char) -> LockedPkg {
310 LockedPkg {
311 name: name.to_owned(),
312 source: format!("git+https://github.com/x/{name}"),
313 tag: Some("v1.0.0".to_owned()),
314 rev: None,
315 branch: None,
316 sha: sha_char.to_string().repeat(40),
317 entry: PathBuf::from("src"),
318 patch_dir: None,
319 patch_base: None,
320 }
321 }
322
323 #[test]
326 fn read_empty_lockfile() {
327 let toml = "version = 1\n";
328 let f = write_temp(toml);
329 let lf = Lockfile::read(f.path()).unwrap();
330 assert_eq!(lf.version, 1);
331 assert!(lf.pkg.is_empty());
332 }
333
334 #[test]
337 fn read_single_pkg() {
338 let toml = r#"
339version = 1
340
341[[pkg]]
342name = "foo"
343source = "git+https://github.com/x/foo"
344tag = "v1.2.0"
345sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
346entry = "src"
347"#;
348 let f = write_temp(toml);
349 let lf = Lockfile::read(f.path()).unwrap();
350
351 assert_eq!(lf.version, 1);
352 assert_eq!(lf.pkg.len(), 1);
353
354 let pkg = &lf.pkg[0];
355 assert_eq!(pkg.name, "foo");
356 assert_eq!(pkg.source, "git+https://github.com/x/foo");
357 assert_eq!(pkg.tag.as_deref(), Some("v1.2.0"));
358 assert!(pkg.rev.is_none());
359 assert!(pkg.branch.is_none());
360 assert_eq!(pkg.sha, "a".repeat(40));
361 assert_eq!(pkg.entry, PathBuf::from("src"));
362 }
363
364 #[test]
367 fn read_multiple_pkgs() {
368 let toml = r#"
369version = 1
370
371[[pkg]]
372name = "foo"
373source = "git+https://github.com/x/foo"
374tag = "v1.2.0"
375sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
376entry = "src"
377
378[[pkg]]
379name = "bar"
380source = "git+https://github.com/y/bar"
381rev = "deadbeef"
382sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
383entry = "lua"
384
385[[pkg]]
386name = "baz"
387source = "git+https://github.com/z/baz"
388branch = "main"
389sha = "cccccccccccccccccccccccccccccccccccccccc"
390entry = "."
391"#;
392 let f = write_temp(toml);
393 let lf = Lockfile::read(f.path()).unwrap();
394
395 assert_eq!(lf.pkg.len(), 3);
396
397 assert_eq!(lf.pkg[0].name, "foo");
399 assert_eq!(lf.pkg[0].tag.as_deref(), Some("v1.2.0"));
400
401 assert_eq!(lf.pkg[1].name, "bar");
402 assert_eq!(lf.pkg[1].rev.as_deref(), Some("deadbeef"));
403
404 assert_eq!(lf.pkg[2].name, "baz");
405 assert_eq!(lf.pkg[2].branch.as_deref(), Some("main"));
406 assert_eq!(lf.pkg[2].entry, PathBuf::from("."));
407 }
408
409 #[test]
412 fn round_trip_write_then_read() {
413 let original = Lockfile {
415 version: 1,
416 pkg: vec![
417 LockedPkg {
418 name: "alib".to_owned(),
419 source: "git+https://github.com/a/alib".to_owned(),
420 tag: None,
421 rev: Some("abc123".to_owned()),
422 branch: None,
423 sha: "a".repeat(40),
424 entry: PathBuf::from("lua"),
425 patch_dir: None,
426 patch_base: None,
427 },
428 LockedPkg {
429 name: "zlib".to_owned(),
430 source: "git+https://github.com/z/zlib".to_owned(),
431 tag: Some("v1.0.0".to_owned()),
432 rev: None,
433 branch: None,
434 sha: "z".repeat(40),
435 entry: PathBuf::from("src"),
436 patch_dir: Some(PathBuf::from("patches/zlib")),
437 patch_base: Some("z".repeat(40)),
438 },
439 ],
440 };
441
442 let f = tempfile::NamedTempFile::new().unwrap();
443 original.write(f.path()).unwrap();
444 let loaded = Lockfile::read(f.path()).unwrap();
445
446 assert_eq!(original, loaded);
447 }
448
449 #[test]
452 fn write_sorts_by_name() {
453 let lf = Lockfile {
455 version: 1,
456 pkg: vec![
457 pkg_tag("zeta", 'z'),
458 pkg_tag("alpha", 'a'),
459 pkg_tag("mu", 'm'),
460 ],
461 };
462
463 let f = tempfile::NamedTempFile::new().unwrap();
464 lf.write(f.path()).unwrap();
465 let loaded = Lockfile::read(f.path()).unwrap();
466
467 assert_eq!(loaded.pkg[0].name, "alpha");
468 assert_eq!(loaded.pkg[1].name, "mu");
469 assert_eq!(loaded.pkg[2].name, "zeta");
470 }
471
472 #[test]
475 fn missing_file_returns_missing_lockfile_error() {
476 let path = PathBuf::from("/nonexistent/dir/mlua-pkg.lock");
477 let err = Lockfile::read(&path).unwrap_err();
478 assert!(
479 matches!(err, PkgError::MissingLockfile { .. }),
480 "expected MissingLockfile, got: {err}"
481 );
482 }
483
484 #[test]
487 fn invalid_toml_returns_lockfile_parse_error() {
488 let f = write_temp("this is not = [ valid toml");
489 let err = Lockfile::read(f.path()).unwrap_err();
490 assert!(
491 matches!(err, PkgError::LockfileParse { .. }),
492 "expected LockfileParse, got: {err}"
493 );
494 }
495
496 #[test]
499 fn duplicate_name_returns_same_name_conflict() {
500 let toml = r#"
501version = 1
502
503[[pkg]]
504name = "foo"
505source = "git+https://github.com/x/foo"
506sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
507entry = "src"
508
509[[pkg]]
510name = "foo"
511source = "git+https://github.com/y/foo"
512sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
513entry = "lib"
514"#;
515 let f = write_temp(toml);
516 let err = Lockfile::read(f.path()).unwrap_err();
517 assert!(
518 matches!(&err, PkgError::SameNameConflict { name } if name == "foo"),
519 "expected SameNameConflict for 'foo', got: {err}"
520 );
521 }
522
523 #[test]
526 fn default_lockfile_is_version_1_empty() {
527 let lf = Lockfile::default();
528 assert_eq!(lf.version, 1);
529 assert!(lf.pkg.is_empty());
530 }
531}