Skip to main content

mlua_pkg/
lockfile.rs

1//! `mlua-pkg.lock` read / write.
2//!
3//! A [`Lockfile`] captures the resolved package graph at a point in time.
4//! Each `[[pkg]]` entry pins a dependency to a specific commit SHA for
5//! fully-reproducible installs.
6//!
7//! # Schema (TOML)
8//!
9//! ```toml
10//! version = 1
11//!
12//! [[pkg]]
13//! name   = "foo"
14//! source = "git+https://github.com/x/foo"
15//! tag    = "v1.2.0"
16//! sha    = "abc123def456..."   # full 40-char SHA
17//! entry  = "src"
18//!
19//! [[pkg]]
20//! name   = "bar"
21//! source = "git+https://github.com/y/bar"
22//! rev    = "abc123"
23//! sha    = "def456..."
24//! entry  = "src"
25//! ```
26//!
27//! # Stability
28//!
29//! The lockfile is intended to be committed to version control.  [`Lockfile::write`]
30//! sorts packages by name before serializing, producing diff-stable output.
31//!
32//! The `entry` field is always stored with forward-slash separators (`/`) to
33//! remain portable across platforms.
34
35use std::{
36    collections::HashSet,
37    fs,
38    path::{Path, PathBuf},
39};
40
41use serde::{Deserialize, Serialize};
42
43use crate::PkgError;
44
45// ── Lockfile ──────────────────────────────────────────────────────────────────
46
47/// Root structure of `mlua-pkg.lock`.
48///
49/// Use [`Lockfile::read`] to load an existing lockfile and [`Lockfile::write`]
50/// to persist one.  [`Lockfile::default`] creates an empty lockfile with
51/// `version = 1`.
52///
53/// Unknown top-level keys cause an immediate parse error
54/// (`#[serde(deny_unknown_fields)]`).
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct Lockfile {
58    /// Schema version.  Always `1` in this implementation.
59    ///
60    /// Future tooling may bump this number and apply a migration before
61    /// deserializing the rest of the file.
62    pub version: u32,
63
64    /// Locked package entries.  Written as `[[pkg]]` in TOML.
65    ///
66    /// May be empty for a newly-initialized lockfile (no deps installed yet).
67    #[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// ── LockedPkg ─────────────────────────────────────────────────────────────────
81
82/// A single locked package entry in `[[pkg]]`.
83///
84/// Pins one dependency to an exact commit SHA together with the metadata
85/// needed to re-resolve or update it in the future.
86///
87/// Unknown keys cause an immediate parse error (`#[serde(deny_unknown_fields)]`).
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(deny_unknown_fields)]
90pub struct LockedPkg {
91    /// Local package alias.  Must be unique within a lockfile.
92    pub name: String,
93
94    /// Source URL with protocol prefix, e.g. `"git+https://github.com/x/foo"`.
95    ///
96    /// The `git+` prefix follows Cargo lock convention and leaves room for
97    /// future `http+` or `luarocks+` sources.
98    pub source: String,
99
100    /// Git tag used to resolve this package (if any).
101    ///
102    /// At most one of `tag`, `rev`, `branch` is expected to be set.
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub tag: Option<String>,
105
106    /// Git revision (commit SHA short-or-full) supplied by the consumer manifest
107    /// (if any).
108    ///
109    /// When `rev` is set, `sha` must equal its fully-resolved commit SHA.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub rev: Option<String>,
112
113    /// Git branch this package was resolved from (if any).
114    ///
115    /// Non-reproducible by nature; the resolved commit is captured in `sha`.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub branch: Option<String>,
118
119    /// Full 40-character commit SHA that pins this package.
120    ///
121    /// This is the canonical reproducibility anchor.  Short SHAs are **not**
122    /// accepted; the GitFetcher always returns the full SHA.
123    pub sha: String,
124
125    /// Lua `require` entry path relative to the package root.
126    ///
127    /// Stored with forward-slash separators (`"src"`, `"lua"`, `"."`) for
128    /// portability across platforms.
129    #[serde(with = "entry_serde")]
130    pub entry: PathBuf,
131
132    /// Manifest-relative directory of the locally patched package root
133    /// ([`Dep::patch_dir`](crate::manifest::Dep::patch_dir)), when the dep
134    /// declares one.
135    #[serde(
136        default,
137        with = "opt_entry_serde",
138        skip_serializing_if = "Option::is_none"
139    )]
140    pub patch_dir: Option<PathBuf>,
141
142    /// Upstream commit the contents of `patch_dir` were taken from.
143    ///
144    /// Written by `mlua-pkg patch`, preserved across `install`.  While it
145    /// equals the commit the pin resolves to (`sha`), `install` resolves the
146    /// package from `patch_dir`.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub patch_base: Option<String>,
149}
150
151impl LockedPkg {
152    /// The `require` root for this package given its root directory:
153    /// `root/<entry>`, or `root` itself when `entry` is `"."`.
154    ///
155    /// `root` is `<vendored>/<name>` for the symlink placement
156    /// ([`PkgDir::vendored_root`](crate::PkgDir::vendored_root)) and the
157    /// `target_dir` for a copied one.
158    pub fn require_dir(&self, root: &Path) -> PathBuf {
159        join_entry(root, &self.entry)
160    }
161}
162
163/// `root/<entry>`, or `root` when `entry` is `"."` / empty.
164pub 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
196// ── Path serde helper ─────────────────────────────────────────────────────────
197
198/// Custom serde (de)serialization for `PathBuf` fields that must be stored
199/// as forward-slash strings in TOML.
200mod 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        // Replace backslashes with forward slashes for Windows portability.
207        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
217// ── Lockfile impl ─────────────────────────────────────────────────────────────
218
219impl Lockfile {
220    /// Read and parse a `mlua-pkg.lock` file at `path`.
221    ///
222    /// # Errors
223    ///
224    /// | Error | Condition |
225    /// |-------|-----------|
226    /// | [`PkgError::MissingLockfile`] | File does not exist |
227    /// | [`PkgError::LockfileParse`] | Invalid TOML or unknown / missing fields |
228    /// | [`PkgError::SameNameConflict`] | Duplicate `name` in `[[pkg]]` entries |
229    /// | [`PkgError::Io`] | Other I/O failure |
230    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        // Defense-in-depth: detect duplicate package names early.
247        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    /// Write the lockfile to `path`.
260    ///
261    /// Packages are sorted by name before writing to produce diff-stable
262    /// output suitable for version control.
263    ///
264    /// This implementation uses [`fs::write`] (not atomic).  Atomic write
265    /// via `tempfile::NamedTempFile::persist` is a planned enhancement for
266    /// a future subtask.
267    ///
268    /// # Errors
269    ///
270    /// | Error | Condition |
271    /// |-------|-----------|
272    /// | [`PkgError::LockfileWrite`] | TOML serialization failed |
273    /// | [`PkgError::Io`] | File write failed |
274    pub fn write(&self, path: impl AsRef<Path>) -> Result<(), PkgError> {
275        // Sort a clone by name for diff-stable output.
276        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        // `?` auto-converts toml::ser::Error → PkgError::LockfileWrite via #[from].
285        let content = toml::to_string_pretty(&to_serialize)?;
286
287        // `?` auto-converts std::io::Error → PkgError::Io via #[from].
288        fs::write(path, content)?;
289
290        Ok(())
291    }
292}
293
294// ── Unit tests ────────────────────────────────────────────────────────────────
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use std::io::Write as _;
300
301    /// Write `content` to a temp file and return the handle (deleted on drop).
302    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    /// Build a minimal [`LockedPkg`] fixture with tag-based ref.
309    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    // ── 1. Empty lockfile ────────────────────────────────────────────────────
324
325    #[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    // ── 2. Single package ────────────────────────────────────────────────────
335
336    #[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    // ── 3. Multiple packages (tag / rev / branch each) ───────────────────────
365
366    #[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        // Check each pkg in declaration order.
398        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    // ── 4. Round-trip: write → read produces identical Lockfile ─────────────
410
411    #[test]
412    fn round_trip_write_then_read() {
413        // Original is already name-sorted so write order matches.
414        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    // ── 4b. write sorts by name ───────────────────────────────────────────────
450
451    #[test]
452    fn write_sorts_by_name() {
453        // Insert in reverse-alphabetical order.
454        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    // ── 5. Missing file → PkgError::MissingLockfile ──────────────────────────
473
474    #[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    // ── 6. Invalid TOML → PkgError::LockfileParse ───────────────────────────
485
486    #[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    // ── 7. Duplicate name → PkgError::SameNameConflict ──────────────────────
497
498    #[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    // ── 8. default() produces version=1, empty pkg ──────────────────────────
524
525    #[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}