Skip to main content

lux_lib/project/
mod.rs

1use itertools::Itertools;
2use miette::{Diagnostic, NamedSource, SourceSpan};
3use path_slash::PathBufExt;
4use project_toml::{
5    LocalProjectTomlValidationError, PartialProjectToml, RemoteProjectTomlValidationError,
6};
7use std::{
8    io,
9    ops::Deref,
10    path::{Path, PathBuf},
11    str::FromStr,
12};
13use thiserror::Error;
14use toml_edit::{DocumentMut, Item};
15
16use crate::fs;
17
18use crate::{
19    config::Config,
20    git::{
21        self,
22        shorthand::RemoteGitUrlShorthand,
23        url::RemoteGitUrl,
24        utils::{GitError, SemVerTagOrSha},
25    },
26    lua_rockspec::{
27        LocalLuaRockspec, LuaRockspecError, LuaVersionError, PartialLuaRockspec,
28        PartialRockspecError, RemoteLuaRockspec,
29    },
30    lua_version::LuaVersion,
31    package::SpecRev,
32    remote_package_db::RemotePackageDB,
33    rockspec::{
34        lua_dependency::{DependencyType, LuaDependencySpec, LuaDependencyType},
35        LuaVersionCompatibility,
36    },
37};
38use crate::{
39    lockfile::PinnedState,
40    package::{PackageName, PackageReq},
41};
42
43pub(crate) mod gen;
44pub mod project_toml;
45
46pub use project_toml::PROJECT_TOML;
47
48pub const EXTRA_ROCKSPEC: &str = "extra.rockspec";
49
50/// A wrapper around [`toml::de::Error`] plus the original source code and the byte range where the
51/// error occurred so that `miette` can render the failure with line numbers and context.
52#[derive(Debug, Error, Diagnostic)]
53#[error("{message}", message = _inner.message())]
54#[diagnostic(
55    code(lux_lib::toml::deserialize),
56    help("check that the file is valid TOML and matches the expected schema"),
57    url("https://lux.lumen-labs.org/reference/lux-toml")
58)]
59pub struct TomlDeError {
60    #[source]
61    _inner: Box<toml::de::Error>,
62    #[source_code]
63    src: NamedSource<String>,
64    #[label("here")]
65    span: SourceSpan,
66}
67
68impl TomlDeError {
69    pub fn new(name: &str, src: &str, inner: toml::de::Error) -> Self {
70        let src = NamedSource::new(name, src.to_string());
71        let span = inner.span().unwrap_or(0..0).into();
72        Self {
73            _inner: Box::new(inner),
74            src,
75            span,
76        }
77    }
78
79    pub fn inner(&self) -> &toml::de::Error {
80        &self._inner
81    }
82}
83
84pub(crate) fn parse_toml<T>(name: &str, src: &str) -> Result<T, TomlDeError>
85where
86    T: serde::de::DeserializeOwned,
87{
88    toml::from_str(src).map_err(|e| TomlDeError::new(name, src, e))
89}
90
91#[derive(Error, Debug, Diagnostic)]
92#[non_exhaustive]
93#[error(transparent)]
94pub enum ProjectError {
95    #[error(transparent)]
96    #[diagnostic(transparent)]
97    Fs(#[from] fs::FsError),
98    #[diagnostic(transparent)]
99    Project(#[from] LocalProjectTomlValidationError),
100    #[diagnostic(transparent)]
101    Toml(#[from] TomlDeError),
102    #[error("error when parsing 'extra.rockspec'")]
103    #[diagnostic(forward(0))]
104    Rockspec(#[from] PartialRockspecError),
105}
106
107#[derive(Error, Debug, Diagnostic)]
108#[non_exhaustive]
109#[error(transparent)]
110pub enum IntoLocalRockspecError {
111    LocalProjectTomlValidationError(Box<LocalProjectTomlValidationError>),
112    RockspecError(Box<LuaRockspecError>),
113}
114
115#[derive(Error, Debug, Diagnostic)]
116#[non_exhaustive]
117#[error(transparent)]
118pub enum IntoRemoteRockspecError {
119    RocksTomlValidationError(Box<RemoteProjectTomlValidationError>),
120    RockspecError(Box<LuaRockspecError>),
121}
122
123#[derive(Error, Debug, Diagnostic)]
124#[non_exhaustive]
125pub enum ProjectEditError {
126    #[error(transparent)]
127    Io(#[from] tokio::io::Error),
128    #[error(transparent)]
129    #[diagnostic(transparent)]
130    Fs(#[from] fs::FsError),
131    #[error(transparent)]
132    Toml(#[from] toml_edit::TomlError),
133    #[error("error parsing {PROJECT_TOML} after edit. This is probably a bug.")]
134    #[diagnostic(forward(0))]
135    TomlDe(#[from] TomlDeError),
136    #[error(transparent)]
137    #[diagnostic(transparent)]
138    Git(#[from] GitError),
139    #[error("unable to query latest version for '{0}'")]
140    #[diagnostic(help("check your server config and ensure the package has been published"))]
141    LatestVersionNotFound(PackageName),
142    #[error("expected field to be a value, but got '{0}'")]
143    #[diagnostic(
144        help("ensure your {PROJECT_TOML} matches the specification"),
145        url("https://lux.lumen-labs.org/reference/lux-toml")
146    )]
147    ExpectedValue(Box<toml_edit::Item>),
148    #[error("expected string, but got '{0}'")]
149    #[diagnostic(
150        help("ensure your {PROJECT_TOML} matches the specification"),
151        url("https://lux.lumen-labs.org/reference/lux-toml")
152    )]
153    ExpectedString(Box<toml_edit::Value>),
154    #[error(transparent)]
155    #[diagnostic(transparent)]
156    GitUrlShorthandParse(#[from] git::shorthand::ParseError),
157}
158
159#[derive(Error, Debug, Diagnostic)]
160#[non_exhaustive]
161pub enum PinError {
162    #[error("package '{0}' not found in dependencies")]
163    #[diagnostic(help("to add it, run 'lx add {0}'"))]
164    PackageNotFound(PackageName),
165    #[error("dependency '{dep}' is already {}pinned!", if *.pin_state == PinnedState::Unpinned { "un" } else { "" })]
166    PinStateUnchanged {
167        pin_state: PinnedState,
168        dep: PackageName,
169    },
170    #[error("unable to parse {toml_path} for editing")]
171    #[diagnostic(
172        code(lux_lib::toml_edit::parse),
173        help("check that the file is valid TOML and matches the expected schema"),
174        url("https://lux.lumen-labs.org/reference/lux-toml")
175    )]
176    ParseTomlEdit {
177        toml_path: String,
178        source: toml_edit::TomlError,
179    },
180    #[error("error parsing lux.toml after edit. This is probably a bug.")]
181    #[diagnostic(forward(0))]
182    TomlDe(#[from] TomlDeError),
183    #[error(transparent)]
184    Io(#[from] tokio::io::Error),
185    #[error(transparent)]
186    #[diagnostic(transparent)]
187    Fs(#[from] fs::FsError),
188}
189
190/// A newtype for the project root directory.
191/// This is used to ensure that the project root is a valid project directory.
192#[derive(Clone, Debug)]
193#[cfg_attr(test, derive(Default))]
194pub struct ProjectRoot(PathBuf);
195
196impl ProjectRoot {
197    pub(crate) fn new() -> Self {
198        Self(PathBuf::new())
199    }
200}
201
202impl AsRef<Path> for ProjectRoot {
203    fn as_ref(&self) -> &Path {
204        self.0.as_ref()
205    }
206}
207
208impl Deref for ProjectRoot {
209    type Target = PathBuf;
210
211    fn deref(&self) -> &Self::Target {
212        &self.0
213    }
214}
215
216/// Lux project, with methods for managing dependencies, etc.
217#[derive(Clone, Debug)]
218pub struct Project {
219    /// The path where the `lux.toml` resides.
220    root: ProjectRoot,
221    /// The parsed lux.toml.
222    toml: PartialProjectToml,
223}
224
225impl Project {
226    /// Load a project at the specified path, if it exists
227    #[tracing::instrument(level = "trace", skip_all)]
228    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
229        if !start.as_ref().exists() {
230            return Ok(None);
231        }
232
233        if start.as_ref().join(PROJECT_TOML).exists() {
234            let project_toml_path = start.as_ref().join(PROJECT_TOML);
235            let toml_content = fs::sync::read_to_string(&project_toml_path)?;
236            let root = start.as_ref();
237
238            let mut project = Project {
239                root: ProjectRoot(root.to_path_buf()),
240                toml: PartialProjectToml::new(
241                    &project_toml_path.to_string_lossy(),
242                    &toml_content,
243                    ProjectRoot(root.to_path_buf()),
244                )?,
245            };
246
247            if let Some(extra_rockspec) = project.extra_rockspec()? {
248                project.toml = project.toml.merge(extra_rockspec);
249            }
250
251            Ok(Some(project))
252        } else {
253            Ok(None)
254        }
255    }
256
257    /// Get the `lux.toml` path.
258    pub fn toml_path(&self) -> PathBuf {
259        self.root.join(PROJECT_TOML)
260    }
261
262    /// Get the `extra.rockspec` path.
263    pub fn extra_rockspec_path(&self) -> PathBuf {
264        self.root.join(EXTRA_ROCKSPEC)
265    }
266
267    pub fn root(&self) -> &ProjectRoot {
268        &self.root
269    }
270
271    pub fn toml(&self) -> &PartialProjectToml {
272        &self.toml
273    }
274
275    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
276        self.toml()
277            .into_local()
278            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
279            .to_lua_rockspec()
280            .map_err(|err| IntoLocalRockspecError::RockspecError(Box::new(err)))
281    }
282
283    pub fn remote_rockspec(
284        &self,
285        specrev: Option<SpecRev>,
286    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
287        self.toml()
288            .into_remote(specrev)
289            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
290            .to_lua_rockspec()
291            .map_err(|err| IntoRemoteRockspecError::RockspecError(Box::new(err)))
292    }
293
294    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
295        if self.extra_rockspec_path().exists() {
296            Ok(Some(PartialLuaRockspec::new(
297                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
298            )?))
299        } else {
300            Ok(None)
301        }
302    }
303
304    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
305        self.toml().lua_version_matches(config)
306    }
307
308    pub async fn add(
309        &mut self,
310        dependencies: DependencyType<&PackageReq>,
311        package_db: &RemotePackageDB,
312    ) -> Result<(), ProjectEditError> {
313        let mut project_toml =
314            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
315
316        prepare_dependency_tables(&mut project_toml);
317        let table = match dependencies {
318            DependencyType::Regular(_) => &mut project_toml["dependencies"],
319            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
320            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
321            DependencyType::External(_) => &mut project_toml["external_dependencies"],
322        };
323
324        match dependencies {
325            DependencyType::Regular(ref deps)
326            | DependencyType::Build(ref deps)
327            | DependencyType::Test(ref deps) => {
328                for dep in deps {
329                    let dep_version_str = if dep.version_req().is_any() {
330                        package_db
331                            .latest_version(dep.name())
332                            .map(|latest_version| latest_version.to_string())
333                            .unwrap_or_else(|| dep.version_req().to_string())
334                    } else {
335                        dep.version_req().to_string()
336                    };
337                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
338                }
339            }
340            DependencyType::External(ref deps) => {
341                for (name, dep) in deps {
342                    if let Some(path) = &dep.header {
343                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
344                    }
345                    if let Some(path) = &dep.library {
346                        table[name]["library"] =
347                            toml_edit::value(path.to_slash_lossy().to_string());
348                    }
349                }
350            }
351        };
352
353        let toml_content = project_toml.to_string();
354        fs::tokio::write(self.toml_path(), &toml_content).await?;
355        self.toml = PartialProjectToml::new(
356            self.toml_path().to_str().unwrap_or("<lux.toml>"),
357            &toml_content,
358            self.root.clone(),
359        )?;
360
361        Ok(())
362    }
363
364    pub async fn add_git(
365        &mut self,
366        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
367        config: &Config,
368    ) -> Result<(), ProjectEditError> {
369        let mut project_toml =
370            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
371
372        prepare_dependency_tables(&mut project_toml);
373        let table = match dependencies {
374            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
375            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
376            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
377        };
378
379        match dependencies {
380            LuaDependencyType::Regular(urls)
381            | LuaDependencyType::Build(urls)
382            | LuaDependencyType::Test(urls) => {
383                for url in urls {
384                    let git_url: RemoteGitUrl = url.clone().into();
385                    let mut dep_entry = toml_edit::table();
386                    match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
387                        SemVerTagOrSha::SemVerTag(tag) => {
388                            dep_entry["git"] = Item::Value(url.to_string().into());
389                            dep_entry["version"] = Item::Value(tag.clone().into());
390                            if tag.contains("-") {
391                                // Tag contains a specrev.
392                                dep_entry["rev"] = Item::Value(tag.into());
393                            }
394                        }
395                        SemVerTagOrSha::CommitSha(sha) => {
396                            dep_entry["git"] = Item::Value(url.to_string().into());
397                            dep_entry["version"] = Item::Value(sha.into());
398                        }
399                    }
400                    table[git_url.repo()] = dep_entry;
401                }
402            }
403        }
404
405        let toml_content = project_toml.to_string();
406        fs::tokio::write(self.toml_path(), &toml_content).await?;
407        self.toml = PartialProjectToml::new(
408            self.toml_path().to_str().unwrap_or("<lux.toml>"),
409            &toml_content,
410            self.root.clone(),
411        )?;
412
413        Ok(())
414    }
415
416    pub async fn remove(
417        &mut self,
418        dependencies: DependencyType<&PackageName>,
419    ) -> Result<(), ProjectEditError> {
420        let mut project_toml =
421            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
422
423        prepare_dependency_tables(&mut project_toml);
424        let table = match dependencies {
425            DependencyType::Regular(_) => &mut project_toml["dependencies"],
426            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
427            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
428            DependencyType::External(_) => &mut project_toml["external_dependencies"],
429        };
430
431        match dependencies {
432            DependencyType::Regular(ref deps)
433            | DependencyType::Build(ref deps)
434            | DependencyType::Test(ref deps) => {
435                for dep in deps {
436                    table[dep.to_string()] = Item::None;
437                }
438            }
439            DependencyType::External(ref deps) => {
440                for (name, dep) in deps {
441                    if dep.header.is_some() {
442                        table[name]["header"] = Item::None;
443                    }
444                    if dep.library.is_some() {
445                        table[name]["library"] = Item::None;
446                    }
447                }
448            }
449        };
450
451        let toml_content = project_toml.to_string();
452        fs::tokio::write(self.toml_path(), &toml_content).await?;
453        self.toml = PartialProjectToml::new(
454            self.toml_path().to_str().unwrap_or("<lux.toml>"),
455            &toml_content,
456            self.root.clone(),
457        )?;
458
459        Ok(())
460    }
461
462    pub async fn upgrade(
463        &mut self,
464        dependencies: LuaDependencyType<&PackageName>,
465        package_db: &RemotePackageDB,
466        config: &Config,
467    ) -> Result<(), ProjectEditError> {
468        let mut project_toml =
469            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
470
471        prepare_dependency_tables(&mut project_toml);
472        let table = match dependencies {
473            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
474            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
475            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
476        };
477
478        match dependencies {
479            LuaDependencyType::Regular(deps)
480            | LuaDependencyType::Build(deps)
481            | LuaDependencyType::Test(deps) => {
482                let latest_rock_version_str =
483                    |dep: &PackageName| -> Result<String, ProjectEditError> {
484                        Ok(package_db
485                            .latest_version(dep)
486                            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?
487                            .to_string())
488                    };
489                for dep in deps {
490                    let mut dep_item = table[dep.to_string()].clone();
491                    match &dep_item {
492                        Item::Value(_) => {
493                            let dep_version_str = latest_rock_version_str(dep)?;
494                            table[dep.to_string()] = toml_edit::value(dep_version_str);
495                        }
496                        Item::Table(tbl) => {
497                            match tbl.get("git") {
498                                Some(git_item) => {
499                                    let git_value =
500                                        git_item.clone().into_value().map_err(|err| {
501                                            ProjectEditError::ExpectedValue(Box::new(err))
502                                        })?;
503                                    let git_url_str = git_value.as_str().ok_or(
504                                        ProjectEditError::ExpectedString(Box::new(
505                                            git_value.clone(),
506                                        )),
507                                    )?;
508                                    let shorthand: RemoteGitUrlShorthand = git_url_str.parse()?;
509                                    match git::utils::latest_semver_tag_or_commit_sha(
510                                        &shorthand.into(),
511                                        config,
512                                    )? {
513                                        SemVerTagOrSha::SemVerTag(latest_tag) => {
514                                            table[dep.to_string()]["version"] =
515                                                Item::Value(latest_tag.clone().into());
516                                            if latest_tag.contains("-") {
517                                                // Tag contains a specrev.
518                                                table[dep.to_string()]["rev"] =
519                                                    Item::Value(latest_tag.into());
520                                            }
521                                        }
522                                        SemVerTagOrSha::CommitSha(latest_sha) => {
523                                            table[dep.to_string()]["version"] =
524                                                Item::Value(latest_sha.into());
525                                        }
526                                    }
527                                    table[dep.to_string()] = dep_item;
528                                }
529                                None => {
530                                    let dep_version_str = latest_rock_version_str(dep)?;
531                                    dep_item["version".to_string()] =
532                                        toml_edit::value(dep_version_str);
533                                    table[dep.to_string()] = dep_item;
534                                }
535                            }
536                        }
537                        _ => {}
538                    }
539                }
540            }
541        }
542
543        let toml_content = project_toml.to_string();
544        fs::tokio::write(self.toml_path(), &toml_content).await?;
545        self.toml = PartialProjectToml::new(
546            self.toml_path().to_str().unwrap_or("<lux.toml>"),
547            &toml_content,
548            self.root.clone(),
549        )?;
550
551        Ok(())
552    }
553
554    pub async fn upgrade_all(
555        &mut self,
556        package_db: &RemotePackageDB,
557        config: &Config,
558    ) -> Result<(), ProjectEditError> {
559        if let Some(dependencies) = &self.toml().dependencies {
560            let packages = dependencies
561                .iter()
562                .map(|dep| dep.name())
563                .cloned()
564                .collect_vec();
565            self.upgrade(
566                LuaDependencyType::Regular(packages.iter().collect()),
567                package_db,
568                config,
569            )
570            .await?;
571        }
572        if let Some(dependencies) = &self.toml().build_dependencies {
573            let packages = dependencies
574                .iter()
575                .map(|dep| dep.name())
576                .cloned()
577                .collect_vec();
578            self.upgrade(
579                LuaDependencyType::Build(packages.iter().collect()),
580                package_db,
581                config,
582            )
583            .await?;
584        }
585        if let Some(dependencies) = &self.toml().test_dependencies {
586            let packages = dependencies
587                .iter()
588                .map(|dep| dep.name())
589                .cloned()
590                .collect_vec();
591            self.upgrade(
592                LuaDependencyType::Test(packages.iter().collect()),
593                package_db,
594                config,
595            )
596            .await?;
597        }
598        Ok(())
599    }
600
601    pub async fn set_pinned_state(
602        &mut self,
603        dependencies: LuaDependencyType<&PackageName>,
604        pin: PinnedState,
605    ) -> Result<(), PinError> {
606        let mut project_toml =
607            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
608                .map_err(|source| PinError::ParseTomlEdit {
609                toml_path: self.toml_path().to_slash_lossy().to_string(),
610                source,
611            })?;
612
613        prepare_dependency_tables(&mut project_toml);
614        let table = match dependencies {
615            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
616            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
617            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
618        };
619
620        match dependencies {
621            LuaDependencyType::Regular(ref _deps) => {
622                self.toml.dependencies = Some(
623                    self.toml
624                        .dependencies
625                        .take()
626                        .unwrap_or_default()
627                        .into_iter()
628                        .map(|dep| LuaDependencySpec { pin, ..dep })
629                        .collect(),
630                )
631            }
632            LuaDependencyType::Build(ref _deps) => {
633                self.toml.build_dependencies = Some(
634                    self.toml
635                        .build_dependencies
636                        .take()
637                        .unwrap_or_default()
638                        .into_iter()
639                        .map(|dep| LuaDependencySpec { pin, ..dep })
640                        .collect(),
641                )
642            }
643            LuaDependencyType::Test(ref _deps) => {
644                self.toml.test_dependencies = Some(
645                    self.toml
646                        .test_dependencies
647                        .take()
648                        .unwrap_or_default()
649                        .into_iter()
650                        .map(|dep| LuaDependencySpec { pin, ..dep })
651                        .collect(),
652                )
653            }
654        }
655
656        match dependencies {
657            LuaDependencyType::Regular(ref deps)
658            | LuaDependencyType::Build(ref deps)
659            | LuaDependencyType::Test(ref deps) => {
660                for dep in deps {
661                    let mut dep_item = table[dep.to_string()].clone();
662                    match dep_item {
663                        version @ Item::Value(_) => match &pin {
664                            PinnedState::Unpinned => {}
665                            PinnedState::Pinned => {
666                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
667                                    dep_entry.set_implicit(true);
668                                    dep_entry["version"] = version;
669                                    dep_entry["pin"] = toml_edit::value(true);
670                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
671                                }
672                            }
673                        },
674                        Item::Table(_) => {
675                            dep_item["pin".to_string()] = toml_edit::value(pin.as_bool());
676                            table[dep.to_string()] = dep_item;
677                        }
678                        _ => {}
679                    }
680                }
681            }
682        }
683
684        let toml_content = project_toml.to_string();
685        fs::tokio::write(self.toml_path(), &toml_content).await?;
686        self.toml = PartialProjectToml::new(
687            self.toml_path().to_str().unwrap_or("<lux.toml>"),
688            &toml_content,
689            self.root.clone(),
690        )?;
691
692        Ok(())
693    }
694
695    pub fn project_files(&self) -> Vec<PathBuf> {
696        project_files(&self.root().0)
697    }
698}
699
700/// Get the files that Lux treats as project files
701/// This respects ignore files and excludes hidden files and directories.
702pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
703    ignore::WalkBuilder::new(src)
704        .add(src.join(".cargo"))
705        .follow_links(false)
706        .build()
707        .filter_map(Result::ok)
708        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
709        .map(|entry| entry.into_path())
710        .collect_vec()
711}
712
713fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
714    if !project_toml.contains_table("dependencies") {
715        if let Ok(mut table) = toml_edit::table().into_table() {
716            table.set_implicit(true);
717            project_toml["dependencies"] = toml_edit::Item::Table(table);
718        }
719    }
720    if !project_toml.contains_table("build_dependencies") {
721        if let Ok(mut table) = toml_edit::table().into_table() {
722            table.set_implicit(true);
723            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
724        }
725    }
726    if !project_toml.contains_table("test_dependencies") {
727        if let Ok(mut table) = toml_edit::table().into_table() {
728            table.set_implicit(true);
729            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
730        }
731    }
732    if !project_toml.contains_table("external_dependencies") {
733        if let Ok(mut table) = toml_edit::table().into_table() {
734            table.set_implicit(true);
735            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
736        }
737    }
738}
739
740// TODO: More project-based test
741#[cfg(test)]
742mod tests {
743    use std::collections::HashMap;
744
745    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
746    use url::Url;
747
748    use super::*;
749    use crate::{
750        fs,
751        lua_rockspec::ExternalDependencySpec,
752        manifest::{Manifest, ManifestMetadata},
753        package::PackageReq,
754        rockspec::Rockspec,
755    };
756
757    #[tokio::test]
758    async fn test_add_various_dependencies() {
759        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
760            .join("resources/test/sample-projects/no-build-spec/");
761        let project_root = assert_fs::TempDir::new().unwrap();
762        project_root.copy_from(&sample_project, &["**"]).unwrap();
763        let project_root: PathBuf = project_root.path().into();
764        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
765        let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
766        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
767            .unwrap()
768            .into()];
769
770        let test_manifest_path =
771            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
772        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
773        let metadata = ManifestMetadata::new(&content).unwrap();
774        let package_db = Manifest::new(
775            Url::parse("https://example.com").unwrap(),
776            metadata,
777            Default::default(),
778        )
779        .into();
780
781        project
782            .add(
783                DependencyType::Regular(add_dependencies.iter().collect_vec()),
784                &package_db,
785            )
786            .await
787            .unwrap();
788
789        project
790            .add(
791                DependencyType::Build(add_dependencies.iter().collect_vec()),
792                &package_db,
793            )
794            .await
795            .unwrap();
796        project
797            .add(
798                DependencyType::Test(add_dependencies.iter().collect_vec()),
799                &package_db,
800            )
801            .await
802            .unwrap();
803
804        project
805            .add(
806                DependencyType::External(HashMap::from([(
807                    "lib".into(),
808                    ExternalDependencySpec {
809                        library: Some("path.so".into()),
810                        header: None,
811                    },
812                )])),
813                &package_db,
814            )
815            .await
816            .unwrap();
817
818        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
819        // written correctly)
820        let project = Project::from_exact(&project_root).unwrap().unwrap();
821        let validated_toml = project.toml().into_remote(None).unwrap();
822
823        assert_eq!(
824            validated_toml.dependencies().current_platform(),
825            &expected_dependencies
826        );
827        assert_eq!(
828            validated_toml.build_dependencies().current_platform(),
829            &expected_dependencies
830        );
831        assert_eq!(
832            validated_toml.test_dependencies().current_platform(),
833            &expected_dependencies
834        );
835        assert_eq!(
836            validated_toml
837                .external_dependencies()
838                .current_platform()
839                .get("lib")
840                .unwrap(),
841            &ExternalDependencySpec {
842                library: Some("path.so".into()),
843                header: None
844            }
845        );
846    }
847
848    #[tokio::test]
849    async fn test_remove_dependencies() {
850        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
851            .join("resources/test/sample-projects/dependencies/");
852        let project_root = assert_fs::TempDir::new().unwrap();
853        project_root.copy_from(&sample_project, &["**"]).unwrap();
854        let project_root: PathBuf = project_root.path().into();
855        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
856        let lua_cjson = "lua-cjson".into();
857        let plenary_nvim = "plenary.nvim".into();
858        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
859        project
860            .remove(DependencyType::Regular(remove_dependencies.clone()))
861            .await
862            .unwrap();
863        let check = |project: &Project| {
864            for name in &remove_dependencies {
865                assert!(!project
866                    .toml()
867                    .dependencies
868                    .clone()
869                    .unwrap_or_default()
870                    .iter()
871                    .any(|dep| &dep.name() == name));
872            }
873        };
874        check(&project);
875        // check again after reloading lux.toml
876        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
877        check(&reloaded_project);
878    }
879
880    #[tokio::test]
881    async fn test_extra_rockspec_parsing() {
882        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
883            .join("resources/test/sample-projects/extra-rockspec/");
884        let project_root = assert_fs::TempDir::new().unwrap();
885        project_root.copy_from(&sample_project, &["**"]).unwrap();
886        let project_root: PathBuf = project_root.path().into();
887        let project = Project::from_exact(project_root).unwrap().unwrap();
888
889        let extra_rockspec = project.extra_rockspec().unwrap();
890
891        assert!(extra_rockspec.is_some());
892
893        let rocks = project.toml().into_remote(None).unwrap();
894
895        assert_eq!(rocks.package().to_string(), "custom-package");
896    }
897
898    #[tokio::test]
899    async fn test_pin_dependencies() {
900        test_pin_unpin_dependencies(PinnedState::Pinned).await
901    }
902
903    #[tokio::test]
904    async fn test_unpin_dependencies() {
905        test_pin_unpin_dependencies(PinnedState::Unpinned).await
906    }
907
908    async fn test_pin_unpin_dependencies(pin: PinnedState) {
909        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
910            .join("resources/test/sample-projects/dependencies/");
911        let project_root = assert_fs::TempDir::new().unwrap();
912        project_root.copy_from(&sample_project, &["**"]).unwrap();
913        let project_root: PathBuf = project_root.path().into();
914        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
915        let lua_cjson = "lua-cjson".into();
916        let plenary_nvim = "plenary.nvim".into();
917        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
918        project
919            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
920            .await
921            .unwrap();
922        let check = |project: &Project| {
923            for name in &pin_dependencies {
924                assert!(project
925                    .toml()
926                    .dependencies
927                    .clone()
928                    .unwrap_or_default()
929                    .iter()
930                    .any(|dep| &dep.name() == name && dep.pin == pin));
931            }
932        };
933        check(&project);
934        // check again after reloading lux.toml
935        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
936        check(&reloaded_project);
937    }
938
939    #[tokio::test]
940    async fn project_files_includes_cargo_directory() {
941        let project_root = assert_fs::TempDir::new().unwrap();
942        let cargo_dir = project_root.child(".cargo");
943        cargo_dir.create_dir_all().unwrap();
944        let cargo_config = cargo_dir.join("config.toml");
945        fs::tokio::write(&cargo_config, "").await.unwrap();
946        let project_files = project_files(&project_root);
947        assert!(project_files.contains(&cargo_config.to_path_buf()));
948    }
949
950    #[tokio::test]
951    async fn from_exact_on_nonexistent_directory() {
952        let empty_dir = assert_fs::TempDir::new().unwrap();
953        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
954    }
955}