Skip to main content

lux_lib/build/
mod.rs

1use crate::build::backend::{BuildBackend, BuildInfo, RunBuildArgs};
2use crate::fs;
3use crate::lockfile::{LockfileError, RemotePackageSourceUrl};
4use crate::lua_installation::LuaInstallationError;
5use crate::lua_rockspec::LuaVersionError;
6use crate::lua_version::LuaVersion;
7use crate::operations::UnpackError;
8use crate::rockspec::Rockspec;
9use crate::tree::{EntryType, InstallTree, TreeError};
10use bon::Builder;
11use bytes::Bytes;
12use std::collections::HashMap;
13use std::fs::DirEntry;
14use std::path::PathBuf;
15use std::{io, path::Path};
16use tracing::Instrument;
17
18use crate::{
19    config::Config,
20    hash::HasIntegrity,
21    lockfile::{LockedPackage, LockedPackageSpec},
22    lua_installation::LuaInstallation,
23    lua_rockspec::BuildBackendSpec,
24    operations::FetchSrcError,
25};
26use builtin::BuiltinBuildError;
27use cmake::CMakeError;
28use command::CommandError;
29use external_dependency::{ExternalDependencyError, ExternalDependencyInfo};
30
31use itertools::Itertools;
32use luarocks::LuarocksBuildError;
33use make::MakeError;
34
35use miette::Diagnostic;
36use patch::{Patch, PatchError};
37use rust_binary::RustBinaryError;
38use rust_mlua::RustError;
39use source::SourceBuildError;
40use ssri::Integrity;
41use thiserror::Error;
42use treesitter_parser::TreesitterBuildError;
43use utils::{recursive_copy_dir, CompileCFilesError, InstallBinaryError};
44
45mod builtin;
46mod cmake;
47mod command;
48mod luarocks;
49#[cfg(feature = "luau-transpile")]
50mod luau;
51mod make;
52mod patch;
53mod rust_binary;
54mod rust_mlua;
55mod source;
56mod treesitter_parser;
57
58pub(crate) mod backend;
59pub(crate) mod utils;
60pub(crate) use builtin::autodetect_modules;
61
62pub mod external_dependency;
63
64#[derive(Debug)]
65pub(crate) enum RemotePackageSourceSpec {
66    RockSpec(Option<RemotePackageSourceUrl>),
67    SrcRock(SrcRockSource),
68}
69
70/// A packed .src.rock archive.
71#[derive(Debug)]
72pub(crate) struct SrcRockSource {
73    pub bytes: Bytes,
74    pub source_url: RemotePackageSourceUrl,
75}
76
77#[derive(Error, Debug, Diagnostic)]
78#[non_exhaustive]
79pub enum BuildError {
80    #[error("builtin build failed")]
81    #[diagnostic(forward(0))]
82    Builtin(#[from] BuiltinBuildError),
83    #[error("cmake build failed")]
84    #[diagnostic(forward(0))]
85    CMake(#[from] CMakeError),
86    #[error("make build failed")]
87    #[diagnostic(forward(0))]
88    Make(#[from] MakeError),
89    #[error("command build failed")]
90    #[diagnostic(forward(0))]
91    Command(#[from] CommandError),
92    #[error("rust-mlua build failed")]
93    #[diagnostic(forward(0))]
94    Rust(#[from] RustError),
95    #[error("rust-binary build failed")]
96    #[diagnostic(forward(0))]
97    RustBinary(#[from] RustBinaryError),
98    #[error("treesitter-parser build failed")]
99    #[diagnostic(forward(0))]
100    TreesitterBuild(#[from] TreesitterBuildError),
101    #[error("luarocks build failed")]
102    #[diagnostic(forward(0))]
103    LuarocksBuild(#[from] LuarocksBuildError),
104    #[error("building from rock source failed")]
105    #[diagnostic(forward(0))]
106    SourceBuild(#[from] Box<SourceBuildError>),
107    #[error("unsupported build backend for luau")]
108    #[diagnostic(help("lux can build luau packages with the following build backends: 'builtin', 'source' & 'none'"))]
109    LuauUnsupportedBuildBackend,
110    #[error("luau does not support native dependencies")]
111    #[diagnostic(help("the luau runtime cannot load C libraries. use pure-Luau packages or Lua"))]
112    LuauNativeDepsUnsupported,
113    #[error("IO operation failed")]
114    Io(#[from] io::Error),
115    #[error(transparent)]
116    #[diagnostic(transparent)]
117    Fs(#[from] fs::FsError),
118    #[error(transparent)]
119    #[diagnostic(transparent)]
120    Lockfile(#[from] LockfileError),
121    #[error(transparent)]
122    #[diagnostic(transparent)]
123    Tree(#[from] TreeError),
124
125    #[error(transparent)]
126    #[diagnostic(transparent)]
127    ExternalDependencyError(#[from] ExternalDependencyError),
128    #[error(transparent)]
129    #[diagnostic(transparent)]
130    PatchError(#[from] PatchError),
131    #[error(transparent)]
132    #[diagnostic(transparent)]
133    CompileCFiles(#[from] CompileCFilesError),
134    #[error(transparent)]
135    #[diagnostic(transparent)]
136    LuaVersion(#[from] LuaVersionError),
137    #[error(
138        r#"source integrity mismatch.
139- source: {src}
140- expected: {expected}
141- got: {actual}"#
142    )]
143    #[diagnostic(help(
144        r#"the source may have been modified or a tag may have been moved.
145check the source, then rerun the command with `--no-lock` to update the hash."#
146    ))]
147    SourceIntegrityMismatch {
148        src: String,
149        expected: Integrity,
150        actual: Integrity,
151    },
152    #[error("failed to unpack src.rock")]
153    #[diagnostic(forward(0))]
154    UnpackSrcRock(#[source] UnpackError),
155    #[error("failed to fetch rock source")]
156    #[diagnostic(forward(0))]
157    FetchSrcError(#[from] FetchSrcError),
158    #[error("failed to install binary '{file_name}'")]
159    InstallBinary {
160        file_name: String,
161        #[diagnostic_source]
162        source: InstallBinaryError,
163    },
164    #[error(transparent)]
165    #[diagnostic(transparent)]
166    LuaInstallation(#[from] LuaInstallationError),
167}
168
169impl From<SourceBuildError> for BuildError {
170    fn from(source: SourceBuildError) -> Self {
171        Self::SourceBuild(Box::new(source))
172    }
173}
174
175#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
176pub enum BuildBehaviour {
177    /// If a package with the same version is already installed, do nothing.
178    /// Otherwise, behave like [`BuildBehaviour::Conflict`].
179    Ignore,
180    /// Error if an entrypoint with the same name is already installed, even if
181    /// it is the same version.
182    Conflict,
183    /// Remove any installed entrypoint with the same name and rebuild.
184    Force,
185}
186
187#[tracing::instrument(level = "trace", skip_all)]
188async fn run_build<R: Rockspec + HasIntegrity, T: InstallTree + Sync>(
189    rockspec: &R,
190    args: RunBuildArgs<'_, T>,
191) -> Result<BuildInfo, BuildError> {
192    if args.tree.version() == &LuaVersion::Luau
193        && rockspec
194            .build()
195            .current_platform()
196            .build_backend
197            .as_ref()
198            .is_some_and(|backend| {
199                !matches!(
200                    backend,
201                    BuildBackendSpec::Builtin(_) | BuildBackendSpec::Source
202                )
203            })
204    {
205        Err(BuildError::LuauUnsupportedBuildBackend)
206    } else {
207        Ok(
208            match rockspec.build().current_platform().build_backend.to_owned() {
209                Some(BuildBackendSpec::Builtin(build_spec)) => build_spec.run(args).await?,
210                Some(BuildBackendSpec::Make(make_spec)) => make_spec.run(args).await?,
211                Some(BuildBackendSpec::CMake(cmake_spec)) => cmake_spec.run(args).await?,
212                Some(BuildBackendSpec::Command(command_spec)) => command_spec.run(args).await?,
213                Some(BuildBackendSpec::RustMlua(rust_mlua_spec)) => {
214                    rust_mlua_spec.run(args).await?
215                }
216                Some(BuildBackendSpec::RustBinary(rust_binary_spec)) => {
217                    rust_binary_spec.run(args).await?
218                }
219                Some(BuildBackendSpec::TreesitterParser(treesitter_parser_spec)) => {
220                    treesitter_parser_spec.run(args).await?
221                }
222                Some(BuildBackendSpec::LuaRock(build_backend_name)) => {
223                    luarocks::build(&build_backend_name, rockspec, args).await?
224                }
225                Some(BuildBackendSpec::Source) => source::build(args).await?,
226                None => BuildInfo::default(),
227            },
228        )
229    }
230}
231
232#[allow(clippy::too_many_arguments)]
233#[tracing::instrument(level = "trace", skip(rockspec, tree, config))]
234async fn install<R: Rockspec + HasIntegrity, T: InstallTree>(
235    rockspec: &R,
236    tree: &T,
237    package: &LockedPackageSpec,
238    lua: &LuaInstallation,
239    build_dir: &Path,
240    entry_type: &EntryType,
241    config: &Config,
242) -> Result<(), BuildError> {
243    let install_spec = &rockspec.build().current_platform().install;
244    let layout = tree.layout_for(package);
245    {
246        let span = tracing::info_span!("Copying Lua modules");
247        let _enter = span.enter();
248        for (target, source) in &install_spec.lua {
249            let _enter = span.enter();
250            let absolute_source = build_dir.join(source);
251            utils::copy_lua_to_module_path(&absolute_source, target, &layout.src)?;
252        }
253    }
254    {
255        let span = tracing::info_span!("Compiling C libraries");
256        let _enter = span.enter();
257        for (target, source) in &install_spec.lib {
258            let absolute_source = build_dir.join(source);
259            let resolved_target = layout.lib.join(target);
260            // NOTE: Some rockspecs have redundant module definitions,
261            // assuming that the linker outputs them to the build dir root.
262            // See https://github.com/lumen-oss/lux/issues/2101
263            if !resolved_target.exists() {
264                if let Some(parent) = resolved_target.parent() {
265                    fs::tokio::create_dir_all(&parent).await?;
266                }
267                fs::tokio::copy(&absolute_source, &resolved_target)
268                    .instrument(tracing::trace_span!("copying target"))
269                    .await?;
270            }
271            utils::make_writable(&resolved_target).await?;
272        }
273    }
274    if entry_type.is_entrypoint() {
275        let span = tracing::info_span!("Installing binaries");
276        let _enter = span.enter();
277        let deploy_spec = rockspec.deploy().current_platform();
278        for (target, source) in &install_spec.bin {
279            utils::install_binary(
280                &build_dir.join(source),
281                target,
282                tree,
283                lua,
284                deploy_spec,
285                config,
286            )
287            .instrument(tracing::trace_span!("installing binary"))
288            .await
289            .map_err(|err| BuildError::InstallBinary {
290                file_name: target.clone(),
291                source: err,
292            })?;
293        }
294    }
295    if !install_spec.conf.is_empty() {
296        let span = tracing::info_span!("Copying configuration files");
297        let _enter = span.enter();
298        for (target, source) in &install_spec.conf {
299            let absolute_source = build_dir.join(source);
300            let target = {
301                let joined = layout.conf.join(target);
302                match joined.file_name() {
303                    Some(_) => joined,
304                    None => joined.join(absolute_source.file_name().unwrap_or_default()),
305                }
306            };
307            if let Some(parent_dir) = target.parent() {
308                fs::tokio::create_dir_all(parent_dir)
309                    .instrument(tracing::trace_span!("creating configuration directory"))
310                    .await?;
311            }
312            fs::tokio::copy(&absolute_source, &target)
313                .instrument(tracing::trace_span!("copying configuration file"))
314                .await?;
315            utils::make_writable(&target).await?;
316        }
317    }
318    Ok(())
319}
320
321// TODO(vhyrro): break apart deployment into a separate step (once we implement transactions)
322#[derive(Builder)]
323#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
324pub(crate) struct Deploy<'a, R: Rockspec + HasIntegrity, T: InstallTree + Sync> {
325    #[builder(start_fn)]
326    rockspec: &'a R,
327    #[builder(start_fn)]
328    tree: &'a T,
329    package: LockedPackage,
330    lua: &'a LuaInstallation,
331    source_root: &'a Path,
332    entry_type: EntryType,
333    config: &'a Config,
334    behaviour: BuildBehaviour,
335}
336
337impl<R, T, State> DeployBuilder<'_, R, T, State>
338where
339    R: Rockspec + HasIntegrity,
340    T: InstallTree + Sync,
341    State: deploy_builder::State + deploy_builder::IsComplete,
342{
343    pub(crate) async fn deploy(self) -> Result<LockedPackage, BuildError> {
344        do_deploy(self._build()).await
345    }
346}
347
348#[tracing::instrument(level = "trace", skip_all, fields(package = deploy.rockspec.package().to_string()))]
349async fn do_deploy<R, T>(deploy: Deploy<'_, R, T>) -> Result<LockedPackage, BuildError>
350where
351    R: Rockspec + HasIntegrity,
352    T: InstallTree + Sync,
353{
354    let Deploy {
355        rockspec,
356        tree,
357        package,
358        lua,
359        source_root,
360        entry_type,
361        config,
362        behaviour,
363    } = deploy;
364
365    if matches!(behaviour, BuildBehaviour::Ignore | BuildBehaviour::Conflict) {
366        if let Some(existing) = tree.lockfile()?.get(&package.id()) {
367            return Ok(existing.clone());
368        }
369    }
370
371    // TODO(vhyrro): When we implement transactionality make this a Drop guard
372    // or something else.
373    tree.prepare(&package.spec)?;
374    let layout = tree.layout_for(&package.spec);
375
376    let rock_source = rockspec.source().current_platform();
377    let build_dir = resolve_source_dir(
378        source_root,
379        rock_source.unpack_dir.as_deref(),
380        &rockspec.build().current_platform().copy_directories,
381    )?;
382
383    Patch::new(&build_dir, &rockspec.build().current_platform().patches).apply()?;
384
385    let external_dependencies = rockspec
386        .external_dependencies()
387        .current_platform()
388        .iter()
389        .map(|(name, dep)| {
390            ExternalDependencyInfo::probe(name, dep, config.external_deps())
391                .map(|info| (name.clone(), info))
392        })
393        .try_collect::<_, HashMap<_, _>, _>()?;
394
395    let output = run_build(
396        rockspec,
397        RunBuildArgs::new()
398            .package(&package.spec)
399            .no_install(false)
400            .lua(lua)
401            .external_dependencies(&external_dependencies)
402            .deploy(rockspec.deploy().current_platform())
403            .config(config)
404            .tree(tree)
405            .build_dir(&build_dir)
406            .build(),
407    )
408    .await?;
409
410    let mut binaries = rockspec.binaries();
411    binaries.extend(output.binaries);
412    tree.lockfile()?
413        .write_guard()
414        .set_binaries(&package, binaries);
415
416    install(
417        rockspec,
418        tree,
419        &package.spec,
420        lua,
421        &build_dir,
422        &entry_type,
423        config,
424    )
425    .await?;
426
427    for directory in rockspec
428        .build()
429        .current_platform()
430        .copy_directories
431        .iter()
432        .filter(|dir| {
433            dir.file_name()
434                .is_some_and(|name| name != "doc" && name != "docs")
435        })
436    {
437        recursive_copy_dir(&build_dir.join(directory), &layout.etc.join(directory)).await?;
438    }
439
440    recursive_copy_doc_dir(&layout.doc, &build_dir).await?;
441
442    if let Ok(rockspec_str) = rockspec.to_lua_remote_rockspec_string() {
443        fs::sync::write(layout.rockspec_path(), rockspec_str)?;
444    }
445
446    tree.finalize(&package, entry_type)?;
447
448    Ok(package)
449}
450
451fn is_source_or_etc_dir(dir: &DirEntry, copy_dirs: &[PathBuf]) -> bool {
452    let dir_name = dir.file_name().to_string_lossy().to_string();
453    matches!(dir_name.as_str(), "lua" | "src")
454        || copy_dirs
455            .iter()
456            .any(|copy_dir_name| copy_dir_name == &PathBuf::from(&dir_name))
457}
458
459/// Resolve the directory containing a rock's sources within an extracted source archive.
460/// When the rockspec has no `source.dir`, fall back to the archive's single top-level directory.
461pub(crate) fn resolve_source_dir(
462    source_root: &Path,
463    unpack_dir: Option<&Path>,
464    copy_dirs: &[PathBuf],
465) -> Result<PathBuf, fs::FsError> {
466    if let Some(unpack_dir) = unpack_dir {
467        return Ok(source_root.join(unpack_dir));
468    }
469    // Some older/off-spec rockspecs don't specify a `source.dir`.
470    // After unpacking the archive, if
471    //
472    //   - there exist no Lua or C sources
473    //   - there exists a single subdirectory that is not a source
474    //     or etc directory
475    //
476    // we assume it's the `source.dir`.
477    // Unlike the LuaRocks implementation - which filters when fetching sources -
478    // we only infer `source.dir` if the directory name is not 'src', 'lua'
479    // or one of the `build.copy_directories`.
480    // This allows us to build local projects with only a `src` directory.
481    //
482    // LuaRocks implementation:
483    // https://github.com/luarocks/luarocks/blob/4188fdb235aca66530d274c782374cf6afba09b8/src/luarocks/fetch.tl?plain=1#L526
484    let has_lua_or_c_sources = fs::sync::read_dir(source_root)?
485        .filter_map(Result::ok)
486        .filter(|entry| entry.path().is_file())
487        .any(|entry| {
488            entry.path().extension().is_some_and(|ext| {
489                matches!(
490                    ext.to_string_lossy().to_string().as_str(),
491                    "lua" | "luau" | "c"
492                )
493            })
494        });
495    if has_lua_or_c_sources {
496        return Ok(source_root.to_path_buf());
497    }
498    let dir_entries = fs::sync::read_dir(source_root)?
499        .filter_map(Result::ok)
500        .filter(|entry| entry.path().is_dir())
501        .collect_vec();
502    if dir_entries.len() == 1 && !is_source_or_etc_dir(&dir_entries[0], copy_dirs) {
503        Ok(dir_entries[0].path())
504    } else {
505        Ok(source_root.to_path_buf())
506    }
507}
508
509#[tracing::instrument(level = "trace")]
510async fn recursive_copy_doc_dir(target_doc_dir: &Path, build_dir: &Path) -> Result<(), BuildError> {
511    let mut doc_dir = build_dir.join("doc");
512    if !doc_dir.exists() {
513        doc_dir = build_dir.join("docs");
514    }
515    recursive_copy_dir(&doc_dir, target_doc_dir).await?;
516    Ok(())
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522    use predicates::prelude::*;
523    use std::{
524        io::{Cursor, Write},
525        path::PathBuf,
526    };
527    use zip::write::SimpleFileOptions;
528
529    use assert_fs::{
530        assert::PathAssert,
531        prelude::{PathChild, PathCopy},
532    };
533
534    use is_executable::IsExecutable;
535
536    use crate::pipeline::build_local::Build;
537    use crate::{
538        config::ConfigBuilder,
539        lockfile::{LockConstraint, LockedPackageHashes, LockedPackageSpec, OptState, PinnedState},
540        lua_installation::{detect_installed_lua_version, LuaInstallation},
541        lua_rockspec::RemoteLuaRockspec,
542        lua_version::LuaVersion,
543        package::PackageSpec,
544        project::Project,
545        remote_package_source::RemotePackageSource,
546    };
547    #[cfg(feature = "impure_tests")]
548    use crate::{
549        operations::{unpack_rockspec, DownloadedPackedRockBytes},
550        tree::Tree,
551    };
552
553    #[tokio::test]
554    async fn test_builtin_build() {
555        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
556        let project_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
557            .join("resources/test/sample-projects/no-build-spec/");
558        let tree_dir = assert_fs::TempDir::new().unwrap();
559        let config = ConfigBuilder::new()
560            .unwrap()
561            .lua_version(lua_version)
562            .user_tree(Some(tree_dir.to_path_buf()))
563            .build()
564            .unwrap();
565        let build_dir = assert_fs::TempDir::new().unwrap();
566        build_dir.copy_from(&project_root, &["**"]).unwrap();
567        let tree = config
568            .user_tree(config.lua_version().cloned().unwrap())
569            .unwrap();
570        let lua_version = config.lua_version().unwrap_or(&LuaVersion::Lua51);
571        let lua = LuaInstallation::new(lua_version, &config).await.unwrap();
572        let project = Project::from_exact(&project_root).unwrap().unwrap();
573        let rockspec = project.toml().into_remote(None).unwrap();
574        let package = LockedPackage::from(
575            &PackageSpec::new(rockspec.package().clone(), rockspec.version().clone()),
576            LockConstraint::Unconstrained,
577            RemotePackageSource::Test,
578            None,
579            LockedPackageHashes {
580                rockspec: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
581                    .parse()
582                    .unwrap(),
583                source: "sha256-uU0nuZNNPgilLlLX2n2r+sSE7+N6U4DukIj3rOLvzek="
584                    .parse()
585                    .unwrap(),
586            },
587        );
588        tree.prepare(&package.spec).unwrap();
589        let src_dir = tree.layout_for(&package.spec).src;
590        run_build(
591            &rockspec,
592            RunBuildArgs::new()
593                .package(&package.spec)
594                .no_install(false)
595                .lua(&lua)
596                .external_dependencies(&HashMap::default())
597                .deploy(rockspec.deploy().current_platform())
598                .config(&config)
599                .tree(&tree)
600                .build_dir(&build_dir)
601                .build(),
602        )
603        .await
604        .unwrap();
605        let foo_dir = src_dir.join("foo");
606        assert!(foo_dir.is_dir());
607        let foo_init = foo_dir.join("init.lua");
608        assert!(foo_init.is_file());
609        assert!(std::fs::read_to_string(&foo_init)
610            .unwrap()
611            .contains("return true"));
612        let foo_bar_dir = foo_dir.join("bar");
613        assert!(foo_bar_dir.is_dir());
614        let foo_bar_init = foo_bar_dir.join("init.lua");
615        assert!(foo_bar_init.is_file());
616        assert!(std::fs::read_to_string(&foo_bar_init)
617            .unwrap()
618            .contains("return true"));
619        let foo_bar_baz = foo_bar_dir.join("baz.lua");
620        assert!(foo_bar_baz.is_file());
621        assert!(std::fs::read_to_string(&foo_bar_baz)
622            .unwrap()
623            .contains("return true"));
624        let bin_file = tree_dir
625            .child(lua_version.to_string())
626            .child("bin")
627            .child("hello");
628        bin_file.assert(predicate::path::is_file());
629        bin_file.assert(predicate::str::contains("#!/usr/bin/env bash"));
630        bin_file.assert(predicate::str::contains("echo \"Hello\""));
631    }
632
633    #[cfg(unix)]
634    #[tokio::test]
635    async fn test_install_bin_sets_executable_permissions() {
636        let rockspec = RemoteLuaRockspec::new(
637            r#"
638rockspec_format = "3.0"
639package = "test-builtin-bin"
640version = "1.0-1"
641source = { url = "https://github.com/lumen-oss/luarocks-stub" }
642build = {
643    type = "builtin",
644    install = { bin = { mytool = "mytool" } },
645}
646deploy = { wrap_bin_scripts = false }
647"#,
648        )
649        .unwrap();
650        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
651        let tree_dir = assert_fs::TempDir::new().unwrap();
652        let build_dir = assert_fs::TempDir::new().unwrap();
653        let config = ConfigBuilder::new()
654            .unwrap()
655            .lua_version(lua_version)
656            .user_tree(Some(tree_dir.to_path_buf()))
657            .build()
658            .unwrap();
659        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
660        let tree = config
661            .user_tree(config.lua_version().cloned().unwrap())
662            .unwrap();
663        let package = LockedPackageSpec::new(
664            rockspec.package(),
665            rockspec.version(),
666            LockConstraint::Unconstrained,
667            Vec::new(),
668            Vec::new(),
669            &PinnedState::default(),
670            &OptState::default(),
671        );
672        tree.prepare(&package).unwrap();
673        crate::fs::tokio::write(build_dir.path().join("mytool"), "#!/bin/sh\necho hi\n")
674            .await
675            .unwrap();
676        install(
677            &rockspec,
678            &tree,
679            &package,
680            &lua,
681            build_dir.path(),
682            &EntryType::Entrypoint,
683            &config,
684        )
685        .await
686        .unwrap();
687        assert!(tree.bin().join("mytool").is_executable());
688    }
689
690    #[tokio::test]
691    async fn git_src_rock_with_repo_directory_builds() {
692        let dir = assert_fs::TempDir::new().unwrap();
693        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
694        let config = ConfigBuilder::new()
695            .unwrap()
696            .user_tree(Some(dir.to_path_buf()))
697            .lua_version(lua_version)
698            .build()
699            .unwrap();
700        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
701        let tree = config.user_tree(lua.version.clone()).unwrap();
702
703        let rockspec_content = r#"
704package = "git-src-rock"
705version = "1.0.0-1"
706source = { url = "git+https://example.com/example/git-src-rock.git" }
707dependencies = { "lua >= 5.1" }
708build = {
709    type = "builtin",
710    modules = { ["git_src_rock"] = "lua/git_src_rock.lua" },
711}
712"#;
713        let rockspec = RemoteLuaRockspec::new(rockspec_content).unwrap();
714
715        let mut archive = Cursor::new(Vec::new());
716        {
717            let mut zip = zip::ZipWriter::new(&mut archive);
718            zip.start_file(
719                "git-src-rock-1.0.0-1.rockspec",
720                SimpleFileOptions::default(),
721            )
722            .unwrap();
723            zip.write_all(rockspec_content.as_bytes()).unwrap();
724            zip.start_file(
725                "git-src-rock/lua/git_src_rock.lua",
726                SimpleFileOptions::default(),
727            )
728            .unwrap();
729            zip.write_all(b"return {}").unwrap();
730            zip.finish().unwrap();
731        }
732        let bytes = Bytes::from(archive.into_inner());
733
734        let package = Build::new()
735            .rockspec(&rockspec)
736            .lua(&lua)
737            .tree(&tree)
738            .entry_type(EntryType::Entrypoint)
739            .config(&config)
740            .behaviour(BuildBehaviour::Force)
741            .source_spec(RemotePackageSourceSpec::SrcRock(SrcRockSource {
742                bytes,
743                source_url: RemotePackageSourceUrl::Url {
744                    url: "https://example.org/git-src-rock-1.0.0-1.src.rock"
745                        .parse()
746                        .unwrap(),
747                },
748            }))
749            .build()
750            .await
751            .unwrap();
752
753        assert!(tree
754            .layout_for(package.spec())
755            .src
756            .join("git_src_rock.lua")
757            .is_file());
758    }
759
760    #[tokio::test]
761    async fn test_install_conf_directory_target() {
762        let rockspec = RemoteLuaRockspec::new(
763            r#"
764rockspec_format = "3.0"
765package = "test-install-conf"
766version = "1.0-1"
767source = { url = "https://github.com/lumen-oss/luarocks-stub" }
768build = {
769    type = "builtin",
770    install = {
771        conf = {
772            [".."] = "shell.nix",
773            ["plain.conf"] = "plain.conf",
774        },
775    },
776}
777"#,
778        )
779        .unwrap();
780        let lua_version = detect_installed_lua_version().or(Some(LuaVersion::Lua51));
781        let tree_dir = assert_fs::TempDir::new().unwrap();
782        let build_dir = assert_fs::TempDir::new().unwrap();
783        let config = ConfigBuilder::new()
784            .unwrap()
785            .lua_version(lua_version)
786            .user_tree(Some(tree_dir.to_path_buf()))
787            .build()
788            .unwrap();
789        let lua = LuaInstallation::new_from_config(&config).await.unwrap();
790        let tree = config
791            .user_tree(config.lua_version().cloned().unwrap())
792            .unwrap();
793        let package = LockedPackageSpec::new(
794            rockspec.package(),
795            rockspec.version(),
796            LockConstraint::Unconstrained,
797            Vec::new(),
798            Vec::new(),
799            &PinnedState::default(),
800            &OptState::default(),
801        );
802        tree.prepare(&package).unwrap();
803        for file in ["shell.nix", "plain.conf"] {
804            crate::fs::tokio::write(build_dir.path().join(file), "return {}")
805                .await
806                .unwrap();
807        }
808        install(
809            &rockspec,
810            &tree,
811            &package,
812            &lua,
813            build_dir.path(),
814            &EntryType::Entrypoint,
815            &config,
816        )
817        .await
818        .unwrap();
819        let layout = tree.layout_for(&package);
820        assert!(layout.etc.join("shell.nix").is_file());
821        assert!(layout.conf.join("plain.conf").is_file());
822    }
823
824    #[cfg(feature = "impure_tests")]
825    const LUATEST_SRC_ROCK: &str = "resources/test/luatest-0.2-1.src.rock";
826    #[cfg(feature = "impure_tests")]
827    const LUATEST_SRC_ROCK_SOURCE_HASH: &str =
828        "sha256-2jS0XOq0iIVhsZJ3BVqXSlKsx2vsqCAaaYyqcBEb7RI=";
829    #[cfg(feature = "impure_tests")]
830    const LUATEST_ROCKSPEC_HASH: &str = "sha256-NljJ20A+VadUyhhBjrRnojeQjSqRpQy7FWcgjUt2Fdc=";
831
832    #[cfg(feature = "impure_tests")]
833    fn luatest_src_rock_bytes() -> Bytes {
834        Bytes::copy_from_slice(
835            &std::fs::read(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(LUATEST_SRC_ROCK))
836                .unwrap(),
837        )
838    }
839
840    #[cfg(feature = "impure_tests")]
841    fn luatest_config(dir: &assert_fs::TempDir) -> Config {
842        ConfigBuilder::new()
843            .unwrap()
844            .user_tree(Some(dir.to_path_buf()))
845            .lua_version(Some(LuaVersion::Lua51))
846            .build()
847            .unwrap()
848    }
849
850    #[cfg(feature = "impure_tests")]
851    async fn build_luatest(
852        config: &Config,
853        tree: &Tree,
854        entry_type: EntryType,
855        behaviour: BuildBehaviour,
856    ) -> LockedPackage {
857        let bytes = luatest_src_rock_bytes();
858        let rock = DownloadedPackedRockBytes {
859            name: "luatest".into(),
860            version: "0.2-1".parse().unwrap(),
861            bytes: bytes.clone(),
862            file_name: "luatest-0.2-1.src.rock".into(),
863            url: "https://example.org/luatest-0.2-1.src.rock"
864                .parse()
865                .unwrap(),
866        };
867        let rockspec = unpack_rockspec(&rock).await.unwrap();
868        let lua = LuaInstallation::new_from_config(config).await.unwrap();
869        Build::new()
870            .rockspec(&rockspec)
871            .lua(&lua)
872            .tree(tree)
873            .entry_type(entry_type)
874            .config(config)
875            .behaviour(behaviour)
876            .source_spec(RemotePackageSourceSpec::SrcRock(SrcRockSource {
877                bytes,
878                source_url: RemotePackageSourceUrl::Url {
879                    url: "https://example.org/luatest-0.2-1.src.rock"
880                        .parse()
881                        .unwrap(),
882                },
883            }))
884            .build()
885            .await
886            .unwrap()
887    }
888
889    #[cfg(feature = "impure_tests")]
890    #[tokio::test]
891    async fn luatest_src_rock_produces_expected_hashes() {
892        let dir = assert_fs::TempDir::new().unwrap();
893        let config = luatest_config(&dir);
894        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
895
896        let package =
897            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
898
899        assert_eq!(
900            package.hashes().source,
901            LUATEST_SRC_ROCK_SOURCE_HASH.parse().unwrap()
902        );
903        assert_eq!(
904            package.hashes().rockspec,
905            LUATEST_ROCKSPEC_HASH.parse().unwrap()
906        );
907        assert_eq!(
908            package.hashes().source,
909            luatest_src_rock_bytes().hash().await.unwrap()
910        );
911        assert!(tree.bin().join("luatest").is_file());
912    }
913
914    #[cfg(feature = "impure_tests")]
915    #[tokio::test]
916    async fn force_build_is_deterministic() {
917        let dir1 = assert_fs::TempDir::new().unwrap();
918        let config1 = luatest_config(&dir1);
919        let tree1 = config1.user_tree(LuaVersion::Lua51).unwrap();
920
921        let dir2 = assert_fs::TempDir::new().unwrap();
922        let config2 = luatest_config(&dir2);
923        let tree2 = config2.user_tree(LuaVersion::Lua51).unwrap();
924
925        let package1 = build_luatest(
926            &config1,
927            &tree1,
928            EntryType::Entrypoint,
929            BuildBehaviour::Force,
930        )
931        .await;
932        let package2 = build_luatest(
933            &config2,
934            &tree2,
935            EntryType::Entrypoint,
936            BuildBehaviour::Force,
937        )
938        .await;
939
940        assert_eq!(package1, package2);
941        assert_eq!(package1.hashes(), package2.hashes());
942    }
943
944    #[cfg(feature = "impure_tests")]
945    #[tokio::test]
946    async fn noforce_skips_rebuild_when_package_is_locked() {
947        let dir = assert_fs::TempDir::new().unwrap();
948        let config = luatest_config(&dir);
949        let tree = config.user_tree(LuaVersion::Lua51).unwrap();
950
951        let package =
952            build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
953        let bin = tree.bin().join("luatest");
954        assert!(bin.is_file());
955
956        // Simulate a committed install
957        {
958            let mut lockfile = tree.lockfile().unwrap().write_guard();
959            lockfile.add_entrypoint(&package);
960        }
961
962        // Simulate a partially broken install.
963        std::fs::remove_file(&bin).unwrap();
964
965        let skipped = build_luatest(
966            &config,
967            &tree,
968            EntryType::Entrypoint,
969            BuildBehaviour::Ignore,
970        )
971        .await;
972        assert_eq!(skipped.id(), package.id());
973        assert!(!bin.is_file());
974
975        build_luatest(&config, &tree, EntryType::Entrypoint, BuildBehaviour::Force).await;
976        assert!(bin.is_file());
977    }
978}