From 583457d4ea25629dd6f1d7531d77a94038e72952 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Mon, 11 May 2026 13:47:43 +0800 Subject: [PATCH] feat: add fs module and pkgindex custom module loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add C++ fs module (register_fs_module) with symlink, readlink, is_symlink, exists, entries, files, dirs, copy_file, mkdir_p, remove, remove_all, basename, dirname - Extend import() to support xim.pkgindex.* — loads .lua modules from /libs/ directory with caching - Add pkgindex_dir to ExecutionContext for hook-time module loading - Set _PKGINDEX_DIR global before L_dofile for top-level imports - Add tests/main.cpp (gtest entry point, was missing) - Add 8 new tests for fs module and pkgindex module loading --- src/lua-stdlib/prelude.lua | 28 +++++ src/xpkg-executor.cppm | 239 +++++++++++++++++++++++++++++++++++++ src/xpkg-lua-stdlib.cppm | 28 +++++ tests/main.cpp | 6 + tests/test_executor.cpp | 231 +++++++++++++++++++++++++++++++++++ 5 files changed, 532 insertions(+) create mode 100644 tests/main.cpp diff --git a/src/lua-stdlib/prelude.lua b/src/lua-stdlib/prelude.lua index 88b7ff9..3af3f5c 100644 --- a/src/lua-stdlib/prelude.lua +++ b/src/lua-stdlib/prelude.lua @@ -15,6 +15,34 @@ function import(mod_path) _G[name] = _LIBXPKG_MODULES[name] return _LIBXPKG_MODULES[name] end + -- pkgindex custom modules: import("xim.pkgindex.") + -- Loads /libs/.lua from the package index repository. + -- Modules are cached in _LIBXPKG_MODULES after first load. + local pkgindex_mod = mod_path:match("xim%.pkgindex%.(.+)") + if pkgindex_mod then + -- Check cache first + if _LIBXPKG_MODULES[pkgindex_mod] then + _G[pkgindex_mod] = _LIBXPKG_MODULES[pkgindex_mod] + return _LIBXPKG_MODULES[pkgindex_mod] + end + -- _PKGINDEX_DIR is set early (before L_dofile) so top-level imports work; + -- _RUNTIME.pkgindex_dir is set later by inject_context for hook calls. + local pkgindex_dir = _PKGINDEX_DIR + or (_RUNTIME and _RUNTIME.pkgindex_dir) + if pkgindex_dir and pkgindex_dir ~= "" then + local mod_file = pkgindex_dir .. "/libs/" .. pkgindex_mod .. ".lua" + local loader = loadfile(mod_file) + if loader then + local ok, mod = pcall(loader) + if ok and mod then + _LIBXPKG_MODULES[pkgindex_mod] = mod + _G[pkgindex_mod] = mod + return mod + end + end + end + end + -- Stub for unknown imports (platform, base.runtime, etc.) local log = _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] if log then log.debug("unknown module '%s', returning stub", mod_path) end diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index b063978..a64354b 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -41,6 +41,7 @@ struct ExecutionContext { // The current package's own exports (rule 2 in the predicate trigger). DepExport self_exports; std::string subos_sysrootdir; + std::string pkgindex_dir; // package index repo root (for custom module loading) }; struct HookResult { @@ -266,6 +267,229 @@ void register_os_funcs(lua::State* L) { lua::pop(L, 1); // pop os table } +// Register C++-backed fs module into _LIBXPKG_MODULES["fs"]. +// Provides filesystem primitives that are missing from the os.* layer: +// symlink creation/reading, file/dir enumeration, single-file copy, etc. +// All functions use std::filesystem — no shell commands. +void register_fs_module(lua::State* L) { + lua::newtable(L); // the fs module table + + // fs.symlink(src, dst) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* src = lua::tostring(L, 1); + const char* dst = lua::tostring(L, 2); + if (!src || !dst) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + fs::path sp(src); + if (fs::is_directory(sp, ec)) + fs::create_directory_symlink(sp, fs::path(dst), ec); + else + fs::create_symlink(sp, fs::path(dst), ec); + lua::pushboolean(L, ec ? 0 : 1); + return 1; + }); + lua::setfield(L, -2, "symlink"); + + // fs.readlink(path) -> string | nil + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushnil(L); return 1; } + std::error_code ec; + auto target = fs::read_symlink(fs::path(p), ec); + if (ec) { lua::pushnil(L); return 1; } + lua::pushstring(L, target.string().c_str()); + return 1; + }); + lua::setfield(L, -2, "readlink"); + + // fs.is_symlink(path) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + lua::pushboolean(L, fs::is_symlink(fs::path(p), ec) ? 1 : 0); + return 1; + }); + lua::setfield(L, -2, "is_symlink"); + + // fs.exists(path) -> bool (follows symlinks) + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + lua::pushboolean(L, fs::exists(fs::path(p), ec) ? 1 : 0); + return 1; + }); + lua::setfield(L, -2, "exists"); + + // fs.is_directory(path) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + lua::pushboolean(L, fs::is_directory(fs::path(p), ec) ? 1 : 0); + return 1; + }); + lua::setfield(L, -2, "is_directory"); + + // fs.is_file(path) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + lua::pushboolean(L, fs::is_regular_file(fs::path(p), ec) ? 1 : 0); + return 1; + }); + lua::setfield(L, -2, "is_file"); + + // fs.mkdir_p(path) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + fs::create_directories(fs::path(p), ec); + lua::pushboolean(L, ec ? 0 : 1); + return 1; + }); + lua::setfield(L, -2, "mkdir_p"); + + // fs.remove(path) -> bool (single file or empty dir) + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + lua::pushboolean(L, fs::remove(fs::path(p), ec) ? 1 : 0); + return 1; + }); + lua::setfield(L, -2, "remove"); + + // fs.remove_all(path) -> bool + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + fs::remove_all(fs::path(p), ec); + lua::pushboolean(L, 1); + return 1; + }); + lua::setfield(L, -2, "remove_all"); + + // fs.copy_file(src, dst) -> bool (single file, overwrite) + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* src = lua::tostring(L, 1); + const char* dst = lua::tostring(L, 2); + if (!src || !dst) { lua::pushboolean(L, 0); return 1; } + std::error_code ec; + fs::copy_file(fs::path(src), fs::path(dst), + fs::copy_options::overwrite_existing, ec); + lua::pushboolean(L, ec ? 0 : 1); + return 1; + }); + lua::setfield(L, -2, "copy_file"); + + // fs.entries(dir) -> table of {path, name, type} + // type: "file", "directory", "symlink", "other" + // Non-recursive. Returns full paths. + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* dir = lua::tostring(L, 1); + lua::newtable(L); + if (!dir) return 1; + std::error_code ec; + fs::path dp(dir); + if (!fs::is_directory(dp, ec)) return 1; + int idx = 1; + for (auto& entry : fs::directory_iterator(dp, ec)) { + lua::newtable(L); + lua::pushstring(L, entry.path().string().c_str()); + lua::setfield(L, -2, "path"); + lua::pushstring(L, entry.path().filename().string().c_str()); + lua::setfield(L, -2, "name"); + const char* etype = "other"; + if (entry.is_symlink(ec)) etype = "symlink"; + else if (entry.is_regular_file(ec)) etype = "file"; + else if (entry.is_directory(ec)) etype = "directory"; + lua::pushstring(L, etype); + lua::setfield(L, -2, "type"); + lua::rawseti(L, -2, idx++); + } + return 1; + }); + lua::setfield(L, -2, "entries"); + + // fs.files(dir, recursive?) -> table of file paths + // recursive: optional bool, default false + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* dir = lua::tostring(L, 1); + bool recursive = lua::toboolean(L, 2); + lua::newtable(L); + if (!dir) return 1; + std::error_code ec; + fs::path dp(dir); + if (!fs::is_directory(dp, ec)) return 1; + int idx = 1; + if (recursive) { + for (auto& entry : fs::recursive_directory_iterator(dp, ec)) { + if (entry.is_regular_file(ec)) { + lua::pushstring(L, entry.path().string().c_str()); + lua::rawseti(L, -2, idx++); + } + } + } else { + for (auto& entry : fs::directory_iterator(dp, ec)) { + if (entry.is_regular_file(ec)) { + lua::pushstring(L, entry.path().string().c_str()); + lua::rawseti(L, -2, idx++); + } + } + } + return 1; + }); + lua::setfield(L, -2, "files"); + + // fs.dirs(dir) -> table of subdirectory paths (non-recursive) + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* dir = lua::tostring(L, 1); + lua::newtable(L); + if (!dir) return 1; + std::error_code ec; + fs::path dp(dir); + if (!fs::is_directory(dp, ec)) return 1; + int idx = 1; + for (auto& entry : fs::directory_iterator(dp, ec)) { + if (entry.is_directory(ec)) { + lua::pushstring(L, entry.path().string().c_str()); + lua::rawseti(L, -2, idx++); + } + } + return 1; + }); + lua::setfield(L, -2, "dirs"); + + // fs.basename(path) -> string + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushstring(L, ""); return 1; } + lua::pushstring(L, fs::path(p).filename().string().c_str()); + return 1; + }); + lua::setfield(L, -2, "basename"); + + // fs.dirname(path) -> string + lua::pushcfunction(L, [](lua::State* L) -> int { + const char* p = lua::tostring(L, 1); + if (!p) { lua::pushstring(L, ""); return 1; } + lua::pushstring(L, fs::path(p).parent_path().string().c_str()); + return 1; + }); + lua::setfield(L, -2, "dirname"); + + // Store into _LIBXPKG_MODULES["fs"] + lua::getglobal(L, "_LIBXPKG_MODULES"); + lua::insert(L, -2); // stack: [modules, fs_table] + lua::setfield(L, -2, "fs"); // modules["fs"] = fs_table + lua::pop(L, 1); // pop modules +} + // Load all xim.libxpkg.* modules into _LIBXPKG_MODULES table, then run prelude bool load_stdlib(lua::State* L, std::string& err_out) { // Create empty _LIBXPKG_MODULES table @@ -324,6 +548,9 @@ bool load_stdlib(lua::State* L, std::string& err_out) { // Override shell-based os.* with C++ std::filesystem implementations register_os_funcs(L); + // Register C++-backed fs module (symlink, entries, etc.) + register_fs_module(L); + return true; } @@ -342,6 +569,7 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) set_string_field(L, "bin_dir", ctx.bin_dir.string()); set_string_field(L, "project_data_dir", ctx.project_data_dir.string()); set_string_field(L, "subos_sysrootdir", ctx.subos_sysrootdir); + set_string_field(L, "pkgindex_dir", ctx.pkgindex_dir); auto push_string_array = [&](const std::vector& v, const char* field) { lua::newtable(L); @@ -679,6 +907,17 @@ create_executor(const fs::path& pkg_path) { return std::unexpected(err); } + // Derive pkgindex root from pkg_path for top-level import("xim.pkgindex.*"). + // Package files live at /pkgs//.lua — 3 levels up. + { + auto pkgindex = pkg_path.parent_path().parent_path().parent_path(); + std::error_code ec; + if (fs::is_directory(pkgindex / "libs", ec)) { + lua::pushstring(L, pkgindex.string().c_str()); + lua::setglobal(L, "_PKGINDEX_DIR"); + } + } + if (lua::L_dofile(L, pkg_path.string().c_str()) != lua::OK) { err = lua::tostring(L, -1); lua::close(L); diff --git a/src/xpkg-lua-stdlib.cppm b/src/xpkg-lua-stdlib.cppm index d1fa2ed..def0db5 100644 --- a/src/xpkg-lua-stdlib.cppm +++ b/src/xpkg-lua-stdlib.cppm @@ -23,6 +23,34 @@ function import(mod_path) _G[name] = _LIBXPKG_MODULES[name] return _LIBXPKG_MODULES[name] end + -- pkgindex custom modules: import("xim.pkgindex.") + -- Loads /libs/.lua from the package index repository. + -- Modules are cached in _LIBXPKG_MODULES after first load. + local pkgindex_mod = mod_path:match("xim%.pkgindex%.(.+)") + if pkgindex_mod then + -- Check cache first + if _LIBXPKG_MODULES[pkgindex_mod] then + _G[pkgindex_mod] = _LIBXPKG_MODULES[pkgindex_mod] + return _LIBXPKG_MODULES[pkgindex_mod] + end + -- _PKGINDEX_DIR is set early (before L_dofile) so top-level imports work; + -- _RUNTIME.pkgindex_dir is set later by inject_context for hook calls. + local pkgindex_dir = _PKGINDEX_DIR + or (_RUNTIME and _RUNTIME.pkgindex_dir) + if pkgindex_dir and pkgindex_dir ~= "" then + local mod_file = pkgindex_dir .. "/libs/" .. pkgindex_mod .. ".lua" + local loader = loadfile(mod_file) + if loader then + local ok, mod = pcall(loader) + if ok and mod then + _LIBXPKG_MODULES[pkgindex_mod] = mod + _G[pkgindex_mod] = mod + return mod + end + end + end + end + -- Stub for unknown imports (platform, base.runtime, etc.) local log = _LIBXPKG_MODULES and _LIBXPKG_MODULES["log"] if log then log.debug("unknown module '%s', returning stub", mod_path) end diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 0000000..5ebbc76 --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,6 @@ +#include + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/test_executor.cpp b/tests/test_executor.cpp index 8e5989a..610792c 100644 --- a/tests/test_executor.cpp +++ b/tests/test_executor.cpp @@ -842,6 +842,237 @@ TEST(ExecutorTest, RunHook_DoesNotImplicitlyStamp) { fs::remove_all(temp); } +// ---- fs module tests ---- + +TEST(ExecutorTest, FsModule_SymlinkAndReadlink) { +#ifdef _WIN32 + GTEST_SKIP() << "Symlink tests are POSIX-specific"; +#endif + const fs::path temp = make_temp_dir("libxpkg-fs-symlink-"); + write_text(temp / "target.txt", "hello"); + + auto pkg = temp / "fs_symlink.lua"; + write_text(pkg, std::string( + "package = { name = \"fs_symlink\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "import('xim.libxpkg.fs')\n" + "function xpkg_main(dir)\n" + " local src = dir .. '/target.txt'\n" + " local dst = dir .. '/link.txt'\n" + " if not fs.symlink(src, dst) then error('symlink failed') end\n" + " if not fs.is_symlink(dst) then error('is_symlink failed') end\n" + " local target = fs.readlink(dst)\n" + " if target ~= src then error('readlink mismatch: ' .. tostring(target)) end\n" + "end\n")); + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.args = {temp.string()}; + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + EXPECT_TRUE(fs::is_symlink(temp / "link.txt")); + fs::remove_all(temp); +} + +TEST(ExecutorTest, FsModule_Entries) { + const fs::path temp = make_temp_dir("libxpkg-fs-entries-"); + write_text(temp / "a.txt", "a"); + write_text(temp / "b.txt", "b"); + fs::create_directories(temp / "subdir"); + + auto pkg = temp / "fs_entries.lua"; + write_text(pkg, std::string( + "package = { name = \"fs_entries\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "import('xim.libxpkg.fs')\n" + "function xpkg_main(dir)\n" + " local entries = fs.entries(dir)\n" + " if #entries < 3 then error('expected >= 3 entries, got ' .. #entries) end\n" + " local has_file, has_dir = false, false\n" + " for _, e in ipairs(entries) do\n" + " if e.name == 'a.txt' and e.type == 'file' then has_file = true end\n" + " if e.name == 'subdir' and e.type == 'directory' then has_dir = true end\n" + " end\n" + " if not has_file then error('missing file entry') end\n" + " if not has_dir then error('missing dir entry') end\n" + "end\n")); + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.args = {temp.string()}; + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + +TEST(ExecutorTest, FsModule_FilesRecursive) { + const fs::path temp = make_temp_dir("libxpkg-fs-files-"); + // Use a subdirectory to avoid counting the test pkg .lua file + const fs::path data = temp / "data"; + fs::create_directories(data / "a" / "b"); + write_text(data / "top.txt", "t"); + write_text(data / "a" / "mid.txt", "m"); + write_text(data / "a" / "b" / "deep.txt", "d"); + + auto pkg = temp / "fs_files.lua"; + write_text(pkg, std::string( + "package = { name = \"fs_files\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "import('xim.libxpkg.fs')\n" + "function xpkg_main(dir)\n" + " local flat = fs.files(dir)\n" + " local deep = fs.files(dir, true)\n" + " if #flat ~= 1 then error('flat expected 1, got ' .. #flat) end\n" + " if #deep ~= 3 then error('deep expected 3, got ' .. #deep) end\n" + "end\n")); + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.args = {data.string()}; + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + +TEST(ExecutorTest, FsModule_CopyFile) { + const fs::path temp = make_temp_dir("libxpkg-fs-copyfile-"); + write_text(temp / "src.txt", "content"); + + auto pkg = temp / "fs_cp.lua"; + write_text(pkg, std::string( + "package = { name = \"fs_cp\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "import('xim.libxpkg.fs')\n" + "function xpkg_main(dir)\n" + " if not fs.copy_file(dir..'/src.txt', dir..'/dst.txt') then error('copy_file failed') end\n" + " if not fs.is_file(dir..'/dst.txt') then error('dst missing') end\n" + "end\n")); + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.args = {temp.string()}; + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + EXPECT_TRUE(fs::exists(temp / "dst.txt")); + fs::remove_all(temp); +} + +TEST(ExecutorTest, FsModule_MkdirP_Remove) { + const fs::path temp = make_temp_dir("libxpkg-fs-mkdir-"); + + auto pkg = temp / "fs_mkdir.lua"; + write_text(pkg, std::string( + "package = { name = \"fs_mkdir\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "import('xim.libxpkg.fs')\n" + "function xpkg_main(dir)\n" + " local nested = dir .. '/a/b/c'\n" + " if not fs.mkdir_p(nested) then error('mkdir_p failed') end\n" + " if not fs.is_directory(nested) then error('not a dir') end\n" + " if not fs.exists(nested) then error('not exists') end\n" + " fs.remove_all(dir .. '/a')\n" + " if fs.exists(dir .. '/a') then error('remove_all failed') end\n" + "end\n")); + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.args = {temp.string()}; + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + +// ---- pkgindex custom module loading tests ---- + +TEST(ExecutorTest, PkgindexCustomModule_LoadsFromLibsDir) { + const fs::path temp = make_temp_dir("libxpkg-pkgindex-mod-"); + // Create pkgindex structure: /libs/mymod.lua, /pkgs/t/test.lua + fs::create_directories(temp / "pkgindex" / "libs"); + fs::create_directories(temp / "pkgindex" / "pkgs" / "t"); + + // Write custom module + write_text(temp / "pkgindex" / "libs" / "mymod.lua", + "local M = {}\n" + "function M.greet() return 'hello from mymod' end\n" + "return M\n"); + + // Write package that uses it (import inside xpkg_main so _RUNTIME is set) + auto pkg = temp / "pkgindex" / "pkgs" / "t" / "test.lua"; + write_text(pkg, std::string( + "package = { name = \"test\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function xpkg_main()\n" + " import('xim.pkgindex.mymod')\n" + " local msg = mymod.greet()\n" + " if msg ~= 'hello from mymod' then error('wrong: ' .. tostring(msg)) end\n" + "end\n")); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.pkgindex_dir = (temp / "pkgindex").string(); + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + +TEST(ExecutorTest, PkgindexCustomModule_CachesAcrossCalls) { + const fs::path temp = make_temp_dir("libxpkg-pkgindex-cache-"); + fs::create_directories(temp / "pkgindex" / "libs"); + fs::create_directories(temp / "pkgindex" / "pkgs" / "t"); + + // Module with a counter to verify it's only loaded once + write_text(temp / "pkgindex" / "libs" / "counter.lua", + "local M = { count = 0 }\n" + "M.count = M.count + 1\n" + "return M\n"); + + auto pkg = temp / "pkgindex" / "pkgs" / "t" / "test.lua"; + write_text(pkg, std::string( + "package = { name = \"test\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function xpkg_main()\n" + " import('xim.pkgindex.counter')\n" + " local c1 = counter.count\n" + " import('xim.pkgindex.counter') -- second import should hit cache\n" + " local c2 = counter.count\n" + " if c1 ~= 1 then error('first load count should be 1, got ' .. tostring(c1)) end\n" + " if c1 ~= c2 then error('module reloaded: ' .. tostring(c1) .. ' vs ' .. tostring(c2)) end\n" + "end\n")); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.pkgindex_dir = (temp / "pkgindex").string(); + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + +TEST(ExecutorTest, PkgindexCustomModule_UnknownReturnsStub) { + const fs::path temp = make_temp_dir("libxpkg-pkgindex-unknown-"); + fs::create_directories(temp / "pkgindex" / "libs"); + fs::create_directories(temp / "pkgindex" / "pkgs" / "t"); + + auto pkg = temp / "pkgindex" / "pkgs" / "t" / "test.lua"; + write_text(pkg, std::string( + "package = { name = \"test\", xpm = { linux = { [\"0.0.1\"] = {} } } }\n" + "function xpkg_main()\n" + " import('xim.pkgindex.nonexistent')\n" + " -- Should get a stub proxy, not crash\n" + " local x = tostring(nonexistent.something)\n" + "end\n")); + + auto exec = create_executor(pkg); + ASSERT_TRUE(exec.has_value()) << exec.error(); + ExecutionContext ctx; + ctx.platform = "linux"; + ctx.pkgindex_dir = (temp / "pkgindex").string(); + auto r = exec->run_script(ctx); + EXPECT_TRUE(r.success) << r.error; + fs::remove_all(temp); +} + TEST(ExecutorTest, ApplyInstallStamp_IsIdempotent) { // Calling apply_install_stamp_if_empty twice is safe — the second // call sees a non-empty dir (the first call's stamp) and no-ops.