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