Skip to main content

mlua_pkg/
resolvers.rs

1//! Resolver implementations: leaves and combinators.
2//!
3//! # Leaf Resolvers
4//!
5//! Terminal resolvers that directly produce values.
6//!
7//! | Resolver | Source | Match condition | Use case |
8//! |----------|--------|----------------|----------|
9//! | [`MemoryResolver`] | `HashMap<String, String>` | Name is registered | `include_str!` embedding, preload |
10//! | [`NativeResolver`] | `Fn(&Lua) -> Result<Value>` | Name is registered | Build tables from Rust (`@std/*`, etc.) |
11//! | [`FsResolver`] | Filesystem | File exists | Sandboxed, `init.lua` fallback |
12//! | [`AssetResolver`] | Filesystem | Known extension + file exists | Auto-convert non-Lua resources (JSON->Table, etc.) |
13//!
14//! # Combinators
15//!
16//! Resolvers that compose other Resolvers. Since they implement the [`Resolver`] trait,
17//! they can be added to a Registry just like leaves, and combinators can nest.
18//!
19//! | Combinator | Behavior | Use case |
20//! |------------|----------|----------|
21//! | [`PrefixResolver`] | `"prefix.rest"` -> strip prefix -> delegate to inner Resolver | Namespace mounting |
22//!
23//! # Composition patterns
24//!
25//! ```text
26//! Registry (Chain)
27//! +- NativeResolver                 @std/http  -> Rust factory
28//! +- PrefixResolver("game", ...)    game.xxx   -> delegate to inner Resolver
29//! |   +- FsResolver(game_dir/)      xxx        -> game_dir/xxx.lua
30//! +- FsResolver(scripts/)           game       -> scripts/game/init.lua
31//! |                                  lib.utils  -> scripts/lib/utils.lua
32//! +- AssetResolver(assets/)         config.json -> JSON parse -> Table
33//! ```
34//!
35//! [`PrefixResolver`] acts as a namespace mount point.
36//! `require("game")` (init.lua) is handled by the outer [`FsResolver`],
37//! while `require("game.engine")` is handled by [`PrefixResolver`].
38//! Responsibilities are clearly separated.
39
40use std::collections::HashMap;
41use std::path::{Path, PathBuf};
42
43use mlua::{Lua, LuaSerdeExt, Result, Value};
44
45use crate::sandbox::{FsSandbox, InitError, ReadError, SandboxedFs, SymlinkAwareSandbox};
46use crate::{ResolveError, Resolver};
47
48type NativeFactory = Box<dyn Fn(&Lua) -> Result<Value> + Send + Sync>;
49
50/// Domain conversion from ReadError to ResolveError.
51///
52/// Attaches module name domain context to infrastructure-layer errors
53/// that occur during `resolve()` execution.
54///
55/// `sanitized_path` should be a relative path within the sandbox.
56/// Absolute paths generated inside FsSandbox (containing host OS information)
57/// are replaced with relative paths during conversion to prevent leaking
58/// to the Lua side.
59fn read_to_resolve_error(err: ReadError, name: &str, sanitized_path: &Path) -> ResolveError {
60    match err {
61        ReadError::Traversal { .. } => ResolveError::PathTraversal {
62            name: name.to_owned(),
63        },
64        ReadError::Io { source, .. } => ResolveError::Io {
65            path: sanitized_path.to_path_buf(),
66            source,
67        },
68    }
69}
70
71// -- MemoryResolver --
72
73/// Resolver that holds Lua source strings in memory.
74///
75/// Makes modules embedded via `include_str!` or dynamically generated
76/// sources available through `require`.
77///
78/// Cross-module `require` chains also work
79/// (delegated to other Resolvers via the Registry).
80///
81/// ```rust
82/// use mlua_pkg::resolvers::MemoryResolver;
83///
84/// let r = MemoryResolver::new()
85///     .add("mylib", "return { version = 1 }")
86///     .add("mylib.utils", "return { helper = true }");
87/// ```
88pub struct MemoryResolver {
89    modules: HashMap<String, String>,
90}
91
92impl Default for MemoryResolver {
93    fn default() -> Self {
94        Self::new()
95    }
96}
97
98impl MemoryResolver {
99    pub fn new() -> Self {
100        Self {
101            modules: HashMap::new(),
102        }
103    }
104
105    /// Register a module. Duplicate names are overwritten.
106    pub fn add(mut self, name: impl Into<String>, source: impl Into<String>) -> Self {
107        self.modules.insert(name.into(), source.into());
108        self
109    }
110}
111
112impl Resolver for MemoryResolver {
113    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
114        let source = self.modules.get(name)?;
115        Some(lua.load(source.as_str()).set_name(name).eval())
116    }
117}
118
119// -- NativeResolver --
120
121/// Resolver that builds Lua Values directly from Rust functions.
122///
123/// Provides native modules like `@std/http`.
124/// Since the factory function returns a Lua Value, table construction
125/// and function registration are fully controlled on the Rust side.
126///
127/// ```rust
128/// use mlua_pkg::resolvers::NativeResolver;
129/// use mlua::Value;
130///
131/// let r = NativeResolver::new().add("@std/version", |lua| {
132///     lua.create_string("1.0.0").map(Value::String)
133/// });
134/// ```
135pub struct NativeResolver {
136    modules: HashMap<String, NativeFactory>,
137}
138
139impl Default for NativeResolver {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl NativeResolver {
146    pub fn new() -> Self {
147        Self {
148            modules: HashMap::new(),
149        }
150    }
151
152    /// Register a native module.
153    pub fn add(
154        mut self,
155        name: impl Into<String>,
156        factory: impl Fn(&Lua) -> Result<Value> + Send + Sync + 'static,
157    ) -> Self {
158        self.modules.insert(name.into(), Box::new(factory));
159        self
160    }
161}
162
163impl Resolver for NativeResolver {
164    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
165        let factory = self.modules.get(name)?;
166        Some(factory(lua))
167    }
168}
169
170// -- FsResolver --
171
172/// Sandboxed filesystem Resolver.
173///
174/// Resolves `require("lib.helper")` to `{root}/lib/helper.lua`.
175/// Converts module separator to path separator and searches in order:
176///
177/// 1. `{root}/{name}.{extension}`
178/// 2. `{root}/{name}/{init_name}.{extension}`
179///
180/// Defaults to [`LuaConvention::LUA54`](crate::LuaConvention::LUA54).
181/// Use [`with_convention()`](FsResolver::with_convention) for bulk changes,
182/// or individual methods for partial overrides.
183///
184/// I/O goes through the [`SandboxedFs`] trait. Use [`with_sandbox`](FsResolver::with_sandbox)
185/// to inject test mocks or alternative backends.
186///
187/// # Errors
188///
189/// `new()` returns [`InitError::RootNotFound`] if the root does not exist.
190pub struct FsResolver {
191    sandbox: Box<dyn SandboxedFs>,
192    extension: String,
193    init_name: String,
194    module_separator: char,
195}
196
197impl FsResolver {
198    /// Build an FsResolver backed by the real filesystem.
199    pub fn new(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
200        let fs = FsSandbox::new(root)?;
201        Ok(Self::with_sandbox(fs))
202    }
203
204    /// Inject an arbitrary [`SandboxedFs`] implementation.
205    pub fn with_sandbox(sandbox: impl SandboxedFs + 'static) -> Self {
206        let conv = crate::LuaConvention::default();
207        Self {
208            sandbox: Box::new(sandbox),
209            extension: conv.extension.to_owned(),
210            init_name: conv.init_name.to_owned(),
211            module_separator: conv.module_separator,
212        }
213    }
214
215    /// Apply a [`LuaConvention`](crate::LuaConvention) in bulk.
216    pub fn with_convention(self, conv: crate::LuaConvention) -> Self {
217        Self {
218            extension: conv.extension.to_owned(),
219            init_name: conv.init_name.to_owned(),
220            module_separator: conv.module_separator,
221            ..self
222        }
223    }
224
225    /// Change the file extension (default: `lua`).
226    pub fn with_extension(mut self, ext: impl Into<String>) -> Self {
227        self.extension = ext.into();
228        self
229    }
230
231    /// Change the package entry point filename (default: `init`).
232    ///
233    /// `require("pkg")` resolves to `pkg/{init_name}.{extension}`.
234    pub fn with_init_name(mut self, name: impl Into<String>) -> Self {
235        self.init_name = name.into();
236        self
237    }
238
239    /// Change the module name separator (default: `.`).
240    ///
241    /// `require("a{sep}b")` is converted to `a/b.{extension}`.
242    pub fn with_module_separator(mut self, sep: char) -> Self {
243        self.module_separator = sep;
244        self
245    }
246}
247
248impl Resolver for FsResolver {
249    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
250        let relative = name.replace(self.module_separator, "/");
251
252        let candidates = [
253            PathBuf::from(format!("{relative}.{}", self.extension)),
254            PathBuf::from(format!("{relative}/{}.{}", self.init_name, self.extension)),
255        ];
256
257        for candidate in &candidates {
258            match self.sandbox.read(candidate) {
259                Ok(Some(file)) => {
260                    let source_name = candidate.display().to_string();
261                    return Some(lua.load(file.content).set_name(source_name).eval());
262                }
263                Ok(None) => continue,
264                Err(e) => {
265                    return Some(Err(mlua::Error::external(read_to_resolve_error(
266                        e, name, candidate,
267                    ))));
268                }
269            }
270        }
271
272        None
273    }
274}
275
276// -- AssetResolver --
277
278type AssetParserFn = Box<dyn Fn(&Lua, &str) -> Result<Value> + Send + Sync>;
279
280/// Resolver that registers parsers by extension and auto-converts non-Lua resources.
281///
282/// Parsers are registered per extension via `.parser()`.
283/// For unregistered extensions, no I/O is performed and `None` is returned.
284///
285/// Filenames are treated literally (no dot-to-path conversion).
286///
287/// # Built-in parsers
288///
289/// | Factory function | Conversion |
290/// |-----------------|------------|
291/// | [`json_parser()`] | Parse with `serde_json` -> Lua Table |
292/// | [`text_parser()`] | Return as-is as Lua String |
293///
294/// # Examples
295///
296/// ```rust
297/// use mlua_pkg::resolvers::{AssetResolver, json_parser, text_parser};
298///
299/// # fn example() -> Result<(), mlua_pkg::sandbox::InitError> {
300/// let resolver = AssetResolver::new("./assets")?
301///     .parser("json", json_parser())
302///     .parser("sql", text_parser())
303///     .parser("css", text_parser());
304/// # Ok(())
305/// # }
306/// ```
307///
308/// Custom parsers can also be registered as closures:
309///
310/// ```rust
311/// use mlua_pkg::resolvers::{AssetResolver, json_parser};
312///
313/// # fn example() -> Result<(), mlua_pkg::sandbox::InitError> {
314/// let resolver = AssetResolver::new("./assets")?
315///     .parser("json", json_parser())
316///     .parser("csv", |lua, content| {
317///         // Split by lines and convert to a Lua table
318///         let t = lua.create_table()?;
319///         for (i, line) in content.lines().enumerate() {
320///             t.set(i + 1, lua.create_string(line)?)?;
321///         }
322///         Ok(mlua::Value::Table(t))
323///     });
324/// # Ok(())
325/// # }
326/// ```
327///
328/// I/O goes through the [`SandboxedFs`] trait. Use [`with_sandbox`](AssetResolver::with_sandbox)
329/// to inject test mocks or alternative backends.
330///
331/// # Design decision: why extension keys are `String`
332///
333/// Parser registration uses `HashMap<String, BoxFn>`.
334/// String keys are chosen over enums to prioritize extensibility (Open/Closed),
335/// allowing users to freely register custom parsers for any extension.
336///
337/// Impact of a typo: `parsers.get(ext)` returns `None` -> safely falls through to the
338/// next Resolver. No panic/UB occurs. Setup code is small, so typos surface immediately in tests.
339///
340/// # Errors
341///
342/// `new()` returns [`InitError::RootNotFound`] if the root does not exist.
343pub struct AssetResolver {
344    sandbox: Box<dyn SandboxedFs>,
345    parsers: HashMap<String, AssetParserFn>,
346}
347
348impl AssetResolver {
349    /// Build an AssetResolver backed by the real filesystem.
350    pub fn new(root: impl Into<PathBuf>) -> std::result::Result<Self, InitError> {
351        let fs = FsSandbox::new(root)?;
352        Ok(Self::with_sandbox(fs))
353    }
354
355    /// Inject an arbitrary [`SandboxedFs`] implementation.
356    pub fn with_sandbox(sandbox: impl SandboxedFs + 'static) -> Self {
357        Self {
358            sandbox: Box::new(sandbox),
359            parsers: HashMap::new(),
360        }
361    }
362
363    /// Register a parser for an extension. Duplicate extensions are overwritten.
364    pub fn parser(
365        mut self,
366        ext: impl Into<String>,
367        f: impl Fn(&Lua, &str) -> Result<Value> + Send + Sync + 'static,
368    ) -> Self {
369        self.parsers.insert(ext.into(), Box::new(f));
370        self
371    }
372}
373
374/// JSON -> Lua Table parser.
375///
376/// Parses with `serde_json` and converts to a Lua Table via [`LuaSerdeExt::to_value`].
377/// Returns [`ResolveError::AssetParse`] on parse failure.
378pub fn json_parser() -> impl Fn(&Lua, &str) -> Result<Value> + Send + Sync {
379    |lua, content| {
380        let json: serde_json::Value = serde_json::from_str(content).map_err(|e| {
381            mlua::Error::external(ResolveError::AssetParse {
382                source: Box::new(e),
383            })
384        })?;
385        lua.to_value(&json)
386    }
387}
388
389/// Text -> Lua String parser.
390///
391/// Returns the file content as-is as a Lua String.
392/// Use for `.txt`, `.sql`, `.html`, `.css`, etc.
393pub fn text_parser() -> impl Fn(&Lua, &str) -> Result<Value> + Send + Sync {
394    |lua, content| lua.create_string(content).map(Value::String)
395}
396
397impl Resolver for AssetResolver {
398    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
399        let ext = Path::new(name).extension()?.to_str()?;
400        let parser = self.parsers.get(ext)?;
401
402        let asset_path = Path::new(name);
403        let file = match self.sandbox.read(asset_path) {
404            Ok(Some(file)) => file,
405            Ok(None) => return None,
406            Err(e) => {
407                return Some(Err(mlua::Error::external(read_to_resolve_error(
408                    e, name, asset_path,
409                ))));
410            }
411        };
412
413        Some(parser(lua, &file.content))
414    }
415}
416
417// -- PrefixResolver --
418
419/// Combinator that routes to an inner Resolver by name prefix.
420///
421/// Receives `require("{prefix}{sep}{rest}")`, strips the prefix and separator,
422/// and delegates `{rest}` to the inner Resolver.
423/// Returns `None` for names that don't match the prefix.
424///
425/// # Match rules
426///
427/// | Input | prefix="sm", sep='.' | Result |
428/// |-------|---------------------|--------|
429/// | `"sm.helper"` | Strip `"sm."` -> `"helper"` | Delegate to inner Resolver |
430/// | `"sm.ui.btn"` | Strip `"sm."` -> `"ui.btn"` | Delegate to inner Resolver (multi-level) |
431/// | `"sm"` | No separator -> no match | `None` (handled by outer Resolver) |
432/// | `"smtp"` | Does not start with `"sm."` | `None` |
433/// | `"other.x"` | Prefix mismatch | `None` |
434///
435/// # Design intent
436///
437/// `require("sm")` (package root = init.lua) is **outside** PrefixResolver's scope.
438/// The outer [`FsResolver`] handles it via init.lua fallback.
439/// This clearly separates responsibilities:
440///
441/// - **PrefixResolver**: `sm.xxx` -> submodules within the namespace
442/// - **FsResolver**: `sm` -> `sm/init.lua` (package entry point)
443///
444/// # Composition example
445///
446/// ```rust
447/// use mlua_pkg::{Registry, resolvers::*};
448/// use mlua::Lua;
449///
450/// let lua = Lua::new();
451/// let mut reg = Registry::new();
452///
453/// // "game.xxx" -> resolve within game_modules/
454/// reg.add(PrefixResolver::new("game",
455///     MemoryResolver::new()
456///         .add("engine", "return { version = 2 }")
457///         .add("utils", "return { helper = true }")));
458///
459/// // "game" -> init.lua provided directly via MemoryResolver
460/// reg.add(MemoryResolver::new()
461///     .add("game", "return { name = 'game' }"));
462///
463/// reg.install(&lua).unwrap();
464///
465/// // require("game.engine") -> PrefixResolver -> MemoryResolver("engine")
466/// // require("game")        -> MemoryResolver("game")
467/// ```
468pub struct PrefixResolver {
469    prefix: String,
470    separator: char,
471    inner: Box<dyn Resolver>,
472}
473
474impl PrefixResolver {
475    /// Build a prefix router with `.` separator.
476    ///
477    /// `require("{prefix}.{rest}")` -> `inner.resolve("{rest}")`
478    pub fn new(prefix: impl Into<String>, inner: impl Resolver + 'static) -> Self {
479        Self {
480            prefix: prefix.into(),
481            separator: crate::LuaConvention::default().module_separator,
482            inner: Box::new(inner),
483        }
484    }
485
486    /// Apply a [`LuaConvention`](crate::LuaConvention) in bulk.
487    pub fn with_convention(mut self, conv: crate::LuaConvention) -> Self {
488        self.separator = conv.module_separator;
489        self
490    }
491
492    /// Change the separator (default: `.`).
493    pub fn with_separator(mut self, separator: char) -> Self {
494        self.separator = separator;
495        self
496    }
497}
498
499impl Resolver for PrefixResolver {
500    fn resolve(&self, lua: &Lua, name: &str) -> Option<Result<Value>> {
501        let mut prefix_with_sep = String::with_capacity(self.prefix.len() + 1);
502        prefix_with_sep.push_str(&self.prefix);
503        prefix_with_sep.push(self.separator);
504
505        let rest = name.strip_prefix(&prefix_with_sep)?;
506        self.inner.resolve(lua, rest)
507    }
508}
509
510// -- VendoredResolver --
511
512/// Resolver that exposes the `.mlua-pkgs/vendored/` directory to `require`.
513///
514/// `VendoredResolver` is a thin wrapper over [`FsResolver`] rooted at the
515/// `vendored_root` directory (typically `.mlua-pkgs/vendored/`).
516///
517/// Each package in that directory is expected to be a symlink (or real
518/// directory) created by the `mlua-pkg install` CLI (ST5).  For example,
519/// `require("foo")` resolves to `vendored/foo/init.lua`, and
520/// `require("foo.bar")` resolves to `vendored/foo/bar.lua`.
521///
522/// # Responsibilities
523///
524/// - **This resolver reads** — it does not create symlinks or directories.
525/// - **The CLI creates** — `mlua-pkg install` is responsible for populating
526///   `.mlua-pkgs/vendored/` before this resolver is used.
527///
528/// # Construction
529///
530/// | Constructor | Use when |
531/// |------------|----------|
532/// | [`VendoredResolver::from_lockfile`] | Normal usage: lockfile path + vendored root |
533/// | [`VendoredResolver::new`] | Low-level: vendored root already exists and is populated |
534///
535/// # Errors
536///
537/// `new()` returns [`InitError::RootNotFound`] if `vendored_root` does not exist.
538/// `from_lockfile()` returns [`PkgError::MissingLockfile`] if the lockfile is absent,
539/// or [`PkgError::SameNameConflict`] if duplicate package names are found.
540pub struct VendoredResolver {
541    inner: FsResolver,
542}
543
544// FsResolver wraps a sandbox that is not Debug; implement manually.
545impl std::fmt::Debug for VendoredResolver {
546    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
547        f.debug_struct("VendoredResolver").finish_non_exhaustive()
548    }
549}
550
551impl VendoredResolver {
552    /// Low-level constructor: wrap an existing `vendored_root` directory.
553    ///
554    /// Does **not** read a lockfile.  Use [`from_lockfile`](Self::from_lockfile)
555    /// for the normal flow.
556    ///
557    /// # Errors
558    ///
559    /// Returns [`InitError::RootNotFound`] if `vendored_root` does not exist.
560    pub fn new(
561        vendored_root: impl Into<PathBuf>,
562    ) -> std::result::Result<Self, crate::sandbox::InitError> {
563        let sandbox = SymlinkAwareSandbox::new(vendored_root)?;
564        let inner = FsResolver::with_sandbox(sandbox);
565        Ok(Self { inner })
566    }
567
568    /// Normal constructor: read `lockfile_path` and wrap `vendored_root`.
569    ///
570    /// Reads and validates the lockfile, then constructs an [`FsResolver`]
571    /// rooted at `vendored_root`.  For each package in the lockfile, emits a
572    /// `tracing::warn!` if the corresponding `vendored_root/<name>` symlink or
573    /// directory is absent (the CLI has not yet installed that package).
574    /// Resolution will simply return `None` for missing packages at runtime,
575    /// matching normal `FsResolver` miss behaviour.
576    ///
577    /// `vendored_root` is created automatically if it does not exist, to avoid
578    /// requiring a prior `mlua-pkg install` just to construct the resolver.
579    ///
580    /// # Errors
581    ///
582    /// | Error | Condition |
583    /// |-------|-----------|
584    /// | [`PkgError::MissingLockfile`] | `lockfile_path` does not exist |
585    /// | [`PkgError::LockfileParse`] | Invalid TOML in the lockfile |
586    /// | [`PkgError::SameNameConflict`] | Duplicate package names in the lockfile |
587    /// | [`PkgError::Io`] | I/O failure while creating `vendored_root` or reading the lockfile |
588    pub fn from_lockfile(
589        lockfile_path: impl AsRef<Path>,
590        vendored_root: impl AsRef<Path>,
591    ) -> std::result::Result<Self, crate::PkgError> {
592        let vendored_root = vendored_root.as_ref();
593        let lockfile = crate::lockfile::Lockfile::read(lockfile_path)?;
594
595        // Auto-create vendored_root if absent — callers should not need to run
596        // `mlua-pkg install` just to get a working resolver skeleton.
597        if !vendored_root.exists() {
598            std::fs::create_dir_all(vendored_root)?;
599        }
600
601        // Warn for each package whose vendored entry is absent.
602        // Broken symlinks are handled via symlink_metadata (does not follow
603        // the target), so even a dangling symlink counts as "present".
604        for pkg in &lockfile.pkg {
605            let entry = vendored_root.join(&pkg.name);
606            if std::fs::symlink_metadata(&entry).is_err() {
607                // No tracing dependency — use eprintln as a lightweight warning.
608                // ST5 / real usage will add tracing; for now this is informational.
609                eprintln!(
610                    "mlua-pkg: vendored/{} not found — run `mlua-pkg install`",
611                    pkg.name
612                );
613            }
614        }
615
616        // Construct the inner FsResolver backed by SymlinkAwareSandbox so that
617        // directory symlinks under vendored_root (created by `mlua-pkg install`)
618        // are followed and their targets are accessible.  If vendored_root was
619        // just created it is empty; that is fine — resolve() will return None
620        // for all names until `mlua-pkg install` populates the symlinks.
621        let sandbox = SymlinkAwareSandbox::new(vendored_root).map_err(|e| {
622            // InitError::RootNotFound after we just created it would be unusual,
623            // but surface it as an Io error to keep PkgError self-contained.
624            crate::PkgError::Io {
625                source: std::io::Error::new(
626                    std::io::ErrorKind::NotFound,
627                    format!("vendored root init error: {e}"),
628                ),
629            }
630        })?;
631        let inner = FsResolver::with_sandbox(sandbox);
632
633        Ok(Self { inner })
634    }
635}
636
637impl Resolver for VendoredResolver {
638    /// Delegate resolution to the inner [`FsResolver`].
639    ///
640    /// `require("foo")` resolves to `vendored/foo.lua` or `vendored/foo/init.lua`.
641    /// `require("foo.bar")` resolves to `vendored/foo/bar.lua`.
642    fn resolve(&self, lua: &Lua, name: &str) -> Option<mlua::Result<mlua::Value>> {
643        self.inner.resolve(lua, name)
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use crate::sandbox::{FileContent, ReadError};
651
652    /// Asserts that `resolve()` returns `Some(Ok(value))` and returns the value.
653    fn must_resolve(resolver: &dyn Resolver, lua: &Lua, name: &str) -> Value {
654        match resolver.resolve(lua, name) {
655            Some(Ok(v)) => v,
656            Some(Err(e)) => panic!("resolve('{name}') returned Err: {e}"),
657            None => panic!("resolve('{name}') returned None"),
658        }
659    }
660
661    /// Asserts that `resolve()` returns `Some(Err(_))` and returns the error message.
662    fn must_resolve_err(resolver: &dyn Resolver, lua: &Lua, name: &str) -> String {
663        match resolver.resolve(lua, name) {
664            Some(Err(e)) => e.to_string(),
665            Some(Ok(_)) => panic!("resolve('{name}') returned Ok, expected Err"),
666            None => panic!("resolve('{name}') returned None, expected Some(Err)"),
667        }
668    }
669
670    /// Extracts a table field from a Value.
671    fn get_field<V: mlua::FromLua>(value: &Value, key: impl mlua::IntoLua) -> V {
672        value
673            .as_table()
674            .expect("expected Table value")
675            .get::<V>(key)
676            .expect("table field access failed")
677    }
678
679    /// Mock sandbox for I/O-free testing.
680    struct MockSandbox {
681        files: HashMap<PathBuf, String>,
682    }
683
684    impl MockSandbox {
685        fn new() -> Self {
686            Self {
687                files: HashMap::new(),
688            }
689        }
690
691        fn with_file(mut self, path: impl Into<PathBuf>, content: &str) -> Self {
692            self.files.insert(path.into(), content.to_owned());
693            self
694        }
695    }
696
697    impl SandboxedFs for MockSandbox {
698        fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
699            match self.files.get(relative) {
700                Some(content) => Ok(Some(FileContent {
701                    content: content.clone(),
702                    resolved_path: relative.to_path_buf(),
703                })),
704                None => Ok(None),
705            }
706        }
707    }
708
709    #[test]
710    fn fs_resolver_dot_to_path_conversion() {
711        let mock = MockSandbox::new().with_file("lib/helper.lua", "return { name = 'mocked' }");
712        let resolver = FsResolver::with_sandbox(mock);
713
714        let lua = mlua::Lua::new();
715        let value = must_resolve(&resolver, &lua, "lib.helper");
716        assert_eq!(get_field::<String>(&value, "name"), "mocked");
717    }
718
719    #[test]
720    fn fs_resolver_init_lua_fallback() {
721        let mock = MockSandbox::new().with_file("mypkg/init.lua", "return { from_init = true }");
722        let resolver = FsResolver::with_sandbox(mock);
723
724        let lua = mlua::Lua::new();
725        let value = must_resolve(&resolver, &lua, "mypkg");
726        assert!(get_field::<bool>(&value, "from_init"));
727    }
728
729    #[test]
730    fn fs_resolver_miss_returns_none() {
731        let mock = MockSandbox::new();
732        let resolver = FsResolver::with_sandbox(mock);
733
734        let lua = mlua::Lua::new();
735        assert!(resolver.resolve(&lua, "nonexistent").is_none());
736    }
737
738    #[test]
739    fn fs_resolver_custom_extension() {
740        let mock = MockSandbox::new().with_file("lib/helper.luau", "return { name = 'luau_mod' }");
741        let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
742
743        let lua = mlua::Lua::new();
744        let value = must_resolve(&resolver, &lua, "lib.helper");
745        assert_eq!(get_field::<String>(&value, "name"), "luau_mod");
746    }
747
748    #[test]
749    fn fs_resolver_custom_init_name() {
750        let mock = MockSandbox::new().with_file("mypkg/mod.lua", "return { from_mod = true }");
751        let resolver = FsResolver::with_sandbox(mock).with_init_name("mod");
752
753        let lua = mlua::Lua::new();
754        let value = must_resolve(&resolver, &lua, "mypkg");
755        assert!(get_field::<bool>(&value, "from_mod"));
756    }
757
758    #[test]
759    fn fs_resolver_custom_extension_ignores_default() {
760        // .lua is not resolved when .luau is configured
761        let mock = MockSandbox::new().with_file("helper.lua", "return 'wrong'");
762        let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
763
764        let lua = mlua::Lua::new();
765        assert!(resolver.resolve(&lua, "helper").is_none());
766    }
767
768    #[test]
769    fn fs_resolver_with_convention_luau() {
770        let mock = MockSandbox::new()
771            .with_file("lib/helper.luau", "return { name = 'luau' }")
772            .with_file("pkg/init.luau", "return { pkg = true }");
773        let resolver = FsResolver::with_sandbox(mock).with_convention(crate::LuaConvention::LUAU);
774
775        let lua = mlua::Lua::new();
776
777        let value = must_resolve(&resolver, &lua, "lib.helper");
778        assert_eq!(get_field::<String>(&value, "name"), "luau");
779
780        let value = must_resolve(&resolver, &lua, "pkg");
781        assert!(get_field::<bool>(&value, "pkg"));
782    }
783
784    #[test]
785    fn convention_then_override() {
786        // Partial override via individual method after with_convention
787        let mock = MockSandbox::new().with_file("pkg/mod.luau", "return { ok = true }");
788        let resolver = FsResolver::with_sandbox(mock)
789            .with_convention(crate::LuaConvention::LUAU)
790            .with_init_name("mod");
791
792        let lua = mlua::Lua::new();
793        let value = must_resolve(&resolver, &lua, "pkg");
794        assert!(get_field::<bool>(&value, "ok"));
795    }
796
797    #[test]
798    fn lua_convention_default_is_lua54() {
799        assert_eq!(crate::LuaConvention::default(), crate::LuaConvention::LUA54);
800    }
801
802    #[test]
803    fn asset_resolver_json_to_table() {
804        let mock = MockSandbox::new().with_file("config.json", r#"{"port": 8080}"#);
805        let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
806
807        let lua = mlua::Lua::new();
808        let value = must_resolve(&resolver, &lua, "config.json");
809        assert_eq!(get_field::<i32>(&value, "port"), 8080);
810    }
811
812    #[test]
813    fn asset_resolver_text_to_string() {
814        let mock = MockSandbox::new().with_file("query.sql", "SELECT 1");
815        let resolver = AssetResolver::with_sandbox(mock).parser("sql", text_parser());
816
817        let lua = mlua::Lua::new();
818        let value = must_resolve(&resolver, &lua, "query.sql");
819        let s: String = lua.unpack(value).expect("unpack String failed");
820        assert_eq!(s, "SELECT 1");
821    }
822
823    #[test]
824    fn asset_resolver_unregistered_ext_returns_none() {
825        let mock = MockSandbox::new().with_file("data.xyz", "stuff");
826        let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
827
828        let lua = mlua::Lua::new();
829        assert!(resolver.resolve(&lua, "data.xyz").is_none());
830    }
831
832    #[test]
833    fn asset_resolver_no_ext_returns_none() {
834        let mock = MockSandbox::new();
835        let resolver = AssetResolver::with_sandbox(mock);
836
837        let lua = mlua::Lua::new();
838        assert!(resolver.resolve(&lua, "noext").is_none());
839    }
840
841    #[test]
842    fn asset_resolver_custom_parser() {
843        let mock = MockSandbox::new().with_file("data.csv", "a,b,c");
844        let resolver = AssetResolver::with_sandbox(mock).parser("csv", |lua, content| {
845            let t = lua.create_table()?;
846            for (i, field) in content.split(',').enumerate() {
847                t.set(i + 1, lua.create_string(field)?)?;
848            }
849            Ok(Value::Table(t))
850        });
851
852        let lua = mlua::Lua::new();
853        let value = must_resolve(&resolver, &lua, "data.csv");
854        assert_eq!(get_field::<String>(&value, 1), "a");
855    }
856
857    // -- I/O error propagation tests --
858
859    /// Mock sandbox that returns I/O errors for all reads.
860    struct IoErrorSandbox {
861        kind: std::io::ErrorKind,
862    }
863
864    impl SandboxedFs for IoErrorSandbox {
865        fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
866            Err(ReadError::Io {
867                path: relative.to_path_buf(),
868                source: std::io::Error::new(self.kind, "mock I/O error"),
869            })
870        }
871    }
872
873    #[test]
874    fn fs_resolver_propagates_io_error() {
875        let resolver = FsResolver::with_sandbox(IoErrorSandbox {
876            kind: std::io::ErrorKind::PermissionDenied,
877        });
878
879        let lua = mlua::Lua::new();
880        let msg = must_resolve_err(&resolver, &lua, "anything");
881        assert!(
882            msg.contains("I/O error"),
883            "expected ResolveError::Io message: {msg}"
884        );
885    }
886
887    #[test]
888    fn asset_resolver_propagates_io_error() {
889        let resolver = AssetResolver::with_sandbox(IoErrorSandbox {
890            kind: std::io::ErrorKind::PermissionDenied,
891        })
892        .parser("json", json_parser());
893
894        let lua = mlua::Lua::new();
895        let msg = must_resolve_err(&resolver, &lua, "data.json");
896        assert!(
897            msg.contains("I/O error"),
898            "expected ResolveError::Io message: {msg}"
899        );
900    }
901
902    // -- PrefixResolver tests --
903
904    #[test]
905    fn prefix_strips_and_delegates() {
906        let inner = MemoryResolver::new().add("helper", "return 'from helper'");
907        let resolver = PrefixResolver::new("sm", inner);
908
909        let lua = mlua::Lua::new();
910        let value = must_resolve(&resolver, &lua, "sm.helper");
911        let s: String = lua.unpack(value).expect("unpack String failed");
912        assert_eq!(s, "from helper");
913    }
914
915    #[test]
916    fn prefix_non_matching_returns_none() {
917        let inner = MemoryResolver::new().add("helper", "return 'x'");
918        let resolver = PrefixResolver::new("sm", inner);
919
920        let lua = mlua::Lua::new();
921        assert!(resolver.resolve(&lua, "other.helper").is_none());
922    }
923
924    #[test]
925    fn prefix_exact_match_without_separator_returns_none() {
926        let inner = MemoryResolver::new().add("helper", "return 'x'");
927        let resolver = PrefixResolver::new("sm", inner);
928
929        let lua = mlua::Lua::new();
930        // "sm" alone is outside PrefixResolver's scope (handled by outer Resolver)
931        assert!(resolver.resolve(&lua, "sm").is_none());
932    }
933
934    #[test]
935    fn prefix_no_substring_match() {
936        let inner = MemoryResolver::new().add("tp", "return 'x'");
937        let resolver = PrefixResolver::new("sm", inner);
938
939        let lua = mlua::Lua::new();
940        // "smtp" is not "sm" + "." + "tp"
941        assert!(resolver.resolve(&lua, "smtp").is_none());
942    }
943
944    #[test]
945    fn prefix_custom_separator() {
946        let inner = MemoryResolver::new().add("http", "return 'http mod'");
947        let resolver = PrefixResolver::new("@std", inner).with_separator('/');
948
949        let lua = mlua::Lua::new();
950        let value = must_resolve(&resolver, &lua, "@std/http");
951        let s: String = lua.unpack(value).expect("unpack String failed");
952        assert_eq!(s, "http mod");
953    }
954
955    #[test]
956    fn prefix_nested_name() {
957        let mock = MockSandbox::new().with_file("ui/button.lua", "return { name = 'button' }");
958        let resolver = PrefixResolver::new("game", FsResolver::with_sandbox(mock));
959
960        let lua = mlua::Lua::new();
961        // "game.ui.button" -> strip "game." -> "ui.button" -> FsResolver: ui/button.lua
962        let value = must_resolve(&resolver, &lua, "game.ui.button");
963        assert_eq!(get_field::<String>(&value, "name"), "button");
964    }
965
966    #[test]
967    fn prefix_inner_miss_returns_none() {
968        let inner = MemoryResolver::new().add("helper", "return 'x'");
969        let resolver = PrefixResolver::new("sm", inner);
970
971        let lua = mlua::Lua::new();
972        // "sm.nonexistent" -> strip -> "nonexistent" -> inner returns None -> None
973        assert!(resolver.resolve(&lua, "sm.nonexistent").is_none());
974    }
975
976    // -- VendoredResolver tests --
977
978    /// Write a one-pkg lockfile TOML to `dir/mlua-pkg.lock` and return the path.
979    fn write_vendored_lockfile(dir: &Path, pkg_name: &str, entry: &str) -> PathBuf {
980        let content = format!(
981            "version = 1\n\n[[pkg]]\nname = {pkg_name:?}\nsource = \"git+https://github.com/x/{pkg_name}\"\nsha = \"{sha}\"\nentry = {entry:?}\n",
982            sha = "a".repeat(40),
983        );
984        let path = dir.join("mlua-pkg.lock");
985        std::fs::write(&path, content).unwrap();
986        path
987    }
988
989    // TC 1: lockfile not found → MissingLockfile
990    #[test]
991    fn vendored_from_lockfile_missing_returns_error() {
992        let tmp = tempfile::tempdir().unwrap();
993        let lockfile = tmp.path().join("nonexistent.lock");
994        let vendored = tmp.path().join("vendored");
995
996        let err = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap_err();
997        assert!(
998            matches!(err, crate::PkgError::MissingLockfile { .. }),
999            "expected MissingLockfile, got: {err}"
1000        );
1001    }
1002
1003    // TC 2: lockfile 1 pkg + vendored/foo (dir with init.lua) → require("foo") resolves
1004    #[test]
1005    fn vendored_resolver_single_pkg_init_lua() {
1006        let tmp = tempfile::tempdir().unwrap();
1007        let vendored = tmp.path().join("vendored");
1008        let lockfile = write_vendored_lockfile(tmp.path(), "foo", ".");
1009
1010        // Simulate `mlua-pkg install`: create vendored/foo/ with init.lua
1011        let foo_dir = vendored.join("foo");
1012        std::fs::create_dir_all(&foo_dir).unwrap();
1013        std::fs::write(foo_dir.join("init.lua"), "return { pkg = 'foo' }").unwrap();
1014
1015        let resolver = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap();
1016        let lua = mlua::Lua::new();
1017
1018        let value = must_resolve(&resolver, &lua, "foo");
1019        assert_eq!(get_field::<String>(&value, "pkg"), "foo");
1020    }
1021
1022    // TC 3: require("foo.bar") → vendored/foo/bar.lua (FsResolver dot-to-path)
1023    #[test]
1024    fn vendored_resolver_dot_to_path_sub_module() {
1025        let tmp = tempfile::tempdir().unwrap();
1026        let vendored = tmp.path().join("vendored");
1027        let lockfile = write_vendored_lockfile(tmp.path(), "foo", ".");
1028
1029        let foo_dir = vendored.join("foo");
1030        std::fs::create_dir_all(&foo_dir).unwrap();
1031        std::fs::write(foo_dir.join("bar.lua"), "return { sub = 'bar' }").unwrap();
1032
1033        let resolver = VendoredResolver::from_lockfile(&lockfile, &vendored).unwrap();
1034        let lua = mlua::Lua::new();
1035
1036        // "foo.bar" → FsResolver: dot-separator → foo/bar.lua
1037        let value = must_resolve(&resolver, &lua, "foo.bar");
1038        assert_eq!(get_field::<String>(&value, "sub"), "bar");
1039    }
1040
1041    // TC 4: VendoredResolver::new low-level constructor works with existing dir
1042    #[test]
1043    fn vendored_new_with_existing_dir() {
1044        let tmp = tempfile::tempdir().unwrap();
1045        let vendored = tmp.path().join("vendored");
1046        std::fs::create_dir_all(&vendored).unwrap();
1047
1048        // Write a pkg file directly into vendored root
1049        std::fs::write(vendored.join("mypkg.lua"), "return 'direct'").unwrap();
1050
1051        let resolver = VendoredResolver::new(&vendored).unwrap();
1052        let lua = mlua::Lua::new();
1053
1054        let value = must_resolve(&resolver, &lua, "mypkg");
1055        let s: String = lua.unpack(value).unwrap();
1056        assert_eq!(s, "direct");
1057    }
1058
1059    // TC 5: VendoredResolver is Send + Sync (compile-time check)
1060    #[test]
1061    fn vendored_resolver_is_send_sync() {
1062        fn assert_send_sync<T: Send + Sync>() {}
1063        assert_send_sync::<VendoredResolver>();
1064    }
1065}