use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use crate::PkgError;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Lockfile {
pub version: u32,
#[serde(rename = "pkg", default, skip_serializing_if = "Vec::is_empty")]
pub pkg: Vec<LockedPkg>,
}
impl Default for Lockfile {
fn default() -> Self {
Self {
version: 1,
pkg: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LockedPkg {
pub name: String,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rev: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
pub sha: String,
#[serde(with = "entry_serde")]
pub entry: PathBuf,
}
mod entry_serde {
use std::path::{Path, PathBuf};
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(path: &Path, serializer: S) -> Result<S::Ok, S::Error> {
let s = path.to_string_lossy().replace('\\', "/");
serializer.serialize_str(&s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<PathBuf, D::Error> {
let s = String::deserialize(deserializer)?;
Ok(PathBuf::from(s))
}
}
impl Lockfile {
pub fn read(path: impl AsRef<Path>) -> Result<Self, PkgError> {
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
PkgError::MissingLockfile {
path: path.to_path_buf(),
}
} else {
PkgError::Io { source: e }
}
})?;
let lockfile: Self =
toml::from_str(&content).map_err(|source| PkgError::LockfileParse { source })?;
let mut seen: HashSet<&str> = HashSet::with_capacity(lockfile.pkg.len());
for pkg in &lockfile.pkg {
if !seen.insert(pkg.name.as_str()) {
return Err(PkgError::SameNameConflict {
name: pkg.name.clone(),
});
}
}
Ok(lockfile)
}
pub fn write(&self, path: impl AsRef<Path>) -> Result<(), PkgError> {
let mut sorted_pkg = self.pkg.clone();
sorted_pkg.sort_by(|a, b| a.name.cmp(&b.name));
let to_serialize = Self {
version: self.version,
pkg: sorted_pkg,
};
let content = toml::to_string_pretty(&to_serialize)?;
fs::write(path, content)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
fn write_temp(content: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(content.as_bytes()).unwrap();
f
}
fn pkg_tag(name: &str, sha_char: char) -> LockedPkg {
LockedPkg {
name: name.to_owned(),
source: format!("git+https://github.com/x/{name}"),
tag: Some("v1.0.0".to_owned()),
rev: None,
branch: None,
sha: sha_char.to_string().repeat(40),
entry: PathBuf::from("src"),
}
}
#[test]
fn read_empty_lockfile() {
let toml = "version = 1\n";
let f = write_temp(toml);
let lf = Lockfile::read(f.path()).unwrap();
assert_eq!(lf.version, 1);
assert!(lf.pkg.is_empty());
}
#[test]
fn read_single_pkg() {
let toml = r#"
version = 1
[[pkg]]
name = "foo"
source = "git+https://github.com/x/foo"
tag = "v1.2.0"
sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
entry = "src"
"#;
let f = write_temp(toml);
let lf = Lockfile::read(f.path()).unwrap();
assert_eq!(lf.version, 1);
assert_eq!(lf.pkg.len(), 1);
let pkg = &lf.pkg[0];
assert_eq!(pkg.name, "foo");
assert_eq!(pkg.source, "git+https://github.com/x/foo");
assert_eq!(pkg.tag.as_deref(), Some("v1.2.0"));
assert!(pkg.rev.is_none());
assert!(pkg.branch.is_none());
assert_eq!(pkg.sha, "a".repeat(40));
assert_eq!(pkg.entry, PathBuf::from("src"));
}
#[test]
fn read_multiple_pkgs() {
let toml = r#"
version = 1
[[pkg]]
name = "foo"
source = "git+https://github.com/x/foo"
tag = "v1.2.0"
sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
entry = "src"
[[pkg]]
name = "bar"
source = "git+https://github.com/y/bar"
rev = "deadbeef"
sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
entry = "lua"
[[pkg]]
name = "baz"
source = "git+https://github.com/z/baz"
branch = "main"
sha = "cccccccccccccccccccccccccccccccccccccccc"
entry = "."
"#;
let f = write_temp(toml);
let lf = Lockfile::read(f.path()).unwrap();
assert_eq!(lf.pkg.len(), 3);
assert_eq!(lf.pkg[0].name, "foo");
assert_eq!(lf.pkg[0].tag.as_deref(), Some("v1.2.0"));
assert_eq!(lf.pkg[1].name, "bar");
assert_eq!(lf.pkg[1].rev.as_deref(), Some("deadbeef"));
assert_eq!(lf.pkg[2].name, "baz");
assert_eq!(lf.pkg[2].branch.as_deref(), Some("main"));
assert_eq!(lf.pkg[2].entry, PathBuf::from("."));
}
#[test]
fn round_trip_write_then_read() {
let original = Lockfile {
version: 1,
pkg: vec![
LockedPkg {
name: "alib".to_owned(),
source: "git+https://github.com/a/alib".to_owned(),
tag: None,
rev: Some("abc123".to_owned()),
branch: None,
sha: "a".repeat(40),
entry: PathBuf::from("lua"),
},
LockedPkg {
name: "zlib".to_owned(),
source: "git+https://github.com/z/zlib".to_owned(),
tag: Some("v1.0.0".to_owned()),
rev: None,
branch: None,
sha: "z".repeat(40),
entry: PathBuf::from("src"),
},
],
};
let f = tempfile::NamedTempFile::new().unwrap();
original.write(f.path()).unwrap();
let loaded = Lockfile::read(f.path()).unwrap();
assert_eq!(original, loaded);
}
#[test]
fn write_sorts_by_name() {
let lf = Lockfile {
version: 1,
pkg: vec![
pkg_tag("zeta", 'z'),
pkg_tag("alpha", 'a'),
pkg_tag("mu", 'm'),
],
};
let f = tempfile::NamedTempFile::new().unwrap();
lf.write(f.path()).unwrap();
let loaded = Lockfile::read(f.path()).unwrap();
assert_eq!(loaded.pkg[0].name, "alpha");
assert_eq!(loaded.pkg[1].name, "mu");
assert_eq!(loaded.pkg[2].name, "zeta");
}
#[test]
fn missing_file_returns_missing_lockfile_error() {
let path = PathBuf::from("/nonexistent/dir/mlua-pkg.lock");
let err = Lockfile::read(&path).unwrap_err();
assert!(
matches!(err, PkgError::MissingLockfile { .. }),
"expected MissingLockfile, got: {err}"
);
}
#[test]
fn invalid_toml_returns_lockfile_parse_error() {
let f = write_temp("this is not = [ valid toml");
let err = Lockfile::read(f.path()).unwrap_err();
assert!(
matches!(err, PkgError::LockfileParse { .. }),
"expected LockfileParse, got: {err}"
);
}
#[test]
fn duplicate_name_returns_same_name_conflict() {
let toml = r#"
version = 1
[[pkg]]
name = "foo"
source = "git+https://github.com/x/foo"
sha = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
entry = "src"
[[pkg]]
name = "foo"
source = "git+https://github.com/y/foo"
sha = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
entry = "lib"
"#;
let f = write_temp(toml);
let err = Lockfile::read(f.path()).unwrap_err();
assert!(
matches!(&err, PkgError::SameNameConflict { name } if name == "foo"),
"expected SameNameConflict for 'foo', got: {err}"
);
}
#[test]
fn default_lockfile_is_version_1_empty() {
let lf = Lockfile::default();
assert_eq!(lf.version, 1);
assert!(lf.pkg.is_empty());
}
}