Skip to main content

lux_lib/project/
mod.rs

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