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