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};
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#[cfg(test)]
511mod tests {
512    use super::*;
513    use crate::sandbox::{FileContent, ReadError};
514
515    /// Asserts that `resolve()` returns `Some(Ok(value))` and returns the value.
516    fn must_resolve(resolver: &dyn Resolver, lua: &Lua, name: &str) -> Value {
517        match resolver.resolve(lua, name) {
518            Some(Ok(v)) => v,
519            Some(Err(e)) => panic!("resolve('{name}') returned Err: {e}"),
520            None => panic!("resolve('{name}') returned None"),
521        }
522    }
523
524    /// Asserts that `resolve()` returns `Some(Err(_))` and returns the error message.
525    fn must_resolve_err(resolver: &dyn Resolver, lua: &Lua, name: &str) -> String {
526        match resolver.resolve(lua, name) {
527            Some(Err(e)) => e.to_string(),
528            Some(Ok(_)) => panic!("resolve('{name}') returned Ok, expected Err"),
529            None => panic!("resolve('{name}') returned None, expected Some(Err)"),
530        }
531    }
532
533    /// Extracts a table field from a Value.
534    fn get_field<V: mlua::FromLua>(value: &Value, key: impl mlua::IntoLua) -> V {
535        value
536            .as_table()
537            .expect("expected Table value")
538            .get::<V>(key)
539            .expect("table field access failed")
540    }
541
542    /// Mock sandbox for I/O-free testing.
543    struct MockSandbox {
544        files: HashMap<PathBuf, String>,
545    }
546
547    impl MockSandbox {
548        fn new() -> Self {
549            Self {
550                files: HashMap::new(),
551            }
552        }
553
554        fn with_file(mut self, path: impl Into<PathBuf>, content: &str) -> Self {
555            self.files.insert(path.into(), content.to_owned());
556            self
557        }
558    }
559
560    impl SandboxedFs for MockSandbox {
561        fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
562            match self.files.get(relative) {
563                Some(content) => Ok(Some(FileContent {
564                    content: content.clone(),
565                    resolved_path: relative.to_path_buf(),
566                })),
567                None => Ok(None),
568            }
569        }
570    }
571
572    #[test]
573    fn fs_resolver_dot_to_path_conversion() {
574        let mock = MockSandbox::new().with_file("lib/helper.lua", "return { name = 'mocked' }");
575        let resolver = FsResolver::with_sandbox(mock);
576
577        let lua = mlua::Lua::new();
578        let value = must_resolve(&resolver, &lua, "lib.helper");
579        assert_eq!(get_field::<String>(&value, "name"), "mocked");
580    }
581
582    #[test]
583    fn fs_resolver_init_lua_fallback() {
584        let mock = MockSandbox::new().with_file("mypkg/init.lua", "return { from_init = true }");
585        let resolver = FsResolver::with_sandbox(mock);
586
587        let lua = mlua::Lua::new();
588        let value = must_resolve(&resolver, &lua, "mypkg");
589        assert!(get_field::<bool>(&value, "from_init"));
590    }
591
592    #[test]
593    fn fs_resolver_miss_returns_none() {
594        let mock = MockSandbox::new();
595        let resolver = FsResolver::with_sandbox(mock);
596
597        let lua = mlua::Lua::new();
598        assert!(resolver.resolve(&lua, "nonexistent").is_none());
599    }
600
601    #[test]
602    fn fs_resolver_custom_extension() {
603        let mock = MockSandbox::new().with_file("lib/helper.luau", "return { name = 'luau_mod' }");
604        let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
605
606        let lua = mlua::Lua::new();
607        let value = must_resolve(&resolver, &lua, "lib.helper");
608        assert_eq!(get_field::<String>(&value, "name"), "luau_mod");
609    }
610
611    #[test]
612    fn fs_resolver_custom_init_name() {
613        let mock = MockSandbox::new().with_file("mypkg/mod.lua", "return { from_mod = true }");
614        let resolver = FsResolver::with_sandbox(mock).with_init_name("mod");
615
616        let lua = mlua::Lua::new();
617        let value = must_resolve(&resolver, &lua, "mypkg");
618        assert!(get_field::<bool>(&value, "from_mod"));
619    }
620
621    #[test]
622    fn fs_resolver_custom_extension_ignores_default() {
623        // .lua is not resolved when .luau is configured
624        let mock = MockSandbox::new().with_file("helper.lua", "return 'wrong'");
625        let resolver = FsResolver::with_sandbox(mock).with_extension("luau");
626
627        let lua = mlua::Lua::new();
628        assert!(resolver.resolve(&lua, "helper").is_none());
629    }
630
631    #[test]
632    fn fs_resolver_with_convention_luau() {
633        let mock = MockSandbox::new()
634            .with_file("lib/helper.luau", "return { name = 'luau' }")
635            .with_file("pkg/init.luau", "return { pkg = true }");
636        let resolver = FsResolver::with_sandbox(mock).with_convention(crate::LuaConvention::LUAU);
637
638        let lua = mlua::Lua::new();
639
640        let value = must_resolve(&resolver, &lua, "lib.helper");
641        assert_eq!(get_field::<String>(&value, "name"), "luau");
642
643        let value = must_resolve(&resolver, &lua, "pkg");
644        assert!(get_field::<bool>(&value, "pkg"));
645    }
646
647    #[test]
648    fn convention_then_override() {
649        // Partial override via individual method after with_convention
650        let mock = MockSandbox::new().with_file("pkg/mod.luau", "return { ok = true }");
651        let resolver = FsResolver::with_sandbox(mock)
652            .with_convention(crate::LuaConvention::LUAU)
653            .with_init_name("mod");
654
655        let lua = mlua::Lua::new();
656        let value = must_resolve(&resolver, &lua, "pkg");
657        assert!(get_field::<bool>(&value, "ok"));
658    }
659
660    #[test]
661    fn lua_convention_default_is_lua54() {
662        assert_eq!(crate::LuaConvention::default(), crate::LuaConvention::LUA54);
663    }
664
665    #[test]
666    fn asset_resolver_json_to_table() {
667        let mock = MockSandbox::new().with_file("config.json", r#"{"port": 8080}"#);
668        let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
669
670        let lua = mlua::Lua::new();
671        let value = must_resolve(&resolver, &lua, "config.json");
672        assert_eq!(get_field::<i32>(&value, "port"), 8080);
673    }
674
675    #[test]
676    fn asset_resolver_text_to_string() {
677        let mock = MockSandbox::new().with_file("query.sql", "SELECT 1");
678        let resolver = AssetResolver::with_sandbox(mock).parser("sql", text_parser());
679
680        let lua = mlua::Lua::new();
681        let value = must_resolve(&resolver, &lua, "query.sql");
682        let s: String = lua.unpack(value).expect("unpack String failed");
683        assert_eq!(s, "SELECT 1");
684    }
685
686    #[test]
687    fn asset_resolver_unregistered_ext_returns_none() {
688        let mock = MockSandbox::new().with_file("data.xyz", "stuff");
689        let resolver = AssetResolver::with_sandbox(mock).parser("json", json_parser());
690
691        let lua = mlua::Lua::new();
692        assert!(resolver.resolve(&lua, "data.xyz").is_none());
693    }
694
695    #[test]
696    fn asset_resolver_no_ext_returns_none() {
697        let mock = MockSandbox::new();
698        let resolver = AssetResolver::with_sandbox(mock);
699
700        let lua = mlua::Lua::new();
701        assert!(resolver.resolve(&lua, "noext").is_none());
702    }
703
704    #[test]
705    fn asset_resolver_custom_parser() {
706        let mock = MockSandbox::new().with_file("data.csv", "a,b,c");
707        let resolver = AssetResolver::with_sandbox(mock).parser("csv", |lua, content| {
708            let t = lua.create_table()?;
709            for (i, field) in content.split(',').enumerate() {
710                t.set(i + 1, lua.create_string(field)?)?;
711            }
712            Ok(Value::Table(t))
713        });
714
715        let lua = mlua::Lua::new();
716        let value = must_resolve(&resolver, &lua, "data.csv");
717        assert_eq!(get_field::<String>(&value, 1), "a");
718    }
719
720    // -- I/O error propagation tests --
721
722    /// Mock sandbox that returns I/O errors for all reads.
723    struct IoErrorSandbox {
724        kind: std::io::ErrorKind,
725    }
726
727    impl SandboxedFs for IoErrorSandbox {
728        fn read(&self, relative: &Path) -> std::result::Result<Option<FileContent>, ReadError> {
729            Err(ReadError::Io {
730                path: relative.to_path_buf(),
731                source: std::io::Error::new(self.kind, "mock I/O error"),
732            })
733        }
734    }
735
736    #[test]
737    fn fs_resolver_propagates_io_error() {
738        let resolver = FsResolver::with_sandbox(IoErrorSandbox {
739            kind: std::io::ErrorKind::PermissionDenied,
740        });
741
742        let lua = mlua::Lua::new();
743        let msg = must_resolve_err(&resolver, &lua, "anything");
744        assert!(
745            msg.contains("I/O error"),
746            "expected ResolveError::Io message: {msg}"
747        );
748    }
749
750    #[test]
751    fn asset_resolver_propagates_io_error() {
752        let resolver = AssetResolver::with_sandbox(IoErrorSandbox {
753            kind: std::io::ErrorKind::PermissionDenied,
754        })
755        .parser("json", json_parser());
756
757        let lua = mlua::Lua::new();
758        let msg = must_resolve_err(&resolver, &lua, "data.json");
759        assert!(
760            msg.contains("I/O error"),
761            "expected ResolveError::Io message: {msg}"
762        );
763    }
764
765    // -- PrefixResolver tests --
766
767    #[test]
768    fn prefix_strips_and_delegates() {
769        let inner = MemoryResolver::new().add("helper", "return 'from helper'");
770        let resolver = PrefixResolver::new("sm", inner);
771
772        let lua = mlua::Lua::new();
773        let value = must_resolve(&resolver, &lua, "sm.helper");
774        let s: String = lua.unpack(value).expect("unpack String failed");
775        assert_eq!(s, "from helper");
776    }
777
778    #[test]
779    fn prefix_non_matching_returns_none() {
780        let inner = MemoryResolver::new().add("helper", "return 'x'");
781        let resolver = PrefixResolver::new("sm", inner);
782
783        let lua = mlua::Lua::new();
784        assert!(resolver.resolve(&lua, "other.helper").is_none());
785    }
786
787    #[test]
788    fn prefix_exact_match_without_separator_returns_none() {
789        let inner = MemoryResolver::new().add("helper", "return 'x'");
790        let resolver = PrefixResolver::new("sm", inner);
791
792        let lua = mlua::Lua::new();
793        // "sm" alone is outside PrefixResolver's scope (handled by outer Resolver)
794        assert!(resolver.resolve(&lua, "sm").is_none());
795    }
796
797    #[test]
798    fn prefix_no_substring_match() {
799        let inner = MemoryResolver::new().add("tp", "return 'x'");
800        let resolver = PrefixResolver::new("sm", inner);
801
802        let lua = mlua::Lua::new();
803        // "smtp" is not "sm" + "." + "tp"
804        assert!(resolver.resolve(&lua, "smtp").is_none());
805    }
806
807    #[test]
808    fn prefix_custom_separator() {
809        let inner = MemoryResolver::new().add("http", "return 'http mod'");
810        let resolver = PrefixResolver::new("@std", inner).with_separator('/');
811
812        let lua = mlua::Lua::new();
813        let value = must_resolve(&resolver, &lua, "@std/http");
814        let s: String = lua.unpack(value).expect("unpack String failed");
815        assert_eq!(s, "http mod");
816    }
817
818    #[test]
819    fn prefix_nested_name() {
820        let mock = MockSandbox::new().with_file("ui/button.lua", "return { name = 'button' }");
821        let resolver = PrefixResolver::new("game", FsResolver::with_sandbox(mock));
822
823        let lua = mlua::Lua::new();
824        // "game.ui.button" -> strip "game." -> "ui.button" -> FsResolver: ui/button.lua
825        let value = must_resolve(&resolver, &lua, "game.ui.button");
826        assert_eq!(get_field::<String>(&value, "name"), "button");
827    }
828
829    #[test]
830    fn prefix_inner_miss_returns_none() {
831        let inner = MemoryResolver::new().add("helper", "return 'x'");
832        let resolver = PrefixResolver::new("sm", inner);
833
834        let lua = mlua::Lua::new();
835        // "sm.nonexistent" -> strip -> "nonexistent" -> inner returns None -> None
836        assert!(resolver.resolve(&lua, "sm.nonexistent").is_none());
837    }
838}