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 (ST3) always returns the full SHA.
123    pub sha: String,
124
125    /// Resolved Lua `require` entry path within the vendored directory.
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
133// ── Path serde helper ─────────────────────────────────────────────────────────
134
135/// Custom serde (de)serialization for `PathBuf` fields that must be stored
136/// as forward-slash strings in TOML.
137mod 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        // Replace backslashes with forward slashes for Windows portability.
144        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
154// ── Lockfile impl ─────────────────────────────────────────────────────────────
155
156impl Lockfile {
157    /// Read and parse a `mlua-pkg.lock` file at `path`.
158    ///
159    /// # Errors
160    ///
161    /// | Error | Condition |
162    /// |-------|-----------|
163    /// | [`PkgError::MissingLockfile`] | File does not exist |
164    /// | [`PkgError::LockfileParse`] | Invalid TOML or unknown / missing fields |
165    /// | [`PkgError::SameNameConflict`] | Duplicate `name` in `[[pkg]]` entries |
166    /// | [`PkgError::Io`] | Other I/O failure |
167    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        // Defense-in-depth: detect duplicate package names early.
184        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    /// Write the lockfile to `path`.
197    ///
198    /// Packages are sorted by name before writing to produce diff-stable
199    /// output suitable for version control.
200    ///
201    /// This implementation uses [`fs::write`] (not atomic).  Atomic write
202    /// via `tempfile::NamedTempFile::persist` is a planned enhancement for
203    /// a future subtask.
204    ///
205    /// # Errors
206    ///
207    /// | Error | Condition |
208    /// |-------|-----------|
209    /// | [`PkgError::LockfileWrite`] | TOML serialization failed |
210    /// | [`PkgError::Io`] | File write failed |
211    pub fn write(&self, path: impl AsRef<Path>) -> Result<(), PkgError> {
212        // Sort a clone by name for diff-stable output.
213        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        // `?` auto-converts toml::ser::Error → PkgError::LockfileWrite via #[from].
222        let content = toml::to_string_pretty(&to_serialize)?;
223
224        // `?` auto-converts std::io::Error → PkgError::Io via #[from].
225        fs::write(path, content)?;
226
227        Ok(())
228    }
229}
230
231// ── Unit tests ────────────────────────────────────────────────────────────────
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use std::io::Write as _;
237
238    /// Write `content` to a temp file and return the handle (deleted on drop).
239    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    /// Build a minimal [`LockedPkg`] fixture with tag-based ref.
246    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    // ── 1. Empty lockfile ────────────────────────────────────────────────────
259
260    #[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    // ── 2. Single package ────────────────────────────────────────────────────
270
271    #[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    // ── 3. Multiple packages (tag / rev / branch each) ───────────────────────
300
301    #[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        // Check each pkg in declaration order.
333        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    // ── 4. Round-trip: write → read produces identical Lockfile ─────────────
345
346    #[test]
347    fn round_trip_write_then_read() {
348        // Original is already name-sorted so write order matches.
349        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    // ── 4b. write sorts by name ───────────────────────────────────────────────
381
382    #[test]
383    fn write_sorts_by_name() {
384        // Insert in reverse-alphabetical order.
385        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    // ── 5. Missing file → PkgError::MissingLockfile ──────────────────────────
404
405    #[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    // ── 6. Invalid TOML → PkgError::LockfileParse ───────────────────────────
416
417    #[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    // ── 7. Duplicate name → PkgError::SameNameConflict ──────────────────────
428
429    #[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    // ── 8. default() produces version=1, empty pkg ──────────────────────────
455
456    #[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}