Skip to main content

lux_lib/operations/
dist_bin.rs

1use std::{
2    io,
3    path::{Path, PathBuf},
4    process::Stdio,
5};
6
7use bon::Builder;
8use itertools::Itertools;
9use thiserror::Error;
10use walkdir::WalkDir;
11
12use crate::{
13    build::utils::c_dylib_extension,
14    config::Config,
15    fs,
16    lua_installation::{LuaInstallation, LuaInstallationError},
17    lua_rockspec::LuaModule,
18    operations::{InstallProject, InstallProjectError},
19    project::{project_toml::LocalProjectTomlValidationError, Project},
20    rockspec::Rockspec,
21    tree::{InstallTree, TreeError},
22};
23
24/// Compile a Lux project and all its dependencies into a single
25/// static binary that does not require a Lua installation.
26///
27/// Projects with native Lua modules additionally ship those modules as
28/// shared libraries copied next to the binary, which resolves them via its
29/// rpath at runtime.
30///
31/// Based on [luastatic](https://github.com/ers35/luastatic)
32#[derive(Builder)]
33#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
34pub struct DistProjectBin<'a, T>
35where
36    T: InstallTree,
37{
38    /// The project to compile.
39    project: &'a Project,
40
41    config: &'a Config,
42
43    /// Tree in which to install the project before compiling.
44    tree: &'a T,
45
46    /// Destination path for the compiled binary.
47    /// Defaults to `<cwd>/<package>[.exe]`.
48    output: Option<PathBuf>,
49}
50
51use miette::Diagnostic;
52#[derive(Error, Debug, Diagnostic)]
53#[non_exhaustive]
54pub enum DistProjectBinError {
55    #[error("error installing project")]
56    #[diagnostic(forward(0))]
57    InstallProject(#[from] Box<InstallProjectError>),
58    #[error(transparent)]
59    #[diagnostic(transparent)]
60    LocalProjectTomlValidation(#[from] LocalProjectTomlValidationError),
61    #[error(transparent)]
62    #[diagnostic(transparent)]
63    Tree(#[from] TreeError),
64    #[cfg(not(target_os = "linux"))]
65    #[error(
66        r#"Lua binary libraries are only linkable on Linux.
67Cannot link the following binaries:
68{0}"#
69    )]
70    CannotLinkBinaryLibs(String),
71    #[error(transparent)]
72    #[diagnostic(transparent)]
73    LuaInstallation(#[from] LuaInstallationError),
74    #[error(transparent)]
75    CC(#[from] cc::Error),
76    #[error(transparent)]
77    Io(#[from] io::Error),
78    #[error(transparent)]
79    #[diagnostic(transparent)]
80    Fs(#[from] fs::FsError),
81    #[error("C compilation failed (exit {status}):\nstdout: {stdout}\nstderr: {stderr}")]
82    CompilationFailed {
83        status: std::process::ExitStatus,
84        stdout: String,
85        stderr: String,
86    },
87}
88
89impl From<InstallProjectError> for DistProjectBinError {
90    fn from(source: InstallProjectError) -> Self {
91        Self::InstallProject(Box::new(source))
92    }
93}
94
95impl<T, State> DistProjectBinBuilder<'_, T, State>
96where
97    T: InstallTree + Sync + Send + Clone + 'static,
98    State: dist_project_bin_builder::State + dist_project_bin_builder::IsComplete,
99{
100    pub async fn compile(self) -> Result<PathBuf, DistProjectBinError> {
101        do_dist_project_bin(self._build()).await
102    }
103}
104
105/// Files collected from an installed tree for binary compilation.
106#[derive(Debug)]
107struct InstalledFiles {
108    /// Lua source files (.lua) to embed.
109    src: Vec<(LuaModule, PathBuf)>,
110    /// Native Lua modules (.so/.dll/.dylib) to link.
111    lib: Vec<PathBuf>,
112}
113
114#[tracing::instrument(name = "Distributing binary", skip_all)]
115
116async fn do_dist_project_bin<T>(args: DistProjectBin<'_, T>) -> Result<PathBuf, DistProjectBinError>
117where
118    T: InstallTree + Sync + Send + Clone + 'static,
119{
120    let package = InstallProject::new()
121        .project(args.project)
122        .config(args.config)
123        .tree(args.tree)
124        .build()
125        .await?;
126
127    let files = collect_installed_files(args.tree)?;
128
129    let project_toml = args.project.toml().into_local()?;
130    let pkg_name = project_toml.package().to_string();
131
132    let entrypoint_module = project_toml
133        .run()
134        .and_then(|r| r.current_platform().args.as_ref())
135        .map(|args| args.first().as_str())
136        .map(entrypoint_stem)
137        .unwrap_or_else(|| pkg_name.clone());
138
139    let lua = LuaInstallation::new_from_config(args.config).await?;
140
141    let output = match args.output {
142        Some(output) => output,
143        None => {
144            let mut p = PathBuf::from(&pkg_name);
145            if cfg!(target_env = "msvc") {
146                p.set_extension("exe");
147            }
148            p
149        }
150    };
151    let output = if output.is_absolute() {
152        output
153    } else {
154        std::env::current_dir()?.join(output)
155    };
156
157    let lib_root = args.tree.layout_for(&package).lib;
158    let c_src = generate_c_source(&entrypoint_module, &files, &lib_root).await?;
159
160    let work_dir = fs::tempfile::tempdir()?;
161    let c_path = work_dir.path().join(format!("{pkg_name}.static.c"));
162    fs::tokio::write(&c_path, &c_src).await?;
163
164    compile_binary(&c_path, &output, &lua, &files.lib, &work_dir, args.config).await?;
165
166    Ok(output)
167}
168
169#[allow(clippy::result_large_err)]
170fn collect_installed_files(tree: &impl InstallTree) -> Result<InstalledFiles, DistProjectBinError> {
171    let mut lua_sources = Vec::new();
172    let mut native_modules = Vec::new();
173    let c_dylib_ext = c_dylib_extension();
174
175    for package in tree.list()?.values().flatten() {
176        let layout = tree.layout_for(package);
177        if layout.src.is_dir() {
178            let src_canonical = layout.src.canonicalize().unwrap_or(layout.src.clone());
179            for path in WalkDir::new(&src_canonical)
180                .into_iter()
181                .filter_map(|e| e.ok())
182                .map(|e| e.into_path())
183                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "lua"))
184            {
185                let rel = path
186                    .strip_prefix(&src_canonical)
187                    .unwrap_or(&path)
188                    .with_extension("");
189                if let Ok(module) = LuaModule::from_pathbuf(rel) {
190                    lua_sources.push((module, path));
191                }
192            }
193        }
194
195        if layout.lib.is_dir() {
196            let lib_canononical = layout.lib.canonicalize().unwrap_or(layout.lib.clone());
197            for path in WalkDir::new(&lib_canononical)
198                .into_iter()
199                .filter_map(|e| e.ok())
200                .map(|e| e.into_path())
201                .filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == c_dylib_ext))
202            {
203                native_modules.push(path);
204            }
205        }
206    }
207    #[cfg(not(target_os = "linux"))]
208    if !native_modules.is_empty() {
209        return Err(DistProjectBinError::CannotLinkBinaryLibs(
210            native_modules
211                .iter()
212                .unique()
213                .map(|p| p.to_string_lossy())
214                .join("\n"),
215        ));
216    }
217
218    // NOTE: A `FlatDistTree` can produce duplicates, as all modules share the same `src`.
219    Ok(InstalledFiles {
220        src: lua_sources.into_iter().unique().collect(),
221        lib: native_modules.into_iter().unique().collect(),
222    })
223}
224
225/// Derive a module stem from a run-spec arg like `"src/main.lua"` -> `"main"`.
226fn entrypoint_stem(arg: &str) -> String {
227    PathBuf::from(arg)
228        .file_stem()
229        .map(|s| s.to_string_lossy().into_owned())
230        .unwrap_or_else(|| arg.to_owned())
231}
232
233/// Derive the Lua module name from an installed lib path.
234/// e.g. `<tree>/lib/foo/bar.so` -> `("foo.bar", "foo_bar")`.
235fn module_names(lib_root: &Path, path: &Path) -> (String, String) {
236    let rel = path.strip_prefix(lib_root).unwrap_or(path);
237    let dotpath = rel
238        .with_extension("")
239        .to_string_lossy()
240        .replace(std::path::MAIN_SEPARATOR, ".");
241    let underscore = dotpath.replace(['.', '-'], "_");
242    (dotpath, underscore)
243}
244
245/// Generate a C source file embedding all Lua sources.
246async fn generate_c_source(
247    entrypoint_module: &str,
248    files: &InstalledFiles,
249    lib_root: &Path,
250) -> Result<String, fs::FsError> {
251    let mut out = String::from(C_PREAMBLE);
252
253    out.push_str("#ifdef __cplusplus\nextern \"C\" {\n#endif\n");
254    for path in &files.lib {
255        let (_, underscore) = module_names(lib_root, path);
256        out.push_str(&format!("int luaopen_{underscore}(lua_State *L);\n"));
257    }
258    out.push_str("#ifdef __cplusplus\n}\n#endif\n\n");
259
260    for (i, (_, path)) in files.src.iter().enumerate() {
261        let bytes = fs::tokio::read(path).await?;
262        let hex = bytes_to_hex(&bytes);
263        out.push_str(&format!(
264            "static const unsigned char lua_src_{i}[] = {{{hex}}};\n"
265        ));
266    }
267
268    let loader_with_entrypoint = format!(
269        "{LUA_LOADER_SOURCE}local func = lua_loader(\"{entrypoint_module}\")\n\
270         if type(func) == \"function\" then\n\
271         \tfunc(unpack(arg))\n\
272         else\n\
273         \terror(func, 0)\n\
274         end\n"
275    );
276    let loader_hex = bytes_to_hex(loader_with_entrypoint.as_bytes());
277    out.push_str(&format!(
278        "static const unsigned char lua_loader_program[] = {{{loader_hex}}};\n\n"
279    ));
280
281    out.push_str("int main(int argc, char *argv[]) {\n");
282    out.push_str("  lua_State *L = luaL_newstate();\n");
283    out.push_str("  luaL_openlibs(L);\n");
284    out.push_str("  createargtable(L, argv, argc, 0);\n\n");
285
286    out.push_str(&format!(
287        "  if (luaL_loadbuffer(L, (const char*)lua_loader_program, sizeof(lua_loader_program), \"{entrypoint_module}\") != LUA_OK) {{\n"
288    ));
289    out.push_str("    fprintf(stderr, \"luaL_loadbuffer: %s\\n\", lua_tostring(L, -1));\n");
290    out.push_str("    lua_close(L); return 1;\n  }\n\n");
291
292    out.push_str("  /* lua_bundle */\n  lua_newtable(L);\n");
293
294    for (i, (module, _)) in files.src.iter().enumerate() {
295        out.push_str(&format!(
296            "  lua_pushlstring(L, (const char*)lua_src_{i}, sizeof(lua_src_{i}));\n"
297        ));
298        out.push_str(&format!("  lua_setfield(L, -2, \"{module}\");\n"));
299    }
300
301    for path in &files.lib {
302        let (dotpath, underscore) = module_names(lib_root, path);
303        out.push_str(&format!("  lua_pushcfunction(L, luaopen_{underscore});\n"));
304        out.push_str(&format!("  lua_setfield(L, -2, \"{dotpath}\");\n"));
305    }
306
307    out.push_str("\n  if (docall(L, 1, LUA_MULTRET)) {\n");
308    out.push_str("    const char *msg = lua_tostring(L, 1);\n");
309    out.push_str("    if (msg) fprintf(stderr, \"%s\\n\", msg);\n");
310    out.push_str("    lua_close(L); return 1;\n  }\n");
311    out.push_str("  lua_close(L);\n  return 0;\n}\n");
312
313    Ok(out)
314}
315
316fn bytes_to_hex(bytes: &[u8]) -> String {
317    bytes
318        .iter()
319        .map(|b| format!("0x{b:02x}"))
320        .collect::<Vec<_>>()
321        .join(", ")
322}
323
324async fn compile_binary(
325    c_path: &Path,
326    output: &Path,
327    lua: &LuaInstallation,
328    native_modules: &[PathBuf],
329    work_dir: &tempfile::TempDir,
330    config: &Config,
331) -> Result<(), DistProjectBinError> {
332    let mut build = cc::Build::new();
333    let host = target_lexicon::Triple::host().to_string();
334
335    let intermediate_dir = fs::tempfile::tempdir()?;
336    build
337        .cargo_output(false)
338        .cargo_metadata(false)
339        .cargo_warnings(false)
340        .warnings(config.verbose())
341        .host(&host)
342        .target(&host)
343        .opt_level(config.build_profile().opt_level())
344        .out_dir(&intermediate_dir);
345
346    let compiler = build.try_get_compiler()?;
347
348    let is_msvc = compiler.is_like_msvc();
349    // Suppress all warnings
350    if is_msvc {
351        build.flag("-W0");
352    } else {
353        build.flag("-w");
354    }
355
356    let mut cmd: tokio::process::Command = compiler.to_command().into();
357    cmd.current_dir(work_dir.path());
358    cmd.arg(c_path);
359
360    for include in lua.includes() {
361        cmd.arg(format!("-I{}", include.display()));
362    }
363
364    // Native modules have to be linked dynamically.
365    // We copy them next to the output binary and link them by basename,
366    // so the binary can find them via its rpath at runtime.
367    let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
368    if !native_modules.is_empty() {
369        fs::tokio::create_dir_all(output_dir).await?;
370        cmd.arg(format!("-L{}", output_dir.display()));
371        for module in native_modules {
372            let file_name = module
373                .file_name()
374                .ok_or_else(|| {
375                    io::Error::new(
376                        io::ErrorKind::InvalidInput,
377                        format!("native module path '{}' has no file name", module.display()),
378                    )
379                })?
380                .to_string_lossy();
381            fs::tokio::copy(module, output_dir.join(&*file_name)).await?;
382            cmd.arg(format!("-l:{file_name}"));
383        }
384    }
385    cmd.arg("-o").arg(output);
386    cmd.args(lua.lib_link_args(&compiler));
387
388    #[cfg(not(target_env = "msvc"))]
389    {
390        cmd.arg("-rdynamic");
391        cmd.arg("-lm");
392        // Link with libdl because liblua was built with support loading
393        // shared objects and the operating system depends on it.
394        #[cfg(target_family = "unix")]
395        cmd.arg("-ldl");
396    }
397
398    #[cfg(target_family = "unix")]
399    if !native_modules.is_empty() {
400        cmd.arg(if cfg!(target_os = "macos") {
401            "-Wl,-rpath,@loader_path"
402        } else {
403            "-Wl,-rpath,$ORIGIN"
404        });
405    }
406
407    let out = cmd
408        .stdout(Stdio::piped())
409        .stderr(Stdio::piped())
410        .output()
411        .await?;
412
413    if !out.status.success() {
414        return Err(DistProjectBinError::CompilationFailed {
415            status: out.status,
416            stdout: String::from_utf8_lossy(&out.stdout).into(),
417            stderr: String::from_utf8_lossy(&out.stderr).into(),
418        });
419    }
420
421    Ok(())
422}
423
424const C_PREAMBLE: &str = r#"
425#ifdef __cplusplus
426extern "C" {
427#endif
428#include <lauxlib.h>
429#include <lua.h>
430#include <lualib.h>
431#ifdef __cplusplus
432}
433#endif
434#include <signal.h>
435#include <stdio.h>
436#include <stdlib.h>
437#include <string.h>
438
439#if LUA_VERSION_NUM == 501
440  #define LUA_OK 0
441#endif
442
443static lua_State *globalL = NULL;
444
445static void lstop(lua_State *L, lua_Debug *ar) {
446  (void)ar;
447  lua_sethook(L, NULL, 0, 0);
448  luaL_error(L, "interrupted!");
449}
450
451static void laction(int i) {
452  signal(i, SIG_DFL);
453  lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1);
454}
455
456static void createargtable(lua_State *L, char **argv, int argc, int script) {
457  int i, narg;
458  if (script == argc) script = 0;
459  narg = argc - (script + 1);
460  lua_createtable(L, narg, script + 1);
461  for (i = 0; i < argc; i++) {
462    lua_pushstring(L, argv[i]);
463    lua_rawseti(L, -2, i - script);
464  }
465  lua_setglobal(L, "arg");
466}
467
468static int msghandler(lua_State *L) {
469  const char *msg = lua_tostring(L, 1);
470  if (msg == NULL) {
471    if (luaL_callmeta(L, 1, "__tostring") && lua_type(L, -1) == LUA_TSTRING)
472      return 1;
473    msg = lua_pushfstring(L, "(error object is a %s value)", luaL_typename(L, 1));
474  }
475  lua_getglobal(L, "debug");
476  lua_getfield(L, -1, "traceback");
477  lua_remove(L, -2);
478  lua_pushstring(L, msg);
479  lua_remove(L, -3);
480  lua_pushinteger(L, 2);
481  lua_call(L, 2, 1);
482  return 1;
483}
484
485static int docall(lua_State *L, int narg, int nres) {
486  int status;
487  int base = lua_gettop(L) - narg;
488  lua_pushcfunction(L, msghandler);
489  lua_insert(L, base);
490  globalL = L;
491  signal(SIGINT, laction);
492  status = lua_pcall(L, narg, nres, base);
493  signal(SIGINT, SIG_DFL);
494  lua_remove(L, base);
495  return status;
496}
497
498"#;
499
500const LUA_LOADER_SOURCE: &str = r#"local args = {...}
501local lua_bundle = args[1]
502
503local function load_string(str, name)
504	if _VERSION == "Lua 5.1" then
505		return loadstring(str, name)
506	else
507		return load(str, name)
508	end
509end
510
511local function lua_loader(name)
512	local separator = package.config:sub(1, 1)
513	name = name:gsub(separator, ".")
514	local mod = lua_bundle[name] or lua_bundle[name .. ".init"]
515	if mod then
516		if type(mod) == "string" then
517			local chunk, errstr = load_string(mod, name)
518			if chunk then
519				return chunk
520			else
521				error(
522					("error loading module '%s' from static Lua bundle:\n\t%s"):format(name, errstr),
523					0
524				)
525			end
526		elseif type(mod) == "function" then
527			return mod
528		end
529	else
530		return ("\n\tno module '%s' in static Lua bundle"):format(name)
531	end
532end
533table.insert(package.loaders or package.searchers, 2, lua_loader)
534
535local unpack = unpack or table.unpack
536"#;
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541
542    use assert_fs::fixture::PathCopy;
543    #[cfg(target_os = "linux")]
544    use assert_fs::prelude::{PathChild, PathCreateDir};
545    use assert_fs::TempDir;
546
547    use crate::lua_installation::detect_installed_lua_version;
548    use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
549    #[cfg(target_os = "linux")]
550    use crate::{
551        fs,
552        lockfile::{LocalPackage, LocalPackageHashes, LockConstraint},
553        package::PackageSpec,
554        remote_package_source::RemotePackageSource,
555        rockspec::RockBinaries,
556    };
557
558    #[cfg(target_os = "linux")]
559    fn mk_dummy_package(spec: PackageSpec) -> LocalPackage {
560        let hashes = LocalPackageHashes {
561            rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
562                .parse()
563                .unwrap(),
564            source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
565                .parse()
566                .unwrap(),
567        };
568        LocalPackage::from(
569            &spec,
570            LockConstraint::Unconstrained,
571            RockBinaries::default(),
572            RemotePackageSource::Test,
573            None,
574            hashes,
575        )
576    }
577
578    #[cfg(target_os = "linux")]
579    #[tokio::test]
580    async fn test_collect_installed_files() {
581        let staging = TempDir::new().unwrap();
582        let config = ConfigBuilder::new()
583            .unwrap()
584            .lua_version(Some(LuaVersion::Lua51))
585            .build()
586            .unwrap();
587        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
588
589        let pkg_a = mk_dummy_package(PackageSpec::new("foo".into(), "1.0.0-1".parse().unwrap()));
590        tree.prepare(&pkg_a).unwrap();
591        let layout_a = tree.layout_for(&pkg_a);
592        staging
593            .child(layout_a.src.strip_prefix(staging.path()).unwrap())
594            .create_dir_all()
595            .unwrap();
596        fs::tokio::write(layout_a.src.join("foo.lua"), "return {}")
597            .await
598            .unwrap();
599
600        let pkg_b = mk_dummy_package(PackageSpec::new("bar".into(), "2.0.0-1".parse().unwrap()));
601        tree.prepare(&pkg_b).unwrap();
602        let layout_b = tree.layout_for(&pkg_b);
603        staging
604            .child(layout_b.src.strip_prefix(staging.path()).unwrap())
605            .create_dir_all()
606            .unwrap();
607        fs::tokio::write(layout_b.src.join("bar.lua"), "return {}")
608            .await
609            .unwrap();
610        staging
611            .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
612            .create_dir_all()
613            .unwrap();
614        fs::tokio::write(
615            layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
616            "",
617        )
618        .await
619        .unwrap();
620
621        {
622            let lockfile = tree.lockfile().unwrap();
623            let mut lockfile = lockfile.write_guard();
624            lockfile.add_entrypoint(&pkg_a);
625            lockfile.add_entrypoint(&pkg_b);
626        }
627
628        let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
629        let files = collect_installed_files(&tree).unwrap();
630
631        assert_eq!(files.src.len(), 2);
632        assert!(files
633            .src
634            .iter()
635            .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
636        assert_eq!(files.lib.len(), 1);
637        assert!(files.lib[0]
638            .extension()
639            .is_some_and(|e| e == c_dylib_extension()));
640    }
641
642    #[tokio::test]
643    async fn test_collect_installed_files_from_sample_project() {
644        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
645            .join("resources/test/sample-projects/only-src/");
646        let project_dir = TempDir::new().unwrap();
647        project_dir.copy_from(&sample, &["**"]).unwrap();
648
649        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
650        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
651        let config = ConfigBuilder::new()
652            .unwrap()
653            .lua_version(lua_version)
654            .build()
655            .unwrap();
656
657        let staging = TempDir::new().unwrap();
658        let tree = FlatDistTree::new(
659            staging.to_path_buf(),
660            config.lua_version().cloned().unwrap(),
661            &config,
662        )
663        .unwrap();
664
665        InstallProject::new()
666            .project(&project)
667            .config(&config)
668            .tree(&tree)
669            .build()
670            .await
671            .unwrap();
672
673        let files = collect_installed_files(&tree).unwrap();
674
675        let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
676
677        assert!(
678            module_keys.contains(&"main"),
679            "expected 'main' in {module_keys:?}"
680        );
681        assert!(
682            module_keys.contains(&"foo"),
683            "expected 'foo' in {module_keys:?}"
684        );
685    }
686
687    #[tokio::test]
688    async fn test_dist_bin_from_lua_source_compiles_and_runs() {
689        test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
690    }
691
692    #[tokio::test]
693    #[cfg(target_os = "linux")]
694    async fn test_dist_bin_from_c_source_compiles_and_runs() {
695        test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
696    }
697
698    async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
699        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
700        let project_dir = TempDir::new().unwrap();
701        project_dir.copy_from(&sample, &["**"]).unwrap();
702
703        let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
704        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
705        let config = ConfigBuilder::new()
706            .unwrap()
707            .lua_version(lua_version)
708            .build()
709            .unwrap();
710
711        let staging = TempDir::new().unwrap();
712        let tree = FlatDistTree::new(
713            staging.to_path_buf(),
714            config.lua_version().cloned().unwrap(),
715            &config,
716        )
717        .unwrap();
718
719        let out_dir = TempDir::new().unwrap();
720        let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
721            "sample-project.exe"
722        } else {
723            "sample-project"
724        });
725
726        DistProjectBin::new()
727            .project(&project)
728            .config(&config)
729            .tree(&tree)
730            .output(binary.clone())
731            .compile()
732            .await
733            .unwrap();
734
735        assert!(binary.is_file(), "binary not produced");
736
737        drop(staging);
738
739        let out = tokio::process::Command::new(&binary)
740            .output()
741            .await
742            .unwrap();
743
744        assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
745        assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
746    }
747}