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        let version = self
684            .version_template
685            .try_generate(&self.lux_toml_dir, specrev)?;
686        let source =
687            self.source_template
688                .try_generate(&self.lux_toml_dir, &self.package, &version)?;
689        let source = PerPlatform::new(RemoteRockSource::try_from(source).map_err(|err| {
690            RemoteProjectTomlValidationError::LocalProjectTomlValidationError(
691                LocalProjectTomlValidationError::RockSource(err),
692            )
693        })?);
694        let mut local = self.into_local()?;
695        local.version = version;
696
697        let validated = RemoteProjectToml { source, local };
698
699        Ok(validated)
700    }
701
702    // In the not-yet-validated struct, we create getters only
703    // for the non-optional fields.
704    pub fn package(&self) -> &PackageName {
705        &self.package
706    }
707
708    /// Returns the current package version, which may be generated from a template
709    pub fn version(&self) -> Result<PackageVersion, GenerateVersionError> {
710        self.version_template.try_generate(&self.lux_toml_dir, None)
711    }
712
713    /// Merge the `ProjectToml` struct with an unvalidated `LuaRockspec`.
714    /// The final merged struct can then be validated.
715    pub fn merge(self, other: PartialLuaRockspec) -> Self {
716        PartialProjectToml {
717            package: other.package.unwrap_or(self.package),
718            version_template: self.version_template,
719            lua: other
720                .dependencies
721                .as_ref()
722                .and_then(|deps| {
723                    deps.iter()
724                        .find(|dep| dep.name() == &"lua".into())
725                        .and_then(|dep| {
726                            if dep.version_req().is_any() {
727                                None
728                            } else {
729                                Some(dep.version_req().clone())
730                            }
731                        })
732                })
733                .or(self.lua),
734            build: other.build.unwrap_or(self.build),
735            run: self.run,
736            description: other.description.or(self.description),
737            supported_platforms: other
738                .supported_platforms
739                .map(|platform_support| platform_support.platforms().clone())
740                .or(self.supported_platforms),
741            dependencies: other
742                .dependencies
743                .map(|deps| {
744                    deps.into_iter()
745                        .filter(|dep| dep.name() != &"lua".into())
746                        .collect()
747                })
748                .or(self.dependencies),
749            build_dependencies: other.build_dependencies.or(self.build_dependencies),
750            test_dependencies: other.test_dependencies.or(self.test_dependencies),
751            external_dependencies: other.external_dependencies.or(self.external_dependencies),
752            source_template: self.source_template,
753            test: other.test.or(self.test),
754            deploy: other.deploy.or(self.deploy),
755            rockspec_format: other.rockspec_format.or(self.rockspec_format),
756
757            // Keep the project root the same, as it is not part of the lua rockspec
758            project: self.project,
759            lux_toml_dir: self.lux_toml_dir.clone(),
760        }
761    }
762
763    fn resolve_lua_dependency_spec(&self, dep: LuaDependencySpec) -> LuaDependencySpec {
764        match &dep.source {
765            Some(RockSourceSpec::File(path)) if path.is_dir() => dep,
766            Some(RockSourceSpec::File(path)) => LuaDependencySpec {
767                source: Some(RockSourceSpec::File(self.lux_toml_dir.join(path))),
768                ..dep
769            },
770            _ => dep,
771        }
772    }
773}
774
775// This is automatically implemented for `RemoteProjectToml`,
776// but we also add a special implementation for `ProjectToml` (as providing a lua version
777// is required even by the non-validated struct).
778impl LuaVersionCompatibility for PartialProjectToml {
779    fn validate_lua_version(&self, version: &LuaVersion) -> Result<(), LuaVersionError> {
780        if self.supports_lua_version(version) {
781            Ok(())
782        } else {
783            Err(LuaVersionError::LuaVersionUnsupported(
784                version.clone(),
785                self.package().to_owned(),
786                self.version_template
787                    .try_generate(&self.lux_toml_dir, None)
788                    .unwrap_or(PackageVersion::default_dev_version()),
789            ))
790        }
791    }
792
793    fn validate_lua_version_from_config(&self, config: &Config) -> Result<(), LuaVersionError> {
794        let _ = self.lua_version_matches(config)?;
795        Ok(())
796    }
797
798    fn lua_version_matches(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
799        let version = LuaVersion::from(config)?.clone();
800        if self.supports_lua_version(&version) {
801            Ok(version)
802        } else {
803            Err(LuaVersionError::LuaVersionUnsupported(
804                version,
805                self.package.clone(),
806                self.version_template
807                    .try_generate(&self.lux_toml_dir, None)
808                    .unwrap_or(PackageVersion::default_dev_version()),
809            ))
810        }
811    }
812
813    fn supports_lua_version(&self, lua_version: &LuaVersion) -> bool {
814        self.lua.as_ref().is_none_or(|lua| {
815            if lua.is_luau() {
816                return matches!(lua_version, LuaVersion::Luau);
817            }
818            lua.matches(&lua_version.as_version())
819        })
820    }
821
822    fn lua_version(&self) -> Option<LuaVersion> {
823        if self.lua.as_ref().is_some_and(|lua| lua.is_luau()) {
824            return Some(LuaVersion::Luau);
825        }
826        for (possibility, version) in [
827            ("5.5.0", LuaVersion::Lua55),
828            ("5.4.0", LuaVersion::Lua54),
829            ("5.3.0", LuaVersion::Lua53),
830            ("5.2.0", LuaVersion::Lua52),
831            ("5.1.0", LuaVersion::Lua51),
832        ] {
833            let possibility = unsafe { possibility.parse().unwrap_unchecked() };
834            if self
835                .lua
836                .as_ref()
837                .is_none_or(|lua| lua.matches(&possibility))
838            {
839                return Some(version);
840            }
841        }
842        None
843    }
844}
845
846/// Lux-specific project configuration.
847///
848/// This is not part of the rockspec format and is ignored when generating a rockspec.
849#[derive(Debug, Clone, Deserialize)]
850#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
851pub(crate) struct ProjectSpec {
852    /// The project root directory, relative to the directory containing the `lux.toml`.
853    /// Useful for multi-project workspaces, where the build files
854    /// (e.g. the cargo workspace `Cargo.toml`) do not live next to the `lux.toml`.
855    root_dir: Option<PathBuf>,
856    /// The project root directory, resolved from `root_dir` when deserializing.
857    #[serde(skip, default = "ProjectRoot::new")]
858    pub(crate) root: ProjectRoot,
859}
860
861impl Default for ProjectSpec {
862    fn default() -> Self {
863        Self {
864            root_dir: None,
865            root: ProjectRoot::new(),
866        }
867    }
868}
869
870// TODO(vhyrro): Move this struct into a different directory.
871#[derive(Debug, Clone, Deserialize)]
872#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
873pub struct RunSpec {
874    /// The command to execute when running the project
875    pub(crate) command: Option<RunCommand>,
876    /// Arguments to pass to the command
877    #[cfg_attr(feature = "schema", schemars(with = "Option<Vec<String>>"))]
878    pub(crate) args: Option<NonEmpty<String>>,
879}
880
881/// The `lux.toml` file, after being properly deserialized.
882/// This struct may be used to build a local version of a project.
883/// To build a rockspec, use `RemoteProjectToml`.
884#[derive(Debug)]
885pub struct LocalProjectToml {
886    package: PackageName,
887    version: PackageVersion,
888    lua: PackageVersionReq,
889    rockspec_format: Option<RockspecFormat>,
890    run: Option<PerPlatform<RunSpec>>,
891    description: RockDescription,
892    supported_platforms: PlatformSupport,
893    dependencies: PerPlatform<Vec<LuaDependencySpec>>,
894    build_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
895    external_dependencies: PerPlatform<HashMap<String, ExternalDependencySpec>>,
896    test_dependencies: PerPlatform<Vec<LuaDependencySpec>>,
897    test: PerPlatform<TestSpec>,
898    build: PerPlatform<BuildSpec>,
899    deploy: PerPlatform<DeploySpec>,
900
901    // Used for simpler serialization
902    internal: PartialProjectToml,
903
904    /// A source pointing to the current project's root.
905    source: PerPlatform<RemoteRockSource>,
906}
907
908impl LocalProjectToml {
909    pub fn run(&self) -> Option<&PerPlatform<RunSpec>> {
910        self.run.as_ref()
911    }
912
913    /// Convert this project TOML to a Lua rockspec.
914    /// Fails if there is no valid project root or if there are off-spec dependencies.
915    pub fn to_lua_rockspec(&self) -> Result<LocalLuaRockspec, LuaRockspecError> {
916        if let Some(dep) = self
917            .dependencies()
918            .per_platform
919            .values()
920            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
921            .collect_vec()
922            .first()
923        {
924            return Err(LuaRockspecError::OffSpecDependency(dep.name().clone()));
925        }
926        if let Some(dep) = self
927            .build_dependencies()
928            .per_platform
929            .values()
930            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
931            .collect_vec()
932            .first()
933        {
934            return Err(LuaRockspecError::OffSpecBuildDependency(dep.name().clone()));
935        }
936        if let Some(dep) = self
937            .test_dependencies()
938            .per_platform
939            .values()
940            .filter_map(|deps| deps.iter().find(|dep| dep.source().is_some()))
941            .collect_vec()
942            .first()
943        {
944            return Err(LuaRockspecError::OffSpecTestDependency(dep.name().clone()));
945        }
946        LocalLuaRockspec::new(
947            &self.to_lua_remote_rockspec_string()?,
948            ProjectRoot(self.internal.lux_toml_dir.clone()),
949        )
950    }
951
952    /// Renders the rockspec for local builds, using the rockspec's own version
953    /// and its source pointing at the project root.
954    pub fn to_local_rockspec(&self) -> Result<RemoteLuaRockspec, LuaRockspecError> {
955        Ok(LocalLuaRockspec::from_project_toml(self).into_remote_with_source(self.source.clone()))
956    }
957
958    fn render_rockspec(
959        &self,
960        version: &PackageVersion,
961        source: Option<DisplayLuaKV>,
962    ) -> Result<String, ProjectTomlError> {
963        let starter = format!(
964            r#"
965rockspec_format = "{}"
966package = "{}"
967version = "{}""#,
968            self.rockspec_format
969                .as_ref()
970                .unwrap_or(&RockspecFormat::default()),
971            self.package,
972            version
973        );
974
975        let mut template = Vec::new();
976
977        if self.description != RockDescription::default() {
978            template.push(self.description.display_lua());
979        }
980
981        if self.supported_platforms != PlatformSupport::default() {
982            template.push(self.supported_platforms.display_lua());
983        }
984
985        {
986            let mut dependencies = self.internal.dependencies.clone().unwrap_or_default();
987            if !self.lua.is_luau() {
988                dependencies.insert(
989                    0,
990                    PackageReq {
991                        name: "lua".into(),
992                        version_req: self.lua.clone(),
993                    }
994                    .into(),
995                );
996            }
997            template.push(Dependencies(&dependencies).display_lua());
998        }
999
1000        let mut build_dependencies = self
1001            .internal
1002            .build_dependencies
1003            .as_ref()
1004            .cloned()
1005            .unwrap_or_default();
1006
1007        let build_backend_dependency = self
1008            .internal
1009            .build
1010            .build_type
1011            .as_ref()
1012            .and_then(|build_type| build_type.luarocks_build_backend());
1013
1014        if let Some(build_backend_dependency) = build_backend_dependency {
1015            build_dependencies.push(build_backend_dependency);
1016        }
1017
1018        if !build_dependencies.is_empty() {
1019            template.push(BuildDependencies(&build_dependencies).display_lua());
1020        }
1021
1022        match self.internal.external_dependencies {
1023            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1024                template.push(ExternalDependencies(external_dependencies).display_lua());
1025            }
1026            _ => {}
1027        }
1028
1029        if let Some(source) = source {
1030            template.push(source);
1031        }
1032
1033        template.push(
1034            build_spec_with_autodetected_modules(&self.internal.build, &self.internal.lux_toml_dir)
1035                .display_lua(),
1036        );
1037
1038        let unformatted_code = std::iter::once(starter)
1039            .chain(template.into_iter().map(|kv| kv.to_string()))
1040            .join("\n\n");
1041        let result = match stylua_lib::format_code(
1042            &unformatted_code,
1043            stylua_lib::Config::default(),
1044            None,
1045            stylua_lib::OutputVerification::Full,
1046        ) {
1047            Ok(formatted_code) => formatted_code,
1048            Err(_) => unformatted_code,
1049        };
1050        validate_generated_lua(&result)?;
1051        Ok(result)
1052    }
1053}
1054
1055impl Rockspec for LocalProjectToml {
1056    type Error = ProjectTomlError;
1057
1058    fn package(&self) -> &PackageName {
1059        &self.package
1060    }
1061
1062    fn version(&self) -> &PackageVersion {
1063        &self.version
1064    }
1065
1066    fn description(&self) -> &RockDescription {
1067        &self.description
1068    }
1069
1070    fn supported_platforms(&self) -> &PlatformSupport {
1071        &self.supported_platforms
1072    }
1073
1074    fn lua(&self) -> &PackageVersionReq {
1075        &self.lua
1076    }
1077
1078    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1079        &self.dependencies
1080    }
1081
1082    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1083        &self.build_dependencies
1084    }
1085
1086    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1087        &self.external_dependencies
1088    }
1089
1090    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1091        &self.test_dependencies
1092    }
1093
1094    fn build(&self) -> &PerPlatform<BuildSpec> {
1095        &self.build
1096    }
1097
1098    fn test(&self) -> &PerPlatform<TestSpec> {
1099        &self.test
1100    }
1101
1102    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1103        &mut self.build
1104    }
1105
1106    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1107        &mut self.test
1108    }
1109
1110    fn format(&self) -> &Option<RockspecFormat> {
1111        &self.rockspec_format
1112    }
1113
1114    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1115        &self.source
1116    }
1117
1118    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1119        &mut self.source
1120    }
1121
1122    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1123        &self.deploy
1124    }
1125
1126    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1127        &mut self.deploy
1128    }
1129
1130    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1131        let lux_toml_dir = &self.internal.lux_toml_dir;
1132        let version = self
1133            .internal
1134            .version_template
1135            .try_generate(lux_toml_dir, None)?;
1136        let source =
1137            self.internal
1138                .source_template
1139                .try_generate(lux_toml_dir, &self.package, &version)?;
1140        self.render_rockspec(&version, Some(source.display_lua()))
1141    }
1142}
1143
1144/// Creates a [`BuildSpecInternal`] with auto-detected modules (e.g. *.luau sources)
1145/// that LuaRocks does not auto-detect, so that LuaRocks can install them.
1146fn build_spec_with_autodetected_modules(
1147    spec: &BuildSpecInternal,
1148    project_root: &Path,
1149) -> BuildSpecInternal {
1150    let builtin_capable = spec
1151        .build_type
1152        .as_ref()
1153        .is_none_or(|build_type| matches!(build_type, BuildType::Builtin | BuildType::None));
1154    if !builtin_capable {
1155        return spec.clone();
1156    }
1157    let detected = match autodetect_modules(project_root, Default::default()) {
1158        Ok(modules) => modules
1159            .into_iter()
1160            .filter_map(|(module, spec)| match spec {
1161                ModuleSpec::SourcePath(path)
1162                    if path.extension().is_some_and(|ext| ext == "luau") =>
1163                {
1164                    Some((module, path))
1165                }
1166                _ => None,
1167            })
1168            .collect_vec(),
1169        Err(_) => return spec.clone(),
1170    };
1171    if detected.is_empty() {
1172        spec.clone()
1173    } else {
1174        let mut new_spec = spec.clone();
1175        let modules = new_spec.builtin_spec.get_or_insert_with(Default::default);
1176        for (module, path) in detected {
1177            modules
1178                .entry(LuaTableKey::StringKey(module.to_string()))
1179                .or_insert(ModuleSpecInternal::SourcePath(path));
1180        }
1181        new_spec
1182    }
1183}
1184
1185fn validate_generated_lua(lua_str: &str) -> Result<(), ProjectTomlError> {
1186    let mut lua = ottavino::Lua::core();
1187    lua.try_enter(|ctx| {
1188        let closure = ottavino::Closure::load(ctx, None, lua_str.as_bytes())?;
1189        let executor = ottavino::Executor::start(ctx, closure.into(), ());
1190        if !executor.step(ctx, &mut ottavino::Fuel::with(ROCKSPEC_FUEL_LIMIT))? {
1191            return Ok(Err(ProjectTomlError::FuelLimitExceeded));
1192        }
1193        Ok(Ok(()))
1194    })
1195    .map_err(|err| ProjectTomlError::GeneratedInvalidLua(err.to_string()))?
1196}
1197
1198#[derive(Error, Debug, Diagnostic)]
1199#[non_exhaustive]
1200#[error(transparent)]
1201pub enum ProjectTomlIntegrityError {
1202    LuaRockspecError(#[from] LuaRockspecError),
1203    IoError(#[from] io::Error),
1204    #[error(transparent)]
1205    #[diagnostic(transparent)]
1206    Fs(#[from] fs::FsError),
1207}
1208
1209impl HasIntegrity for LocalProjectToml {
1210    async fn hash(&self) -> io::Result<Integrity> {
1211        match self.to_lua_rockspec() {
1212            Ok(lua_rockspec) => lua_rockspec.hash().await,
1213            Err(_) => self.internal.hash().await,
1214        }
1215    }
1216}
1217
1218/// The `lux.toml`, after being validated and prepared for upload
1219#[derive(Debug)]
1220pub struct RemoteProjectToml {
1221    local: LocalProjectToml,
1222    source: PerPlatform<RemoteRockSource>,
1223}
1224
1225impl RemoteProjectToml {
1226    pub fn to_lua_rockspec(&self) -> Result<RemoteLuaRockspec, LuaRockspecError> {
1227        RemoteLuaRockspec::new(&self.to_lua_remote_rockspec_string()?)
1228    }
1229}
1230
1231impl Rockspec for RemoteProjectToml {
1232    type Error = ProjectTomlError;
1233
1234    fn package(&self) -> &PackageName {
1235        self.local.package()
1236    }
1237
1238    fn version(&self) -> &PackageVersion {
1239        self.local.version()
1240    }
1241
1242    fn description(&self) -> &RockDescription {
1243        self.local.description()
1244    }
1245
1246    fn supported_platforms(&self) -> &PlatformSupport {
1247        self.local.supported_platforms()
1248    }
1249
1250    fn lua(&self) -> &PackageVersionReq {
1251        self.local.lua()
1252    }
1253
1254    fn dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1255        self.local.dependencies()
1256    }
1257
1258    fn build_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1259        self.local.build_dependencies()
1260    }
1261
1262    fn external_dependencies(&self) -> &PerPlatform<HashMap<String, ExternalDependencySpec>> {
1263        self.local.external_dependencies()
1264    }
1265
1266    fn test_dependencies(&self) -> &PerPlatform<Vec<LuaDependencySpec>> {
1267        self.local.test_dependencies()
1268    }
1269
1270    fn build(&self) -> &PerPlatform<BuildSpec> {
1271        self.local.build()
1272    }
1273
1274    fn test(&self) -> &PerPlatform<TestSpec> {
1275        self.local.test()
1276    }
1277
1278    fn build_mut(&mut self) -> &mut PerPlatform<BuildSpec> {
1279        self.local.build_mut()
1280    }
1281
1282    fn test_mut(&mut self) -> &mut PerPlatform<TestSpec> {
1283        self.local.test_mut()
1284    }
1285
1286    fn format(&self) -> &Option<RockspecFormat> {
1287        self.local.format()
1288    }
1289
1290    fn source(&self) -> &PerPlatform<RemoteRockSource> {
1291        &self.source
1292    }
1293
1294    fn source_mut(&mut self) -> &mut PerPlatform<RemoteRockSource> {
1295        &mut self.source
1296    }
1297
1298    fn deploy(&self) -> &PerPlatform<DeploySpec> {
1299        self.local.deploy()
1300    }
1301
1302    fn deploy_mut(&mut self) -> &mut PerPlatform<DeploySpec> {
1303        self.local.deploy_mut()
1304    }
1305
1306    fn to_lua_remote_rockspec_string(&self) -> Result<String, Self::Error> {
1307        let lux_toml_dir = &self.local.internal.lux_toml_dir;
1308        let starter = format!(
1309            r#"
1310rockspec_format = "{}"
1311package = "{}"
1312version = "{}""#,
1313            self.local
1314                .rockspec_format
1315                .as_ref()
1316                .unwrap_or(&RockspecFormat::default()),
1317            self.local.package,
1318            self.version()
1319        );
1320
1321        let mut template = Vec::new();
1322
1323        if self.local.description != RockDescription::default() {
1324            template.push(self.local.description.display_lua());
1325        }
1326
1327        if self.local.supported_platforms != PlatformSupport::default() {
1328            template.push(self.local.supported_platforms.display_lua());
1329        }
1330
1331        {
1332            let mut dependencies = self.local.internal.dependencies.clone().unwrap_or_default();
1333            if !self.local.lua.is_luau() {
1334                dependencies.insert(
1335                    0,
1336                    PackageReq {
1337                        name: "lua".into(),
1338                        version_req: self.local.lua.clone(),
1339                    }
1340                    .into(),
1341                );
1342            }
1343            template.push(Dependencies(&dependencies).display_lua());
1344        }
1345
1346        let mut build_dependencies = self
1347            .local
1348            .internal
1349            .build_dependencies
1350            .as_ref()
1351            .cloned()
1352            .unwrap_or_default();
1353
1354        let build_backend_dependency = self
1355            .local
1356            .internal
1357            .build
1358            .build_type
1359            .as_ref()
1360            .and_then(|build_type| build_type.luarocks_build_backend());
1361
1362        if let Some(build_backend_dependency) = build_backend_dependency {
1363            build_dependencies.push(build_backend_dependency);
1364        }
1365
1366        if !build_dependencies.is_empty() {
1367            template.push(BuildDependencies(&build_dependencies).display_lua());
1368        }
1369
1370        match self.local.internal.external_dependencies {
1371            Some(ref external_dependencies) if !external_dependencies.is_empty() => {
1372                template.push(ExternalDependencies(external_dependencies).display_lua());
1373            }
1374            _ => {}
1375        }
1376
1377        let source = self.local.internal.source_template.try_generate(
1378            lux_toml_dir,
1379            &self.local.internal.package,
1380            self.version(),
1381        )?;
1382        template.push(source.display_lua());
1383
1384        if let Some(ref deploy) = self.local.internal.deploy {
1385            template.push(deploy.display_lua());
1386        }
1387
1388        template.push(
1389            build_spec_with_autodetected_modules(&self.local.internal.build, lux_toml_dir)
1390                .display_lua(),
1391        );
1392
1393        let unformatted_code = std::iter::once(starter)
1394            .chain(template.into_iter().map(|kv| kv.to_string()))
1395            .join("\n\n");
1396        let result = match stylua_lib::format_code(
1397            &unformatted_code,
1398            stylua_lib::Config::default(),
1399            None,
1400            stylua_lib::OutputVerification::Full,
1401        ) {
1402            Ok(formatted_code) => formatted_code,
1403            Err(_) => unformatted_code,
1404        };
1405        validate_generated_lua(&result)?;
1406        Ok(result)
1407    }
1408}
1409
1410impl HasIntegrity for RemoteProjectToml {
1411    async fn hash(&self) -> io::Result<Integrity> {
1412        self.to_lua_rockspec()
1413            .map_err(|err| {
1414                io::Error::other(format!(
1415                    "unable to convert remote project to Lua rockspec:\n{}",
1416                    err
1417                ))
1418            })?
1419            .hash()
1420            .await
1421    }
1422}
1423
1424#[cfg(test)]
1425mod tests {
1426    use std::path::PathBuf;
1427
1428    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
1429    use git2::Repository;
1430    use url::Url;
1431
1432    use crate::{
1433        git::{url::RemoteGitUrl, GitRef, GitSource},
1434        lua_rockspec::{PartialLuaRockspec, PerPlatform, RemoteLuaRockspec, RockSourceSpec},
1435        project::{Project, ProjectRoot, PROJECT_TOML},
1436        rockspec::{lua_dependency::LuaDependencySpec, Rockspec},
1437    };
1438
1439    use super::PartialProjectToml;
1440
1441    #[test]
1442    fn project_toml_parsing() {
1443        let project_toml = r#"
1444        package = "my-package"
1445        version = "1.0.0"
1446        lua = "5.3"
1447
1448        rockspec_format = "1.0"
1449
1450        [source]
1451        url = "https://example.com"
1452
1453        [dependencies]
1454        foo = "1.0"
1455        bar = ">=2.0"
1456
1457        [run]
1458        args = ["--foo", "--bar"]
1459
1460        [build]
1461        type = "builtin"
1462        "#;
1463
1464        let project =
1465            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1466        let _ = project.into_remote(None).unwrap();
1467
1468        let project_toml = r#"
1469        package = "my-package"
1470        version = "1.0.0"
1471        lua = "5.1"
1472
1473        [description]
1474        summary = "A summary"
1475        detailed = "A detailed description"
1476        license = "MIT"
1477        homepage = "https://example.com"
1478        issues_url = "https://example.com/issues"
1479        maintainer = "John Doe"
1480        labels = ["label1", "label2"]
1481
1482        [supported_platforms]
1483        linux = true
1484        windows = false
1485
1486        [dependencies]
1487        foo = "1.0"
1488        bar = ">=2.0"
1489
1490        [build_dependencies]
1491        baz = "1.0"
1492
1493        [external_dependencies.foo]
1494        header = "foo.h"
1495
1496        [external_dependencies.bar]
1497        library = "libbar.so"
1498
1499        [test_dependencies]
1500        busted = "69.420"
1501
1502        [source]
1503        url = "https://example.com"
1504        hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU="
1505        file = "my-package-1.0.0.tar.gz"
1506        dir = "my-package-1.0.0"
1507
1508        [test]
1509        type = "command"
1510        script = "test.lua"
1511        flags = [ "foo", "bar" ]
1512
1513        [run]
1514        command = "my-command"
1515        args = ["--foo", "--bar"]
1516
1517        [build]
1518        type = "builtin"
1519        "#;
1520
1521        let project =
1522            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1523        let _ = project.into_remote(None).unwrap();
1524    }
1525
1526    #[test]
1527    fn compare_project_toml_with_rockspec() {
1528        let project_toml = r#"
1529        package = "my-package"
1530        version = "1.0.0"
1531        lua = "5.1"
1532
1533        # For testing, specify a custom rockspec format
1534        # (defaults to 3.0)
1535        rockspec_format = "1.0"
1536
1537        [description]
1538        summary = "A summary"
1539        detailed = "A detailed description"
1540        license = "MIT"
1541        homepage = "https://example.com"
1542        issues_url = "https://example.com/issues"
1543        maintainer = "John Doe"
1544        labels = ["label1", "label2"]
1545
1546        [supported_platforms]
1547        linux = true
1548        windows = false
1549
1550        [dependencies]
1551        foo = "1.0"
1552        bar = ">=2.0"
1553
1554        [build_dependencies]
1555        baz = "1.0"
1556
1557        [external_dependencies.foo]
1558        header = "foo.h"
1559
1560        [external_dependencies.bar]
1561        library = "libbar.so"
1562
1563        [test_dependencies]
1564        busted = "1.0"
1565
1566        [source]
1567        url = "https://example.com"
1568        file = "my-package-1.0.0.tar.gz"
1569        dir = "my-package-1.0.0"
1570
1571        [test]
1572        type = "command"
1573        script = "test.lua"
1574        flags = [ "foo", "bar" ]
1575
1576        [run]
1577        command = "my-command"
1578        args = ["--foo", "--bar"]
1579
1580        [deploy]
1581        wrap_bin_scripts = false
1582
1583        [build]
1584        type = "builtin"
1585
1586        [build.install.lua]
1587        "foo.bar" = "src/bar.lua"
1588
1589        [build.install.lib]
1590        "foo.baz" = "src/baz.c"
1591
1592        [build.install.bin]
1593        "bla" = "src/bla"
1594
1595        [build.install.conf]
1596        "cfg.conf" = "resources/config.conf"
1597        "#;
1598
1599        let expected_rockspec = r#"
1600            rockspec_format = "1.0"
1601            package = "my-package"
1602            version = "1.0.0"
1603
1604            source = {
1605                url = "https://example.com",
1606                file = "my-package-1.0.0.tar.gz",
1607                dir = "my-package-1.0.0",
1608            }
1609
1610            description = {
1611                summary = "A summary",
1612                detailed = "A detailed description",
1613                license = "MIT",
1614                homepage = "https://example.com",
1615                issues_url = "https://example.com/issues",
1616                maintainer = "John Doe",
1617                labels = {"label1", "label2"},
1618            }
1619
1620            supported_platforms = {"linux", "!windows"}
1621
1622            dependencies = {
1623                "lua ==5.1",
1624                "foo ==1.0",
1625                "bar >=2.0",
1626            }
1627
1628            build_dependencies = {
1629                "baz ==1.0",
1630            }
1631
1632            external_dependencies = {
1633                foo = { header = "foo.h" },
1634                bar = { library = "libbar.so" },
1635            }
1636
1637            source = {
1638                url = "https://example.com",
1639                hash = "sha256-di00mD8txN7rjaVpvxzNbnQsAh6H16zUtJZapH7U4HU=",
1640                file = "my-package-1.0.0.tar.gz",
1641                dir = "my-package-1.0.0",
1642            }
1643
1644            test = {
1645                type = "command",
1646                script = "test.lua",
1647                flags = {"foo", "bar"},
1648            }
1649
1650            deploy = {
1651                wrap_bin_scripts = false,
1652            }
1653
1654            build = {
1655                type = "builtin",
1656                install = {
1657                    lua = {
1658                        ["foo.bar"] = "src/bar.lua",
1659                    },
1660                    lib = {
1661                        ["foo.baz"] = "src/baz.c",
1662                    },
1663                    bin = {
1664                        bla = "src/bla",
1665                    },
1666                    conf = {
1667                        ["cfg.conf"] = "resources/config.conf",
1668                    },
1669                },
1670            }
1671        "#;
1672
1673        let expected_rockspec = RemoteLuaRockspec::new(expected_rockspec).unwrap();
1674
1675        let project_toml =
1676            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1677        let rockspec = project_toml
1678            .into_remote(None)
1679            .unwrap()
1680            .to_lua_rockspec()
1681            .unwrap();
1682
1683        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1684            let mut v = v.current_platform().clone();
1685            v.sort_by(|a, b| a.name().cmp(b.name()));
1686            v
1687        };
1688
1689        assert_eq!(rockspec.package(), expected_rockspec.package());
1690        assert_eq!(rockspec.version(), expected_rockspec.version());
1691        assert_eq!(rockspec.description(), expected_rockspec.description());
1692        assert_eq!(
1693            rockspec.supported_platforms(),
1694            expected_rockspec.supported_platforms()
1695        );
1696        assert_eq!(
1697            sorted_package_reqs(rockspec.dependencies()),
1698            sorted_package_reqs(expected_rockspec.dependencies())
1699        );
1700        assert_eq!(
1701            sorted_package_reqs(rockspec.build_dependencies()),
1702            sorted_package_reqs(expected_rockspec.build_dependencies())
1703        );
1704        assert_eq!(
1705            rockspec.external_dependencies(),
1706            expected_rockspec.external_dependencies()
1707        );
1708        assert_eq!(rockspec.source(), expected_rockspec.source());
1709        assert_eq!(rockspec.build(), expected_rockspec.build());
1710        assert_eq!(rockspec.format(), expected_rockspec.format());
1711    }
1712
1713    #[test]
1714    fn merge_project_toml_with_partial_rockspec() {
1715        let project_toml = r#"
1716        package = "my-package"
1717        version = "1.0.0"
1718        lua = "5.1"
1719
1720        # For testing, specify a custom rockspec format
1721        # (defaults to 3.0)
1722        rockspec_format = "1.0"
1723
1724        [description]
1725        summary = "A summary"
1726        detailed = "A detailed description"
1727        license = "MIT"
1728        homepage = "https://example.com"
1729        issues_url = "https://example.com/issues"
1730        maintainer = "John Doe"
1731        labels = ["label1", "label2"]
1732
1733        [supported_platforms]
1734        linux = true
1735        windows = false
1736
1737        [dependencies]
1738        foo = "1.0"
1739        bar = ">=2.0"
1740
1741        [build_dependencies]
1742        baz = "1.0"
1743
1744        [external_dependencies.foo]
1745        header = "foo.h"
1746
1747        [external_dependencies.bar]
1748        library = "libbar.so"
1749
1750        [test_dependencies]
1751        busted = "1.0"
1752
1753        [source]
1754        url = "https://example.com"
1755        file = "my-package-1.0.0.tar.gz"
1756        dir = "my-package-1.0.0"
1757
1758        [test]
1759        type = "command"
1760        script = "test.lua"
1761        flags = [ "foo", "bar" ]
1762
1763        [run]
1764        command = "my-command"
1765        args = [ "--foo", "--bar" ]
1766
1767        [build]
1768        type = "builtin"
1769        "#;
1770
1771        let mergable_rockspec_content = r#"
1772            rockspec_format = "1.0"
1773            package = "my-package-overwritten"
1774
1775            description = {
1776                summary = "A summary overwritten",
1777                detailed = "A detailed description overwritten",
1778                license = "GPL-2.0",
1779                homepage = "https://example.com/overwritten",
1780                issues_url = "https://example.com/issues/overwritten",
1781                maintainer = "John Doe Overwritten",
1782                labels = {"over", "written"},
1783            }
1784
1785            -- Inverted supported platforms
1786            supported_platforms = {"!linux", "windows"}
1787
1788            dependencies = {
1789                "lua 5.1",
1790                "foo >1.0",
1791                "bar <=2.0",
1792            }
1793
1794            build_dependencies = {
1795                "baz >1.0",
1796            }
1797
1798            external_dependencies = {
1799                foo = { header = "overwritten.h" },
1800                bar = { library = "overwritten.so" },
1801            }
1802
1803            test = {
1804                type = "command",
1805                script = "overwritten.lua",
1806                flags = {"over", "written"},
1807            }
1808
1809            build = {
1810                type = "builtin",
1811            }
1812        "#;
1813
1814        let remote_rockspec_content = format!(
1815            r#"{}
1816            version = "1.0.0"
1817            source = {{
1818                url = "https://example.com",
1819                file = "my-package-1.0.0.tar.gz",
1820                dir = "my-package-1.0.0",
1821            }}
1822        "#,
1823            mergable_rockspec_content
1824        );
1825
1826        let project_toml =
1827            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1828        let partial_rockspec = PartialLuaRockspec::new(mergable_rockspec_content).unwrap();
1829        let expected_rockspec = RemoteLuaRockspec::new(&remote_rockspec_content).unwrap();
1830
1831        let merged = project_toml
1832            .merge(partial_rockspec)
1833            .into_remote(None)
1834            .unwrap();
1835
1836        let sorted_package_reqs = |v: &PerPlatform<Vec<LuaDependencySpec>>| {
1837            let mut v = v.current_platform().clone();
1838            v.sort_by(|a, b| a.name().cmp(b.name()));
1839            v
1840        };
1841
1842        assert_eq!(merged.package(), expected_rockspec.package());
1843        assert_eq!(merged.version(), expected_rockspec.version());
1844        assert_eq!(merged.description(), expected_rockspec.description());
1845        assert_eq!(
1846            merged.supported_platforms(),
1847            expected_rockspec.supported_platforms()
1848        );
1849        assert_eq!(
1850            sorted_package_reqs(merged.dependencies()),
1851            sorted_package_reqs(expected_rockspec.dependencies())
1852        );
1853        assert_eq!(
1854            sorted_package_reqs(merged.build_dependencies()),
1855            sorted_package_reqs(expected_rockspec.build_dependencies())
1856        );
1857        assert_eq!(
1858            merged.external_dependencies(),
1859            expected_rockspec.external_dependencies()
1860        );
1861        assert_eq!(merged.source(), expected_rockspec.source());
1862        assert_eq!(merged.build(), expected_rockspec.build());
1863        assert_eq!(merged.format(), expected_rockspec.format());
1864        // Ensure that the run command is retained after merge.
1865        assert!(merged.local.run().is_some());
1866    }
1867
1868    #[test]
1869    fn wally_dependency_parsing() {
1870        let project_toml = r#"
1871        package = "my-package"
1872        version = "1.0.0"
1873        lua = "5.1"
1874
1875        [dependencies.roact]
1876        wally = "jsdotperf/roact@^1.4.0"
1877
1878        [build]
1879        type = "builtin"
1880        "#;
1881
1882        let project =
1883            PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap();
1884        let deps = project.dependencies.unwrap();
1885        assert_eq!(deps.len(), 1);
1886        assert_eq!(deps[0].name().to_string(), "roact");
1887        assert!(matches!(
1888            deps[0].source(),
1889            Some(RockSourceSpec::Wally(req)) if req.name().to_string() == "jsdotperf/roact"
1890        ));
1891    }
1892
1893    #[test]
1894    fn wally_dependency_conflicts_with_version() {
1895        let project_toml = r#"
1896        package = "my-package"
1897        version = "1.0.0"
1898        lua = "5.1"
1899
1900        [dependencies.roact]
1901        wally = "jsdotperf/roact@^1.4.0"
1902        version = "1.4.0"
1903
1904        [build]
1905        type = "builtin"
1906        "#;
1907
1908        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default()).unwrap_err();
1909    }
1910
1911    #[test]
1912    fn project_toml_with_lua_in_dependencies() {
1913        let project_toml = r#"
1914        package = "my-package"
1915        version = "1.0.0"
1916        # lua = ">5.1"
1917
1918        [dependencies]
1919        lua = "5.1" # disallowed
1920
1921        [build]
1922        type = "builtin"
1923        "#;
1924
1925        PartialProjectToml::new(PROJECT_TOML, project_toml, ProjectRoot::default())
1926            .unwrap()
1927            .into_local()
1928            .unwrap_err();
1929    }
1930
1931    #[test]
1932    fn project_toml_with_invalid_run_command() {
1933        for command in ["lua", "lua5.1", "lua5.2", "lua5.3", "lua5.4", "luajit"] {
1934            let project_toml = format!(
1935                r#"
1936                package = "my-package"
1937                version = "1.0.0"
1938                lua = "5.1"
1939
1940                [build]
1941                type = "builtin"
1942
1943                [run]
1944                command = "{command}"
1945                "#,
1946            );
1947
1948            PartialProjectToml::new(PROJECT_TOML, &project_toml, ProjectRoot::default())
1949                .unwrap_err();
1950        }
1951    }
1952
1953    #[test]
1954    fn project_root_dir_override_and_rockspec_generation() {
1955        let project_toml = r#"
1956        package = "my-package"
1957        version = "1.0.0"
1958        lua = "5.1"
1959
1960        [project]
1961        root_dir = ".."
1962
1963        [source]
1964        url = "https://example.com"
1965
1966        [build]
1967        type = "builtin"
1968        "#;
1969
1970        let workspace_root = assert_fs::TempDir::new().unwrap();
1971        let lux_toml_dir = workspace_root.child("proj");
1972        lux_toml_dir.create_dir_all().unwrap();
1973        std::fs::write(lux_toml_dir.join(PROJECT_TOML), project_toml).unwrap();
1974
1975        let project = PartialProjectToml::new(
1976            PROJECT_TOML,
1977            project_toml,
1978            ProjectRoot(lux_toml_dir.to_path_buf()),
1979        )
1980        .unwrap();
1981        let local = project.into_local().unwrap();
1982        let RockSourceSpec::File(root_dir) = &local.source().current_platform().source_spec else {
1983            panic!("expected a file source");
1984        };
1985        assert_eq!(
1986            root_dir,
1987            &std::fs::canonicalize(workspace_root.path()).unwrap()
1988        );
1989
1990        // The `[project]` table must be ignored when generating a rockspec.
1991        let rockspec = local.to_lua_rockspec().unwrap();
1992        assert!(!rockspec
1993            .to_lua_remote_rockspec_string()
1994            .unwrap()
1995            .contains("root_dir"));
1996    }
1997
1998    #[test]
1999    fn generate_non_deterministic_git_source() {
2000        let rockspec_content = r#"
2001            package = "test-package"
2002            version = "1.0.0"
2003            lua = ">=5.1"
2004
2005            [source]
2006            url = "git+https://exaple.com/repo.git"
2007
2008            [build]
2009            type = "builtin"
2010        "#;
2011
2012        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2013            .unwrap()
2014            .into_remote(None)
2015            .unwrap_err();
2016    }
2017
2018    #[test]
2019    fn generate_deterministic_git_source() {
2020        let rockspec_content = r#"
2021            package = "test-package"
2022            version = "1.0.0"
2023            lua = ">=5.1"
2024
2025            [source]
2026            url = "git+https://exaple.com/owner/repo.git"
2027            tag = "v0.1.0"
2028
2029            [build]
2030            type = "builtin"
2031        "#;
2032
2033        PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2034            .unwrap()
2035            .into_remote(None)
2036            .unwrap();
2037    }
2038
2039    #[test]
2040    fn generate_git_source_with_branch() {
2041        let rockspec_content = r#"
2042            package = "test-package"
2043            version = "1.0.0"
2044            lua = ">=5.1"
2045
2046            [source]
2047            url = "git+https://example.com/owner/repo.git"
2048            branch = "main"
2049
2050            [build]
2051            type = "builtin"
2052        "#;
2053
2054        let toml = PartialProjectToml::new(PROJECT_TOML, rockspec_content, ProjectRoot::default())
2055            .unwrap()
2056            .into_remote(None)
2057            .unwrap();
2058
2059        let lua = toml.to_lua_remote_rockspec_string().unwrap();
2060        assert!(lua.contains("branch = \"main\""));
2061        assert!(!lua.contains("tag = "));
2062    }
2063
2064    fn init_git_repo(path: &std::path::Path) -> Repository {
2065        let repo = Repository::init(path).unwrap();
2066        {
2067            let tree_id = repo.index().unwrap().write_tree().unwrap();
2068            let tree = repo.find_tree(tree_id).unwrap();
2069            let sig = git2::Signature::now("name", "email").unwrap();
2070            repo.commit(Some("HEAD"), &sig, &sig, "initial", &tree, &[])
2071                .unwrap();
2072        }
2073        repo
2074    }
2075
2076    fn init_sample_project_repo(temp_dir: &assert_fs::TempDir) -> Repository {
2077        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2078            .join("resources/test/sample-projects/source-template/");
2079        temp_dir.copy_from(&sample_project, &["**"]).unwrap();
2080        init_git_repo(temp_dir.path())
2081    }
2082
2083    fn create_tag(repo: &Repository, name: &str) {
2084        let sig = git2::Signature::now("name", "email").unwrap();
2085        let id = repo.head().unwrap().target().unwrap();
2086        let obj = repo.find_object(id, None).unwrap();
2087        repo.tag(name, &obj, &sig, "msg", true).unwrap();
2088    }
2089
2090    #[test]
2091    fn test_git_project_generate_dev_source() {
2092        let project_root = assert_fs::TempDir::new().unwrap();
2093        init_sample_project_repo(&project_root);
2094        let project = Project::from_exact(&project_root).unwrap().unwrap();
2095        let remote_project_toml = project.toml().into_remote(None).unwrap();
2096        let source = remote_project_toml.source.current_platform();
2097        let source_spec = &source.source_spec;
2098        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2099        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2100            let expected_url: RemoteGitUrl =
2101                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2102            assert_eq!(url, &expected_url);
2103            assert!(git_ref.is_some());
2104        }
2105        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2106    }
2107
2108    #[test]
2109    fn test_git_project_generate_non_semver_tag_source() {
2110        let project_root = assert_fs::TempDir::new().unwrap();
2111        let repo = init_sample_project_repo(&project_root);
2112        let tag_name = "bla";
2113        create_tag(&repo, tag_name);
2114        let project = Project::from_exact(&project_root).unwrap().unwrap();
2115        let remote_project_toml = project.toml().into_remote(None).unwrap();
2116        let source = remote_project_toml.source.current_platform();
2117        let source_spec = &source.source_spec;
2118        assert!(matches!(source_spec, &RockSourceSpec::Git { .. }));
2119        if let RockSourceSpec::Git(GitSource { url, git_ref }) = source_spec {
2120            let expected_url: RemoteGitUrl =
2121                "https://github.com/lumen-oss/lux.git".parse().unwrap();
2122            assert_eq!(url, &expected_url);
2123            assert_eq!(git_ref, &Some(GitRef::Tag(tag_name.to_string())));
2124        }
2125        assert_eq!(source.unpack_dir, Some("lux-dev".into()));
2126    }
2127
2128    #[test]
2129    fn test_git_project_generate_release_source_tag_with_v_prefix() {
2130        let project_root = assert_fs::TempDir::new().unwrap();
2131        let repo = init_sample_project_repo(&project_root);
2132        let tag_name = "v1.0.0";
2133        create_tag(&repo, "bla");
2134        create_tag(&repo, tag_name);
2135        let project = Project::from_exact(&project_root).unwrap().unwrap();
2136        let remote_project_toml = project.toml().into_remote(None).unwrap();
2137        let source = remote_project_toml.source.current_platform();
2138        let source_spec = &source.source_spec;
2139        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2140        if let RockSourceSpec::Url(url) = source_spec {
2141            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/v1.0.0.zip"
2142                .parse()
2143                .unwrap();
2144            assert_eq!(url, &expected_url);
2145        }
2146        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2147    }
2148
2149    #[test]
2150    fn test_git_project_generate_release_source_tag_without_v_prefix() {
2151        let project_root = assert_fs::TempDir::new().unwrap();
2152        let repo = init_sample_project_repo(&project_root);
2153        create_tag(&repo, "bla");
2154        let tag_name = "1.0.0";
2155        create_tag(&repo, tag_name);
2156        let project = Project::from_exact(&project_root).unwrap().unwrap();
2157        let remote_project_toml = project.toml().into_remote(None).unwrap();
2158        let source = remote_project_toml.source.current_platform();
2159        let source_spec = &source.source_spec;
2160        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2161        if let RockSourceSpec::Url(url) = source_spec {
2162            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2163                .parse()
2164                .unwrap();
2165            assert_eq!(url, &expected_url);
2166        }
2167        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2168    }
2169
2170    #[test]
2171    fn test_git_project_in_subdirectory() {
2172        let temp_dir = assert_fs::TempDir::new().unwrap();
2173        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2174            .join("resources/test/sample-projects/source-template/");
2175        let project_dir = temp_dir.child("lux");
2176        project_dir.create_dir_all().unwrap();
2177        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2178        let repo = init_git_repo(temp_dir.path());
2179        create_tag(&repo, "bla");
2180        let tag_name = "1.0.0";
2181        create_tag(&repo, tag_name);
2182        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2183        let remote_project_toml = project.toml().into_remote(None).unwrap();
2184        let source = remote_project_toml.source.current_platform();
2185        let source_spec = &source.source_spec;
2186        assert!(matches!(source_spec, &RockSourceSpec::Url { .. }));
2187        if let RockSourceSpec::Url(url) = source_spec {
2188            let expected_url: Url = "https://github.com/lumen-oss/lux/archive/refs/tags/1.0.0.zip"
2189                .parse()
2190                .unwrap();
2191            assert_eq!(url, &expected_url);
2192        }
2193        assert_eq!(source.unpack_dir, Some("lux-1.0.0".into()));
2194    }
2195
2196    #[test]
2197    fn test_git_project_in_subdirectory_generate_dev_source() {
2198        let temp_dir = assert_fs::TempDir::new().unwrap();
2199        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
2200            .join("resources/test/sample-projects/source-template/");
2201        let project_dir = temp_dir.child("lux");
2202        project_dir.create_dir_all().unwrap();
2203        project_dir.copy_from(&sample_project, &["**"]).unwrap();
2204        init_git_repo(temp_dir.path());
2205
2206        let project = Project::from_exact(&project_dir).unwrap().unwrap();
2207        let remote_project_toml = project.toml().into_remote(None).unwrap();
2208        let RockSourceSpec::Git(GitSource { git_ref, .. }) =
2209            &remote_project_toml.source.current_platform().source_spec
2210        else {
2211            panic!("expected a git source");
2212        };
2213        assert!(git_ref.is_some());
2214    }
2215}