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 (ST4).
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 (ST3).
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 entry
98    /// contents into this directory (versionable in the consumer's git tree)
99    /// instead of creating a `.mlua-pkgs/vendored/<name>` symlink.
100    pub target_dir: Option<PathBuf>,
101}
102
103impl Dep {
104    /// Validate that at most one of `tag`, `rev`, `branch` is set.
105    fn validate_ref_exclusivity(&self, dep_name: &str) -> Result<(), PkgError> {
106        let count = [
107            self.tag.is_some(),
108            self.rev.is_some(),
109            self.branch.is_some(),
110        ]
111        .into_iter()
112        .filter(|&b| b)
113        .count();
114        if count > 1 {
115            return Err(PkgError::Validation {
116                message: format!(
117                    "dep '{dep_name}': only one of `tag`, `rev`, `branch` may be specified, \
118                     but multiple are set"
119                ),
120            });
121        }
122        Ok(())
123    }
124}
125
126// ── Manifest ─────────────────────────────────────────────────────────────────
127
128/// Parsed `mlua-pkg.toml`.
129///
130/// Shared schema for consumer and author manifests.  The `deps` map is empty
131/// for author-side manifests (packages that are depended upon, not consumers).
132///
133/// Unknown top-level keys cause an immediate parse error
134/// (`#[serde(deny_unknown_fields)]`).
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(deny_unknown_fields)]
137pub struct Manifest {
138    /// `[package]` section — required in all manifests.
139    pub package: Package,
140
141    /// `[deps]` table — optional; absent in author-side manifests.
142    ///
143    /// Keys are the local package alias used in `require()`.
144    #[serde(default)]
145    pub deps: HashMap<String, Dep>,
146}
147
148impl Manifest {
149    /// Read and parse a `mlua-pkg.toml` file at `path`.
150    ///
151    /// Performs post-parse validation after TOML deserialization:
152    /// - Each `[deps]` entry must specify at most one of `tag`, `rev`, `branch`.
153    ///
154    /// # Errors
155    ///
156    /// - [`PkgError::Io`] — file cannot be read.
157    /// - [`PkgError::ManifestParse`] — TOML is syntactically invalid, a
158    ///   required field is missing, an unknown field is present, or a type
159    ///   mismatch occurs.
160    /// - [`PkgError::Validation`] — post-parse invariants are violated (e.g.
161    ///   `tag` and `rev` both set on the same dependency).
162    pub fn from_path(path: impl AsRef<Path>) -> Result<Self, PkgError> {
163        let content = fs::read_to_string(path)?;
164        let manifest: Self = toml::from_str(&content)?;
165        manifest.validate()?;
166        Ok(manifest)
167    }
168
169    /// Run post-parse semantic validation across all dependency entries.
170    fn validate(&self) -> Result<(), PkgError> {
171        for (name, dep) in &self.deps {
172            dep.validate_ref_exclusivity(name)?;
173        }
174        Ok(())
175    }
176}
177
178// ── Unit tests ────────────────────────────────────────────────────────────────
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use std::io::Write as _;
184
185    /// Write `content` to a temporary file and return the handle.
186    /// The file is deleted when the handle is dropped.
187    fn temp_manifest(content: &str) -> tempfile::NamedTempFile {
188        let mut f = tempfile::NamedTempFile::new().unwrap();
189        f.write_all(content.as_bytes()).unwrap();
190        f
191    }
192
193    // ── 1. Consumer happy path ─────────────────────────────────────────────
194
195    #[test]
196    fn consumer_happy_path() {
197        let toml = r#"
198[package]
199name = "my-app"
200version = "0.1.0"
201
202[deps]
203foo = { git = "https://github.com/x/foo", tag = "v1.2.0" }
204bar = { git = "https://github.com/y/bar", rev = "abc123" }
205baz = { git = "https://github.com/z/baz", branch = "main" }
206
207[deps.qux]
208git   = "https://github.com/q/qux"
209tag   = "v2.0.0"
210entry = "lib"
211"#;
212        let f = temp_manifest(toml);
213        let m = Manifest::from_path(f.path()).unwrap();
214
215        assert_eq!(m.package.name, "my-app");
216        assert_eq!(m.package.version, "0.1.0");
217        assert!(m.package.entry.is_none());
218        assert_eq!(m.deps.len(), 4);
219
220        let foo = &m.deps["foo"];
221        assert_eq!(foo.git, "https://github.com/x/foo");
222        assert_eq!(foo.tag.as_deref(), Some("v1.2.0"));
223        assert!(foo.rev.is_none());
224        assert!(foo.branch.is_none());
225        assert!(foo.entry.is_none());
226
227        let bar = &m.deps["bar"];
228        assert_eq!(bar.rev.as_deref(), Some("abc123"));
229        assert!(bar.tag.is_none());
230
231        let baz = &m.deps["baz"];
232        assert_eq!(baz.branch.as_deref(), Some("main"));
233        assert!(baz.tag.is_none());
234
235        let qux = &m.deps["qux"];
236        assert_eq!(qux.tag.as_deref(), Some("v2.0.0"));
237        assert_eq!(qux.entry, Some(PathBuf::from("lib")));
238    }
239
240    // ── 2. Author happy path ───────────────────────────────────────────────
241
242    #[test]
243    fn author_happy_path() {
244        let toml = r#"
245[package]
246name    = "foo"
247version = "1.2.0"
248entry   = "src"
249"#;
250        let f = temp_manifest(toml);
251        let m = Manifest::from_path(f.path()).unwrap();
252
253        assert_eq!(m.package.name, "foo");
254        assert_eq!(m.package.version, "1.2.0");
255        assert_eq!(m.package.entry, Some(PathBuf::from("src")));
256        assert!(m.deps.is_empty());
257    }
258
259    // ── 3. tag + rev are mutually exclusive ───────────────────────────────
260
261    #[test]
262    fn tag_and_rev_mutually_exclusive() {
263        let toml = r#"
264[package]
265name    = "my-app"
266version = "0.1.0"
267
268[deps.bad]
269git = "https://github.com/x/bad"
270tag = "v1.0.0"
271rev = "abc123"
272"#;
273        let f = temp_manifest(toml);
274        let err = Manifest::from_path(f.path()).unwrap_err();
275        assert!(
276            matches!(err, PkgError::Validation { .. }),
277            "expected Validation error, got: {err}"
278        );
279        assert!(err.to_string().contains("bad"));
280    }
281
282    // ── 4. Invalid TOML ───────────────────────────────────────────────────
283
284    #[test]
285    fn invalid_toml_returns_parse_error() {
286        let toml = "this is not valid = [ toml";
287        let f = temp_manifest(toml);
288        let err = Manifest::from_path(f.path()).unwrap_err();
289        assert!(
290            matches!(err, PkgError::ManifestParse { .. }),
291            "expected ManifestParse error, got: {err}"
292        );
293    }
294
295    // ── 5. Missing [package] section ──────────────────────────────────────
296
297    #[test]
298    fn missing_package_section_returns_parse_error() {
299        let toml = r#"
300[deps]
301foo = { git = "https://github.com/x/foo", tag = "v1.0.0" }
302"#;
303        let f = temp_manifest(toml);
304        let err = Manifest::from_path(f.path()).unwrap_err();
305        assert!(
306            matches!(err, PkgError::ManifestParse { .. }),
307            "expected ManifestParse error for missing [package], got: {err}"
308        );
309    }
310
311    // ── 6. Round-trip serialization (Serialize derive ground-truth) ────────
312
313    #[test]
314    fn round_trip_serialize_deserialize() {
315        let original = Manifest {
316            package: Package {
317                name: "roundtrip".into(),
318                version: "0.1.0".into(),
319                entry: None,
320            },
321            deps: {
322                let mut m = HashMap::new();
323                m.insert(
324                    "lib".into(),
325                    Dep {
326                        git: "https://github.com/x/lib".into(),
327                        tag: Some("v1.0.0".into()),
328                        rev: None,
329                        branch: None,
330                        entry: None,
331                        target_dir: None,
332                    },
333                );
334                m
335            },
336        };
337
338        let serialized = toml::to_string(&original).unwrap();
339        let deserialized: Manifest = toml::from_str(&serialized).unwrap();
340        assert_eq!(original, deserialized);
341    }
342
343    // ── extra: all three ref fields set is also a validation error ────────
344
345    #[test]
346    fn all_three_ref_fields_is_validation_error() {
347        let toml = r#"
348[package]
349name    = "my-app"
350version = "0.1.0"
351
352[deps.oops]
353git    = "https://github.com/x/oops"
354tag    = "v1.0.0"
355rev    = "abc123"
356branch = "main"
357"#;
358        let f = temp_manifest(toml);
359        let err = Manifest::from_path(f.path()).unwrap_err();
360        assert!(matches!(err, PkgError::Validation { .. }));
361    }
362
363    // ── extra: unknown field in [package] is rejected ─────────────────────
364
365    #[test]
366    fn unknown_field_in_package_is_rejected() {
367        let toml = r#"
368[package]
369name    = "my-app"
370version = "0.1.0"
371unknown = "should-fail"
372"#;
373        let f = temp_manifest(toml);
374        let err = Manifest::from_path(f.path()).unwrap_err();
375        assert!(
376            matches!(err, PkgError::ManifestParse { .. }),
377            "expected ManifestParse error for unknown field, got: {err}"
378        );
379    }
380}