Skip to main content

lux_lib/project/
project_toml.rs

1//! Structs and utilities for `lux.toml`
2
3use crate::fs;
4use crate::git::shorthand::RemoteGitUrlShorthand;
5use crate::git::{GitRef, GitSource};
6use crate::hash::HasIntegrity;
7use crate::lockfile::OptState;
8use crate::lockfile::PinnedState;
9use crate::lua_rockspec::DeploySpec;
10use crate::lua_rockspec::LocalLuaRockspec;
11use crate::lua_rockspec::LocalRockSource;
12use crate::lua_rockspec::LuaRockspecError;
13use crate::lua_rockspec::RemoteLuaRockspec;
14use crate::lua_rockspec::RockSourceSpec;
15use crate::lua_version::LuaVersion;
16use crate::operations::RunCommand;
17use crate::package::PackageNameList;
18use crate::package::SpecRev;
19use crate::project::TomlDeError;
20use crate::rockspec::lua_dependency::LuaDependencySpec;
21use crate::ROCKSPEC_FUEL_LIMIT;
22use std::io;
23use std::path::Path;
24use std::{collections::HashMap, path::PathBuf};
25
26use crate::{
27    build::autodetect_modules,
28    config::Config,
29    lua_rockspec::{
30        BuildSpec, BuildSpecInternal, BuildSpecInternalError, BuildType, DisplayAsLuaKV,
31        DisplayLuaKV, ExternalDependencies, ExternalDependencySpec, LuaTableKey, LuaVersionError,
32        ModuleSpec, ModuleSpecInternal, PartialLuaRockspec, PerPlatform, PlatformIdentifier,
33        PlatformSupport, PlatformValidationError, RemoteRockSource, RockDescription,
34        RockSourceError, RockspecFormat, TestSpec, TestSpecDecodeError, TestSpecInternal,
35    },
36    package::{
37        BuildDependencies, Dependencies, PackageName, PackageReq, PackageVersion, PackageVersionReq,
38    },
39    rockspec::{LuaVersionCompatibility, Rockspec},
40};
41use itertools::Itertools;
42use miette::Diagnostic;
43use nonempty::NonEmpty;
44
45use crate::package::wally::PackageReq as WallyPackageReq;
46use serde::de;
47use serde::{Deserialize, Deserializer};
48use ssri::Integrity;
49use thiserror::Error;
50
51use super::gen::GenerateSourceError;
52use super::gen::RockSourceTemplate;
53use super::r#gen::GenerateVersionError;
54use super::r#gen::PackageVersionTemplate;
55use super::ProjectRoot;
56
57pub const PROJECT_TOML: &str = "lux.toml";
58
59#[derive(Deserialize)]
60#[serde(untagged)]
61#[allow(clippy::large_enum_variant)] // This is ok because it's just a Deserialize helper
62enum DependencyEntry {
63    Simple(PackageVersionReq),
64    Detailed(DependencyTableEntry),
65}
66
67#[derive(Debug, Deserialize)]
68struct DependencyTableEntry {
69    #[serde(default)]
70    version: Option<PackageVersionReq>,
71    #[serde(default)]
72    opt: Option<bool>,
73    #[serde(default)]
74    pin: Option<bool>,
75    #[serde(default)]
76    git: Option<RemoteGitUrlShorthand>,
77    #[serde(default)]
78    path: Option<PathBuf>,
79    #[serde(default)]
80    rev: Option<String>,
81    #[serde(default)]
82    wally: Option<WallyPackageReq>,
83}
84
85fn parse_map_to_dependency_vec_opt<'de, D>(
86    deserializer: D,
87) -> Result<Option<Vec<LuaDependencySpec>>, D::Error>
88where
89    D: Deserializer<'de>,
90{
91    let packages: Option<HashMap<PackageName, DependencyEntry>> =
92        Option::deserialize(deserializer)?;
93
94    match packages {
95        None => Ok(None),
96        Some(packages) => Ok(Some(
97            packages
98                .into_iter()
99                .map(|(name, spec)| match spec {
100                    DependencyEntry::Simple(version_req) => {
101                        Ok(PackageReq { name, version_req }.into())
102                    }
103                    DependencyEntry::Detailed(entry) => {
104                        let DependencyTableEntry {
105                            version,
106                            opt,
107                            pin,
108                            git,
109                            path,
110                            rev,
111                            wally,
112                        } = entry;
113
114                        let (version_req, source) = if let Some(wally) = wally {
115                            if version.is_some() || git.is_some() || rev.is_some() || path.is_some() {
116                                return Err(de::Error::custom(format!(
117                                    "dependency '{}' specifies 'wally', which cannot be combined with 'version', 'git', 'rev', or 'path'",
118                                    name
119                                )));
120                            }
121                            (
122                                PackageVersionReq::SemVer(wally.version_req().clone()),
123                                Some(RockSourceSpec::Wally(wally)),
124                            )
125                        } else {
126                            let version = version.ok_or_else(|| {
127                                de::Error::custom(format!(
128                                    "dependency '{name}' is missing a 'version' or 'wally' field"
129                                ))
130                            })?;
131                            let source = match (git, rev, path) {
132                                (None, None, None) => Ok(None),
133                                (None, Some(_), None) => Err(de::Error::custom(format!(
134                                    "dependency {} specifies a 'rev', but missing a 'git' field",
135                                    name
136                                ))),
137                                (Some(git), Some(rev), None) => {
138                                    Ok(Some(RockSourceSpec::Git(GitSource {
139                                        url: git.into(),
140                                        git_ref: Some(GitRef::Tag(rev)),
141                                    })))
142                                }
143                                (Some(git), None, None) => Ok(Some(RockSourceSpec::Git(GitSource {
144                                    url: git.into(),
145                                    git_ref: Some(GitRef::Tag(
146                                        version
147                                            .clone()
148                                            .to_string()
149                                            .trim_start_matches("=")
150                                            .to_string(),
151                                    )),
152                                }))),
153                                (None, None, Some(path)) => Ok(Some(RockSourceSpec::File(path))),
154                                (_, _, Some(_)) => Err(de::Error::custom(format!(
155                                    "dependency '{}' specifies a 'path', which cannot be combined with 'git' or 'rev'",
156                                    name
157                                ))),
158                            }?;
159                            (version, source)
160                        };
161
162                        Ok(LuaDependencySpec {
163                            package_req: PackageReq { name, version_req },
164                            opt: OptState::from(opt.unwrap_or(false)),
165                            pin: PinnedState::from(pin.unwrap_or(false)),
166                            source,
167                        })
168                    }
169                })
170                .try_collect()?,
171        )),
172    }
173}
174
175#[derive(Debug, Error, Diagnostic)]
176pub enum ProjectTomlError {
177    #[error("error generating rockspec source")]
178    #[diagnostic(forward(0))]
179    GenerateSource(#[from] GenerateSourceError),
180    #[error("error generating rockspec version")]
181    #[diagnostic(forward(0))]
182    GenerateVersion(#[from] GenerateVersionError),
183    #[error("generated rockspec exceeds computational limit of {ROCKSPEC_FUEL_LIMIT} steps")]
184    #[diagnostic(help(
185        r#"the rockspec may be too complex or contain too many dependencies.
186        Try reducing the number of dependencies or simplifying build instructions."#
187    ))]
188    FuelLimitExceeded,
189    #[error(
190        r#"generated invalid Lua from TOML:
191{0}
192"#
193    )]
194    #[diagnostic(help("this is a bug in Lux. please report it."))]
195    GeneratedInvalidLua(String),
196}
197
198#[derive(Debug, Error, Diagnostic)]
199pub enum LocalProjectTomlValidationError {
200    #[error("no lua version provided")]
201    #[diagnostic(help(
202        r#"add a `lua` field to your lux.toml, for example:
203
204  lua = ">=5.1""#
205    ))]
206    NoLuaVersion,
207    #[error("could not decode the test spec")]
208    #[diagnostic(forward(0))]
209    TestSpecError(#[from] TestSpecDecodeError),
210    #[error("could not decode the build spec")]
211    #[diagnostic(forward(0))]
212    BuildSpecInternal(#[from] BuildSpecInternalError),
213    #[error(transparent)]
214    #[diagnostic(transparent)]
215    PlatformValidation(#[from] PlatformValidationError),
216    #[error("{}copy_directories cannot contain a rockspec name", ._0.as_ref().map(|p| format!("{p}: ")).unwrap_or_default())]
217    CopyDirectoriesContainRockspecName(Option<String>),
218    #[error("could not decode the source spec")]
219    #[diagnostic(forward(0))]
220    RockSource(#[from] RockSourceError),
221    #[error("duplicate dependencies: {0}")]
222    #[diagnostic(help("remove the duplicate entries from the [dependencies] table."))]
223    DuplicateDependencies(PackageNameList),
224    #[error("duplicate test dependencies: {0}")]
225    #[diagnostic(help("remove the duplicate entries from the [test_dependencies] table."))]
226    DuplicateTestDependencies(PackageNameList),
227    #[error("duplicate build dependencies: {0}")]
228    #[diagnostic(help("remove the duplicate entries from the [build_dependencies] table."))]
229    DuplicateBuildDependencies(PackageNameList),
230    #[error(
231        r#"dependencies field cannot contain 'lua'.
232        Please provide the version in the top-level 'lua' field
233
234        Example:
235
236        ```toml
237        package = "my-package"
238        lua = ">=5.1"
239
240        [dependencies]
241        # do not add Lua here!
242        ```
243        "#
244    )]
245    DependenciesContainLua,
246    #[error("error generating rockspec source")]
247    #[diagnostic(forward(0))]
248    GenerateSource(#[from] GenerateSourceError),
249    #[error("error generating rockspec version")]
250    #[diagnostic(forward(0))]
251    GenerateVersion(#[from] GenerateVersionError),
252}
253
254#[derive(Debug, Error, Diagnostic)]
255pub enum RemoteProjectTomlValidationError {
256    #[error("error generating rockspec source")]
257    #[diagnostic(forward(0))]
258    GenerateSource(#[from] GenerateSourceError),
259    #[error("error generating rockspec version")]
260    #[diagnostic(forward(0))]
261    GenerateVersion(#[from] GenerateVersionError),
262    #[error(transparent)]
263    #[diagnostic(transparent)]
264    LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
265}
266
267/// The `lux.toml` file for a project.
268/// The only required fields are `package` and `build`, which are required to build a project using `lux build`.
269/// The rest of the fields are optional, but are required to build a rockspec.
270#[derive(Clone, Debug, Deserialize)]
271#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
272pub struct PartialProjectToml {
273    /// The name of the rock. Lux converts it to lower case when publishing.
274    #[cfg_attr(
275        feature = "schema",
276        schemars(extend("x-lux-toml-example" = r#"package = "my-lua-project""#))
277    )]
278    pub(crate) package: PackageName,
279    /// The package version. If unset, Lux derives it from the current SemVer
280    /// git tag (optional `v` prefix), falling back to "dev".
281    ///
282    /// Versions like `1.0` are corrected to `1.0.0`.
283    /// Non-SemVer versions (e.g. `1.0-rc1` instead of `1.0.0-rc1`) are treated
284    /// as Dev versions and cannot be compared with SemVer versions.
285    #[serde(default, rename = "version")]
286    #[cfg_attr(feature = "schema", schemars(with = "String"))]
287    #[cfg_attr(
288        feature = "schema",
289        schemars(extend("x-lux-toml-example" = r#"version = "1.0.0""#))
290    )]
291    pub(crate) version_template: PackageVersionTemplate,
292    /// How to build the project. See the build backend fields below.
293    ///
294    /// Rocks with C sources require a C compiler. Lux does not bundle one.
295    #[serde(default)]
296    pub(crate) build: BuildSpecInternal,
297    /// The LuaRocks rockspec format version to write to the generated rockspec ("1.0", "2.0", or "3.0").
298    /// Default: "3.0".
299    #[cfg_attr(
300        feature = "schema",
301        schemars(extend("x-lux-toml-example" = r#"rockspec_format = "3.0""#))
302    )]
303    pub(crate) rockspec_format: Option<RockspecFormat>,
304    /// How to run the project with `lx run`.
305    #[serde(default)]
306    #[cfg_attr(
307        feature = "schema",
308        schemars(extend("x-lux-toml-example" = r#"
309[run]
310args = ["src/main.lua"]
311"#))
312    )]
313    pub(crate) run: Option<RunSpec>,
314    /// The Lua version requirement, e.g. ">= 5.1". Required to build a rockspec.
315    /// Set to "luau" to build and run the project with Luau.
316    #[serde(default)]
317    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
318    #[cfg_attr(
319        feature = "schema",
320        schemars(extend("x-lux-toml-example" = r#"lua = ">=5.1""#))
321    )]
322    pub(crate) lua: Option<PackageVersionReq>,
323    /// Metadata published to LuaRocks.
324    #[serde(default)]
325    #[cfg_attr(
326        feature = "schema",
327        schemars(extend("x-lux-toml-example" = r#"
328[description]
329summary = "A one-line description of the package"
330detailed = """
331A longer description of the package
332that often spans multiple lines.
333"""
334license = "GPLv2"
335homepage = "https://lumen-oss.github.io"
336issues_url = "https://github.com/lumen-oss/lux/issues"
337maintainer = "neorocks org"
338labels = ["neovim", "tls", "https"]
339"#))
340    )]
341    pub(crate) description: Option<RockDescription>,
342    /// Platforms this rock supports, mapping platform identifiers to `true` or
343    /// `false` (e.g. `linux = true`).
344    #[serde(default)]
345    #[cfg_attr(
346        feature = "schema",
347        schemars(with = "Option<std::collections::BTreeMap<String, bool>>")
348    )]
349    #[cfg_attr(
350        feature = "schema",
351        schemars(extend("x-lux-toml-example" = r#"
352[supported_platforms]
353linux = true
354windows = false
355"#))
356    )]
357    pub(crate) supported_platforms: Option<HashMap<PlatformIdentifier, bool>>,
358    /// Runtime Lua dependencies, mapping package names to a version requirement
359    /// (e.g. `foo = "1.0"`) or to a detailed table with `version`, `opt`, `pin`,
360    /// `git`, `rev`, and `path` keys.
361    ///
362    /// Version requirements support the operators `==`, `~=`, `<`, `>`, `<=`,
363    /// `>=`, and `~>`. `~> 2` means `>= 2, < 3`. An operator is optional;
364    /// without one, the requirement is treated as `==`. Version requirements
365    /// are mandatory.
366    ///
367    /// Dependencies are declared under `lua`, `[dependencies]`,
368    /// `[build_dependencies]`, `[test_dependencies]`, and
369    /// `[external_dependencies]`.
370    ///
371    /// Git dependencies support the `github:` (default), `gitlab:`,
372    /// `codeberg:`, and `sourcehut:` shorthands, as well as full HTTPS and SSH
373    /// URLs. If `rev` is unset, `version` is used as the commit SHA or tag.
374    /// Lux searches the repository root for a `lux.toml` or `.rockspec` to
375    /// determine the build specification, defaulting to `builtin` if none is
376    /// found. Rocks with git dependencies cannot be published to LuaRocks. Git
377    /// dependencies that require a custom LuaRocks build backend are not
378    /// supported.
379    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
380    #[cfg_attr(
381        feature = "schema",
382        schemars(
383            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
384        )
385    )]
386    #[cfg_attr(
387        feature = "schema",
388        schemars(extend("x-lux-toml-example" = r#"
389[dependencies]
390luafilesystem = "1.0.0"
391
392[dependencies.argparse]
393version = "0.7"
394opt = true
395
396[dependencies.foo]
397version = "09511782201302ade916d4b250d01a6c61b56844"
398git = "github:lunarmodules/foo"
399
400[dependencies.bar]
401path = "../bar"
402version = "1.0.0"
403"#))
404    )]
405    pub(crate) dependencies: Option<Vec<LuaDependencySpec>>,
406    /// Dependencies needed at build time. Same syntax as `dependencies`.
407    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
408    #[cfg_attr(
409        feature = "schema",
410        schemars(
411            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
412        )
413    )]
414    pub(crate) build_dependencies: Option<Vec<LuaDependencySpec>>,
415    /// System (C/FFI) dependencies, discovered via pkg-config. Each entry may
416    /// set `header` and/or `library`.
417    #[serde(default)]
418    #[cfg_attr(
419        feature = "schema",
420        schemars(extend("x-lux-toml-example" = r#"
421[external_dependencies.OPENSSL]
422header = "openssl/ssl.h"
423library = "ssl"
424"#))
425    )]
426    pub(crate) external_dependencies: Option<HashMap<String, ExternalDependencySpec>>,
427    /// Dependencies needed when running `lx test`. Same syntax as `dependencies`.
428    #[serde(default, deserialize_with = "parse_map_to_dependency_vec_opt")]
429    #[cfg_attr(
430        feature = "schema",
431        schemars(
432            with = "Option<std::collections::BTreeMap<String, crate::schema::DependencyEntry>>"
433        )
434    )]
435    #[cfg_attr(
436        feature = "schema",
437        schemars(extend("x-lux-toml-example" = r#"
438[test_dependencies]
439busted = ">=2.0.0"
440nlua = "~>0.3"
441"#))
442    )]
443    pub(crate) test_dependencies: Option<Vec<LuaDependencySpec>>,
444    /// Templates for generating the source URL of a release.
445    ///
446    /// Variable substitutions:
447    /// - `$(PACKAGE)`: package name
448    /// - `$(VERSION)`: package version (from the git tag if not declared)
449    /// - `$(REF)`: git tag or revision (priority: SemVer tag, tag, commit SHA)
450    /// - `$(<VAR_NAME>)`: any environment variable
451    #[serde(default, rename = "source")]
452    #[cfg_attr(
453        feature = "schema",
454        schemars(extend("x-lux-toml-example" = r#"
455[source]
456url = "https://host.com/owner/$(PACKAGE)/archive/refs/tags/$(REF).zip"
457dev = "git+https://host.com/owner/$(PACKAGE).git"
458"#))
459    )]
460    pub(crate) source_template: RockSourceTemplate,
461    /// How to run the test suite with `lx test`.
462    ///
463    /// The `busted` backend is used when a `.busted` file exists and no backend
464    /// is specified. `busted-nlua` is used when a `.busted` file exists or
465    /// `busted` and `nlua` are `test_dependencies`. The `busted-nlua` backend is
466    /// currently broken on macOS and Windows. Luau projects default to `tiniest`.
467    #[serde(default)]
468    #[cfg_attr(
469        feature = "schema",
470        schemars(extend("x-lux-toml-example" = r#"
471[test]
472type = "busted"
473flags = []
474"#))
475    )]
476    pub(crate) test: Option<TestSpecInternal>,
477    /// Additional install and deployment options.
478    #[serde(default)]
479    #[cfg_attr(
480        feature = "schema",
481        schemars(extend("x-lux-toml-example" = r#"
482[deploy]
483wrap_bin_scripts = false
484"#))
485    )]
486    pub(crate) deploy: Option<DeploySpec>,
487    /// Lux-specific project settings, ignored when generating a rockspec.
488    #[serde(default)]
489    #[cfg_attr(
490        feature = "schema",
491        schemars(extend("x-lux-toml-example" = r#"
492[project]
493root_dir = ".."
494"#))
495    )]
496    pub(crate) project: ProjectSpec,
497
498    /// Used to bind the TOML to the directory containing the `lux.toml`.
499    #[serde(skip, default = "PathBuf::new")]
500    lux_toml_dir: PathBuf,
501}
502
503impl HasIntegrity for PartialProjectToml {
504    async fn hash(&self) -> io::Result<Integrity> {
505        let toml_file = self.lux_toml_dir.join(PROJECT_TOML);
506        let content = fs::sync::read_to_string(&toml_file).map_err(io::Error::other)?;
507        Ok(Integrity::from(&content))
508    }
509}
510
511impl PartialProjectToml {
512    pub(crate) fn new(
513        name: &str,
514        str: &str,
515        lux_toml_dir: ProjectRoot,
516    ) -> Result<Self, TomlDeError> {
517        let mut this: Self = super::parse_toml(name, str)?;
518        this.lux_toml_dir = lux_toml_dir.to_path_buf();
519        this.project.root = match this.project.root_dir {
520            Some(ref root_dir) => {
521                let relative_project_root = lux_toml_dir.join(root_dir);
522                let project_root =
523                    std::fs::canonicalize(&relative_project_root).unwrap_or(relative_project_root);
524                ProjectRoot(project_root)
525            }
526            None => lux_toml_dir,
527        };
528        Ok(this)
529    }
530
531    /// This project's Lua version requirement, if any
532    pub fn lua(&self) -> Option<&PackageVersionReq> {
533        self.lua.as_ref()
534    }
535
536    /// Convert the `PartialProjectToml` struct into a `LocalProjectToml` struct, making
537    /// it ready to be used for building a project.
538    pub fn into_local(&self) -> Result<LocalProjectToml, LocalProjectTomlValidationError> {
539        let project_toml = self.clone();
540
541        // Disallow `lua` to be part of the `dependencies` field
542        if project_toml
543            .dependencies
544            .as_ref()
545            .is_some_and(|deps| deps.iter().any(|dep| dep.name() == &"lua".into()))
546        {
547            return Err(LocalProjectTomlValidationError::DependenciesContainLua);
548        }
549
550        let get_duplicates = |dependencies: &Option<Vec<LuaDependencySpec>>| {
551            dependencies
552                .iter()
553                .flat_map(|deps| {
554                    deps.iter()
555                        .map(|dep| dep.package_req().name())
556                        .duplicates()
557                        .cloned()
558                })
559                .collect_vec()
560        };
561        let duplicate_dependencies = get_duplicates(&self.dependencies);
562        if !duplicate_dependencies.is_empty() {
563            return Err(LocalProjectTomlValidationError::DuplicateDependencies(
564                PackageNameList::new(duplicate_dependencies),
565            ));
566        }
567        let duplicate_test_dependencies = get_duplicates(&self.test_dependencies);
568        if !duplicate_test_dependencies.is_empty() {
569            return Err(LocalProjectTomlValidationError::DuplicateTestDependencies(
570                PackageNameList::new(duplicate_test_dependencies),
571            ));
572        }
573        let duplicate_build_dependencies = get_duplicates(&self.build_dependencies);
574        if !duplicate_build_dependencies.is_empty() {
575            return Err(LocalProjectTomlValidationError::DuplicateBuildDependencies(
576                PackageNameList::new(duplicate_build_dependencies),
577            ));
578        }
579
580        let validated = LocalProjectToml {
581            internal: project_toml.clone(),
582
583            package: project_toml.package,
584            version: project_toml
585                .version_template
586                .try_generate(&self.lux_toml_dir, None)
587                .unwrap_or(PackageVersion::default_dev_version()),
588            lua: project_toml
589                .lua
590                .ok_or(LocalProjectTomlValidationError::NoLuaVersion)?,
591            description: project_toml.description.unwrap_or_default(),
592            run: project_toml.run.map(PerPlatform::new),
593            supported_platforms: PlatformSupport::parse(
594                &project_toml
595                    .supported_platforms
596                    .unwrap_or_default()
597                    .into_iter()
598                    .map(|(platform, supported)| {
599                        if supported {
600                            format!("{platform}")
601                        } else {
602                            format!("!{platform}")
603                        }
604                    })
605                    .collect_vec(),
606            )?,
607            // Merge dependencies internally with lua version
608            // so the output of `dependencies()` is consistent
609            dependencies: PerPlatform::new(
610                project_toml
611                    .dependencies
612                    .unwrap_or_default()
613                    .into_iter()
614                    .map(|dep| self.resolve_lua_dependency_spec(dep))
615                    .collect_vec(),
616            ),
617            test_dependencies: PerPlatform::new(
618                project_toml
619                    .test_dependencies
620                    .unwrap_or_default()
621                    .into_iter()
622                    .map(|dep| self.resolve_lua_dependency_spec(dep))
623                    .collect_vec(),
624            ),
625            build_dependencies: PerPlatform::new(
626                project_toml
627                    .build_dependencies
628                    .unwrap_or_default()
629                    .into_iter()
630                    .map(|dep| self.resolve_lua_dependency_spec(dep))
631                    .collect_vec(),
632            ),
633            external_dependencies: PerPlatform::new(
634                project_toml.external_dependencies.unwrap_or_default(),
635            ),
636            test: PerPlatform::new(TestSpec::try_from(
637                project_toml.test.clone().unwrap_or_default(),
638            )?),
639            build: PerPlatform::new(BuildSpec::from_internal_spec(project_toml.build.clone())?),
640            deploy: PerPlatform::new(project_toml.deploy.clone().unwrap_or_default()),
641            rockspec_format: project_toml.rockspec_format.clone(),
642
643            source: PerPlatform::new(RemoteRockSource {
644                local: LocalRockSource::default(),
645                source_spec: RockSourceSpec::File(self.project.root.to_path_buf()),
646            }),
647        };
648
649        let rockspec_file_name = format!("{}-{}.rockspec", validated.package, validated.version);
650
651        if validated
652            .build
653            .default
654            .copy_directories
655            .contains(&PathBuf::from(&rockspec_file_name))
656        {
657            return Err(LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(None));
658        }
659
660        for (platform, build_override) in &validated.build.per_platform {
661            if build_override
662                .copy_directories
663                .contains(&PathBuf::from(&rockspec_file_name))
664            {
665                return Err(
666                    LocalProjectTomlValidationError::CopyDirectoriesContainRockspecName(Some(
667                        platform.to_string(),
668                    )),
669                );
670            }
671        }
672
673        Ok(validated)
674    }
675
676    /// Convert the `PartialProjectToml` struct into a `RemoteProjectToml` struct, making
677    /// it ready to be serialized into a rockspec.
678    /// A source must be provided for the rockspec to be valid.
679    pub fn into_remote(
680        &self,
681        specrev: Option<SpecRev>,
682    ) -> Result<RemoteProjectToml, RemoteProjectTomlValidationError> {
683        self.into_remote_with_version(None, specrev)
684    }
685
686    /// Like [`Self::into_remote`] but generates the rockspec for a specified
687    /// target version.
688    pub fn into_remote_with_version(
689        &self,
690        version: Option<PackageVersion>,
691        specrev: Option<SpecRev>,
692    ) -> Result<RemoteProjectToml, RemoteProjectTomlValidationError> {
693        let version = match version {
694            Some(version) => version,
695            None => self
696                .version_template
697                .try_generate(&self.lux_toml_dir, specrev)?,
698        };
699        let source =
700            self.source_template
701                .try_generate(&self.lux_toml_dir, &self.package, &version)?;
702        let source = PerPlatform::new(RemoteRockSource::try_from(source).map_err(|err| {
703            RemoteProjectTomlValidationError::LocalProjectTomlValidationError(
704                LocalProjectTomlValidationError::RockSource(err),
705            )
706        })?);
707        let mut local = self.into_local()?;
708        local.version = version;
709
710        let validated = RemoteProjectToml { source, local };
711
712        Ok(validated)
713    }
714
715    // In the not-yet-validated struct, we create getters only
716    // for the non-optional fields.
717    pub fn package(&self) -> &PackageName {
718        &self.package
719    }
720
721    /// Returns the current package version, which may be generated from a template
722    pub fn version(&self) -> Result<PackageVersion, GenerateVersionError> {
723        self.version_template.try_generate(&self.lux_toml_dir, None)
724    }
725
726    /// Merge the `ProjectToml` struct with an unvalidated `LuaRockspec`.
727    /// The final merged struct can then be validated.
728    pub fn merge(self, other: PartialLuaRockspec) -> Self {
729        PartialProjectToml {
730            package: other.package.unwrap_or(self.package),
731            version_template: self.version_template,
732            lua: other
733                .dependencies
734                .as_ref()
735                .and_then(|deps| {
736                    deps.iter()
737                        .find(|dep| dep.name() == &"lua".into())
738                        .and_then(|dep| {
739                            if dep.version_req().is_any() {
740                                None
741                            } else {
742                                Some(dep.version_req().clone())
743                            }
744                        })
745                })
746                .or(self.lua),
747            build: other.build.unwrap_or(self.build),
748            run: self.run,
749            description: other.description.or(self.description),
750            supported_platforms: other
751                .supported_platforms
752                .map(|platform_support| platform_support.platforms().clone())
753                .or(self.supported_platforms),
754            dependencies: other
755                .dependencies
756                .map(|deps| {
757                    deps.into_iter()
758                        .filter(|dep| dep.name() != &"lua".into())
759                        .collect()
760                })
761                .or(self.dependencies),
762            build_dependencies: other.build_dependencies.or(self.build_dependencies),
763            test_dependencies: other.test_dependencies.or(self.test_dependencies),
764            external_dependencies: other.external_dependencies.or(self.external_dependencies),
765            source_template: self.source_template,
766            test: other.test.or(self.test),
767            deploy: other.deploy.or(self.deploy),
768            rockspec_format: other.rockspec_format.or(self.rockspec_format),
769
770            // Keep the project root the same, as it is not part of the lua rockspec
771            project: self.project,
772            lux_toml_dir: self.lux_toml_dir.clone(),
773        }
774    }
775
776    fn resolve_lua_dependency_spec(&self, dep: LuaDependencySpec) -> LuaDependencySpec {
777        match &dep.source {
778            Some(RockSourceSpec::File(path)) if path.is_dir() => dep,
779            Some(RockSourceSpec::File(path)) => LuaDependencySpec {
780                source: Some(RockSourceSpec::File(self.lux_toml_dir.join(path))),
781                ..dep
782            },
783            _ => dep,
784        }
785    }
786}
787
788// This is automatically implemented for `RemoteProjectToml`,
789// but we also add a special implementation for `ProjectToml` (as providing a lua version
790// is required even by the non-validated struct).
791impl LuaVersionCompatibility for PartialProjectToml {
792    fn validate_lua_version(&self, version: &LuaVersion) -> Result<(), LuaVersionError> {
793        if self.supports_lua_version(version) {
794            Ok(())
795        } else {
796            Err(LuaVersionError::LuaVersionUnsupported(
797                version.clone(),
798                self.package().to_owned(),
799                self.version_template
800                    .try_generate(&self.lux_toml_dir, None)
801                    .unwrap_or(PackageVersion::default_dev_version()),
802            ))
803        }
804    }
805
806    fn validate_lua_version_from_config(&self, config: &Config) -> Result<(), LuaVersionError> {
807        let _ = self.lua_version_matches(config)?;
808        Ok(())
809    }
810
811    fn lua_version_matches(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
812        let version = LuaVersion::from(config)?.clone();
813        if self.supports_lua_version(&version) {
814            Ok(version)
815        } else {
816            Err(LuaVersionError::LuaVersionUnsupported(
817                version,
818                self.package.clone(),
819                self.version_template
820                    .try_generate(&self.lux_toml_dir, None)
821                    .unwrap_or(PackageVersion::default_dev_version()),
822            ))
823        }
824    }
825
826    fn supports_lua_version(&self, lua_version: &LuaVersion) -> bool {
827        self.lua.as_ref().is_none_or(|lua| {
828            if lua.is_luau() {
829                return matches!(lua_version, LuaVersion::Luau);
830            }
831            lua.matches(&lua_version.as_version())
832        })
833    }
834
835    fn lua_version(&self) -> Option<LuaVersion> {
836        if self.lua.as_ref().is_some_and(|lua| lua.is_luau()) {
837            return Some(LuaVersion::Luau);
838        }
839        for (possibility, version) in [
840            ("5.5.0", LuaVersion::Lua55),
841            ("5.4.0", LuaVersion::Lua54),
842            ("5.3.0", LuaVersion::Lua53),
843            ("5.2.0", LuaVersion::Lua52),
844            ("5.1.0", LuaVersion::Lua51),
845        ] {
846            let possibility = unsafe { possibility.parse().unwrap_unchecked() };
847            if self
848                .lua
849                .as_ref()
850                .is_none_or(|lua| lua.matches(&possibility))
851            {
852                return Some(version);
853            }
854        }
855        None
856    }
857}
858
859/// Lux-specific project configuration.
860///
861/// This is not part of the rockspec format and is ignored when generating a rockspec.
862#[derive(Debug, Clone, Deserialize)]
863#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
864pub(crate) struct ProjectSpec {
865    /// The project root directory, relative to the directory containing the `lux.toml`.
866    /// Useful for multi-project workspaces, where the build files
867    /// (e.g. the cargo workspace `Cargo.toml`) do not live next to the `lux.toml`.
868    root_dir: Option<PathBuf>,
869    /// The project root directory, resolved from `root_dir` when deserializing.
870    #[serde(skip, default = "ProjectRoot::new")]
871    pub(crate) root: ProjectRoot,
872}
873
874impl Default for ProjectSpec {
875    fn default() -> Self {
876        Self {
877            root_dir: None,
878            root: ProjectRoot::new(),
879        }
880    }
881}
882
883// TODO(vhyrro): Move this struct into a different directory.
884#[derive(Debug, Clone, Deserialize)]
885#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
886pub struct RunSpec {
887    /// The command to execute when running the project
888    pub(crate) command: Option<RunCommand>,
889    /// Arguments to pass to the command
890    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<String>>"))]
891    pub(crate) args: Option<NonEmpty<String>>,
892}
893
894/// The `lux.toml` file, after being properly deserialized.
895/// This struct may be used to build a local version of a project.
896/// To build a rockspec, use `RemoteProjectToml`.
897#[derive(Debug)]
898pub struct LocalProjectToml {
899    package: PackageName,
900    version: PackageVersion,
901    lua: PackageVersionReq,
902    rockspec_format: Option<RockspecFormat>,
903    run: Option<PerPlatform<RunSpec>>,
904    description: RockDescription,
905    supported_platforms: PlatformSupport,
906    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
907    build_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
908    external_dependencies: PerPlatform<HashMap<String, ExternalDependencySpec>>,
909    test_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
910    test: PerPlatform<TestSpec>,
911    build: PerPlatform<BuildSpec>,
912    deploy: PerPlatform<DeploySpec>,
913
914    // Used for simpler serialization
915    internal: PartialProjectToml,
916
917    /// A source pointing to the current project's root.
918    source: PerPlatform<RemoteRockSource>,
919}
920
921impl LocalProjectToml {
922    pub fn run(&self) -> Option<&PerPlatform<RunSpec>> {
923        self.run.as_ref()
924    }
925
926    /// Convert this project TOML to a Lua rockspec.
927    /// Fails if there is no valid project root or if there are off-spec dependencies.
928    pub fn to_lua_rockspec(&self) -> Result<LocalLuaRockspec, LuaRockspecError> {
929        if let Some(dep) = self
930            .dependencies()
931            .per_platform
932            .values()
933            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
934            .collect_vec()
935            .first()
936        {
937            return Err(LuaRockspecError::OffSpecDependency(dep.name().clone()));
938        }
939        if let Some(dep) = self
940            .build_dependencies()
941            .per_platform
942            .values()
943            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
944            .collect_vec()
945            .first()
946        {
947            return Err(LuaRockspecError::OffSpecBuildDependency(dep.name().clone()));
948        }
949        if let Some(dep) = self
950            .test_dependencies()
951            .per_platform
952            .values()
953            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
954            .collect_vec()
955            .first()
956        {
957            return Err(LuaRockspecError::OffSpecTestDependency(dep.name().clone()));
958        }
959        LocalLuaRockspec::new(
960            &self.to_lua_remote_rockspec_string()?,
961            ProjectRoot(self.internal.lux_toml_dir.clone()),
962        )
963    }
964
965    /// Renders the rockspec for local builds, using the rockspec's own version
966    /// and its source pointing at the project root.
967    pub fn to_local_rockspec(&self) -> Result<RemoteLuaRockspec, LuaRockspecError> {
968        Ok(LocalLuaRockspec::from_project_toml(self).into_remote_with_source(self.source.clone()))
969    }
970
971    fn render_rockspec(
972        &self,
973        version: &PackageVersion,
974        source: Option<DisplayLuaKV>,
975    ) -> Result<String, ProjectTomlError> {
976        let starter = format!(
977            r#"
978rockspec_format = "{}"
979package = "{}"
980version = "{}""#,
981            self.rockspec_format
982                .as_ref()
983                .unwrap_or(&RockspecFormat::default()),
984            self.package,
985            version
986        );
987
988        let mut template = Vec::new();
989
990        if self.description != RockDescription::default() {
991            template.push(self.description.display_lua());
992        }
993
994        if self.supported_platforms != PlatformSupport::default() {
995            template.push(self.supported_platforms.display_lua());
996        }
997
998        {
999            let mut dependencies = self.internal.dependencies.clone().unwrap_or_default();
1000            if !self.lua.is_luau() {
1001                dependencies.insert(
1002                    0,
1003                    PackageReq {
1004                        name: "lua".into(),
1005                        version_req: self.lua.clone(),
1006                    }
1007                    .into(),
1008                );
1009            }
1010            template.push(Dependencies(&dependencies).display_lua());
1011        }
1012
1013        let mut build_dependencies = self
1014            .internal
1015            .build_dependencies
1016            .as_ref()
1017            .cloned()
1018            .unwrap_or_default();
1019
1020        let build_backend_dependency = self
1021            .internal
1022            .build
1023            .build_type
1024            .as_ref()
1025            .and_then(|build_type| build_type.luarocks_build_backend());
1026
1027        if let Some(build_backend_dependency) = build_backend_dependency {
1028            build_dependencies.push(build_backend_dependency);
1029        }
1030
1031        if !build_dependencies.is_empty() {
1032            template.push(BuildDependencies(&build_dependencies).display_lua());
1033        }
1034
1035        match self.internal.external_dependencies {
1036            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1037                template.push(ExternalDependencies(external_dependencies).display_lua());
1038            }
1039            _ => {}
1040        }
1041
1042        if let Some(source) = source {
1043            template.push(source);
1044        }
1045
1046        template.push(
1047            build_spec_with_autodetected_modules(&self.internal.build, &self.internal.lux_toml_dir)
1048                .display_lua(),
1049        );
1050
1051        let unformatted_code = std::iter::once(starter)
1052            .chain(template.into_iter().map(|kv| kv.to_string()))
1053            .join("\n\n");
1054        let result = match stylua_lib::format_code(
1055            &unformatted_code,
1056            stylua_lib::Config::default(),
1057            None,
1058            stylua_lib::OutputVerification::Full,
1059        ) {
1060            Ok(formatted_code) => formatted_code,
1061            Err(_) => unformatted_code,
1062        };
1063        validate_generated_lua(&result)?;
1064        Ok(result)
1065    }
1066}
1067
1068impl Rockspec for LocalProjectToml {
1069    type Error = ProjectTomlError;
1070
1071    fn package(&self) -> &PackageName {
1072        &self.package
1073    }
1074
1075    fn version(&self) -> &PackageVersion {
1076        &self.version
1077    }
1078
1079    fn description(&self) -> &RockDescription {
1080        &self.description
1081    }
1082
1083    fn supported_platforms(&self) -> &PlatformSupport {
1084        &self.supported_platforms
1085    }
1086
1087    fn lua(&self) -> &PackageVersionReq {
1088        &self.lua
1089    }
1090
1091    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1092        &self.dependencies
1093    }
1094
1095    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1096        &self.build_dependencies
1097    }
1098
1099    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1100        &self.external_dependencies
1101    }
1102
1103    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1104        &self.test_dependencies
1105    }
1106
1107    fn build(&self) -> &PerPlatform<BuildSpec> {
1108        &self.build
1109    }
1110
1111    fn test(&self) -> &PerPlatform<TestSpec> {
1112        &self.test
1113    }
1114
1115    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1116        &mut self.build
1117    }
1118
1119    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1120        &mut self.test
1121    }
1122
1123    fn format(&self) -> &Option<RockspecFormat> {
1124        &self.rockspec_format
1125    }
1126
1127    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1128        &self.source
1129    }
1130
1131    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1132        &mut self.source
1133    }
1134
1135    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1136        &self.deploy
1137    }
1138
1139    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1140        &mut self.deploy
1141    }
1142
1143    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1144        let lux_toml_dir = &self.internal.lux_toml_dir;
1145        let version = self
1146            .internal
1147            .version_template
1148            .try_generate(lux_toml_dir, None)?;
1149        let source =
1150            self.internal
1151                .source_template
1152                .try_generate(lux_toml_dir, &self.package, &version)?;
1153        self.render_rockspec(&version, Some(source.display_lua()))
1154    }
1155}
1156
1157/// Creates a [`BuildSpecInternal`] with auto-detected modules (e.g. *.luau sources)
1158/// that LuaRocks does not auto-detect, so that LuaRocks can install them.
1159fn build_spec_with_autodetected_modules(
1160    spec: &BuildSpecInternal,
1161    project_root: &Path,
1162) -> BuildSpecInternal {
1163    let builtin_capable = spec
1164        .build_type
1165        .as_ref()
1166        .is_none_or(|build_type| matches!(build_type, BuildType::Builtin | BuildType::None));
1167    if !builtin_capable {
1168        return spec.clone();
1169    }
1170    let detected = match autodetect_modules(project_root, Default::default()) {
1171        Ok(modules) => modules
1172            .into_iter()
1173            .filter_map(|(module, spec)| match spec {
1174                ModuleSpec::SourcePath(path)
1175                    if path.extension().is_some_and(|ext| ext == "luau") =>
1176                {
1177                    Some((module, path))
1178                }
1179                _ => None,
1180            })
1181            .collect_vec(),
1182        Err(_) => return spec.clone(),
1183    };
1184    if detected.is_empty() {
1185        spec.clone()
1186    } else {
1187        let mut new_spec = spec.clone();
1188        let modules = new_spec.builtin_spec.get_or_insert_with(Default::default);
1189        for (module, path) in detected {
1190            modules
1191                .entry(LuaTableKey::StringKey(module.to_string()))
1192                .or_insert(ModuleSpecInternal::SourcePath(path));
1193        }
1194        new_spec
1195    }
1196}
1197
1198fn validate_generated_lua(lua_str: &str) -> Result<(), ProjectTomlError> {
1199    let mut lua = ottavino::Lua::core();
1200    lua.try_enter(|ctx| {
1201        let closure = ottavino::Closure::load(ctx, None, lua_str.as_bytes())?;
1202        let executor = ottavino::Executor::start(ctx, closure.into(), ());
1203        if !executor.step(ctx, &mut ottavino::Fuel::with(ROCKSPEC_FUEL_LIMIT))? {
1204            return Ok(Err(ProjectTomlError::FuelLimitExceeded));
1205        }
1206        Ok(Ok(()))
1207    })
1208    .map_err(|err| ProjectTomlError::GeneratedInvalidLua(err.to_string()))?
1209}
1210
1211#[derive(Error, Debug, Diagnostic)]
1212#[non_exhaustive]
1213#[error(transparent)]
1214pub enum ProjectTomlIntegrityError {
1215    LuaRockspecError(#[from] LuaRockspecError),
1216    IoError(#[from] io::Error),
1217    #[error(transparent)]
1218    #[diagnostic(transparent)]
1219    Fs(#[from] fs::FsError),
1220}
1221
1222impl HasIntegrity for LocalProjectToml {
1223    async fn hash(&self) -> io::Result<Integrity> {
1224        match self.to_lua_rockspec() {
1225            Ok(lua_rockspec) => lua_rockspec.hash().await,
1226            Err(_) => self.internal.hash().await,
1227        }
1228    }
1229}
1230
1231/// The `lux.toml`, after being validated and prepared for upload
1232#[derive(Debug)]
1233pub struct RemoteProjectToml {
1234    local: LocalProjectToml,
1235    source: PerPlatform<RemoteRockSource>,
1236}
1237
1238impl RemoteProjectToml {
1239    pub fn to_lua_rockspec(&self) -> Result<RemoteLuaRockspec, LuaRockspecError> {
1240        RemoteLuaRockspec::new(&self.to_lua_remote_rockspec_string()?)
1241    }
1242}
1243
1244impl Rockspec for RemoteProjectToml {
1245    type Error = ProjectTomlError;
1246
1247    fn package(&self) -> &PackageName {
1248        self.local.package()
1249    }
1250
1251    fn version(&self) -> &PackageVersion {
1252        self.local.version()
1253    }
1254
1255    fn description(&self) -> &RockDescription {
1256        self.local.description()
1257    }
1258
1259    fn supported_platforms(&self) -> &PlatformSupport {
1260        self.local.supported_platforms()
1261    }
1262
1263    fn lua(&self) -> &PackageVersionReq {
1264        self.local.lua()
1265    }
1266
1267    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1268        self.local.dependencies()
1269    }
1270
1271    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1272        self.local.build_dependencies()
1273    }
1274
1275    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1276        self.local.external_dependencies()
1277    }
1278
1279    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1280        self.local.test_dependencies()
1281    }
1282
1283    fn build(&self) -> &PerPlatform<BuildSpec> {
1284        self.local.build()
1285    }
1286
1287    fn test(&self) -> &PerPlatform<TestSpec> {
1288        self.local.test()
1289    }
1290
1291    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1292        self.local.build_mut()
1293    }
1294
1295    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1296        self.local.test_mut()
1297    }
1298
1299    fn format(&self) -> &Option<RockspecFormat> {
1300        self.local.format()
1301    }
1302
1303    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1304        &self.source
1305    }
1306
1307    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1308        &mut self.source
1309    }
1310
1311    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1312        self.local.deploy()
1313    }
1314
1315    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1316        self.local.deploy_mut()
1317    }
1318
1319    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1320        let lux_toml_dir = &self.local.internal.lux_toml_dir;
1321        let starter = format!(
1322            r#"
1323rockspec_format = "{}"
1324package = "{}"
1325version = "{}""#,
1326            self.local
1327                .rockspec_format
1328                .as_ref()
1329                .unwrap_or(&RockspecFormat::default()),
1330            self.local.package,
1331            self.version()
1332        );
1333
1334        let mut template = Vec::new();
1335
1336        if self.local.description != RockDescription::default() {
1337            template.push(self.local.description.display_lua());
1338        }
1339
1340        if self.local.supported_platforms != PlatformSupport::default() {
1341            template.push(self.local.supported_platforms.display_lua());
1342        }
1343
1344        {
1345            let mut dependencies = self.local.internal.dependencies.clone().unwrap_or_default();
1346            if !self.local.lua.is_luau() {
1347                dependencies.insert(
1348                    0,
1349                    PackageReq {
1350                        name: "lua".into(),
1351                        version_req: self.local.lua.clone(),
1352                    }
1353                    .into(),
1354                );
1355            }
1356            template.push(Dependencies(&dependencies).display_lua());
1357        }
1358
1359        let mut build_dependencies = self
1360            .local
1361            .internal
1362            .build_dependencies
1363            .as_ref()
1364            .cloned()
1365            .unwrap_or_default();
1366
1367        let build_backend_dependency = self
1368            .local
1369            .internal
1370            .build
1371            .build_type
1372            .as_ref()
1373            .and_then(|build_type| build_type.luarocks_build_backend());
1374
1375        if let Some(build_backend_dependency) = build_backend_dependency {
1376            build_dependencies.push(build_backend_dependency);
1377        }
1378
1379        if !build_dependencies.is_empty() {
1380            template.push(BuildDependencies(&build_dependencies).display_lua());
1381        }
1382
1383        match self.local.internal.external_dependencies {
1384            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1385                template.push(ExternalDependencies(external_dependencies).display_lua());
1386            }
1387            _ => {}
1388        }
1389
1390        let source = self.local.internal.source_template.try_generate(
1391            lux_toml_dir,
1392            &self.local.internal.package,
1393            self.version(),
1394        )?;
1395        template.push(source.display_lua());
1396
1397        if let Some(ref deploy) = self.local.internal.deploy {
1398            template.push(deploy.display_lua());
1399        }
1400
1401        template.push(
1402            build_spec_with_autodetected_modules(&self.local.internal.build, lux_toml_dir)
1403                .display_lua(),
1404        );
1405
1406        let unformatted_code = std::iter::once(starter)
1407            .chain(template.into_iter().map(|kv| kv.to_string()))
1408            .join("\n\n");
1409        let result = match stylua_lib::format_code(
1410            &unformatted_code,
1411            stylua_lib::Config::default(),
1412            None,
1413            stylua_lib::OutputVerification::Full,
1414        ) {
1415            Ok(formatted_code) => formatted_code,
1416            Err(_) => unformatted_code,
1417        };
1418        validate_generated_lua(&result)?;
1419        Ok(result)
1420    }
1421}
1422
1423impl HasIntegrity for RemoteProjectToml {
1424    async fn hash(&self) -> io::Result<Integrity> {
1425        self.to_lua_rockspec()
1426            .map_err(|err| {
1427                io::Error::other(format!(
1428                    "unable to convert remote project to Lua rockspec:\n{}",
1429                    err
1430                ))
1431            })?
1432            .hash()
1433            .await
1434    }
1435}
1436
1437#[cfg(test)]
1438mod tests {
1439    use std::path::PathBuf;
1440
1441    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
1442    use git2::Repository;
1443    use url::Url;
1444
1445    use crate::{
1446        git::{url::RemoteGitUrl, GitRef, GitSource},
1447        lua_rockspec::{PartialLuaRockspec, PerPlatform, RemoteLuaRockspec, RockSourceSpec},
1448        project::{Project, ProjectRoot, PROJECT_TOML},
1449        rockspec::{lua_dependency::LuaDependencySpec, Rockspec},
1450    };
1451
1452    use super::PartialProjectToml;
1453
1454    #[test]
1455    fn project_toml_parsing() {
1456        let project_toml = r#"
1457        package = "my-package"
1458        version = "1.0.0"
1459        lua = "5.3"
1460
1461        rockspec_format = "1.0"
1462
1463        [source]
1464        url = "https://example.com"
1465
1466        [dependencies]
1467        foo = "1.0"
1468        bar = ">=2.0"
1469
1470        [run]
1471        args = ["--foo", "--bar"]
1472
1473        [build]
1474        type = "builtin"
1475        "#;
1476
1477        let project =
1478            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1479        let _ = project.into_remote(None).unwrap();
1480
1481        let project_toml = r#"
1482        package = "my-package"
1483        version = "1.0.0"
1484        lua = "5.1"
1485
1486        [description]
1487        summary = "A summary"
1488        detailed = "A detailed description"
1489        license = "MIT"
1490        homepage = "https://example.com"
1491        issues_url = "https://example.com/issues"
1492        maintainer = "John Doe"
1493        labels = ["label1", "label2"]
1494
1495        [supported_platforms]
1496        linux = true
1497        windows = false
1498
1499        [dependencies]
1500        foo = "1.0"
1501        bar = ">=2.0"
1502
1503        [build_dependencies]
1504        baz = "1.0"
1505
1506        [external_dependencies.foo]
1507        header = "foo.h"
1508
1509        [external_dependencies.bar]
1510        library = "libbar.so"
1511
1512        [test_dependencies]
1513        busted = "69.420"
1514
1515        [source]
1516        url = "https://example.com"
1517        hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU="
1518        file = "my-package-1.0.0.tar.gz"
1519        dir = "my-package-1.0.0"
1520
1521        [test]
1522        type = "command"
1523        script = "test.lua"
1524        flags = [ "foo", "bar" ]
1525
1526        [run]
1527        command = "my-command"
1528        args = ["--foo", "--bar"]
1529
1530        [build]
1531        type = "builtin"
1532        "#;
1533
1534        let project =
1535            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1536        let _ = project.into_remote(None).unwrap();
1537    }
1538
1539    #[test]
1540    fn compare_project_toml_with_rockspec() {
1541        let project_toml = r#"
1542        package = "my-package"
1543        version = "1.0.0"
1544        lua = "5.1"
1545
1546        # For testing, specify a custom rockspec format
1547        # (defaults to 3.0)
1548        rockspec_format = "1.0"
1549
1550        [description]
1551        summary = "A summary"
1552        detailed = "A detailed description"
1553        license = "MIT"
1554        homepage = "https://example.com"
1555        issues_url = "https://example.com/issues"
1556        maintainer = "John Doe"
1557        labels = ["label1", "label2"]
1558
1559        [supported_platforms]
1560        linux = true
1561        windows = false
1562
1563        [dependencies]
1564        foo = "1.0"
1565        bar = ">=2.0"
1566
1567        [build_dependencies]
1568        baz = "1.0"
1569
1570        [external_dependencies.foo]
1571        header = "foo.h"
1572
1573        [external_dependencies.bar]
1574        library = "libbar.so"
1575
1576        [test_dependencies]
1577        busted = "1.0"
1578
1579        [source]
1580        url = "https://example.com"
1581        file = "my-package-1.0.0.tar.gz"
1582        dir = "my-package-1.0.0"
1583
1584        [test]
1585        type = "command"
1586        script = "test.lua"
1587        flags = [ "foo", "bar" ]
1588
1589        [run]
1590        command = "my-command"
1591        args = ["--foo", "--bar"]
1592
1593        [deploy]
1594        wrap_bin_scripts = false
1595
1596        [build]
1597        type = "builtin"
1598
1599        [build.install.lua]
1600        "foo.bar" = "src/bar.lua"
1601
1602        [build.install.lib]
1603        "foo.baz" = "src/baz.c"
1604
1605        [build.install.bin]
1606        "bla" = "src/bla"
1607
1608        [build.install.conf]
1609        "cfg.conf" = "resources/config.conf"
1610        "#;
1611
1612        let expected_rockspec = r#"
1613            rockspec_format = "1.0"
1614            package = "my-package"
1615            version = "1.0.0"
1616
1617            source = {
1618                url = "https://example.com",
1619                file = "my-package-1.0.0.tar.gz",
1620                dir = "my-package-1.0.0",
1621            }
1622
1623            description = {
1624                summary = "A summary",
1625                detailed = "A detailed description",
1626                license = "MIT",
1627                homepage = "https://example.com",
1628                issues_url = "https://example.com/issues",
1629                maintainer = "John Doe",
1630                labels = {"label1", "label2"},
1631            }
1632
1633            supported_platforms = {"linux", "!windows"}
1634
1635            dependencies = {
1636                "lua ==5.1",
1637                "foo ==1.0",
1638                "bar >=2.0",
1639            }
1640
1641            build_dependencies = {
1642                "baz ==1.0",
1643            }
1644
1645            external_dependencies = {
1646                foo = { header = "foo.h" },
1647                bar = { library = "libbar.so" },
1648            }
1649
1650            source = {
1651                url = "https://example.com",
1652                hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU=",
1653                file = "my-package-1.0.0.tar.gz",
1654                dir = "my-package-1.0.0",
1655            }
1656
1657            test = {
1658                type = "command",
1659                script = "test.lua",
1660                flags = {"foo", "bar"},
1661            }
1662
1663            deploy = {
1664                wrap_bin_scripts = false,
1665            }
1666
1667            build = {
1668                type = "builtin",
1669                install = {
1670                    lua = {
1671                        ["foo.bar"] = "src/bar.lua",
1672                    },
1673                    lib = {
1674                        ["foo.baz"] = "src/baz.c",
1675                    },
1676                    bin = {
1677                        bla = "src/bla",
1678                    },
1679                    conf = {
1680                        ["cfg.conf"] = "resources/config.conf",
1681                    },
1682                },
1683            }
1684        "#;
1685
1686        let expected_rockspec = RemoteLuaRockspec::new(expected_rockspec).unwrap();
1687
1688        let project_toml =
1689            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1690        let rockspec = project_toml
1691            .into_remote(None)
1692            .unwrap()
1693            .to_lua_rockspec()
1694            .unwrap();
1695
1696        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1697            let mut v = v.current_platform().clone();
1698            v.sort_by(|a, b| a.name().cmp(b.name()));
1699            v
1700        };
1701
1702        assert_eq!(rockspec.package(), expected_rockspec.package());
1703        assert_eq!(rockspec.version(), expected_rockspec.version());
1704        assert_eq!(rockspec.description(), expected_rockspec.description());
1705        assert_eq!(
1706            rockspec.supported_platforms(),
1707            expected_rockspec.supported_platforms()
1708        );
1709        assert_eq!(
1710            sorted_package_reqs(rockspec.dependencies()),
1711            sorted_package_reqs(expected_rockspec.dependencies())
1712        );
1713        assert_eq!(
1714            sorted_package_reqs(rockspec.build_dependencies()),
1715            sorted_package_reqs(expected_rockspec.build_dependencies())
1716        );
1717        assert_eq!(
1718            rockspec.external_dependencies(),
1719            expected_rockspec.external_dependencies()
1720        );
1721        assert_eq!(rockspec.source(), expected_rockspec.source());
1722        assert_eq!(rockspec.build(), expected_rockspec.build());
1723        assert_eq!(rockspec.format(), expected_rockspec.format());
1724    }
1725
1726    #[test]
1727    fn merge_project_toml_with_partial_rockspec() {
1728        let project_toml = r#"
1729        package = "my-package"
1730        version = "1.0.0"
1731        lua = "5.1"
1732
1733        # For testing, specify a custom rockspec format
1734        # (defaults to 3.0)
1735        rockspec_format = "1.0"
1736
1737        [description]
1738        summary = "A summary"
1739        detailed = "A detailed description"
1740        license = "MIT"
1741        homepage = "https://example.com"
1742        issues_url = "https://example.com/issues"
1743        maintainer = "John Doe"
1744        labels = ["label1", "label2"]
1745
1746        [supported_platforms]
1747        linux = true
1748        windows = false
1749
1750        [dependencies]
1751        foo = "1.0"
1752        bar = ">=2.0"
1753
1754        [build_dependencies]
1755        baz = "1.0"
1756
1757        [external_dependencies.foo]
1758        header = "foo.h"
1759
1760        [external_dependencies.bar]
1761        library = "libbar.so"
1762
1763        [test_dependencies]
1764        busted = "1.0"
1765
1766        [source]
1767        url = "https://example.com"
1768        file = "my-package-1.0.0.tar.gz"
1769        dir = "my-package-1.0.0"
1770
1771        [test]
1772        type = "command"
1773        script = "test.lua"
1774        flags = [ "foo", "bar" ]
1775
1776        [run]
1777        command = "my-command"
1778        args = [ "--foo", "--bar" ]
1779
1780        [build]
1781        type = "builtin"
1782        "#;
1783
1784        let mergable_rockspec_content = r#"
1785            rockspec_format = "1.0"
1786            package = "my-package-overwritten"
1787
1788            description = {
1789                summary = "A summary overwritten",
1790                detailed = "A detailed description overwritten",
1791                license = "GPL-2.0",
1792                homepage = "https://example.com/overwritten",
1793                issues_url = "https://example.com/issues/overwritten",
1794                maintainer = "John Doe Overwritten",
1795                labels = {"over", "written"},
1796            }
1797
1798            -- Inverted supported platforms
1799            supported_platforms = {"!linux", "windows"}
1800
1801            dependencies = {
1802                "lua 5.1",
1803                "foo >1.0",
1804                "bar <=2.0",
1805            }
1806
1807            build_dependencies = {
1808                "baz >1.0",
1809            }
1810
1811            external_dependencies = {
1812                foo = { header = "overwritten.h" },
1813                bar = { library = "overwritten.so" },
1814            }
1815
1816            test = {
1817                type = "command",
1818                script = "overwritten.lua",
1819                flags = {"over", "written"},
1820            }
1821
1822            build = {
1823                type = "builtin",
1824            }
1825        "#;
1826
1827        let remote_rockspec_content = format!(
1828            r#"{}
1829            version = "1.0.0"
1830            source = {{
1831                url = "https://example.com",
1832                file = "my-package-1.0.0.tar.gz",
1833                dir = "my-package-1.0.0",
1834            }}
1835        "#,
1836            mergable_rockspec_content
1837        );
1838
1839        let project_toml =
1840            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1841        let partial_rockspec = PartialLuaRockspec::new(mergable_rockspec_content).unwrap();
1842        let expected_rockspec = RemoteLuaRockspec::new(&remote_rockspec_content).unwrap();
1843
1844        let merged = project_toml
1845            .merge(partial_rockspec)
1846            .into_remote(None)
1847            .unwrap();
1848
1849        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1850            let mut v = v.current_platform().clone();
1851            v.sort_by(|a, b| a.name().cmp(b.name()));
1852            v
1853        };
1854
1855        assert_eq!(merged.package(), expected_rockspec.package());
1856        assert_eq!(merged.version(), expected_rockspec.version());
1857        assert_eq!(merged.description(), expected_rockspec.description());
1858        assert_eq!(
1859            merged.supported_platforms(),
1860            expected_rockspec.supported_platforms()
1861        );
1862        assert_eq!(
1863            sorted_package_reqs(merged.dependencies()),
1864            sorted_package_reqs(expected_rockspec.dependencies())
1865        );
1866        assert_eq!(
1867            sorted_package_reqs(merged.build_dependencies()),
1868            sorted_package_reqs(expected_rockspec.build_dependencies())
1869        );
1870        assert_eq!(
1871            merged.external_dependencies(),
1872            expected_rockspec.external_dependencies()
1873        );
1874        assert_eq!(merged.source(), expected_rockspec.source());
1875        assert_eq!(merged.build(), expected_rockspec.build());
1876        assert_eq!(merged.format(), expected_rockspec.format());
1877        // Ensure that the run command is retained after merge.
1878        assert!(merged.local.run().is_some());
1879    }
1880
1881    #[test]
1882    fn wally_dependency_parsing() {
1883        let project_toml = r#"
1884        package = "my-package"
1885        version = "1.0.0"
1886        lua = "5.1"
1887
1888        [dependencies.roact]
1889        wally = "jsdotperf/roact@^1.4.0"
1890
1891        [build]
1892        type = "builtin"
1893        "#;
1894
1895        let project =
1896            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1897        let deps = project.dependencies.unwrap();
1898        assert_eq!(deps.len(), 1);
1899        assert_eq!(deps[0].name().to_string(), "roact");
1900        assert!(matches!(
1901            deps[0].source(),
1902            Some(RockSourceSpec::Wally(req)) if req.name().to_string() == "jsdotperf/roact"
1903        ));
1904    }
1905
1906    #[test]
1907    fn wally_dependency_conflicts_with_version() {
1908        let project_toml = r#"
1909        package = "my-package"
1910        version = "1.0.0"
1911        lua = "5.1"
1912
1913        [dependencies.roact]
1914        wally = "jsdotperf/roact@^1.4.0"
1915        version = "1.4.0"
1916
1917        [build]
1918        type = "builtin"
1919        "#;
1920
1921        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap_err();
1922    }
1923
1924    #[test]
1925    fn project_toml_with_lua_in_dependencies() {
1926        let project_toml = r#"
1927        package = "my-package"
1928        version = "1.0.0"
1929        # lua = ">5.1"
1930
1931        [dependencies]
1932        lua = "5.1" # disallowed
1933
1934        [build]
1935        type = "builtin"
1936        "#;
1937
1938        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default())
1939            .unwrap()
1940            .into_local()
1941            .unwrap_err();
1942    }
1943
1944    #[test]
1945    fn project_toml_with_invalid_run_command() {
1946        for command in ["lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"] {
1947            let project_toml = format!(
1948                r#"
1949                package = "my-package"
1950                version = "1.0.0"
1951                lua = "5.1"
1952
1953                [build]
1954                type = "builtin"
1955
1956                [run]
1957                command = "{command}"
1958                "#,
1959            );
1960
1961            PartialProjectToml::new(PROJECT_TOML, &project_toml, ProjectRoot::default())
1962                .unwrap_err();
1963        }
1964    }
1965
1966    #[test]
1967    fn project_root_dir_override_and_rockspec_generation() {
1968        let project_toml = r#"
1969        package = "my-package"
1970        version = "1.0.0"
1971        lua = "5.1"
1972
1973        [project]
1974        root_dir = ".."
1975
1976        [source]
1977        url = "https://example.com"
1978
1979        [build]
1980        type = "builtin"
1981        "#;
1982
1983        let workspace_root = assert_fs::TempDir::new().unwrap();
1984        let lux_toml_dir = workspace_root.child("proj");
1985        lux_toml_dir.create_dir_all().unwrap();
1986        std::fs::write(lux_toml_dir.join(PROJECT_TOML), project_toml).unwrap();
1987
1988        let project = PartialProjectToml::new(
1989            PROJECT_TOML,
1990            project_toml,
1991            ProjectRoot(lux_toml_dir.to_path_buf()),
1992        )
1993        .unwrap();
1994        let local = project.into_local().unwrap();
1995        let RockSourceSpec::File(root_dir) = &local.source().current_platform().source_spec else {
1996            panic!("expected a file source");
1997        };
1998        assert_eq!(
1999            root_dir,
2000            &std::fs::canonicalize(workspace_root.path()).unwrap()
2001        );
2002
2003        // The `[project]` table must be ignored when generating a rockspec.
2004        let rockspec = local.to_lua_rockspec().unwrap();
2005        assert!(!rockspec
2006            .to_lua_remote_rockspec_string()
2007            .unwrap()
2008            .contains("root_dir"));
2009    }
2010
2011    #[test]
2012    fn generate_non_deterministic_git_source() {
2013        let rockspec_content = r#"
2014            package = "test-package"
2015            version = "1.0.0"
2016            lua = ">=5.1"
2017
2018            [source]
2019            url = "git+https://exaple.com/repo.git"
2020
2021            [build]
2022            type = "builtin"
2023        "#;
2024
2025        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2026            .unwrap()
2027            .into_remote(None)
2028            .unwrap_err();
2029    }
2030
2031    #[test]
2032    fn generate_deterministic_git_source() {
2033        let rockspec_content = r#"
2034            package = "test-package"
2035            version = "1.0.0"
2036            lua = ">=5.1"
2037
2038            [source]
2039            url = "git+https://exaple.com/owner/repo.git"
2040            tag = "v0.1.0"
2041
2042            [build]
2043            type = "builtin"
2044        "#;
2045
2046        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2047            .unwrap()
2048            .into_remote(None)
2049            .unwrap();
2050    }
2051
2052    #[test]
2053    fn generate_git_source_with_branch() {
2054        let rockspec_content = r#"
2055            package = "test-package"
2056            version = "1.0.0"
2057            lua = ">=5.1"
2058
2059            [source]
2060            url = "git+https://example.com/owner/repo.git"
2061            branch = "main"
2062
2063            [build]
2064            type = "builtin"
2065        "#;
2066
2067        let toml = PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2068            .unwrap()
2069            .into_remote(None)
2070            .unwrap();
2071
2072        let lua = toml.to_lua_remote_rockspec_string().unwrap();
2073        assert!(lua.contains("branch = \"main\""));
2074        assert!(!lua.contains("tag = "));
2075    }
2076
2077    fn init_git_repo(path: &std::path::Path) -> Repository {
2078        let repo = Repository::init(path).unwrap();
2079        {
2080            let tree_id = repo.index().unwrap().write_tree().unwrap();
2081            let tree = repo.find_tree(tree_id).unwrap();
2082            let sig = git2::Signature::now("name", "email").unwrap();
2083            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
2084                .unwrap();
2085        }
2086        repo
2087    }
2088
2089    fn init_sample_project_repo(temp_dir: &assert_fs::TempDir) -> Repository {
2090        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2091            .join("resources/test/sample-projects/source-template/");
2092        temp_dir.copy_from(&sample_project, &["**"]).unwrap();
2093        init_git_repo(temp_dir.path())
2094    }
2095
2096    fn create_tag(repo: &Repository, name: &str) {
2097        let sig = git2::Signature::now("name", "email").unwrap();
2098        let id = repo.head().unwrap().target().unwrap();
2099        let obj = repo.find_object(id, None).unwrap();
2100        repo.tag(name, &obj, &sig, "msg", true).unwrap();
2101    }
2102
2103    #[test]
2104    fn test_git_project_generate_dev_source() {
2105        let project_root = assert_fs::TempDir::new().unwrap();
2106        init_sample_project_repo(&project_root);
2107        let project = Project::from_exact(&project_root).unwrap().unwrap();
2108        let remote_project_toml = project.toml().into_remote(None).unwrap();
2109        let source = remote_project_toml.source.current_platform();
2110        let source_spec = &source.source_spec;
2111        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2112        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2113            let expected_url: RemoteGitUrl =
2114                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2115            assert_eq!(url, &expected_url);
2116            assert!(git_ref.is_some());
2117        }
2118        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2119    }
2120
2121    #[test]
2122    fn test_git_project_generate_non_semver_tag_source() {
2123        let project_root = assert_fs::TempDir::new().unwrap();
2124        let repo = init_sample_project_repo(&project_root);
2125        let tag_name = "bla";
2126        create_tag(&repo, tag_name);
2127        let project = Project::from_exact(&project_root).unwrap().unwrap();
2128        let remote_project_toml = project.toml().into_remote(None).unwrap();
2129        let source = remote_project_toml.source.current_platform();
2130        let source_spec = &source.source_spec;
2131        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2132        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2133            let expected_url: RemoteGitUrl =
2134                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2135            assert_eq!(url, &expected_url);
2136            assert_eq!(git_ref, &Some(GitRef::Tag(tag_name.to_string())));
2137        }
2138        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2139    }
2140
2141    #[test]
2142    fn test_git_project_generate_release_source_tag_with_v_prefix() {
2143        let project_root = assert_fs::TempDir::new().unwrap();
2144        let repo = init_sample_project_repo(&project_root);
2145        let tag_name = "v1.0.0";
2146        create_tag(&repo, "bla");
2147        create_tag(&repo, tag_name);
2148        let project = Project::from_exact(&project_root).unwrap().unwrap();
2149        let remote_project_toml = project.toml().into_remote(None).unwrap();
2150        let source = remote_project_toml.source.current_platform();
2151        let source_spec = &source.source_spec;
2152        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2153        if let RockSourceSpec::Url(url) = source_spec {
2154            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/v1.0.0.zip"
2155                .parse()
2156                .unwrap();
2157            assert_eq!(url, &expected_url);
2158        }
2159        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2160    }
2161
2162    #[test]
2163    fn test_git_project_generate_release_source_tag_without_v_prefix() {
2164        let project_root = assert_fs::TempDir::new().unwrap();
2165        let repo = init_sample_project_repo(&project_root);
2166        create_tag(&repo, "bla");
2167        let tag_name = "1.0.0";
2168        create_tag(&repo, tag_name);
2169        let project = Project::from_exact(&project_root).unwrap().unwrap();
2170        let remote_project_toml = project.toml().into_remote(None).unwrap();
2171        let source = remote_project_toml.source.current_platform();
2172        let source_spec = &source.source_spec;
2173        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2174        if let RockSourceSpec::Url(url) = source_spec {
2175            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2176                .parse()
2177                .unwrap();
2178            assert_eq!(url, &expected_url);
2179        }
2180        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2181    }
2182
2183    #[test]
2184    fn test_git_project_in_subdirectory() {
2185        let temp_dir = assert_fs::TempDir::new().unwrap();
2186        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2187            .join("resources/test/sample-projects/source-template/");
2188        let project_dir = temp_dir.child("lux");
2189        project_dir.create_dir_all().unwrap();
2190        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2191        let repo = init_git_repo(temp_dir.path());
2192        create_tag(&repo, "bla");
2193        let tag_name = "1.0.0";
2194        create_tag(&repo, tag_name);
2195        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2196        let remote_project_toml = project.toml().into_remote(None).unwrap();
2197        let source = remote_project_toml.source.current_platform();
2198        let source_spec = &source.source_spec;
2199        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2200        if let RockSourceSpec::Url(url) = source_spec {
2201            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2202                .parse()
2203                .unwrap();
2204            assert_eq!(url, &expected_url);
2205        }
2206        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2207    }
2208
2209    #[test]
2210    fn test_git_project_in_subdirectory_generate_dev_source() {
2211        let temp_dir = assert_fs::TempDir::new().unwrap();
2212        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2213            .join("resources/test/sample-projects/source-template/");
2214        let project_dir = temp_dir.child("lux");
2215        project_dir.create_dir_all().unwrap();
2216        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2217        init_git_repo(temp_dir.path());
2218
2219        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2220        let remote_project_toml = project.toml().into_remote(None).unwrap();
2221        let RockSourceSpec::Git(GitSource { git_ref, .. }) =
2222            &remote_project_toml.source.current_platform().source_spec
2223        else {
2224            panic!("expected a git source");
2225        };
2226        assert!(git_ref.is_some());
2227    }
2228}