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