Skip to main content

mlua_pkg/
manifest.rs

1//! `mlua-pkg.toml` manifest parser.
2//!
3//! A single [`Manifest`] type covers both consumer manifests
4//! (with a populated `[deps]` table) and author manifests
5//! (`[package]` only, with an optional `entry` field).
6//!
7//! # Consumer manifest example
8//!
9//! ```toml
10//! [package]
11//! name = "my-app"
12//! version = "0.1.0"
13//!
14//! [deps]
15//! foo  = { git = "https://github.com/x/foo", tag = "v1.2.0" }
16//! bar  = { git = "https://github.com/y/bar", rev = "abc123" }
17//! baz  = { git = "https://github.com/z/baz", branch = "main" }
18//!
19//! [deps.qux]
20//! git   = "https://github.com/q/qux"
21//! tag   = "v2.0.0"
22//! entry = "lib"
23//! ```
24//!
25//! # Author manifest example
26//!
27//! ```toml
28//! [package]
29//! name    = "foo"
30//! version = "1.2.0"
31//! entry   = "src"
32//! ```
33
34use std::{
35    collections::HashMap,
36    fs,
37    path::{Path, PathBuf},
38};
39
40use serde::{Deserialize, Serialize};
41
42use crate::PkgError;
43
44// ── Package ───────────────────────────────────────────────────────────────────
45
46/// `[package]` section of `mlua-pkg.toml`.
47///
48/// Present in both consumer and author manifests.  The `entry` field is used
49/// by author-side manifests to declare the Lua `require` root; consumer-side
50/// manifests typically omit it.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Package {
54    /// Package name (must be unique within a consumer's dependency graph).
55    pub name: String,
56
57    /// Package version string (SemVer expected; not enforced at parse time).
58    pub version: String,
59
60    /// Entry root for Lua `require` resolution.
61    ///
62    /// Used by author-side manifests.  When absent, the fallback chain
63    /// (`src/` → `lua/` → repo root) is applied at install time.
64    pub entry: Option<PathBuf>,
65}
66
67// ── Dep ──────────────────────────────────────────────────────────────────────
68
69/// A git-based dependency declared in `[deps]`.
70///
71/// At most one of `tag`, `rev`, or `branch` may be set.  All three being
72/// absent is accepted at parse time (treated as HEAD resolution); hard
73/// enforcement is deferred to the fetcher.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct Dep {
77    /// Remote git URL (required).
78    pub git: String,
79
80    /// Pin to a specific tag.  Mutually exclusive with `rev` and `branch`.
81    pub tag: Option<String>,
82
83    /// Pin to a specific commit SHA.  Mutually exclusive with `tag` and `branch`.
84    pub rev: Option<String>,
85
86    /// Track a branch (non-reproducible).  Mutually exclusive with `tag` and `rev`.
87    pub branch: Option<String>,
88
89    /// Override the Lua `require` entry root for this dependency.
90    ///
91    /// Takes precedence over the author's own `[package].entry`.
92    /// When absent, the author's `entry` (or the fallback chain) applies.
93    pub entry: Option<PathBuf>,
94
95    /// Vendor target directory relative to the consumer's manifest.
96    ///
97    /// When set, `mlua-pkg install` physically copies the resolved package
98    /// root into this directory (versionable in the consumer's git tree)
99    /// instead of creating a `.mlua-pkgs/vendored/<name>` symlink.  The
100    /// `require` root inside the copy is `<target_dir>/<entry>`.
101    pub target_dir: Option<PathBuf>,
102
103    /// Locally patched copy of the package root, relative to the consumer's
104    /// manifest (e.g. `"patches/foo"`).
105    ///
106    /// `mlua-pkg patch <name>` creates it from the pinned upstream and records
107    /// the base commit as `patch_base` in the lockfile.  `install` resolves
108    /// the package from this directory for as long as the pin still resolves
109    /// to that base; when the pin moves, the upstream is used instead, the
110    /// directory is left untouched, and a warning names both commits.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub patch_dir: Option<PathBuf>,
113
114    /// What `install` does when `patch_dir` cannot be used (the pin moved
115    /// away from `patch_base`, the directory is missing, or no base is
116    /// recorded).  Absent means [`PatchDrift::Warn`].
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub patch_drift: Option<PatchDrift>,
119}
120
121/// Policy for a `patch_dir` that no longer matches the pin.
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum PatchDrift {
125    /// Use the upstream checkout, leave the patch alone, and report a
126    /// warning naming both commits (Cargo `[patch]` behaviour).  Default.
127    #[default]
128    Warn,
129    /// Fail `install` with [`PkgError::PatchDrift`] so a stale patch can
130    /// never be silently bypassed (pnpm / Yarn behaviour).
131    Error,
132}
133
134impl Dep {
135    /// Validate that at most one of `tag`, `rev`, `branch` is set.
136    fn validate_ref_exclusivity(&self, dep_name: &str) -> Result<(), PkgError> {
137        let count = [
138            self.tag.is_some(),
139            self.rev.is_some(),
140            self.branch.is_some(),
141        ]
142        .into_iter()
143        .filter(|&b| b)
144        .count();
145        if let (Some(p), Some(t)) = (&self.patch_dir, &self.target_dir) {
146            if normalize(p) == normalize(t) {
147                return Err(PkgError::Validation {
148                    message: format!(
149                        "dependency '{dep_name}': patch_dir and target_dir are the same \
150                         directory ({}); install would overwrite the patch",
151                        p.display()
152                    ),
153                });
154            }
155        }
156        if count > 1 {
157            return Err(PkgError::Validation {
158                message: format!(
159                    "dep '{dep_name}': only one of `tag`, `rev`, `branch` may be specified, \
160                     but multiple are set"
161                ),
162            });
163        }
164        Ok(())
165    }
166}
167
168/// Path with `.` components and trailing separators dropped, for comparing
169/// two manifest-relative directories.
170fn normalize(p: &Path) -> PathBuf {
171    p.components()
172        .filter(|c| !matches!(c, std::path::Component::CurDir))
173        .collect()
174}
175
176// ── Manifest ─────────────────────────────────────────────────────────────────
177
178/// Parsed `mlua-pkg.toml`.
179///
180/// Shared schema for consumer and author manifests.  The `deps` map is empty
181/// for author-side manifests (packages that are depended upon, not consumers).
182///
183/// Unknown top-level keys cause an immediate parse error
184/// (`#[serde(deny_unknown_fields)]`).
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct Manifest {
188    /// `[package]` section — required in all manifests.
189    pub package: Package,
190
191    /// `[deps]` table — optional; absent in author-side manifests.
192    ///
193    /// Keys are the local package alias used in `require()`.
194    #[serde(default)]
195    pub deps: HashMap<String, Dep>,
196}
197
198impl Manifest {
199    /// Read and parse a `mlua-pkg.toml` file at `path`.
200    ///
201    /// Performs post-parse validation after TOML deserialization:
202    /// - Each `[deps]` entry must specify at most one of `tag`, `rev`, `branch`.
203    ///
204    /// # Errors
205    ///
206    /// - [`PkgError::Io`] — file cannot be read.
207    /// - [`PkgError::ManifestParse`] — TOML is syntactically invalid, a
208    ///   required field is missing, an unknown field is present, or a type
209    ///   mismatch occurs.
210    /// - [`PkgError::Validation`] — post-parse invariants are violated (e.g.
211    ///   `tag` and `rev` both set on the same dependency).
212    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PkgError> {
213        let content = fs::read_to_string(path)?;
214        Self::from_toml_str(&content)
215    }
216
217    /// Parse manifest TOML from a string, with the same validation as
218    /// [`from_path`](Self::from_path).
219    ///
220    /// For callers that hold `mlua-pkg.toml` content in memory (embedded
221    /// config, generated manifests) and want to hand it to
222    /// [`Config::with_manifest`](crate::Config::with_manifest) without a
223    /// round-trip through the filesystem.
224    ///
225    /// # Errors
226    ///
227    /// - [`PkgError::ManifestParse`] / [`PkgError::Validation`] as for
228    ///   [`from_path`](Self::from_path) (no `Io` variant possible here).
229    pub fn from_toml_str(content: &str) -> Result<Self, PkgError> {
230        let manifest: Self = toml::from_str(content)?;
231        manifest.validate()?;
232        Ok(manifest)
233    }
234
235    /// Run post-parse semantic validation across all dependency entries.
236    fn validate(&self) -> Result<(), PkgError> {
237        for (name, dep) in &self.deps {
238            dep.validate_ref_exclusivity(name)?;
239        }
240        Ok(())
241    }
242}
243
244// ── Unit tests ────────────────────────────────────────────────────────────────
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249    use std::io::Write as _;
250
251    /// Write `content` to a temporary file and return the handle.
252    /// The file is deleted when the handle is dropped.
253    fn temp_manifest(content: &str) -> tempfile::NamedTempFile {
254        let mut f = tempfile::NamedTempFile::new().unwrap();
255        f.write_all(content.as_bytes()).unwrap();
256        f
257    }
258
259    // ── 1. Consumer happy path ─────────────────────────────────────────────
260
261    #[test]
262    fn consumer_happy_path() {
263        let toml = r#"
264[package]
265name = "my-app"
266version = "0.1.0"
267
268[deps]
269foo = { git = "https://github.com/x/foo", tag = "v1.2.0" }
270bar = { git = "https://github.com/y/bar", rev = "abc123" }
271baz = { git = "https://github.com/z/baz", branch = "main" }
272
273[deps.qux]
274git   = "https://github.com/q/qux"
275tag   = "v2.0.0"
276entry = "lib"
277"#;
278        let f = temp_manifest(toml);
279        let m = Manifest::from_path(f.path()).unwrap();
280
281        assert_eq!(m.package.name, "my-app");
282        assert_eq!(m.package.version, "0.1.0");
283        assert!(m.package.entry.is_none());
284        assert_eq!(m.deps.len(), 4);
285
286        let foo = &m.deps["foo"];
287        assert_eq!(foo.git, "https://github.com/x/foo");
288        assert_eq!(foo.tag.as_deref(), Some("v1.2.0"));
289        assert!(foo.rev.is_none());
290        assert!(foo.branch.is_none());
291        assert!(foo.entry.is_none());
292
293        let bar = &m.deps["bar"];
294        assert_eq!(bar.rev.as_deref(), Some("abc123"));
295        assert!(bar.tag.is_none());
296
297        let baz = &m.deps["baz"];
298        assert_eq!(baz.branch.as_deref(), Some("main"));
299        assert!(baz.tag.is_none());
300
301        let qux = &m.deps["qux"];
302        assert_eq!(qux.tag.as_deref(), Some("v2.0.0"));
303        assert_eq!(qux.entry, Some(PathBuf::from("lib")));
304    }
305
306    // ── 2. Author happy path ───────────────────────────────────────────────
307
308    #[test]
309    fn author_happy_path() {
310        let toml = r#"
311[package]
312name    = "foo"
313version = "1.2.0"
314entry   = "src"
315"#;
316        let f = temp_manifest(toml);
317        let m = Manifest::from_path(f.path()).unwrap();
318
319        assert_eq!(m.package.name, "foo");
320        assert_eq!(m.package.version, "1.2.0");
321        assert_eq!(m.package.entry, Some(PathBuf::from("src")));
322        assert!(m.deps.is_empty());
323    }
324
325    // ── 3. tag + rev are mutually exclusive ───────────────────────────────
326
327    #[test]
328    fn tag_and_rev_mutually_exclusive() {
329        let toml = r#"
330[package]
331name    = "my-app"
332version = "0.1.0"
333
334[deps.bad]
335git = "https://github.com/x/bad"
336tag = "v1.0.0"
337rev = "abc123"
338"#;
339        let f = temp_manifest(toml);
340        let err = Manifest::from_path(f.path()).unwrap_err();
341        assert!(
342            matches!(err, PkgError::Validation { .. }),
343            "expected Validation error, got: {err}"
344        );
345        assert!(err.to_string().contains("bad"));
346    }
347
348    // ── 4. Invalid TOML ───────────────────────────────────────────────────
349
350    #[test]
351    fn invalid_toml_returns_parse_error() {
352        let toml = "this is not valid = [ toml";
353        let f = temp_manifest(toml);
354        let err = Manifest::from_path(f.path()).unwrap_err();
355        assert!(
356            matches!(err, PkgError::ManifestParse { .. }),
357            "expected ManifestParse error, got: {err}"
358        );
359    }
360
361    // ── 5. Missing [package] section ──────────────────────────────────────
362
363    #[test]
364    fn missing_package_section_returns_parse_error() {
365        let toml = r#"
366[deps]
367foo = { git = "https://github.com/x/foo", tag = "v1.0.0" }
368"#;
369        let f = temp_manifest(toml);
370        let err = Manifest::from_path(f.path()).unwrap_err();
371        assert!(
372            matches!(err, PkgError::ManifestParse { .. }),
373            "expected ManifestParse error for missing [package], got: {err}"
374        );
375    }
376
377    // ── 6. Round-trip serialization (Serialize derive ground-truth) ────────
378
379    #[test]
380    fn round_trip_serialize_deserialize() {
381        let original = Manifest {
382            package: Package {
383                name: "roundtrip".into(),
384                version: "0.1.0".into(),
385                entry: None,
386            },
387            deps: {
388                let mut m = HashMap::new();
389                m.insert(
390                    "lib".into(),
391                    Dep {
392                        git: "https://github.com/x/lib".into(),
393                        tag: Some("v1.0.0".into()),
394                        rev: None,
395                        branch: None,
396                        entry: None,
397                        target_dir: None,
398                        patch_dir: None,
399                        patch_drift: None,
400                    },
401                );
402                m
403            },
404        };
405
406        let serialized = toml::to_string(&original).unwrap();
407        let deserialized: Manifest = toml::from_str(&serialized).unwrap();
408        assert_eq!(original, deserialized);
409    }
410
411    // ── extra: all three ref fields set is also a validation error ────────
412
413    #[test]
414    fn all_three_ref_fields_is_validation_error() {
415        let toml = r#"
416[package]
417name    = "my-app"
418version = "0.1.0"
419
420[deps.oops]
421git    = "https://github.com/x/oops"
422tag    = "v1.0.0"
423rev    = "abc123"
424branch = "main"
425"#;
426        let f = temp_manifest(toml);
427        let err = Manifest::from_path(f.path()).unwrap_err();
428        assert!(matches!(err, PkgError::Validation { .. }));
429    }
430
431    #[test]
432    fn patch_dir_parses_and_must_differ_from_target_dir() {
433        let ok = r#"
434[package]
435name    = "my-app"
436version = "0.1.0"
437
438[deps.lib]
439git       = "https://github.com/x/lib"
440tag       = "v1.0.0"
441patch_dir = "patches/lib"
442target_dir = "lua/lib"
443"#;
444        let f = temp_manifest(ok);
445        let m = Manifest::from_path(f.path()).unwrap();
446        assert_eq!(m.deps["lib"].patch_dir, Some(PathBuf::from("patches/lib")));
447        assert_eq!(m.deps["lib"].patch_drift, None);
448
449        let strict = r#"
450[package]
451name    = "my-app"
452version = "0.1.0"
453
454[deps.lib]
455git         = "https://github.com/x/lib"
456patch_dir   = "patches/lib"
457patch_drift = "error"
458"#;
459        let f = temp_manifest(strict);
460        let m = Manifest::from_path(f.path()).unwrap();
461        assert_eq!(m.deps["lib"].patch_drift, Some(PatchDrift::Error));
462
463        let bogus = strict.replace("\"error\"", "\"panic\"");
464        let f = temp_manifest(&bogus);
465        assert!(matches!(
466            Manifest::from_path(f.path()).unwrap_err(),
467            PkgError::ManifestParse { .. }
468        ));
469
470        let same = r#"
471[package]
472name    = "my-app"
473version = "0.1.0"
474
475[deps.lib]
476git       = "https://github.com/x/lib"
477patch_dir = "./patches/lib"
478target_dir = "patches/lib/"
479"#;
480        let f = temp_manifest(same);
481        let err = Manifest::from_path(f.path()).unwrap_err();
482        assert!(
483            matches!(&err, PkgError::Validation { message } if message.contains("patch_dir")),
484            "{err}"
485        );
486    }
487
488    // ── extra: unknown field in [package] is rejected ─────────────────────
489
490    #[test]
491    fn unknown_field_in_package_is_rejected() {
492        let toml = r#"
493[package]
494name    = "my-app"
495version = "0.1.0"
496unknown = "should-fail"
497"#;
498        let f = temp_manifest(toml);
499        let err = Manifest::from_path(f.path()).unwrap_err();
500        assert!(
501            matches!(err, PkgError::ManifestParse { .. }),
502            "expected ManifestParse error for unknown field, got: {err}"
503        );
504    }
505}