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    lux_toml_dir: ProjectRoot,
221    /// The project root. This is the same as [`Project::lux_toml_dir`],
222    /// unless overridden by the `[project] root_dir` field.
223    root: ProjectRoot,
224    /// The parsed lux.toml.
225    toml: PartialProjectToml,
226}
227
228impl Project {
229    /// Load a project at the specified path, if it exists
230    #[tracing::instrument(level = "trace", skip_all)]
231    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, ProjectError> {
232        if !start.as_ref().exists() {
233            return Ok(None);
234        }
235
236        if start.as_ref().join(PROJECT_TOML).exists() {
237            let project_toml_path = start.as_ref().join(PROJECT_TOML);
238            let toml_content = fs::sync::read_to_string(&project_toml_path)?;
239            let root = start.as_ref();
240
241            let toml = PartialProjectToml::new(
242                &project_toml_path.to_string_lossy(),
243                &toml_content,
244                ProjectRoot(root.to_path_buf()),
245            )?;
246            let mut project = Project {
247                lux_toml_dir: ProjectRoot(root.to_path_buf()),
248                root: toml.project.root.clone(),
249                toml,
250            };
251
252            if let Some(extra_rockspec) = project.extra_rockspec()? {
253                project.toml = project.toml.merge(extra_rockspec);
254            }
255
256            Ok(Some(project))
257        } else {
258            Ok(None)
259        }
260    }
261
262    /// Get the `lux.toml` path.
263    pub fn toml_path(&self) -> PathBuf {
264        self.lux_toml_dir.join(PROJECT_TOML)
265    }
266
267    /// Get the `extra.rockspec` path.
268    pub fn extra_rockspec_path(&self) -> PathBuf {
269        self.lux_toml_dir.join(EXTRA_ROCKSPEC)
270    }
271
272    pub fn root(&self) -> &ProjectRoot {
273        &self.root
274    }
275
276    pub fn toml(&self) -> &PartialProjectToml {
277        &self.toml
278    }
279
280    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
281        self.toml()
282            .into_local()
283            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
284            .to_lua_rockspec()
285            .map_err(|err| IntoLocalRockspecError::RockspecError(Box::new(err)))
286    }
287
288    pub fn remote_rockspec(
289        &self,
290        specrev: Option<SpecRev>,
291    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
292        self.toml()
293            .into_remote(specrev)
294            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
295            .to_lua_rockspec()
296            .map_err(|err| IntoRemoteRockspecError::RockspecError(Box::new(err)))
297    }
298
299    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
300        if self.extra_rockspec_path().exists() {
301            Ok(Some(PartialLuaRockspec::new(
302                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
303            )?))
304        } else {
305            Ok(None)
306        }
307    }
308
309    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
310        self.toml().lua_version_matches(config)
311    }
312
313    pub async fn add(
314        &mut self,
315        dependencies: DependencyType<&PackageReq>,
316        package_db: &RemotePackageDB,
317    ) -> Result<(), ProjectEditError> {
318        let mut project_toml =
319            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
320
321        prepare_dependency_tables(&mut project_toml);
322        let table = match dependencies {
323            DependencyType::Regular(_) => &mut project_toml["dependencies"],
324            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
325            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
326            DependencyType::External(_) => &mut project_toml["external_dependencies"],
327        };
328
329        match dependencies {
330            DependencyType::Regular(ref deps)
331            | DependencyType::Build(ref deps)
332            | DependencyType::Test(ref deps) => {
333                for dep in deps {
334                    let dep_version_str = if dep.version_req().is_any() {
335                        package_db
336                            .latest_version(dep.name())
337                            .map(|latest_version| latest_version.to_string())
338                            .unwrap_or_else(|| dep.version_req().to_string())
339                    } else {
340                        dep.version_req().to_string()
341                    };
342                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
343                }
344            }
345            DependencyType::External(ref deps) => {
346                for (name, dep) in deps {
347                    if let Some(path) = &dep.header {
348                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
349                    }
350                    if let Some(path) = &dep.library {
351                        table[name]["library"] =
352                            toml_edit::value(path.to_slash_lossy().to_string());
353                    }
354                }
355            }
356        };
357
358        let toml_content = project_toml.to_string();
359        fs::tokio::write(self.toml_path(), &toml_content).await?;
360        self.toml = PartialProjectToml::new(
361            self.toml_path().to_str().unwrap_or("<lux.toml>"),
362            &toml_content,
363            self.lux_toml_dir.clone(),
364        )?;
365
366        Ok(())
367    }
368
369    pub async fn add_git(
370        &mut self,
371        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
372        config: &Config,
373    ) -> Result<(), ProjectEditError> {
374        let mut project_toml =
375            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
376
377        prepare_dependency_tables(&mut project_toml);
378        let table = match dependencies {
379            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
380            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
381            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
382        };
383
384        match dependencies {
385            LuaDependencyType::Regular(urls)
386            | LuaDependencyType::Build(urls)
387            | LuaDependencyType::Test(urls) => {
388                for url in urls {
389                    let git_url: RemoteGitUrl = url.clone().into();
390                    let mut dep_entry = toml_edit::table();
391                    match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
392                        SemVerTagOrSha::SemVerTag(tag) => {
393                            dep_entry["git"] = Item::Value(url.to_string().into());
394                            dep_entry["version"] = Item::Value(tag.clone().into());
395                            if tag.contains("-") {
396                                // Tag contains a specrev.
397                                dep_entry["rev"] = Item::Value(tag.into());
398                            }
399                        }
400                        SemVerTagOrSha::CommitSha(sha) => {
401                            dep_entry["git"] = Item::Value(url.to_string().into());
402                            dep_entry["version"] = Item::Value(sha.into());
403                        }
404                    }
405                    table[git_url.repo()] = dep_entry;
406                }
407            }
408        }
409
410        let toml_content = project_toml.to_string();
411        fs::tokio::write(self.toml_path(), &toml_content).await?;
412        self.toml = PartialProjectToml::new(
413            self.toml_path().to_str().unwrap_or("<lux.toml>"),
414            &toml_content,
415            self.lux_toml_dir.clone(),
416        )?;
417
418        Ok(())
419    }
420
421    pub async fn remove(
422        &mut self,
423        dependencies: DependencyType<&PackageName>,
424    ) -> Result<(), ProjectEditError> {
425        let mut project_toml =
426            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
427
428        prepare_dependency_tables(&mut project_toml);
429        let table = match dependencies {
430            DependencyType::Regular(_) => &mut project_toml["dependencies"],
431            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
432            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
433            DependencyType::External(_) => &mut project_toml["external_dependencies"],
434        };
435
436        match dependencies {
437            DependencyType::Regular(ref deps)
438            | DependencyType::Build(ref deps)
439            | DependencyType::Test(ref deps) => {
440                for dep in deps {
441                    table[dep.to_string()] = Item::None;
442                }
443            }
444            DependencyType::External(ref deps) => {
445                for (name, dep) in deps {
446                    if dep.header.is_some() {
447                        table[name]["header"] = Item::None;
448                    }
449                    if dep.library.is_some() {
450                        table[name]["library"] = Item::None;
451                    }
452                }
453            }
454        };
455
456        let toml_content = project_toml.to_string();
457        fs::tokio::write(self.toml_path(), &toml_content).await?;
458        self.toml = PartialProjectToml::new(
459            self.toml_path().to_str().unwrap_or("<lux.toml>"),
460            &toml_content,
461            self.lux_toml_dir.clone(),
462        )?;
463
464        Ok(())
465    }
466
467    pub async fn upgrade(
468        &mut self,
469        dependencies: LuaDependencyType<&PackageName>,
470        package_db: &RemotePackageDB,
471        config: &Config,
472    ) -> Result<(), ProjectEditError> {
473        let mut project_toml =
474            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
475
476        prepare_dependency_tables(&mut project_toml);
477        let table = match dependencies {
478            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
479            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
480            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
481        };
482
483        match dependencies {
484            LuaDependencyType::Regular(deps)
485            | LuaDependencyType::Build(deps)
486            | LuaDependencyType::Test(deps) => {
487                let latest_rock_version_str =
488                    |dep: &PackageName| -> Result<String, ProjectEditError> {
489                        Ok(package_db
490                            .latest_version(dep)
491                            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?
492                            .to_string())
493                    };
494                for dep in deps {
495                    let mut dep_item = table[dep.to_string()].clone();
496                    match &dep_item {
497                        Item::Value(_) => {
498                            let dep_version_str = latest_rock_version_str(dep)?;
499                            table[dep.to_string()] = toml_edit::value(dep_version_str);
500                        }
501                        Item::Table(tbl) => {
502                            match tbl.get("git") {
503                                Some(git_item) => {
504                                    let git_value =
505                                        git_item.clone().into_value().map_err(|err| {
506                                            ProjectEditError::ExpectedValue(Box::new(err))
507                                        })?;
508                                    let git_url_str = git_value.as_str().ok_or(
509                                        ProjectEditError::ExpectedString(Box::new(
510                                            git_value.clone(),
511                                        )),
512                                    )?;
513                                    let shorthand: RemoteGitUrlShorthand = git_url_str.parse()?;
514                                    match git::utils::latest_semver_tag_or_commit_sha(
515                                        &shorthand.into(),
516                                        config,
517                                    )? {
518                                        SemVerTagOrSha::SemVerTag(latest_tag) => {
519                                            table[dep.to_string()]["version"] =
520                                                Item::Value(latest_tag.clone().into());
521                                            if latest_tag.contains("-") {
522                                                // Tag contains a specrev.
523                                                table[dep.to_string()]["rev"] =
524                                                    Item::Value(latest_tag.into());
525                                            }
526                                        }
527                                        SemVerTagOrSha::CommitSha(latest_sha) => {
528                                            table[dep.to_string()]["version"] =
529                                                Item::Value(latest_sha.into());
530                                        }
531                                    }
532                                    table[dep.to_string()] = dep_item;
533                                }
534                                None => {
535                                    let dep_version_str = latest_rock_version_str(dep)?;
536                                    dep_item["version".to_string()] =
537                                        toml_edit::value(dep_version_str);
538                                    table[dep.to_string()] = dep_item;
539                                }
540                            }
541                        }
542                        _ => {}
543                    }
544                }
545            }
546        }
547
548        let toml_content = project_toml.to_string();
549        fs::tokio::write(self.toml_path(), &toml_content).await?;
550        self.toml = PartialProjectToml::new(
551            self.toml_path().to_str().unwrap_or("<lux.toml>"),
552            &toml_content,
553            self.lux_toml_dir.clone(),
554        )?;
555
556        Ok(())
557    }
558
559    pub async fn upgrade_all(
560        &mut self,
561        package_db: &RemotePackageDB,
562        config: &Config,
563    ) -> Result<(), ProjectEditError> {
564        if let Some(dependencies) = &self.toml().dependencies {
565            let packages = dependencies
566                .iter()
567                .map(|dep| dep.name())
568                .cloned()
569                .collect_vec();
570            self.upgrade(
571                LuaDependencyType::Regular(packages.iter().collect()),
572                package_db,
573                config,
574            )
575            .await?;
576        }
577        if let Some(dependencies) = &self.toml().build_dependencies {
578            let packages = dependencies
579                .iter()
580                .map(|dep| dep.name())
581                .cloned()
582                .collect_vec();
583            self.upgrade(
584                LuaDependencyType::Build(packages.iter().collect()),
585                package_db,
586                config,
587            )
588            .await?;
589        }
590        if let Some(dependencies) = &self.toml().test_dependencies {
591            let packages = dependencies
592                .iter()
593                .map(|dep| dep.name())
594                .cloned()
595                .collect_vec();
596            self.upgrade(
597                LuaDependencyType::Test(packages.iter().collect()),
598                package_db,
599                config,
600            )
601            .await?;
602        }
603        Ok(())
604    }
605
606    pub async fn set_pinned_state(
607        &mut self,
608        dependencies: LuaDependencyType<&PackageName>,
609        pin: PinnedState,
610    ) -> Result<(), PinError> {
611        let mut project_toml =
612            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
613                .map_err(|source| PinError::ParseTomlEdit {
614                toml_path: self.toml_path().to_slash_lossy().to_string(),
615                source,
616            })?;
617
618        prepare_dependency_tables(&mut project_toml);
619        let table = match dependencies {
620            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
621            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
622            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
623        };
624
625        match dependencies {
626            LuaDependencyType::Regular(ref _deps) => {
627                self.toml.dependencies = Some(
628                    self.toml
629                        .dependencies
630                        .take()
631                        .unwrap_or_default()
632                        .into_iter()
633                        .map(|dep| LuaDependencySpec { pin, ..dep })
634                        .collect(),
635                )
636            }
637            LuaDependencyType::Build(ref _deps) => {
638                self.toml.build_dependencies = Some(
639                    self.toml
640                        .build_dependencies
641                        .take()
642                        .unwrap_or_default()
643                        .into_iter()
644                        .map(|dep| LuaDependencySpec { pin, ..dep })
645                        .collect(),
646                )
647            }
648            LuaDependencyType::Test(ref _deps) => {
649                self.toml.test_dependencies = Some(
650                    self.toml
651                        .test_dependencies
652                        .take()
653                        .unwrap_or_default()
654                        .into_iter()
655                        .map(|dep| LuaDependencySpec { pin, ..dep })
656                        .collect(),
657                )
658            }
659        }
660
661        match dependencies {
662            LuaDependencyType::Regular(ref deps)
663            | LuaDependencyType::Build(ref deps)
664            | LuaDependencyType::Test(ref deps) => {
665                for dep in deps {
666                    let mut dep_item = table[dep.to_string()].clone();
667                    match dep_item {
668                        version @ Item::Value(_) => match &pin {
669                            PinnedState::Unpinned => {}
670                            PinnedState::Pinned => {
671                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
672                                    dep_entry.set_implicit(true);
673                                    dep_entry["version"] = version;
674                                    dep_entry["pin"] = toml_edit::value(true);
675                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
676                                }
677                            }
678                        },
679                        Item::Table(_) => {
680                            dep_item["pin".to_string()] = toml_edit::value(pin.as_bool());
681                            table[dep.to_string()] = dep_item;
682                        }
683                        _ => {}
684                    }
685                }
686            }
687        }
688
689        let toml_content = project_toml.to_string();
690        fs::tokio::write(self.toml_path(), &toml_content).await?;
691        self.toml = PartialProjectToml::new(
692            self.toml_path().to_str().unwrap_or("<lux.toml>"),
693            &toml_content,
694            self.lux_toml_dir.clone(),
695        )?;
696
697        Ok(())
698    }
699
700    pub fn project_files(&self) -> Vec<PathBuf> {
701        project_files(&self.root().0)
702    }
703}
704
705/// Get the files that Lux treats as project files
706/// This respects ignore files and excludes hidden files and directories.
707pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
708    ignore::WalkBuilder::new(src)
709        .add(src.join(".cargo"))
710        .follow_links(false)
711        .build()
712        .filter_map(Result::ok)
713        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
714        .map(|entry| entry.into_path())
715        .collect_vec()
716}
717
718fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
719    if !project_toml.contains_table("dependencies") {
720        if let Ok(mut table) = toml_edit::table().into_table() {
721            table.set_implicit(true);
722            project_toml["dependencies"] = toml_edit::Item::Table(table);
723        }
724    }
725    if !project_toml.contains_table("build_dependencies") {
726        if let Ok(mut table) = toml_edit::table().into_table() {
727            table.set_implicit(true);
728            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
729        }
730    }
731    if !project_toml.contains_table("test_dependencies") {
732        if let Ok(mut table) = toml_edit::table().into_table() {
733            table.set_implicit(true);
734            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
735        }
736    }
737    if !project_toml.contains_table("external_dependencies") {
738        if let Ok(mut table) = toml_edit::table().into_table() {
739            table.set_implicit(true);
740            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
741        }
742    }
743}
744
745// TODO: More project-based test
746#[cfg(test)]
747mod tests {
748    use std::collections::HashMap;
749
750    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
751    use url::Url;
752
753    use super::*;
754    use crate::{
755        fs,
756        lua_rockspec::ExternalDependencySpec,
757        manifest::{Manifest, ManifestMetadata},
758        package::PackageReq,
759        rockspec::Rockspec,
760    };
761
762    #[tokio::test]
763    async fn test_add_various_dependencies() {
764        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
765            .join("resources/test/sample-projects/no-build-spec/");
766        let project_root = assert_fs::TempDir::new().unwrap();
767        project_root.copy_from(&sample_project, &["**"]).unwrap();
768        let project_root: PathBuf = project_root.path().into();
769        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
770        let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
771        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
772            .unwrap()
773            .into()];
774
775        let test_manifest_path =
776            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
777        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
778        let metadata = ManifestMetadata::new(&content).unwrap();
779        let package_db = Manifest::new(
780            Url::parse("https://example.com").unwrap(),
781            metadata,
782            Default::default(),
783        )
784        .into();
785
786        project
787            .add(
788                DependencyType::Regular(add_dependencies.iter().collect_vec()),
789                &package_db,
790            )
791            .await
792            .unwrap();
793
794        project
795            .add(
796                DependencyType::Build(add_dependencies.iter().collect_vec()),
797                &package_db,
798            )
799            .await
800            .unwrap();
801        project
802            .add(
803                DependencyType::Test(add_dependencies.iter().collect_vec()),
804                &package_db,
805            )
806            .await
807            .unwrap();
808
809        project
810            .add(
811                DependencyType::External(HashMap::from([(
812                    "lib".into(),
813                    ExternalDependencySpec {
814                        library: Some("path.so".into()),
815                        header: None,
816                    },
817                )])),
818                &package_db,
819            )
820            .await
821            .unwrap();
822
823        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
824        // written correctly)
825        let project = Project::from_exact(&project_root).unwrap().unwrap();
826        let validated_toml = project.toml().into_remote(None).unwrap();
827
828        assert_eq!(
829            validated_toml.dependencies().current_platform(),
830            &expected_dependencies
831        );
832        assert_eq!(
833            validated_toml.build_dependencies().current_platform(),
834            &expected_dependencies
835        );
836        assert_eq!(
837            validated_toml.test_dependencies().current_platform(),
838            &expected_dependencies
839        );
840        assert_eq!(
841            validated_toml
842                .external_dependencies()
843                .current_platform()
844                .get("lib")
845                .unwrap(),
846            &ExternalDependencySpec {
847                library: Some("path.so".into()),
848                header: None
849            }
850        );
851    }
852
853    #[tokio::test]
854    async fn test_remove_dependencies() {
855        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
856            .join("resources/test/sample-projects/dependencies/");
857        let project_root = assert_fs::TempDir::new().unwrap();
858        project_root.copy_from(&sample_project, &["**"]).unwrap();
859        let project_root: PathBuf = project_root.path().into();
860        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
861        let lua_cjson = "lua-cjson".into();
862        let plenary_nvim = "plenary.nvim".into();
863        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
864        project
865            .remove(DependencyType::Regular(remove_dependencies.clone()))
866            .await
867            .unwrap();
868        let check = |project: &Project| {
869            for name in &remove_dependencies {
870                assert!(!project
871                    .toml()
872                    .dependencies
873                    .clone()
874                    .unwrap_or_default()
875                    .iter()
876                    .any(|dep| &dep.name() == name));
877            }
878        };
879        check(&project);
880        // check again after reloading lux.toml
881        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
882        check(&reloaded_project);
883    }
884
885    #[tokio::test]
886    async fn test_extra_rockspec_parsing() {
887        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
888            .join("resources/test/sample-projects/extra-rockspec/");
889        let project_root = assert_fs::TempDir::new().unwrap();
890        project_root.copy_from(&sample_project, &["**"]).unwrap();
891        let project_root: PathBuf = project_root.path().into();
892        let project = Project::from_exact(project_root).unwrap().unwrap();
893
894        let extra_rockspec = project.extra_rockspec().unwrap();
895
896        assert!(extra_rockspec.is_some());
897
898        let rocks = project.toml().into_remote(None).unwrap();
899
900        assert_eq!(rocks.package().to_string(), "custom-package");
901    }
902
903    #[tokio::test]
904    async fn test_pin_dependencies() {
905        test_pin_unpin_dependencies(PinnedState::Pinned).await
906    }
907
908    #[tokio::test]
909    async fn test_unpin_dependencies() {
910        test_pin_unpin_dependencies(PinnedState::Unpinned).await
911    }
912
913    async fn test_pin_unpin_dependencies(pin: PinnedState) {
914        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
915            .join("resources/test/sample-projects/dependencies/");
916        let project_root = assert_fs::TempDir::new().unwrap();
917        project_root.copy_from(&sample_project, &["**"]).unwrap();
918        let project_root: PathBuf = project_root.path().into();
919        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
920        let lua_cjson = "lua-cjson".into();
921        let plenary_nvim = "plenary.nvim".into();
922        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
923        project
924            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
925            .await
926            .unwrap();
927        let check = |project: &Project| {
928            for name in &pin_dependencies {
929                assert!(project
930                    .toml()
931                    .dependencies
932                    .clone()
933                    .unwrap_or_default()
934                    .iter()
935                    .any(|dep| &dep.name() == name && dep.pin == pin));
936            }
937        };
938        check(&project);
939        // check again after reloading lux.toml
940        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
941        check(&reloaded_project);
942    }
943
944    #[tokio::test]
945    async fn project_files_includes_cargo_directory() {
946        let project_root = assert_fs::TempDir::new().unwrap();
947        let cargo_dir = project_root.child(".cargo");
948        cargo_dir.create_dir_all().unwrap();
949        let cargo_config = cargo_dir.join("config.toml");
950        fs::tokio::write(&cargo_config, "").await.unwrap();
951        let project_files = project_files(&project_root);
952        assert!(project_files.contains(&cargo_config.to_path_buf()));
953    }
954
955    #[tokio::test]
956    async fn project_root_dir_override() {
957        let root_dir = assert_fs::TempDir::new().unwrap();
958        let lux_toml_dir = root_dir.child("proj");
959        lux_toml_dir.create_dir_all().unwrap();
960        fs::tokio::write(
961            lux_toml_dir.join(PROJECT_TOML),
962            r#"
963            package = "my-package"
964            version = "1.0.0"
965            lua = "5.1"
966
967            [project]
968            root_dir = ".."
969
970            [build]
971            type = "builtin"
972            "#,
973        )
974        .await
975        .unwrap();
976        let project = Project::from_exact(lux_toml_dir.path()).unwrap().unwrap();
977        assert_eq!(
978            std::fs::canonicalize(project.root()).unwrap(),
979            std::fs::canonicalize(root_dir.path()).unwrap()
980        );
981        // The `lux.toml` path must still point to the directory containing the `lux.toml`.
982        assert_eq!(project.toml_path(), lux_toml_dir.join(PROJECT_TOML));
983    }
984
985    #[tokio::test]
986    async fn from_exact_on_nonexistent_directory() {
987        let empty_dir = assert_fs::TempDir::new().unwrap();
988        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
989    }
990}