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