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