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 config::Config,
14 fs,
15 lua_installation::{self, LuaInstallation},
16 lua_rockspec::LuaModule,
17 operations::{InstallProject, InstallProjectError},
18 project::{project_toml::LocalProjectTomlValidationError, Project},
19 rockspec::Rockspec,
20 target::c_dylib_extension,
21 tree::{self, InstallTree},
22};
23
24#[derive(Builder)]
33#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
34pub struct DistProjectBin<'a, T>
35where
36 T: InstallTree,
37{
38 project: &'a Project,
40
41 config: &'a Config,
42
43 tree: &'a T,
45
46 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] tree::Error),
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] lua_installation::Error),
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::Error),
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#[derive(Debug)]
107struct InstalledFiles {
108 src: Vec<(LuaModule, PathBuf)>,
110 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.spec).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.spec);
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 Ok(InstalledFiles {
220 src: lua_sources.into_iter().unique().collect(),
221 lib: native_modules.into_iter().unique().collect(),
222 })
223}
224
225fn 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
233fn 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
245async fn generate_c_source(
247 entrypoint_module: &str,
248 files: &InstalledFiles,
249 lib_root: &Path,
250) -> Result<String, fs::Error> {
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 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 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 #[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 #[cfg(any(target_os = "linux", feature = "impure_tests"))]
541 use super::*;
542 #[cfg(target_os = "linux")]
543 use crate::package;
544
545 #[cfg(target_os = "linux")]
546 use assert_fs::prelude::{PathChild, PathCreateDir};
547 #[cfg(any(target_os = "linux", feature = "impure_tests"))]
548 use assert_fs::TempDir;
549 #[cfg(feature = "impure_tests")]
550 use {crate::lua_installation::detect_installed_lua_version, assert_fs::fixture::PathCopy};
551
552 #[cfg(any(target_os = "linux", feature = "impure_tests"))]
553 use crate::{config::ConfigBuilder, lua_version::LuaVersion, tree::FlatDistTree};
554 #[cfg(target_os = "linux")]
555 use crate::{
556 fs,
557 lockfile::{LockConstraint, LockedPackage, LockedPackageHashes},
558 remote_package_source::RemotePackageSource,
559 };
560
561 #[cfg(target_os = "linux")]
562 fn mk_dummy_package(spec: package::Spec) -> LockedPackage {
563 let hashes = LockedPackageHashes {
564 rockspec: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
565 .parse()
566 .unwrap(),
567 source: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
568 .parse()
569 .unwrap(),
570 };
571 LockedPackage::from(
572 &spec,
573 LockConstraint::Unconstrained,
574 RemotePackageSource::Test,
575 None,
576 hashes,
577 )
578 }
579
580 #[cfg(target_os = "linux")]
581 #[tokio::test]
582 async fn test_collect_installed_files() {
583 let staging = TempDir::new().unwrap();
584 let config = ConfigBuilder::new()
585 .unwrap()
586 .lua_version(Some(LuaVersion::Lua51))
587 .build()
588 .unwrap();
589 let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
590
591 let pkg_a = mk_dummy_package(package::Spec::new("foo".into(), "1.0.0-1".parse().unwrap()));
592 tree.prepare(&pkg_a.spec).unwrap();
593 let layout_a = tree.layout_for(&pkg_a.spec);
594 staging
595 .child(layout_a.src.strip_prefix(staging.path()).unwrap())
596 .create_dir_all()
597 .unwrap();
598 fs::tokio::write(layout_a.src.join("foo.lua"), "return {}")
599 .await
600 .unwrap();
601
602 let pkg_b = mk_dummy_package(package::Spec::new("bar".into(), "2.0.0-1".parse().unwrap()));
603 tree.prepare(&pkg_b.spec).unwrap();
604 let layout_b = tree.layout_for(&pkg_b.spec);
605 staging
606 .child(layout_b.src.strip_prefix(staging.path()).unwrap())
607 .create_dir_all()
608 .unwrap();
609 fs::tokio::write(layout_b.src.join("bar.lua"), "return {}")
610 .await
611 .unwrap();
612 staging
613 .child(layout_b.lib.strip_prefix(staging.path()).unwrap())
614 .create_dir_all()
615 .unwrap();
616 fs::tokio::write(
617 layout_b.lib.join(format!("bar.{}", c_dylib_extension())),
618 "",
619 )
620 .await
621 .unwrap();
622
623 {
624 let lockfile = tree.lockfile().unwrap();
625 let mut lockfile = lockfile.write_guard();
626 lockfile.add_entrypoint(&pkg_a);
627 lockfile.add_entrypoint(&pkg_b);
628 }
629
630 let tree = FlatDistTree::new(staging.to_path_buf(), LuaVersion::Lua51, &config).unwrap();
631 let files = collect_installed_files(&tree).unwrap();
632
633 assert_eq!(files.src.len(), 2);
634 assert!(files
635 .src
636 .iter()
637 .all(|(_, p)| p.extension().is_some_and(|e| e == "lua")));
638 assert_eq!(files.lib.len(), 1);
639 assert!(files.lib[0]
640 .extension()
641 .is_some_and(|e| e == c_dylib_extension()));
642 }
643
644 #[cfg(feature = "impure_tests")]
645 #[tokio::test]
646 async fn test_collect_installed_files_from_sample_project() {
647 let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
648 .join("resources/test/sample-projects/only-src/");
649 let project_dir = TempDir::new().unwrap();
650 project_dir.copy_from(&sample, &["**"]).unwrap();
651
652 let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
653 let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
654 let config = ConfigBuilder::new()
655 .unwrap()
656 .lua_version(lua_version)
657 .build()
658 .unwrap();
659
660 let staging = TempDir::new().unwrap();
661 let tree = FlatDistTree::new(
662 staging.to_path_buf(),
663 config.lua_version().cloned().unwrap(),
664 &config,
665 )
666 .unwrap();
667
668 InstallProject::new()
669 .project(&project)
670 .config(&config)
671 .tree(&tree)
672 .build()
673 .await
674 .unwrap();
675
676 let files = collect_installed_files(&tree).unwrap();
677
678 let module_keys: Vec<&str> = files.src.iter().map(|(m, _)| m.as_str()).collect();
679
680 assert!(
681 module_keys.contains(&"main"),
682 "expected 'main' in {module_keys:?}"
683 );
684 assert!(
685 module_keys.contains(&"foo"),
686 "expected 'foo' in {module_keys:?}"
687 );
688 }
689
690 #[cfg(feature = "impure_tests")]
691 #[tokio::test]
692 async fn test_dist_bin_from_lua_source_compiles_and_runs() {
693 test_dist_bin_compiles_and_runs("resources/test/sample-projects/only-src/", "1").await
694 }
695
696 #[cfg(feature = "impure_tests")]
697 #[tokio::test]
698 #[cfg(target_os = "linux")]
699 async fn test_dist_bin_from_c_source_compiles_and_runs() {
700 test_dist_bin_compiles_and_runs("resources/test/sample-projects/c-src/", "OK").await
701 }
702
703 #[cfg(feature = "impure_tests")]
704 async fn test_dist_bin_compiles_and_runs(sample_project_path: &str, expected_output: &str) {
705 let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(sample_project_path);
706 let project_dir = TempDir::new().unwrap();
707 project_dir.copy_from(&sample, &["**"]).unwrap();
708
709 let project = Project::from_exact(project_dir.path()).unwrap().unwrap();
710 let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
711 let config = ConfigBuilder::new()
712 .unwrap()
713 .lua_version(lua_version)
714 .build()
715 .unwrap();
716
717 let staging = TempDir::new().unwrap();
718 let tree = FlatDistTree::new(
719 staging.to_path_buf(),
720 config.lua_version().cloned().unwrap(),
721 &config,
722 )
723 .unwrap();
724
725 let out_dir = TempDir::new().unwrap();
726 let binary = out_dir.path().join(if cfg!(target_env = "msvc") {
727 "sample-project.exe"
728 } else {
729 "sample-project"
730 });
731
732 DistProjectBin::new()
733 .project(&project)
734 .config(&config)
735 .tree(&tree)
736 .output(binary.clone())
737 .compile()
738 .await
739 .unwrap();
740
741 assert!(binary.is_file(), "binary not produced");
742
743 drop(staging);
744
745 let out = tokio::process::Command::new(&binary)
746 .output()
747 .await
748 .unwrap();
749
750 assert!(out.status.success(), "binary exited non-zero:\n{:?}", out);
751 assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), expected_output);
752 }
753}