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, lockfile::OptState};
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        self, LocalLuaRockspec, LuaVersionError, PartialLuaRockspec, PartialRockspecError,
28        RemoteLuaRockspec,
29    },
30    lua_version::LuaVersion,
31    package::SpecRev,
32    package_db::PackageDB,
33    rockspec::{
34        lua_dependency::{DependencyType, LuaDependencyType},
35        LuaVersionCompatibility,
36    },
37};
38use crate::{
39    lockfile::PinnedState,
40    package::{PackageName, PackageReq, PackageVersionReq},
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 Error {
95    #[error(transparent)]
96    #[diagnostic(transparent)]
97    Fs(#[from] fs::Error),
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    Rockspec(Box<lua_rockspec::Error>),
113}
114
115#[derive(Error, Debug, Diagnostic)]
116#[non_exhaustive]
117#[error(transparent)]
118pub enum IntoRemoteRockspecError {
119    RocksTomlValidationError(Box<RemoteProjectTomlValidationError>),
120    Rockspec(Box<lua_rockspec::Error>),
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::Error),
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::Error),
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>, Error> {
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::Rockspec(Box::new(err)))
286    }
287
288    /// Returns this project's rockspec as a [`RemoteLuaRockspec`] whose source points at the
289    /// project's own directory, so it can be built like any other package.
290    pub fn local_remote_rockspec(&self) -> Result<RemoteLuaRockspec, IntoLocalRockspecError> {
291        self.toml()
292            .into_local()
293            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
294            .to_local_rockspec()
295            .map_err(|err| IntoLocalRockspecError::Rockspec(Box::new(err)))
296    }
297
298    pub fn remote_rockspec(
299        &self,
300        specrev: Option<SpecRev>,
301    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
302        self.toml()
303            .into_remote(specrev)
304            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
305            .to_lua_rockspec()
306            .map_err(|err| IntoRemoteRockspecError::Rockspec(Box::new(err)))
307    }
308
309    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
310        if self.extra_rockspec_path().exists() {
311            Ok(Some(PartialLuaRockspec::new(
312                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
313            )?))
314        } else {
315            Ok(None)
316        }
317    }
318
319    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
320        self.toml().lua_version_matches(config)
321    }
322
323    async fn write_toml(&mut self, project_toml: DocumentMut) -> Result<(), ProjectEditError> {
324        let toml_content = project_toml.to_string();
325        fs::tokio::write(self.toml_path(), &toml_content).await?;
326        self.toml = PartialProjectToml::new(
327            self.toml_path().to_str().unwrap_or("<lux.toml>"),
328            &toml_content,
329            self.lux_toml_dir.clone(),
330        )?;
331        Ok(())
332    }
333
334    pub async fn add(
335        &mut self,
336        dependencies: DependencyType<&PackageReq>,
337        package_db: &PackageDB,
338    ) -> Result<(), ProjectEditError> {
339        let mut project_toml =
340            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
341
342        prepare_dependency_tables(&mut project_toml);
343        let table = match dependencies {
344            DependencyType::Regular(_) => &mut project_toml["dependencies"],
345            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
346            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
347            DependencyType::External(_) => &mut project_toml["external_dependencies"],
348        };
349
350        match dependencies {
351            DependencyType::Regular(ref deps)
352            | DependencyType::Build(ref deps)
353            | DependencyType::Test(ref deps) => {
354                for dep in deps {
355                    let dep_version_str = if dep.version_req().is_any() {
356                        let latest_version =
357                            package_db.latest_version(dep.name()).await.ok_or_else(|| {
358                                ProjectEditError::LatestVersionNotFound(dep.name().clone())
359                            })?;
360                        latest_version
361                            .to_pessimistic_version_req()
362                            .unwrap_or_else(|| latest_version.to_string())
363                    } else {
364                        dep.version_req().to_string()
365                    };
366                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
367                }
368            }
369            DependencyType::External(ref deps) => {
370                for (name, dep) in deps {
371                    if let Some(path) = &dep.header {
372                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
373                    }
374                    if let Some(path) = &dep.library {
375                        table[name]["library"] =
376                            toml_edit::value(path.to_slash_lossy().to_string());
377                    }
378                }
379            }
380        };
381
382        self.write_toml(project_toml).await?;
383        Ok(())
384    }
385
386    pub async fn add_git(
387        &mut self,
388        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
389        config: &Config,
390    ) -> Result<(), ProjectEditError> {
391        let mut project_toml =
392            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
393
394        prepare_dependency_tables(&mut project_toml);
395        let table = match dependencies {
396            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
397            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
398            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
399        };
400
401        match dependencies {
402            LuaDependencyType::Regular(urls)
403            | LuaDependencyType::Build(urls)
404            | LuaDependencyType::Test(urls) => {
405                for url in urls {
406                    let git_url: RemoteGitUrl = url.clone().into();
407                    let mut dep_entry = toml_edit::table();
408                    match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
409                        SemVerTagOrSha::SemVerTag(tag) => {
410                            dep_entry["git"] = Item::Value(url.to_string().into());
411                            dep_entry["version"] = Item::Value(tag.clone().into());
412                            if tag.contains("-") {
413                                // Tag contains a specrev.
414                                dep_entry["rev"] = Item::Value(tag.into());
415                            }
416                        }
417                        SemVerTagOrSha::CommitSha(sha) => {
418                            dep_entry["git"] = Item::Value(url.to_string().into());
419                            dep_entry["version"] = Item::Value(sha.into());
420                        }
421                    }
422                    table[git_url.repo()] = dep_entry;
423                }
424            }
425        }
426
427        self.write_toml(project_toml).await?;
428
429        Ok(())
430    }
431
432    pub async fn remove(
433        &mut self,
434        dependencies: DependencyType<&PackageName>,
435    ) -> Result<(), ProjectEditError> {
436        let mut project_toml =
437            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
438
439        prepare_dependency_tables(&mut project_toml);
440        let table = match dependencies {
441            DependencyType::Regular(_) => &mut project_toml["dependencies"],
442            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
443            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
444            DependencyType::External(_) => &mut project_toml["external_dependencies"],
445        };
446
447        match dependencies {
448            DependencyType::Regular(ref deps)
449            | DependencyType::Build(ref deps)
450            | DependencyType::Test(ref deps) => {
451                for dep in deps {
452                    table[dep.to_string()] = Item::None;
453                }
454            }
455            DependencyType::External(ref deps) => {
456                for (name, dep) in deps {
457                    if dep.header.is_some() {
458                        table[name]["header"] = Item::None;
459                    }
460                    if dep.library.is_some() {
461                        table[name]["library"] = Item::None;
462                    }
463                }
464            }
465        };
466
467        self.write_toml(project_toml).await?;
468
469        Ok(())
470    }
471
472    pub async fn upgrade(
473        &mut self,
474        dependencies: LuaDependencyType<&PackageName>,
475        package_db: &PackageDB,
476        config: &Config,
477    ) -> Result<(), ProjectEditError> {
478        let mut project_toml =
479            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
480
481        prepare_dependency_tables(&mut project_toml);
482        let table = match dependencies {
483            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
484            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
485            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
486        };
487
488        match dependencies {
489            LuaDependencyType::Regular(deps)
490            | LuaDependencyType::Build(deps)
491            | LuaDependencyType::Test(deps) => {
492                for dep in deps {
493                    upgrade_dependency(table, dep, package_db, config).await?;
494                }
495            }
496        }
497
498        self.write_toml(project_toml).await?;
499
500        Ok(())
501    }
502
503    pub async fn upgrade_all(
504        &mut self,
505        package_db: &PackageDB,
506        config: &Config,
507    ) -> Result<(), ProjectEditError> {
508        if let Some(dependencies) = &self.toml().dependencies {
509            let packages = dependencies
510                .iter()
511                .map(|dep| dep.name())
512                .cloned()
513                .collect_vec();
514            self.upgrade(
515                LuaDependencyType::Regular(packages.iter().collect()),
516                package_db,
517                config,
518            )
519            .await?;
520        }
521        if let Some(dependencies) = &self.toml().build_dependencies {
522            let packages = dependencies
523                .iter()
524                .map(|dep| dep.name())
525                .cloned()
526                .collect_vec();
527            self.upgrade(
528                LuaDependencyType::Build(packages.iter().collect()),
529                package_db,
530                config,
531            )
532            .await?;
533        }
534        if let Some(dependencies) = &self.toml().test_dependencies {
535            let packages = dependencies
536                .iter()
537                .map(|dep| dep.name())
538                .cloned()
539                .collect_vec();
540            self.upgrade(
541                LuaDependencyType::Test(packages.iter().collect()),
542                package_db,
543                config,
544            )
545            .await?;
546        }
547        Ok(())
548    }
549
550    async fn set_dependency_flag(
551        &mut self,
552        dependencies: LuaDependencyType<&PackageName>,
553        key: &str,
554        enabled: bool,
555    ) -> Result<(), PinError> {
556        let mut project_toml =
557            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
558                .map_err(|source| PinError::ParseTomlEdit {
559                toml_path: self.toml_path().to_slash_lossy().to_string(),
560                source,
561            })?;
562
563        prepare_dependency_tables(&mut project_toml);
564        let table = match dependencies {
565            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
566            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
567            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
568        };
569
570        match dependencies {
571            LuaDependencyType::Regular(ref deps)
572            | LuaDependencyType::Build(ref deps)
573            | LuaDependencyType::Test(ref deps) => {
574                for dep in deps {
575                    let mut dep_item = table[dep.to_string()].clone();
576                    match dep_item {
577                        version @ Item::Value(_) => {
578                            if enabled {
579                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
580                                    dep_entry.set_implicit(true);
581                                    dep_entry["version"] = version;
582                                    dep_entry[key] = toml_edit::value(true);
583                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
584                                }
585                            }
586                        }
587                        Item::Table(_) => {
588                            dep_item[key] = toml_edit::value(enabled);
589                            table[dep.to_string()] = dep_item;
590                        }
591                        _ => {}
592                    }
593                }
594            }
595        }
596
597        let toml_content = project_toml.to_string();
598        fs::tokio::write(self.toml_path(), &toml_content).await?;
599        self.toml = PartialProjectToml::new(
600            self.toml_path().to_str().unwrap_or("<lux.toml>"),
601            &toml_content,
602            self.lux_toml_dir.clone(),
603        )?;
604
605        Ok(())
606    }
607
608    pub async fn set_pinned_state(
609        &mut self,
610        dependencies: LuaDependencyType<&PackageName>,
611        pin: PinnedState,
612    ) -> Result<(), PinError> {
613        self.set_dependency_flag(dependencies, "pin", pin.as_bool())
614            .await
615    }
616
617    pub async fn set_opt_state(
618        &mut self,
619        dependencies: LuaDependencyType<&PackageName>,
620        opt: OptState,
621    ) -> Result<(), PinError> {
622        self.set_dependency_flag(dependencies, "opt", opt.as_bool())
623            .await
624    }
625
626    pub fn project_files(&self) -> Vec<PathBuf> {
627        project_files(&self.root().0)
628    }
629}
630
631/// Get the files that Lux treats as project files
632/// This respects ignore files and excludes hidden files and directories.
633pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
634    ignore::WalkBuilder::new(src)
635        .add(src.join(".cargo"))
636        .parents(false)
637        .follow_links(false)
638        .build()
639        .filter_map(Result::ok)
640        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
641        .map(|entry| entry.into_path())
642        .collect_vec()
643}
644
645/// Recursively copy the files of `src` into `dest`, honouring ignore files.
646#[tracing::instrument(level = "trace")]
647pub(crate) async fn copy_dir(src: &Path, dest: &Path) -> Result<(), fs::Error> {
648    if src.exists() {
649        for file in project_files(src) {
650            let relative_src_path: PathBuf = pathdiff::diff_paths(src.join(&file), src)
651                .unwrap_or_else(|| unreachable!("diff_path with self"));
652            let target = dest.join(relative_src_path);
653            if let Some(parent) = target.parent() {
654                fs::tokio::create_dir_all(parent).await?;
655            }
656            fs::tokio::copy(&file, &target).await?;
657            fs::make_writable(&target).await?;
658        }
659    }
660    Ok(())
661}
662
663async fn upgraded_version_req(
664    existing: Option<&str>,
665    dep: &PackageName,
666    package_db: &PackageDB,
667) -> Result<Option<String>, ProjectEditError> {
668    if let Some(existing) = existing.and_then(|existing| PackageVersionReq::parse(existing).ok()) {
669        let latest = package_db
670            .latest_version(dep)
671            .await
672            .ok_or(ProjectEditError::LatestVersionNotFound(dep.clone()))?;
673        Ok(latest.upgrade_version_req(&existing))
674    } else {
675        Ok(None)
676    }
677}
678
679async fn upgrade_dependency(
680    table: &mut Item,
681    dep: &PackageName,
682    package_db: &PackageDB,
683    config: &Config,
684) -> Result<(), ProjectEditError> {
685    let mut dep_item = table[dep.to_string()].clone();
686    match &dep_item {
687        Item::Value(val) => {
688            if let Some(upgraded) = upgraded_version_req(val.as_str(), dep, package_db).await? {
689                table[dep.to_string()] = toml_edit::value(upgraded);
690            }
691        }
692        Item::Table(tbl) => {
693            if let Some(git_item) = tbl.get("git") {
694                let latest = resolve_git_latest(git_url_from_item(git_item)?, config)?;
695                apply_git_latest(table, dep, latest);
696            } else {
697                if let Some(upgraded) = upgraded_version_req(
698                    dep_item.get("version").and_then(Item::as_str),
699                    dep,
700                    package_db,
701                )
702                .await?
703                {
704                    dep_item["version"] = toml_edit::value(upgraded);
705                }
706                table[dep.to_string()] = dep_item;
707            }
708        }
709        _ => {}
710    }
711    Ok(())
712}
713
714fn git_url_from_item(git_item: &Item) -> Result<RemoteGitUrlShorthand, ProjectEditError> {
715    let git_value = git_item
716        .clone()
717        .into_value()
718        .map_err(|err| ProjectEditError::ExpectedValue(Box::new(err)))?;
719    let git_url_str = git_value
720        .as_str()
721        .ok_or(ProjectEditError::ExpectedString(Box::new(
722            git_value.clone(),
723        )))?;
724    Ok(git_url_str.parse()?)
725}
726
727fn resolve_git_latest(
728    url: RemoteGitUrlShorthand,
729    config: &Config,
730) -> Result<SemVerTagOrSha, ProjectEditError> {
731    Ok(git::utils::latest_semver_tag_or_commit_sha(
732        &url.into(),
733        config,
734    )?)
735}
736
737fn apply_git_latest(table: &mut Item, dep: &PackageName, latest: SemVerTagOrSha) {
738    match latest {
739        SemVerTagOrSha::SemVerTag(latest_tag) => {
740            table[dep.to_string()]["version"] = Item::Value(latest_tag.clone().into());
741            if latest_tag.contains("-") {
742                table[dep.to_string()]["rev"] = Item::Value(latest_tag.into());
743            }
744        }
745        SemVerTagOrSha::CommitSha(latest_sha) => {
746            table[dep.to_string()]["version"] = Item::Value(latest_sha.into());
747        }
748    }
749}
750
751fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
752    if !project_toml.contains_table("dependencies") {
753        if let Ok(mut table) = toml_edit::table().into_table() {
754            table.set_implicit(true);
755            project_toml["dependencies"] = toml_edit::Item::Table(table);
756        }
757    }
758    if !project_toml.contains_table("build_dependencies") {
759        if let Ok(mut table) = toml_edit::table().into_table() {
760            table.set_implicit(true);
761            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
762        }
763    }
764    if !project_toml.contains_table("test_dependencies") {
765        if let Ok(mut table) = toml_edit::table().into_table() {
766            table.set_implicit(true);
767            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
768        }
769    }
770    if !project_toml.contains_table("external_dependencies") {
771        if let Ok(mut table) = toml_edit::table().into_table() {
772            table.set_implicit(true);
773            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
774        }
775    }
776}
777
778// TODO: More project-based test
779#[cfg(test)]
780mod tests {
781    use std::collections::HashMap;
782
783    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
784    use url::Url;
785
786    use super::*;
787    use crate::{
788        config::ConfigBuilder,
789        fs,
790        lua_rockspec::ExternalDependencySpec,
791        manifest::{Manifest, ManifestMetadata},
792        package::PackageReq,
793        package_db::PackageDB,
794        rockspec::Rockspec,
795    };
796
797    #[tokio::test]
798    async fn test_add_various_dependencies() {
799        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
800            .join("resources/test/sample-projects/no-build-spec/");
801        let project_root = assert_fs::TempDir::new().unwrap();
802        project_root.copy_from(&sample_project, &["**"]).unwrap();
803        let project_root: PathBuf = project_root.path().into();
804        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
805        let add_dependencies = [PackageReq::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
806        let expected_dependencies = vec![PackageReq::new("busted".into(), Some(">= 1.0.0".into()))
807            .unwrap()
808            .into()];
809
810        let test_manifest_path =
811            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
812        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
813        let metadata = ManifestMetadata::new(&content).unwrap();
814        let config = ConfigBuilder::new().unwrap().build().unwrap();
815        let package_db = PackageDB::from_manifest(
816            Manifest::new(
817                Url::parse("https://example.com").unwrap(),
818                metadata,
819                Default::default(),
820            ),
821            &config,
822        );
823
824        project
825            .add(
826                DependencyType::Regular(add_dependencies.iter().collect_vec()),
827                &package_db,
828            )
829            .await
830            .unwrap();
831
832        project
833            .add(
834                DependencyType::Build(add_dependencies.iter().collect_vec()),
835                &package_db,
836            )
837            .await
838            .unwrap();
839        project
840            .add(
841                DependencyType::Test(add_dependencies.iter().collect_vec()),
842                &package_db,
843            )
844            .await
845            .unwrap();
846
847        project
848            .add(
849                DependencyType::External(HashMap::from([(
850                    "lib".into(),
851                    ExternalDependencySpec {
852                        library: Some("path.so".into()),
853                        header: None,
854                    },
855                )])),
856                &package_db,
857            )
858            .await
859            .unwrap();
860
861        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
862        // written correctly)
863        let project = Project::from_exact(&project_root).unwrap().unwrap();
864        let validated_toml = project.toml().into_remote(None).unwrap();
865
866        assert_eq!(
867            validated_toml.dependencies().current_platform(),
868            &expected_dependencies
869        );
870        assert_eq!(
871            validated_toml.build_dependencies().current_platform(),
872            &expected_dependencies
873        );
874        assert_eq!(
875            validated_toml.test_dependencies().current_platform(),
876            &expected_dependencies
877        );
878        assert_eq!(
879            validated_toml
880                .external_dependencies()
881                .current_platform()
882                .get("lib")
883                .unwrap(),
884            &ExternalDependencySpec {
885                library: Some("path.so".into()),
886                header: None
887            }
888        );
889    }
890
891    #[tokio::test]
892    async fn test_add_unpublished_package_does_not_edit_toml() {
893        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
894            .join("resources/test/sample-projects/no-build-spec/");
895        let project_root = assert_fs::TempDir::new().unwrap();
896        project_root.copy_from(&sample_project, &["**"]).unwrap();
897        let project_root: PathBuf = project_root.path().into();
898        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
899        let toml_before = fs::sync::read_to_string(project.toml_path()).unwrap();
900
901        let test_manifest_path =
902            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
903        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
904        let metadata = ManifestMetadata::new(&content).unwrap();
905        let config = ConfigBuilder::new().unwrap().build().unwrap();
906        let package_db = PackageDB::from_manifest(
907            Manifest::new(
908                Url::parse("https://example.com").unwrap(),
909                metadata,
910                Default::default(),
911            ),
912            &config,
913        );
914
915        let unknown_dependencies = [PackageReq::from_str("not-a-package-in-any-manifest").unwrap()];
916        let err = project
917            .add(
918                DependencyType::Regular(unknown_dependencies.iter().collect_vec()),
919                &package_db,
920            )
921            .await
922            .unwrap_err();
923
924        assert!(matches!(err, ProjectEditError::LatestVersionNotFound(_)));
925        assert_eq!(
926            fs::sync::read_to_string(project.toml_path()).unwrap(),
927            toml_before
928        );
929    }
930
931    #[tokio::test]
932    async fn test_upgrade_preserves_version_req_style() {
933        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
934            .join("resources/test/sample-projects/no-build-spec/");
935        let project_root = assert_fs::TempDir::new().unwrap();
936        project_root.copy_from(&sample_project, &["**"]).unwrap();
937        let project_root: PathBuf = project_root.path().into();
938        let toml_path = project_root.join("lux.toml");
939
940        let test_manifest_path =
941            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
942        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
943        let metadata = ManifestMetadata::new(&content).unwrap();
944        let config = ConfigBuilder::default().build().unwrap();
945        let package_db = PackageDB::from_manifest(
946            Manifest::new(
947                Url::parse("https://example.com").unwrap(),
948                metadata,
949                Default::default(),
950            ),
951            &config,
952        );
953
954        let base = r#"package = "sample-project-no-build-spec"
955version = "0.1.0"
956lua = ">=5.1"
957
958[source]
959url = "https://github.com/lumen-oss/luarocks-stub"
960
961[build]
962type = "builtin"
963"#;
964        for (input, expected) in [
965            ("30log = \"~> 1.0\"", "30log = \"~> 1.3\""),
966            ("30log = \"~> 1\"", "30log = \"~> 1\""),
967            ("30log = \">= 1.0.0\"", "30log = \">= 1.0.0\""),
968            ("30log = \"1.0.0\"", "30log = \"1.3.0\""),
969        ] {
970            std::fs::write(&toml_path, format!("{base}\n[dependencies]\n{input}\n")).unwrap();
971            let mut project = Project::from_exact(&project_root).unwrap().unwrap();
972            let packages = [PackageName::from("30log")];
973            project
974                .upgrade(
975                    LuaDependencyType::Regular(packages.iter().collect()),
976                    &package_db,
977                    &config,
978                )
979                .await
980                .unwrap();
981            let rewritten = std::fs::read_to_string(&toml_path).unwrap();
982            assert!(rewritten.contains(expected), "input {input}: {rewritten}");
983        }
984    }
985
986    #[tokio::test]
987    async fn test_remove_dependencies() {
988        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
989            .join("resources/test/sample-projects/dependencies/");
990        let project_root = assert_fs::TempDir::new().unwrap();
991        project_root.copy_from(&sample_project, &["**"]).unwrap();
992        let project_root: PathBuf = project_root.path().into();
993        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
994        let lua_cjson = "lua-cjson".into();
995        let plenary_nvim = "plenary.nvim".into();
996        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
997        project
998            .remove(DependencyType::Regular(remove_dependencies.clone()))
999            .await
1000            .unwrap();
1001        let check = |project: &Project| {
1002            for name in &remove_dependencies {
1003                assert!(!project
1004                    .toml()
1005                    .dependencies
1006                    .clone()
1007                    .unwrap_or_default()
1008                    .iter()
1009                    .any(|dep| &dep.name() == name));
1010            }
1011        };
1012        check(&project);
1013        // check again after reloading lux.toml
1014        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1015        check(&reloaded_project);
1016    }
1017
1018    #[tokio::test]
1019    async fn test_extra_rockspec_parsing() {
1020        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1021            .join("resources/test/sample-projects/extra-rockspec/");
1022        let project_root = assert_fs::TempDir::new().unwrap();
1023        project_root.copy_from(&sample_project, &["**"]).unwrap();
1024        let project_root: PathBuf = project_root.path().into();
1025        let project = Project::from_exact(project_root).unwrap().unwrap();
1026
1027        let extra_rockspec = project.extra_rockspec().unwrap();
1028
1029        assert!(extra_rockspec.is_some());
1030
1031        let rocks = project.toml().into_remote(None).unwrap();
1032
1033        assert_eq!(rocks.package().to_string(), "custom-package");
1034    }
1035
1036    #[tokio::test]
1037    async fn test_pin_dependencies() {
1038        test_pin_unpin_dependencies(PinnedState::Pinned).await
1039    }
1040
1041    #[tokio::test]
1042    async fn test_unpin_dependencies() {
1043        test_pin_unpin_dependencies(PinnedState::Unpinned).await
1044    }
1045
1046    async fn test_pin_unpin_dependencies(pin: PinnedState) {
1047        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1048            .join("resources/test/sample-projects/dependencies/");
1049        let project_root = assert_fs::TempDir::new().unwrap();
1050        project_root.copy_from(&sample_project, &["**"]).unwrap();
1051        let project_root: PathBuf = project_root.path().into();
1052        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1053        let lua_cjson = "lua-cjson".into();
1054        let plenary_nvim = "plenary.nvim".into();
1055        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
1056        project
1057            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
1058            .await
1059            .unwrap();
1060        let check = |project: &Project| {
1061            for name in &pin_dependencies {
1062                assert!(project
1063                    .toml()
1064                    .dependencies
1065                    .clone()
1066                    .unwrap_or_default()
1067                    .iter()
1068                    .any(|dep| &dep.name() == name && dep.pin == pin));
1069            }
1070        };
1071        check(&project);
1072        // check again after reloading lux.toml
1073        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1074        check(&reloaded_project);
1075    }
1076
1077    #[tokio::test]
1078    async fn test_set_opt_state() {
1079        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1080            .join("resources/test/sample-projects/dependencies/");
1081        let project_root = assert_fs::TempDir::new().unwrap();
1082        project_root.copy_from(&sample_project, &["**"]).unwrap();
1083        let project_root: PathBuf = project_root.path().into();
1084        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1085        let lua_cjson = "lua-cjson".into();
1086        let plenary_nvim = "plenary.nvim".into();
1087
1088        project
1089            .set_opt_state(
1090                LuaDependencyType::Regular(vec![&lua_cjson]),
1091                OptState::Optional,
1092            )
1093            .await
1094            .unwrap();
1095
1096        let check = |project: &Project, opt: OptState| {
1097            let dependencies = project.toml().dependencies.clone().unwrap_or_default();
1098            assert!(dependencies
1099                .iter()
1100                .any(|dep| dep.name() == &lua_cjson && *dep.opt() == opt));
1101            assert!(dependencies
1102                .iter()
1103                .any(|dep| dep.name() == &plenary_nvim && *dep.opt() == OptState::Required));
1104        };
1105        check(&project, OptState::Optional);
1106        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1107        check(&reloaded_project, OptState::Optional);
1108
1109        project
1110            .set_opt_state(
1111                LuaDependencyType::Regular(vec![&lua_cjson]),
1112                OptState::Required,
1113            )
1114            .await
1115            .unwrap();
1116        check(&project, OptState::Required);
1117        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1118        check(&reloaded_project, OptState::Required);
1119    }
1120
1121    #[tokio::test]
1122    async fn project_files_includes_cargo_directory() {
1123        let project_root = assert_fs::TempDir::new().unwrap();
1124        let cargo_dir = project_root.child(".cargo");
1125        cargo_dir.create_dir_all().unwrap();
1126        let cargo_config = cargo_dir.join("config.toml");
1127        fs::tokio::write(&cargo_config, "").await.unwrap();
1128        let project_files = project_files(&project_root);
1129        assert!(project_files.contains(&cargo_config.to_path_buf()));
1130    }
1131
1132    #[tokio::test]
1133    async fn project_files_ignores_parent_ignore_files() {
1134        let tmp = assert_fs::TempDir::new().unwrap();
1135        let parent = tmp.child("parent");
1136        parent.create_dir_all().unwrap();
1137        fs::tokio::write(parent.join(".ignore"), "*\n")
1138            .await
1139            .unwrap();
1140        let src = parent.child("etc");
1141        src.create_dir_all().unwrap();
1142        let file = src.join("payload.txt");
1143        fs::tokio::write(&file, "hello").await.unwrap();
1144
1145        let project_files = project_files(&src);
1146        assert!(project_files.contains(&file.to_path_buf()));
1147    }
1148
1149    #[tokio::test]
1150    async fn project_root_dir_override() {
1151        let root_dir = assert_fs::TempDir::new().unwrap();
1152        let lux_toml_dir = root_dir.child("proj");
1153        lux_toml_dir.create_dir_all().unwrap();
1154        fs::tokio::write(
1155            lux_toml_dir.join(PROJECT_TOML),
1156            r#"
1157            package = "my-package"
1158            version = "1.0.0"
1159            lua = "5.1"
1160
1161            [project]
1162            root_dir = ".."
1163
1164            [build]
1165            type = "builtin"
1166            "#,
1167        )
1168        .await
1169        .unwrap();
1170        let project = Project::from_exact(lux_toml_dir.path()).unwrap().unwrap();
1171        assert_eq!(
1172            std::fs::canonicalize(project.root()).unwrap(),
1173            std::fs::canonicalize(root_dir.path()).unwrap()
1174        );
1175        // The `lux.toml` path must still point to the directory containing the `lux.toml`.
1176        assert_eq!(project.toml_path(), lux_toml_dir.join(PROJECT_TOML));
1177    }
1178
1179    #[tokio::test]
1180    async fn from_exact_on_nonexistent_directory() {
1181        let empty_dir = assert_fs::TempDir::new().unwrap();
1182        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
1183    }
1184}