Skip to main content

lux_lib/project/
mod.rs

1use crate::package;
2use itertools::Itertools;
3use miette::{Diagnostic, NamedSource, SourceSpan};
4use path_slash::PathBufExt;
5use project_toml::{
6    LocalProjectTomlValidationError, PartialProjectToml, RemoteProjectTomlValidationError,
7};
8use std::{
9    io,
10    ops::Deref,
11    path::{Path, PathBuf},
12    str::FromStr,
13};
14use thiserror::Error;
15use toml_edit::{DocumentMut, Item};
16
17use crate::{fs, lockfile::OptState};
18
19use crate::lockfile::PinnedState;
20use crate::{
21    config::Config,
22    git::{self, shorthand::RemoteGitUrlShorthand, url::RemoteGitUrl, utils::SemVerTagOrSha},
23    lua_rockspec::{
24        self, LocalLuaRockspec, LuaVersionError, PartialLuaRockspec, PartialRockspecError,
25        RemoteLuaRockspec,
26    },
27    lua_version::LuaVersion,
28    package_db::PackageDB,
29    rockspec::{
30        lua_dependency::{DependencyType, LuaDependencyType},
31        LuaVersionCompatibility,
32    },
33};
34
35pub(crate) mod gen;
36pub mod project_toml;
37
38pub use project_toml::PROJECT_TOML;
39
40pub const EXTRA_ROCKSPEC: &str = "extra.rockspec";
41
42/// A wrapper around [`toml::de::Error`] plus the original source code and the byte range where the
43/// error occurred so that `miette` can render the failure with line numbers and context.
44#[derive(Debug, Error, Diagnostic)]
45#[error("{message}", message = _inner.message())]
46#[diagnostic(
47    code(lux_lib::toml::deserialize),
48    help("check that the file is valid TOML and matches the expected schema"),
49    url("https://lux.lumen-labs.org/reference/lux-toml")
50)]
51pub struct TomlDeError {
52    #[source]
53    _inner: Box<toml::de::Error>,
54    #[source_code]
55    src: NamedSource<String>,
56    #[label("here")]
57    span: SourceSpan,
58}
59
60impl TomlDeError {
61    pub fn new(name: &str, src: &str, inner: toml::de::Error) -> Self {
62        let src = NamedSource::new(name, src.to_string());
63        let span = inner.span().unwrap_or(0..0).into();
64        Self {
65            _inner: Box::new(inner),
66            src,
67            span,
68        }
69    }
70
71    pub fn inner(&self) -> &toml::de::Error {
72        &self._inner
73    }
74}
75
76pub(crate) fn parse_toml<T>(name: &str, src: &str) -> Result<T, TomlDeError>
77where
78    T: serde::de::DeserializeOwned,
79{
80    toml::from_str(src).map_err(|e| TomlDeError::new(name, src, e))
81}
82
83#[derive(Error, Debug, Diagnostic)]
84#[non_exhaustive]
85#[error(transparent)]
86pub enum Error {
87    #[error(transparent)]
88    #[diagnostic(transparent)]
89    Fs(#[from] fs::Error),
90    #[diagnostic(transparent)]
91    Project(#[from] LocalProjectTomlValidationError),
92    #[diagnostic(transparent)]
93    Toml(#[from] TomlDeError),
94    #[error("error when parsing 'extra.rockspec'")]
95    #[diagnostic(forward(0))]
96    Rockspec(#[from] PartialRockspecError),
97}
98
99#[derive(Error, Debug, Diagnostic)]
100#[non_exhaustive]
101#[error(transparent)]
102pub enum IntoLocalRockspecError {
103    LocalProjectTomlValidationError(Box<LocalProjectTomlValidationError>),
104    Rockspec(Box<lua_rockspec::Error>),
105}
106
107#[derive(Error, Debug, Diagnostic)]
108#[non_exhaustive]
109#[error(transparent)]
110pub enum IntoRemoteRockspecError {
111    RocksTomlValidationError(Box<RemoteProjectTomlValidationError>),
112    Rockspec(Box<lua_rockspec::Error>),
113}
114
115#[derive(Error, Debug, Diagnostic)]
116#[non_exhaustive]
117pub enum EditError {
118    #[error(transparent)]
119    Io(#[from] tokio::io::Error),
120    #[error(transparent)]
121    #[diagnostic(transparent)]
122    Fs(#[from] fs::Error),
123    #[error(transparent)]
124    Toml(#[from] toml_edit::TomlError),
125    #[error("error parsing {PROJECT_TOML} after edit. This is probably a bug.")]
126    #[diagnostic(forward(0))]
127    TomlDe(#[from] TomlDeError),
128    #[error(transparent)]
129    #[diagnostic(transparent)]
130    Git(#[from] git::Error),
131    #[error("unable to query latest version for '{0}'")]
132    #[diagnostic(help("check your server config and ensure the package has been published"))]
133    LatestVersionNotFound(package::Name),
134    #[error("expected field to be a value, but got '{0}'")]
135    #[diagnostic(
136        help("ensure your {PROJECT_TOML} matches the specification"),
137        url("https://lux.lumen-labs.org/reference/lux-toml")
138    )]
139    ExpectedValue(Box<toml_edit::Item>),
140    #[error("expected string, 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    ExpectedString(Box<toml_edit::Value>),
146    #[error(transparent)]
147    #[diagnostic(transparent)]
148    GitUrlShorthandParse(#[from] git::shorthand::ParseError),
149}
150
151#[derive(Error, Debug, Diagnostic)]
152#[non_exhaustive]
153pub enum PinError {
154    #[error("package '{0}' not found in dependencies")]
155    #[diagnostic(help("to add it, run 'lx add {0}'"))]
156    PackageNotFound(package::Name),
157    #[error("dependency '{dep}' is already {}pinned!", if *.pin_state == PinnedState::Unpinned { "un" } else { "" })]
158    PinStateUnchanged {
159        pin_state: PinnedState,
160        dep: package::Name,
161    },
162    #[error("unable to parse {toml_path} for editing")]
163    #[diagnostic(
164        code(lux_lib::toml_edit::parse),
165        help("check that the file is valid TOML and matches the expected schema"),
166        url("https://lux.lumen-labs.org/reference/lux-toml")
167    )]
168    ParseTomlEdit {
169        toml_path: String,
170        source: toml_edit::TomlError,
171    },
172    #[error("error parsing lux.toml after edit. This is probably a bug.")]
173    #[diagnostic(forward(0))]
174    TomlDe(#[from] TomlDeError),
175    #[error(transparent)]
176    Io(#[from] tokio::io::Error),
177    #[error(transparent)]
178    #[diagnostic(transparent)]
179    Fs(#[from] fs::Error),
180}
181
182/// A newtype for the project root directory.
183/// This is used to ensure that the project root is a valid project directory.
184#[derive(Clone, Debug)]
185#[cfg_attr(test, derive(Default))]
186pub struct ProjectRoot(PathBuf);
187
188impl ProjectRoot {
189    pub(crate) fn new() -> Self {
190        Self(PathBuf::new())
191    }
192}
193
194impl AsRef<Path> for ProjectRoot {
195    fn as_ref(&self) -> &Path {
196        self.0.as_ref()
197    }
198}
199
200impl Deref for ProjectRoot {
201    type Target = PathBuf;
202
203    fn deref(&self) -> &Self::Target {
204        &self.0
205    }
206}
207
208/// Lux project, with methods for managing dependencies, etc.
209#[derive(Clone, Debug)]
210pub struct Project {
211    /// The path where the `lux.toml` resides.
212    lux_toml_dir: ProjectRoot,
213    /// The project root. This is the same as [`Project::lux_toml_dir`],
214    /// unless overridden by the `[project] root_dir` field.
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>, Error> {
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 = fs::sync::read_to_string(&project_toml_path)?;
231            let root = start.as_ref();
232
233            let toml = PartialProjectToml::new(
234                &project_toml_path.to_string_lossy(),
235                &toml_content,
236                ProjectRoot(root.to_path_buf()),
237            )?;
238            let mut project = Project {
239                lux_toml_dir: ProjectRoot(root.to_path_buf()),
240                root: toml.project.root.clone(),
241                toml,
242            };
243
244            if let Some(extra_rockspec) = project.extra_rockspec()? {
245                project.toml = project.toml.merge(extra_rockspec);
246            }
247
248            Ok(Some(project))
249        } else {
250            Ok(None)
251        }
252    }
253
254    /// Get the `lux.toml` path.
255    pub fn toml_path(&self) -> PathBuf {
256        self.lux_toml_dir.join(PROJECT_TOML)
257    }
258
259    /// Get the `extra.rockspec` path.
260    pub fn extra_rockspec_path(&self) -> PathBuf {
261        self.lux_toml_dir.join(EXTRA_ROCKSPEC)
262    }
263
264    pub fn root(&self) -> &ProjectRoot {
265        &self.root
266    }
267
268    pub fn toml(&self) -> &PartialProjectToml {
269        &self.toml
270    }
271
272    pub fn local_rockspec(&self) -> Result<LocalLuaRockspec, IntoLocalRockspecError> {
273        self.toml()
274            .into_local()
275            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
276            .to_lua_rockspec()
277            .map_err(|err| IntoLocalRockspecError::Rockspec(Box::new(err)))
278    }
279
280    /// Returns this project's rockspec as a [`RemoteLuaRockspec`] whose source points at the
281    /// project's own directory, so it can be built like any other package.
282    pub fn local_remote_rockspec(&self) -> Result<RemoteLuaRockspec, IntoLocalRockspecError> {
283        self.toml()
284            .into_local()
285            .map_err(|err| IntoLocalRockspecError::LocalProjectTomlValidationError(Box::new(err)))?
286            .to_local_rockspec()
287            .map_err(|err| IntoLocalRockspecError::Rockspec(Box::new(err)))
288    }
289
290    pub fn remote_rockspec(
291        &self,
292        specrev: Option<package::version::SpecRev>,
293    ) -> Result<RemoteLuaRockspec, IntoRemoteRockspecError> {
294        self.toml()
295            .into_remote(specrev)
296            .map_err(|err| IntoRemoteRockspecError::RocksTomlValidationError(Box::new(err)))?
297            .to_lua_rockspec()
298            .map_err(|err| IntoRemoteRockspecError::Rockspec(Box::new(err)))
299    }
300
301    pub fn extra_rockspec(&self) -> Result<Option<PartialLuaRockspec>, PartialRockspecError> {
302        if self.extra_rockspec_path().exists() {
303            Ok(Some(PartialLuaRockspec::new(
304                &fs::sync::read_to_string(self.extra_rockspec_path()).map_err(io::Error::other)?,
305            )?))
306        } else {
307            Ok(None)
308        }
309    }
310
311    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
312        self.toml().lua_version_matches(config)
313    }
314
315    async fn write_toml(&mut self, project_toml: DocumentMut) -> Result<(), EditError> {
316        let toml_content = project_toml.to_string();
317        fs::tokio::write(self.toml_path(), &toml_content).await?;
318        self.toml = PartialProjectToml::new(
319            self.toml_path().to_str().unwrap_or("<lux.toml>"),
320            &toml_content,
321            self.lux_toml_dir.clone(),
322        )?;
323        Ok(())
324    }
325
326    pub async fn add(
327        &mut self,
328        dependencies: DependencyType<&package::Req>,
329        package_db: &PackageDB,
330    ) -> Result<(), EditError> {
331        let mut project_toml =
332            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
333
334        prepare_dependency_tables(&mut project_toml);
335        let table = match dependencies {
336            DependencyType::Regular(_) => &mut project_toml["dependencies"],
337            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
338            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
339            DependencyType::External(_) => &mut project_toml["external_dependencies"],
340        };
341
342        match dependencies {
343            DependencyType::Regular(ref deps)
344            | DependencyType::Build(ref deps)
345            | DependencyType::Test(ref deps) => {
346                for dep in deps {
347                    let dep_version_str = if dep.version_req().is_any() {
348                        let latest_version = package_db
349                            .latest_version(dep.name())
350                            .await
351                            .ok_or_else(|| EditError::LatestVersionNotFound(dep.name().clone()))?;
352                        latest_version
353                            .to_pessimistic_version_req()
354                            .unwrap_or_else(|| latest_version.to_string())
355                    } else {
356                        dep.version_req().to_string()
357                    };
358                    table[dep.name().to_string()] = toml_edit::value(dep_version_str);
359                }
360            }
361            DependencyType::External(ref deps) => {
362                for (name, dep) in deps {
363                    if let Some(path) = &dep.header {
364                        table[name]["header"] = toml_edit::value(path.to_slash_lossy().to_string());
365                    }
366                    if let Some(path) = &dep.library {
367                        table[name]["library"] =
368                            toml_edit::value(path.to_slash_lossy().to_string());
369                    }
370                }
371            }
372        };
373
374        self.write_toml(project_toml).await?;
375        Ok(())
376    }
377
378    pub async fn add_git(
379        &mut self,
380        dependencies: LuaDependencyType<&RemoteGitUrlShorthand>,
381        config: &Config,
382    ) -> Result<(), EditError> {
383        let mut project_toml =
384            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
385
386        prepare_dependency_tables(&mut project_toml);
387        let table = match dependencies {
388            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
389            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
390            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
391        };
392
393        match dependencies {
394            LuaDependencyType::Regular(urls)
395            | LuaDependencyType::Build(urls)
396            | LuaDependencyType::Test(urls) => {
397                for url in urls {
398                    let git_url: RemoteGitUrl = url.clone().into();
399                    let mut dep_entry = toml_edit::table();
400                    match git::utils::latest_semver_tag_or_commit_sha(&git_url, config)? {
401                        SemVerTagOrSha::SemVerTag(tag) => {
402                            dep_entry["git"] = Item::Value(url.to_string().into());
403                            dep_entry["version"] = Item::Value(tag.clone().into());
404                            if tag.contains("-") {
405                                // Tag contains a specrev.
406                                dep_entry["rev"] = Item::Value(tag.into());
407                            }
408                        }
409                        SemVerTagOrSha::CommitSha(sha) => {
410                            dep_entry["git"] = Item::Value(url.to_string().into());
411                            dep_entry["version"] = Item::Value(sha.into());
412                        }
413                    }
414                    table[git_url.repo()] = dep_entry;
415                }
416            }
417        }
418
419        self.write_toml(project_toml).await?;
420
421        Ok(())
422    }
423
424    pub async fn remove(
425        &mut self,
426        dependencies: DependencyType<&package::Name>,
427    ) -> Result<(), EditError> {
428        let mut project_toml =
429            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
430
431        prepare_dependency_tables(&mut project_toml);
432        let table = match dependencies {
433            DependencyType::Regular(_) => &mut project_toml["dependencies"],
434            DependencyType::Build(_) => &mut project_toml["build_dependencies"],
435            DependencyType::Test(_) => &mut project_toml["test_dependencies"],
436            DependencyType::External(_) => &mut project_toml["external_dependencies"],
437        };
438
439        match dependencies {
440            DependencyType::Regular(ref deps)
441            | DependencyType::Build(ref deps)
442            | DependencyType::Test(ref deps) => {
443                for dep in deps {
444                    table[dep.to_string()] = Item::None;
445                }
446            }
447            DependencyType::External(ref deps) => {
448                for (name, dep) in deps {
449                    if dep.header.is_some() {
450                        table[name]["header"] = Item::None;
451                    }
452                    if dep.library.is_some() {
453                        table[name]["library"] = Item::None;
454                    }
455                }
456            }
457        };
458
459        self.write_toml(project_toml).await?;
460
461        Ok(())
462    }
463
464    pub async fn upgrade(
465        &mut self,
466        dependencies: LuaDependencyType<&package::Name>,
467        package_db: &PackageDB,
468        config: &Config,
469    ) -> Result<(), EditError> {
470        let mut project_toml =
471            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)?;
472
473        prepare_dependency_tables(&mut project_toml);
474        let table = match dependencies {
475            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
476            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
477            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
478        };
479
480        match dependencies {
481            LuaDependencyType::Regular(deps)
482            | LuaDependencyType::Build(deps)
483            | LuaDependencyType::Test(deps) => {
484                for dep in deps {
485                    upgrade_dependency(table, dep, package_db, config).await?;
486                }
487            }
488        }
489
490        self.write_toml(project_toml).await?;
491
492        Ok(())
493    }
494
495    pub async fn upgrade_all(
496        &mut self,
497        package_db: &PackageDB,
498        config: &Config,
499    ) -> Result<(), EditError> {
500        if let Some(dependencies) = &self.toml().dependencies {
501            let packages = dependencies
502                .iter()
503                .map(|dep| dep.name())
504                .cloned()
505                .collect_vec();
506            self.upgrade(
507                LuaDependencyType::Regular(packages.iter().collect()),
508                package_db,
509                config,
510            )
511            .await?;
512        }
513        if let Some(dependencies) = &self.toml().build_dependencies {
514            let packages = dependencies
515                .iter()
516                .map(|dep| dep.name())
517                .cloned()
518                .collect_vec();
519            self.upgrade(
520                LuaDependencyType::Build(packages.iter().collect()),
521                package_db,
522                config,
523            )
524            .await?;
525        }
526        if let Some(dependencies) = &self.toml().test_dependencies {
527            let packages = dependencies
528                .iter()
529                .map(|dep| dep.name())
530                .cloned()
531                .collect_vec();
532            self.upgrade(
533                LuaDependencyType::Test(packages.iter().collect()),
534                package_db,
535                config,
536            )
537            .await?;
538        }
539        Ok(())
540    }
541
542    async fn set_dependency_flag(
543        &mut self,
544        dependencies: LuaDependencyType<&package::Name>,
545        key: &str,
546        enabled: bool,
547    ) -> Result<(), PinError> {
548        let mut project_toml =
549            toml_edit::DocumentMut::from_str(&fs::tokio::read_to_string(self.toml_path()).await?)
550                .map_err(|source| PinError::ParseTomlEdit {
551                toml_path: self.toml_path().to_slash_lossy().to_string(),
552                source,
553            })?;
554
555        prepare_dependency_tables(&mut project_toml);
556        let table = match dependencies {
557            LuaDependencyType::Regular(_) => &mut project_toml["dependencies"],
558            LuaDependencyType::Build(_) => &mut project_toml["build_dependencies"],
559            LuaDependencyType::Test(_) => &mut project_toml["test_dependencies"],
560        };
561
562        match dependencies {
563            LuaDependencyType::Regular(ref deps)
564            | LuaDependencyType::Build(ref deps)
565            | LuaDependencyType::Test(ref deps) => {
566                for dep in deps {
567                    let mut dep_item = table[dep.to_string()].clone();
568                    match dep_item {
569                        version @ Item::Value(_) => {
570                            if enabled {
571                                if let Ok(mut dep_entry) = toml_edit::table().into_table() {
572                                    dep_entry.set_implicit(true);
573                                    dep_entry["version"] = version;
574                                    dep_entry[key] = toml_edit::value(true);
575                                    table[dep.to_string()] = toml_edit::Item::Table(dep_entry);
576                                }
577                            }
578                        }
579                        Item::Table(_) => {
580                            dep_item[key] = toml_edit::value(enabled);
581                            table[dep.to_string()] = dep_item;
582                        }
583                        _ => {}
584                    }
585                }
586            }
587        }
588
589        let toml_content = project_toml.to_string();
590        fs::tokio::write(self.toml_path(), &toml_content).await?;
591        self.toml = PartialProjectToml::new(
592            self.toml_path().to_str().unwrap_or("<lux.toml>"),
593            &toml_content,
594            self.lux_toml_dir.clone(),
595        )?;
596
597        Ok(())
598    }
599
600    pub async fn set_pinned_state(
601        &mut self,
602        dependencies: LuaDependencyType<&package::Name>,
603        pin: PinnedState,
604    ) -> Result<(), PinError> {
605        self.set_dependency_flag(dependencies, "pin", pin.as_bool())
606            .await
607    }
608
609    pub async fn set_opt_state(
610        &mut self,
611        dependencies: LuaDependencyType<&package::Name>,
612        opt: OptState,
613    ) -> Result<(), PinError> {
614        self.set_dependency_flag(dependencies, "opt", opt.as_bool())
615            .await
616    }
617
618    pub fn project_files(&self) -> Vec<PathBuf> {
619        project_files(&self.root().0)
620    }
621}
622
623/// Get the files that Lux treats as project files
624/// This respects ignore files and excludes hidden files and directories.
625pub(crate) fn project_files(src: &Path) -> Vec<PathBuf> {
626    ignore::WalkBuilder::new(src)
627        .add(src.join(".cargo"))
628        .parents(false)
629        .follow_links(false)
630        .build()
631        .filter_map(Result::ok)
632        .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
633        .map(|entry| entry.into_path())
634        .collect_vec()
635}
636
637/// Recursively copy the files of `src` into `dest`, honouring ignore files.
638#[tracing::instrument(level = "trace")]
639pub(crate) async fn copy_dir(src: &Path, dest: &Path) -> Result<(), fs::Error> {
640    if src.exists() {
641        for file in project_files(src) {
642            let relative_src_path: PathBuf = pathdiff::diff_paths(src.join(&file), src)
643                .unwrap_or_else(|| unreachable!("diff_path with self"));
644            let target = dest.join(relative_src_path);
645            if let Some(parent) = target.parent() {
646                fs::tokio::create_dir_all(parent).await?;
647            }
648            fs::tokio::copy(&file, &target).await?;
649            fs::make_writable(&target).await?;
650        }
651    }
652    Ok(())
653}
654
655async fn upgraded_version_req(
656    existing: Option<&str>,
657    dep: &package::Name,
658    package_db: &PackageDB,
659) -> Result<Option<String>, EditError> {
660    if let Some(existing) = existing.and_then(|existing| package::VersionReq::parse(existing).ok())
661    {
662        let latest = package_db
663            .latest_version(dep)
664            .await
665            .ok_or(EditError::LatestVersionNotFound(dep.clone()))?;
666        Ok(latest.upgrade_version_req(&existing))
667    } else {
668        Ok(None)
669    }
670}
671
672async fn upgrade_dependency(
673    table: &mut Item,
674    dep: &package::Name,
675    package_db: &PackageDB,
676    config: &Config,
677) -> Result<(), EditError> {
678    let mut dep_item = table[dep.to_string()].clone();
679    match &dep_item {
680        Item::Value(val) => {
681            if let Some(upgraded) = upgraded_version_req(val.as_str(), dep, package_db).await? {
682                table[dep.to_string()] = toml_edit::value(upgraded);
683            }
684        }
685        Item::Table(tbl) => {
686            if let Some(git_item) = tbl.get("git") {
687                let latest = resolve_git_latest(git_url_from_item(git_item)?, config)?;
688                apply_git_latest(table, dep, latest);
689            } else {
690                if let Some(upgraded) = upgraded_version_req(
691                    dep_item.get("version").and_then(Item::as_str),
692                    dep,
693                    package_db,
694                )
695                .await?
696                {
697                    dep_item["version"] = toml_edit::value(upgraded);
698                }
699                table[dep.to_string()] = dep_item;
700            }
701        }
702        _ => {}
703    }
704    Ok(())
705}
706
707fn git_url_from_item(git_item: &Item) -> Result<RemoteGitUrlShorthand, EditError> {
708    let git_value = git_item
709        .clone()
710        .into_value()
711        .map_err(|err| EditError::ExpectedValue(Box::new(err)))?;
712    let git_url_str = git_value
713        .as_str()
714        .ok_or(EditError::ExpectedString(Box::new(git_value.clone())))?;
715    Ok(git_url_str.parse()?)
716}
717
718fn resolve_git_latest(
719    url: RemoteGitUrlShorthand,
720    config: &Config,
721) -> Result<SemVerTagOrSha, EditError> {
722    Ok(git::utils::latest_semver_tag_or_commit_sha(
723        &url.into(),
724        config,
725    )?)
726}
727
728fn apply_git_latest(table: &mut Item, dep: &package::Name, latest: SemVerTagOrSha) {
729    match latest {
730        SemVerTagOrSha::SemVerTag(latest_tag) => {
731            table[dep.to_string()]["version"] = Item::Value(latest_tag.clone().into());
732            if latest_tag.contains("-") {
733                table[dep.to_string()]["rev"] = Item::Value(latest_tag.into());
734            }
735        }
736        SemVerTagOrSha::CommitSha(latest_sha) => {
737            table[dep.to_string()]["version"] = Item::Value(latest_sha.into());
738        }
739    }
740}
741
742fn prepare_dependency_tables(project_toml: &mut DocumentMut) {
743    if !project_toml.contains_table("dependencies") {
744        if let Ok(mut table) = toml_edit::table().into_table() {
745            table.set_implicit(true);
746            project_toml["dependencies"] = toml_edit::Item::Table(table);
747        }
748    }
749    if !project_toml.contains_table("build_dependencies") {
750        if let Ok(mut table) = toml_edit::table().into_table() {
751            table.set_implicit(true);
752            project_toml["build_dependencies"] = toml_edit::Item::Table(table);
753        }
754    }
755    if !project_toml.contains_table("test_dependencies") {
756        if let Ok(mut table) = toml_edit::table().into_table() {
757            table.set_implicit(true);
758            project_toml["test_dependencies"] = toml_edit::Item::Table(table);
759        }
760    }
761    if !project_toml.contains_table("external_dependencies") {
762        if let Ok(mut table) = toml_edit::table().into_table() {
763            table.set_implicit(true);
764            project_toml["external_dependencies"] = toml_edit::Item::Table(table);
765        }
766    }
767}
768
769// TODO: More project-based test
770#[cfg(test)]
771mod tests {
772    use std::collections::HashMap;
773
774    use assert_fs::prelude::{PathChild, PathCopy, PathCreateDir};
775    use url::Url;
776
777    use super::*;
778    use crate::{
779        config::ConfigBuilder,
780        fs,
781        lua_rockspec::ExternalDependencySpec,
782        manifest::{Manifest, ManifestMetadata},
783        package_db::PackageDB,
784        rockspec::Rockspec,
785    };
786
787    #[tokio::test]
788    async fn test_add_various_dependencies() {
789        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
790            .join("resources/test/sample-projects/no-build-spec/");
791        let project_root = assert_fs::TempDir::new().unwrap();
792        project_root.copy_from(&sample_project, &["**"]).unwrap();
793        let project_root: PathBuf = project_root.path().into();
794        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
795        let add_dependencies =
796            [package::Req::new("busted".into(), Some(">= 1.0.0".into())).unwrap()];
797        let expected_dependencies =
798            vec![package::Req::new("busted".into(), Some(">= 1.0.0".into()))
799                .unwrap()
800                .into()];
801
802        let test_manifest_path =
803            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
804        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
805        let metadata = ManifestMetadata::new(&content).unwrap();
806        let config = ConfigBuilder::new().unwrap().build().unwrap();
807        let package_db = PackageDB::from_manifest(
808            Manifest::new(
809                Url::parse("https://example.com").unwrap(),
810                metadata,
811                Default::default(),
812            ),
813            &config,
814        );
815
816        project
817            .add(
818                DependencyType::Regular(add_dependencies.iter().collect_vec()),
819                &package_db,
820            )
821            .await
822            .unwrap();
823
824        project
825            .add(
826                DependencyType::Build(add_dependencies.iter().collect_vec()),
827                &package_db,
828            )
829            .await
830            .unwrap();
831        project
832            .add(
833                DependencyType::Test(add_dependencies.iter().collect_vec()),
834                &package_db,
835            )
836            .await
837            .unwrap();
838
839        project
840            .add(
841                DependencyType::External(HashMap::from([(
842                    "lib".into(),
843                    ExternalDependencySpec {
844                        library: Some("path.so".into()),
845                        header: None,
846                    },
847                )])),
848                &package_db,
849            )
850            .await
851            .unwrap();
852
853        // Reparse the lux.toml (not usually necessary, but we want to test that the file was
854        // written correctly)
855        let project = Project::from_exact(&project_root).unwrap().unwrap();
856        let validated_toml = project.toml().into_remote(None).unwrap();
857
858        assert_eq!(
859            validated_toml.dependencies().current_platform(),
860            &expected_dependencies
861        );
862        assert_eq!(
863            validated_toml.build_dependencies().current_platform(),
864            &expected_dependencies
865        );
866        assert_eq!(
867            validated_toml.test_dependencies().current_platform(),
868            &expected_dependencies
869        );
870        assert_eq!(
871            validated_toml
872                .external_dependencies()
873                .current_platform()
874                .get("lib")
875                .unwrap(),
876            &ExternalDependencySpec {
877                library: Some("path.so".into()),
878                header: None
879            }
880        );
881    }
882
883    #[tokio::test]
884    async fn test_add_unpublished_package_does_not_edit_toml() {
885        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
886            .join("resources/test/sample-projects/no-build-spec/");
887        let project_root = assert_fs::TempDir::new().unwrap();
888        project_root.copy_from(&sample_project, &["**"]).unwrap();
889        let project_root: PathBuf = project_root.path().into();
890        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
891        let toml_before = fs::sync::read_to_string(project.toml_path()).unwrap();
892
893        let test_manifest_path =
894            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
895        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
896        let metadata = ManifestMetadata::new(&content).unwrap();
897        let config = ConfigBuilder::new().unwrap().build().unwrap();
898        let package_db = PackageDB::from_manifest(
899            Manifest::new(
900                Url::parse("https://example.com").unwrap(),
901                metadata,
902                Default::default(),
903            ),
904            &config,
905        );
906
907        let unknown_dependencies =
908            [package::Req::from_str("not-a-package-in-any-manifest").unwrap()];
909        let err = project
910            .add(
911                DependencyType::Regular(unknown_dependencies.iter().collect_vec()),
912                &package_db,
913            )
914            .await
915            .unwrap_err();
916
917        assert!(matches!(err, EditError::LatestVersionNotFound(_)));
918        assert_eq!(
919            fs::sync::read_to_string(project.toml_path()).unwrap(),
920            toml_before
921        );
922    }
923
924    #[tokio::test]
925    async fn test_upgrade_preserves_version_req_style() {
926        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
927            .join("resources/test/sample-projects/no-build-spec/");
928        let project_root = assert_fs::TempDir::new().unwrap();
929        project_root.copy_from(&sample_project, &["**"]).unwrap();
930        let project_root: PathBuf = project_root.path().into();
931        let toml_path = project_root.join("lux.toml");
932
933        let test_manifest_path =
934            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/manifest-5.1");
935        let content = String::from_utf8(std::fs::read(&test_manifest_path).unwrap()).unwrap();
936        let metadata = ManifestMetadata::new(&content).unwrap();
937        let config = ConfigBuilder::default().build().unwrap();
938        let package_db = PackageDB::from_manifest(
939            Manifest::new(
940                Url::parse("https://example.com").unwrap(),
941                metadata,
942                Default::default(),
943            ),
944            &config,
945        );
946
947        let base = r#"package = "sample-project-no-build-spec"
948version = "0.1.0"
949lua = ">=5.1"
950
951[source]
952url = "https://github.com/lumen-oss/luarocks-stub"
953
954[build]
955type = "builtin"
956"#;
957        for (input, expected) in [
958            ("30log = \"~> 1.0\"", "30log = \"~> 1.3\""),
959            ("30log = \"~> 1\"", "30log = \"~> 1\""),
960            ("30log = \">= 1.0.0\"", "30log = \">= 1.0.0\""),
961            ("30log = \"1.0.0\"", "30log = \"1.3.0\""),
962        ] {
963            std::fs::write(&toml_path, format!("{base}\n[dependencies]\n{input}\n")).unwrap();
964            let mut project = Project::from_exact(&project_root).unwrap().unwrap();
965            let packages = [package::Name::from("30log")];
966            project
967                .upgrade(
968                    LuaDependencyType::Regular(packages.iter().collect()),
969                    &package_db,
970                    &config,
971                )
972                .await
973                .unwrap();
974            let rewritten = std::fs::read_to_string(&toml_path).unwrap();
975            assert!(rewritten.contains(expected), "input {input}: {rewritten}");
976        }
977    }
978
979    #[tokio::test]
980    async fn test_remove_dependencies() {
981        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
982            .join("resources/test/sample-projects/dependencies/");
983        let project_root = assert_fs::TempDir::new().unwrap();
984        project_root.copy_from(&sample_project, &["**"]).unwrap();
985        let project_root: PathBuf = project_root.path().into();
986        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
987        let lua_cjson = "lua-cjson".into();
988        let plenary_nvim = "plenary.nvim".into();
989        let remove_dependencies = vec![&lua_cjson, &plenary_nvim];
990        project
991            .remove(DependencyType::Regular(remove_dependencies.clone()))
992            .await
993            .unwrap();
994        let check = |project: &Project| {
995            for name in &remove_dependencies {
996                assert!(!project
997                    .toml()
998                    .dependencies
999                    .clone()
1000                    .unwrap_or_default()
1001                    .iter()
1002                    .any(|dep| &dep.name() == name));
1003            }
1004        };
1005        check(&project);
1006        // check again after reloading lux.toml
1007        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1008        check(&reloaded_project);
1009    }
1010
1011    #[tokio::test]
1012    async fn test_extra_rockspec_parsing() {
1013        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1014            .join("resources/test/sample-projects/extra-rockspec/");
1015        let project_root = assert_fs::TempDir::new().unwrap();
1016        project_root.copy_from(&sample_project, &["**"]).unwrap();
1017        let project_root: PathBuf = project_root.path().into();
1018        let project = Project::from_exact(project_root).unwrap().unwrap();
1019
1020        let extra_rockspec = project.extra_rockspec().unwrap();
1021
1022        assert!(extra_rockspec.is_some());
1023
1024        let rocks = project.toml().into_remote(None).unwrap();
1025
1026        assert_eq!(rocks.package().to_string(), "custom-package");
1027    }
1028
1029    #[tokio::test]
1030    async fn test_pin_dependencies() {
1031        test_pin_unpin_dependencies(PinnedState::Pinned).await
1032    }
1033
1034    #[tokio::test]
1035    async fn test_unpin_dependencies() {
1036        test_pin_unpin_dependencies(PinnedState::Unpinned).await
1037    }
1038
1039    async fn test_pin_unpin_dependencies(pin: PinnedState) {
1040        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1041            .join("resources/test/sample-projects/dependencies/");
1042        let project_root = assert_fs::TempDir::new().unwrap();
1043        project_root.copy_from(&sample_project, &["**"]).unwrap();
1044        let project_root: PathBuf = project_root.path().into();
1045        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1046        let lua_cjson = "lua-cjson".into();
1047        let plenary_nvim = "plenary.nvim".into();
1048        let pin_dependencies = vec![&lua_cjson, &plenary_nvim];
1049        project
1050            .set_pinned_state(LuaDependencyType::Regular(pin_dependencies.clone()), pin)
1051            .await
1052            .unwrap();
1053        let check = |project: &Project| {
1054            for name in &pin_dependencies {
1055                assert!(project
1056                    .toml()
1057                    .dependencies
1058                    .clone()
1059                    .unwrap_or_default()
1060                    .iter()
1061                    .any(|dep| &dep.name() == name && dep.pin == pin));
1062            }
1063        };
1064        check(&project);
1065        // check again after reloading lux.toml
1066        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1067        check(&reloaded_project);
1068    }
1069
1070    #[tokio::test]
1071    async fn test_set_opt_state() {
1072        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1073            .join("resources/test/sample-projects/dependencies/");
1074        let project_root = assert_fs::TempDir::new().unwrap();
1075        project_root.copy_from(&sample_project, &["**"]).unwrap();
1076        let project_root: PathBuf = project_root.path().into();
1077        let mut project = Project::from_exact(&project_root).unwrap().unwrap();
1078        let lua_cjson = "lua-cjson".into();
1079        let plenary_nvim = "plenary.nvim".into();
1080
1081        project
1082            .set_opt_state(
1083                LuaDependencyType::Regular(vec![&lua_cjson]),
1084                OptState::Optional,
1085            )
1086            .await
1087            .unwrap();
1088
1089        let check = |project: &Project, opt: OptState| {
1090            let dependencies = project.toml().dependencies.clone().unwrap_or_default();
1091            assert!(dependencies
1092                .iter()
1093                .any(|dep| dep.name() == &lua_cjson && *dep.opt() == opt));
1094            assert!(dependencies
1095                .iter()
1096                .any(|dep| dep.name() == &plenary_nvim && *dep.opt() == OptState::Required));
1097        };
1098        check(&project, OptState::Optional);
1099        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1100        check(&reloaded_project, OptState::Optional);
1101
1102        project
1103            .set_opt_state(
1104                LuaDependencyType::Regular(vec![&lua_cjson]),
1105                OptState::Required,
1106            )
1107            .await
1108            .unwrap();
1109        check(&project, OptState::Required);
1110        let reloaded_project = Project::from_exact(&project_root).unwrap().unwrap();
1111        check(&reloaded_project, OptState::Required);
1112    }
1113
1114    #[tokio::test]
1115    async fn project_files_includes_cargo_directory() {
1116        let project_root = assert_fs::TempDir::new().unwrap();
1117        let cargo_dir = project_root.child(".cargo");
1118        cargo_dir.create_dir_all().unwrap();
1119        let cargo_config = cargo_dir.join("config.toml");
1120        fs::tokio::write(&cargo_config, "").await.unwrap();
1121        let project_files = project_files(&project_root);
1122        assert!(project_files.contains(&cargo_config.to_path_buf()));
1123    }
1124
1125    #[tokio::test]
1126    async fn project_files_ignores_parent_ignore_files() {
1127        let tmp = assert_fs::TempDir::new().unwrap();
1128        let parent = tmp.child("parent");
1129        parent.create_dir_all().unwrap();
1130        fs::tokio::write(parent.join(".ignore"), "*\n")
1131            .await
1132            .unwrap();
1133        let src = parent.child("etc");
1134        src.create_dir_all().unwrap();
1135        let file = src.join("payload.txt");
1136        fs::tokio::write(&file, "hello").await.unwrap();
1137
1138        let project_files = project_files(&src);
1139        assert!(project_files.contains(&file.to_path_buf()));
1140    }
1141
1142    #[tokio::test]
1143    async fn project_root_dir_override() {
1144        let root_dir = assert_fs::TempDir::new().unwrap();
1145        let lux_toml_dir = root_dir.child("proj");
1146        lux_toml_dir.create_dir_all().unwrap();
1147        fs::tokio::write(
1148            lux_toml_dir.join(PROJECT_TOML),
1149            r#"
1150            package = "my-package"
1151            version = "1.0.0"
1152            lua = "5.1"
1153
1154            [project]
1155            root_dir = ".."
1156
1157            [build]
1158            type = "builtin"
1159            "#,
1160        )
1161        .await
1162        .unwrap();
1163        let project = Project::from_exact(lux_toml_dir.path()).unwrap().unwrap();
1164        assert_eq!(
1165            std::fs::canonicalize(project.root()).unwrap(),
1166            std::fs::canonicalize(root_dir.path()).unwrap()
1167        );
1168        // The `lux.toml` path must still point to the directory containing the `lux.toml`.
1169        assert_eq!(project.toml_path(), lux_toml_dir.join(PROJECT_TOML));
1170    }
1171
1172    #[tokio::test]
1173    async fn from_exact_on_nonexistent_directory() {
1174        let empty_dir = assert_fs::TempDir::new().unwrap();
1175        assert!(Project::from_exact(&empty_dir).unwrap().is_none());
1176    }
1177}