Skip to main content

lux_lib/workspace/
mod.rs

1use crate::package;
2use std::{
3    io,
4    ops::Deref,
5    path::{Path, PathBuf},
6};
7
8use crate::{
9    config::{self, Config, ConfigBuilder},
10    fs,
11    lockfile::{self, ReadOnly, WorkspaceLockfile},
12    lua_rockspec::LuaVersionError,
13    lua_version::LuaVersion,
14    project::{self, Project, TomlDeError, PROJECT_TOML},
15    tree::{self, InstallTree, Tree},
16    workspace::workspace_toml::{WorkspaceMemberSpec, WorkspaceToml},
17};
18use glob::glob;
19use itertools::Itertools;
20use lets_find_up::{find_up_with, FindUpKind, FindUpOptions};
21use miette::Diagnostic;
22use nonempty::NonEmpty;
23use path_slash::PathBufExt;
24use thiserror::Error;
25
26pub mod workspace_toml;
27
28pub const WORKSPACE_TOML: &str = PROJECT_TOML;
29pub(crate) const LUX_DIR_NAME: &str = ".lux";
30const EMMYRC: &str = ".emmyrc.json";
31const CONFIG_TOML: &str = "config.toml";
32
33/// A newtype for the workspace root directory.
34/// This is used to ensure that the workspace root is a valid project directory.
35#[derive(Clone, Debug)]
36#[cfg_attr(test, derive(Default))]
37pub struct WorkspaceRoot(PathBuf);
38
39impl AsRef<Path> for WorkspaceRoot {
40    fn as_ref(&self) -> &Path {
41        self.0.as_ref()
42    }
43}
44
45impl Deref for WorkspaceRoot {
46    type Target = PathBuf;
47
48    fn deref(&self) -> &Self::Target {
49        &self.0
50    }
51}
52
53#[derive(Debug, Error, Diagnostic)]
54pub enum Error {
55    #[error("cannot read the current working directory")]
56    #[diagnostic(help("make sure Lux has permissions to read the current working directory"))]
57    GetCwd(io::Error),
58    #[error("error reading workspace TOML at '{toml_path}'")]
59    #[diagnostic(help("make sure the file exists and contains valid UTF-8"))]
60    ReadLuxTOML {
61        toml_path: String,
62        source: io::Error,
63    },
64    #[error("error deserializing {WORKSPACE_TOML}")]
65    #[diagnostic(transparent)]
66    TOML { source: TomlDeError },
67    #[error("no workspace found at '{0}'")]
68    #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
69    WorkspaceNotFound(PathBuf),
70    #[error("glob error: '{0}'")]
71    #[diagnostic(help("check the glob pattern in your {WORKSPACE_TOML}'s '[workspace.members]'"))]
72    Glob(String),
73    #[error(transparent)]
74    #[diagnostic(forward(0))]
75    Project(#[from] project::Error),
76    #[error("no project or workspace found at '{0}'")]
77    #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
78    NoWorkspaceOrProject(PathBuf),
79    #[error("empty workspace at '{0}'")]
80    #[diagnostic(
81        help(
82            "a Lux workspace must have at least one project, declared using '[workspace.members]'"
83        ),
84        url("https://lux.lumen-labs.org/reference/lux-toml")
85    )]
86    EmptyWorkspace(PathBuf),
87    #[error(transparent)]
88    #[diagnostic(transparent)]
89    Lockfile(#[from] lockfile::Error),
90    #[error(transparent)]
91    #[diagnostic(transparent)]
92    Fs(#[from] fs::Error),
93    #[error("package must be specified in a multi-project workspace")]
94    #[diagnostic(help(
95        r#"this workspace contains multiple projects.
96specify the package with '--package=[PACKAGE_NAME]'
97    "#
98    ))]
99    NoPackageSpecified,
100    #[error("package '{0}' not found in workspace '{1}'")]
101    #[diagnostic(help(
102        "make sure it is declared in your {WORKSPACE_TOML}'s '[workspace.members]'"
103    ))]
104    PackageNotFound(package::Name, WorkspaceRoot),
105}
106
107#[derive(Error, Debug, Diagnostic)]
108#[non_exhaustive]
109pub enum TreeError {
110    #[error(transparent)]
111    #[diagnostic(transparent)]
112    Tree(#[from] tree::Error),
113    #[error(transparent)]
114    #[diagnostic(transparent)]
115    LuaVersionError(#[from] LuaVersionError),
116}
117
118/// A workspace, which can contain one or many Lux projects
119#[derive(Clone, Debug)]
120pub struct Workspace {
121    root: WorkspaceRoot,
122    members: NonEmpty<Project>,
123}
124
125#[derive(Error, Debug, Diagnostic)]
126#[non_exhaustive]
127#[error("error loading '{}'", .path.display())]
128pub struct ConfigError {
129    path: PathBuf,
130    #[diagnostic(source)]
131    source: Box<config::Error>,
132}
133
134// TODO: move lockfile from project to workspace
135
136impl Workspace {
137    pub fn current() -> Result<Option<Self>, Error> {
138        let cwd = std::env::current_dir().map_err(Error::GetCwd)?;
139        Self::from(&cwd)
140    }
141
142    pub fn current_or_err() -> Result<Self, Error> {
143        let cwd = std::env::current_dir().map_err(Error::GetCwd)?;
144        Self::current()?.ok_or(Error::NoWorkspaceOrProject(cwd))
145    }
146
147    /// The path where the root `lux.toml` resides.
148    pub fn root(&self) -> &WorkspaceRoot {
149        &self.root
150    }
151
152    /// The members of this workspace.
153    pub fn members(&self) -> &NonEmpty<Project> {
154        &self.members
155    }
156
157    /// Mutable reference to the members of this workspace.
158    pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
159        &mut self.members
160    }
161
162    /// The package names of all workspace members.
163    pub fn member_names(&self) -> Result<NonEmpty<package::Name>, Error> {
164        NonEmpty::from_vec(
165            self.members()
166                .iter()
167                .map(|project| project.toml().package.clone())
168                .collect(),
169        )
170        .ok_or_else(|| Error::EmptyWorkspace(self.root.0.clone()))
171    }
172
173    /// Get a workspace member, defaulting to the first one if none is specified.
174    /// Fails if a package name is specified, but not found.
175    pub fn single_member_or_select(&self, name: &Option<package::Name>) -> Result<&Project, Error> {
176        match name {
177            Some(name) => self
178                .members()
179                .iter()
180                .find(|project| &project.toml().package == name)
181                .ok_or_else(|| Error::PackageNotFound(name.clone(), self.root.clone())),
182            None => Ok(self.members().first()),
183        }
184    }
185
186    /// Get a mutable workspace member, defaulting to the first one if none is specified.
187    /// Fails if a package name is specified, but not found.
188    pub fn single_member_or_select_mut(
189        &mut self,
190        package: &Option<package::Name>,
191    ) -> Result<&mut Project, Error> {
192        match package.as_ref() {
193            Some(package) => self.select_member_mut(package),
194            None => self.single_member_mut(),
195        }
196    }
197
198    /// Get the single member of this workspace, failing if it has multiple members.
199    pub fn single_member(&self) -> Result<&Project, Error> {
200        if self.members().len() == 1 {
201            Ok(self.members().first())
202        } else {
203            Err(Error::NoPackageSpecified)
204        }
205    }
206
207    /// Get the single mutable member of this workspace, failing if it has multiple members.
208    pub fn single_member_mut(&mut self) -> Result<&mut Project, Error> {
209        if self.members().len() == 1 {
210            Ok(self.members_mut().first_mut())
211        } else {
212            Err(Error::NoPackageSpecified)
213        }
214    }
215
216    /// Select a member of this workspace, failing if it is not found.
217    pub fn select_member(&self, package: &package::Name) -> Result<&Project, Error> {
218        let workspace_root = self.root.clone();
219        self.members()
220            .iter()
221            .find(|project| &project.toml().package == package)
222            .ok_or_else(|| Error::PackageNotFound(package.clone(), workspace_root))
223    }
224
225    /// Select a mutable member of this workspace, failing if it is not found.
226    pub fn select_member_mut(&mut self, package: &package::Name) -> Result<&mut Project, Error> {
227        let workspace_root = self.root.clone();
228        self.members_mut()
229            .iter_mut()
230            .find(|project| &project.toml().package == package)
231            .ok_or_else(|| Error::PackageNotFound(package.clone(), workspace_root))
232    }
233
234    /// Get the `lux.lock` lockfile path.
235    pub fn lockfile_path(&self) -> PathBuf {
236        self.root.join("lux.lock")
237    }
238
239    /// Get the `lux.lock` lockfile in the project root.
240    pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, Error> {
241        Ok(WorkspaceLockfile::new(self.lockfile_path())?)
242    }
243
244    /// Get the `lux.lock` lockfile in the project root, if present.
245    pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, Error> {
246        let path = self.lockfile_path();
247        if path.is_file() {
248            Ok(Some(WorkspaceLockfile::load(path)?))
249        } else {
250            Ok(None)
251        }
252    }
253
254    pub fn tree(&self, config: &Config) -> Result<Tree, TreeError> {
255        self.lua_version_tree(self.lua_version(config)?, config)
256    }
257
258    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
259        let mut lua_version = self.members().first().lua_version(config)?;
260        // Ensure the lua version specified by the config matches all projects
261        for project in self.members() {
262            lua_version = project.lua_version(config)?;
263        }
264        Ok(lua_version)
265    }
266
267    pub(crate) fn lua_version_tree(
268        &self,
269        lua_version: LuaVersion,
270        config: &Config,
271    ) -> Result<Tree, TreeError> {
272        let root_dir = config
273            .workspace_tree()
274            .map(|p| p.to_path_buf())
275            .unwrap_or(self.workspace_dir());
276        Ok(Tree::new(root_dir, lua_version, config)?)
277    }
278
279    /// This workspace's .lux directory.
280    pub(crate) fn workspace_dir(&self) -> PathBuf {
281        self.root.join(LUX_DIR_NAME)
282    }
283
284    pub fn test_tree(&self, config: &Config) -> Result<Tree, TreeError> {
285        Ok(self.tree(config)?.test_tree(config)?)
286    }
287
288    pub fn build_tree(&self, config: &Config) -> Result<Tree, TreeError> {
289        Ok(self.tree(config)?.build_tree(config)?)
290    }
291
292    /// The path to this workspace's local config file.
293    pub fn config_file(&self) -> PathBuf {
294        self.workspace_dir().join(CONFIG_TOML)
295    }
296
297    /// Load a [`ConfigBuilder`] from a config.toml that resides in [`Self::workspace_dir`],
298    /// if present.
299    pub fn config(&self) -> Result<Option<ConfigBuilder>, ConfigError> {
300        let config_file = self.config_file();
301        if config_file.is_file() {
302            Ok(Some(ConfigBuilder::from_file(&config_file).map_err(
303                |err| ConfigError {
304                    path: config_file.to_path_buf(),
305                    source: Box::new(err),
306                },
307            )?))
308        } else {
309            Ok(None)
310        }
311    }
312
313    /// Get the `.luarc.json` or `.emmyrc.json` path.
314    pub fn luarc_path(&self, config: &Config) -> PathBuf {
315        let configured_name = config.luarc_file_name();
316        let file_path = self.root.join(configured_name);
317
318        if file_path.is_file() {
319            file_path
320        } else {
321            let emmy_path = self.root.join(EMMYRC);
322            if emmy_path.is_file() {
323                emmy_path
324            } else {
325                file_path
326            }
327        }
328    }
329
330    #[tracing::instrument(level = "trace", skip_all)]
331    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, Error> {
332        if !start.as_ref().exists() {
333            return Ok(None);
334        }
335        if start.as_ref().join(WORKSPACE_TOML).exists() {
336            let toml_path = start.as_ref().join(WORKSPACE_TOML);
337            let toml_content = fs::sync::read_to_string(&toml_path)?;
338            let root = start.as_ref();
339            let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
340            if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
341                Ok(Some(Self::from_toml(&toml_content, root)?))
342            } else {
343                let project = Project::from_exact(root)?
344                    .ok_or_else(|| Error::NoWorkspaceOrProject(root.to_path_buf()))?;
345                Ok(Some(Workspace {
346                    root: WorkspaceRoot(root.to_path_buf()),
347                    members: NonEmpty::new(project),
348                }))
349            }
350        } else {
351            Ok(None)
352        }
353    }
354
355    #[tracing::instrument(level = "trace", skip(start))]
356    pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, Error> {
357        if !start.as_ref().exists() {
358            return Ok(None);
359        }
360        match find_up_with(
361            WORKSPACE_TOML,
362            FindUpOptions {
363                cwd: start.as_ref(),
364                kind: FindUpKind::File,
365            },
366        ) {
367            Ok(Some(path)) => {
368                if let Some(root) = path.parent() {
369                    let toml_content = fs::sync::read_to_string(&path)?;
370                    let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
371                    if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
372                        Ok(Some(Self::from_toml(&toml_content, root)?))
373                    } else {
374                        if let Some(parent) = root.parent() {
375                            match Self::from(parent)? {
376                                Some(workspace)
377                                    if workspace
378                                        .members
379                                        .iter()
380                                        .any(|project| project.root().as_ref() == root) =>
381                                {
382                                    Ok(Some(workspace))
383                                }
384                                _ => {
385                                    let project = Project::from_exact(root)?.ok_or_else(|| {
386                                        Error::NoWorkspaceOrProject(root.to_path_buf())
387                                    })?;
388                                    Ok(Some(Workspace {
389                                        root: WorkspaceRoot(root.to_path_buf()),
390                                        members: NonEmpty::new(project),
391                                    }))
392                                }
393                            }
394                        } else {
395                            Ok(None)
396                        }
397                    }
398                } else {
399                    Ok(None)
400                }
401            }
402            // NOTE: If we hit a read error, it could be because we haven't found a PROJECT_TOML
403            // or WORKSPACE_TOML and have started searching too far upwards.
404            // See for example https://github.com/lumen-oss/lux/issues/532
405            _ => Ok(None),
406        }
407    }
408
409    fn from_toml(toml_content: &str, root: &Path) -> Result<Self, Error> {
410        let toml = WorkspaceToml::new(WORKSPACE_TOML, toml_content)
411            .map_err(|source| Error::TOML { source })?;
412        let mut members = Vec::new();
413        for member in toml.workspace.members {
414            match member {
415                WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
416                    let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
417                        .ok() // This is fine because we fail to deserialize invalid globs
418                        .into_iter()
419                        .flat_map(|paths| {
420                            paths.map(|path| path.map_err(|err| Error::Glob(err.to_string())))
421                        })
422                        .try_collect::<_, Vec<_>, _>()?;
423                    for project_path in potential_paths {
424                        if let Some(project) = Project::from_exact(&project_path)? {
425                            members.push(project)
426                        }
427                    }
428                }
429                WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
430                    let project_path = root.join(relative_project_path);
431                    match Project::from_exact(&project_path)? {
432                        Some(project) => members.push(project),
433                        None => return Err(Error::WorkspaceNotFound(project_path)),
434                    }
435                }
436            }
437        }
438        match NonEmpty::from_vec(members) {
439            Some(members) => Ok(Workspace {
440                root: WorkspaceRoot(root.to_path_buf()),
441                members,
442            }),
443            None => Err(Error::EmptyWorkspace(root.to_path_buf())),
444        }
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::{config::ConfigBuilder, fs, lua_version::LuaVersion};
452    use std::path::PathBuf;
453
454    use assert_fs::prelude::*;
455
456    #[tokio::test]
457    async fn find_single_project_workspace() {
458        let sample_project =
459            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
460        let project_root = assert_fs::TempDir::new().unwrap();
461        project_root.copy_from(&sample_project, &["**"]).unwrap();
462        let work_dir: PathBuf = project_root.join("src");
463        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
464        assert_eq!(workspace.members.len(), 1);
465        let project = workspace.members.first();
466        assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
467    }
468
469    #[tokio::test]
470    async fn find_nested_single_project_workspace() {
471        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
472            .join("resources/test/sample-projects/nested/");
473        let project_root = assert_fs::TempDir::new().unwrap();
474        project_root.copy_from(&sample_project, &["**"]).unwrap();
475        let nested_project_root: PathBuf = project_root.join("nested");
476        let workspace = Workspace::from(&nested_project_root).unwrap().unwrap();
477        assert_eq!(workspace.members.len(), 1);
478        let project = workspace.members.first();
479        assert_eq!(
480            project.root().to_path_buf(),
481            nested_project_root.to_path_buf()
482        );
483    }
484
485    #[tokio::test]
486    async fn find_multi_project_workspace() {
487        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
488            .join("resources/test/sample-projects/multi-project/");
489        let workspace_root = assert_fs::TempDir::new().unwrap();
490        workspace_root
491            .copy_from(&sample_workspace, &["**"])
492            .unwrap();
493        let work_dir: PathBuf = workspace_root.join("projects");
494        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
495        assert_eq!(workspace.members.len(), 2);
496        let foo = workspace.select_member(&"foo".into()).unwrap();
497        assert_eq!(
498            foo.root().to_path_buf(),
499            workspace_root.join("projects/foo").to_path_buf()
500        );
501        let bar = workspace.select_member(&"bar".into()).unwrap();
502        assert_eq!(
503            bar.root().to_path_buf(),
504            workspace_root.join("projects/bar").to_path_buf()
505        );
506    }
507
508    #[tokio::test]
509    async fn find_multi_project_workspace_members_glob() {
510        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
511            .join("resources/test/sample-projects/multi-project/");
512        let workspace_root = assert_fs::TempDir::new().unwrap();
513        workspace_root
514            .copy_from(&sample_workspace, &["**"])
515            .unwrap();
516        let work_dir: PathBuf = workspace_root.join("projects");
517        let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
518        let workspace_toml_content = r#"
519[workspace]
520members = [ "glob:projects/*" ]
521"#;
522        fs::tokio::write(&workspace_toml_file, workspace_toml_content)
523            .await
524            .unwrap();
525
526        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
527        assert_eq!(workspace.members.len(), 2);
528        let foo = workspace.select_member(&"foo".into()).unwrap();
529        assert_eq!(
530            foo.root().to_path_buf(),
531            workspace_root.join("projects/foo").to_path_buf()
532        );
533        let bar = workspace.select_member(&"bar".into()).unwrap();
534        assert_eq!(
535            bar.root().to_path_buf(),
536            workspace_root.join("projects/bar").to_path_buf()
537        );
538    }
539
540    #[tokio::test]
541    async fn test_no_find_workspace_upwards() {
542        let work_dir = assert_fs::TempDir::new().unwrap();
543        assert!(Workspace::from(&work_dir).unwrap().is_none())
544    }
545
546    #[tokio::test]
547    async fn test_luarc_path_custom_config() {
548        let sample_project =
549            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
550        let project_root = assert_fs::TempDir::new().unwrap();
551        project_root.copy_from(&sample_project, &["**"]).unwrap();
552
553        let workspace = Workspace::from(&project_root).unwrap().unwrap();
554
555        let config = ConfigBuilder::default()
556            .luarc_file_name(Some("custom_config.json".to_string()))
557            .build()
558            .unwrap();
559
560        let path = workspace.luarc_path(&config);
561        assert_eq!(path, workspace.root().join("custom_config.json"));
562    }
563
564    #[tokio::test]
565    async fn test_luarc_path_fallback_luarc() {
566        let sample_project =
567            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
568        let project_root = assert_fs::TempDir::new().unwrap();
569        project_root.copy_from(&sample_project, &["**"]).unwrap();
570
571        let luarc_file = project_root.child(".luarc.json");
572        luarc_file.touch().unwrap();
573
574        let workspace = Workspace::from(&project_root).unwrap().unwrap();
575        let config = ConfigBuilder::default().build().unwrap();
576
577        let path = workspace.luarc_path(&config);
578        assert_eq!(path, luarc_file.path().to_path_buf());
579    }
580
581    #[tokio::test]
582    async fn test_luarc_path_fallback_emmyrc() {
583        let sample_project =
584            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
585        let project_root = assert_fs::TempDir::new().unwrap();
586        project_root.copy_from(&sample_project, &["**"]).unwrap();
587
588        let emmyrc_file = project_root.child(".emmyrc.json");
589        emmyrc_file.touch().unwrap();
590
591        let workspace = Workspace::from(&project_root).unwrap().unwrap();
592        let config = ConfigBuilder::default().build().unwrap();
593
594        let path = workspace.luarc_path(&config);
595        assert_eq!(path, emmyrc_file.path().to_path_buf());
596    }
597
598    #[tokio::test]
599    async fn test_workspace_local_config() {
600        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
601            .join("resources/test/sample-projects/multi-project/");
602        let workspace_root = assert_fs::TempDir::new().unwrap();
603        workspace_root
604            .copy_from(&sample_workspace, &["**"])
605            .unwrap();
606
607        let workspace_config_dir = workspace_root.join(LUX_DIR_NAME);
608        fs::tokio::create_dir_all(&workspace_config_dir)
609            .await
610            .unwrap();
611        fs::tokio::write(
612            workspace_config_dir.join(CONFIG_TOML),
613            r#"
614lua_version = "5.4"
615generate_luarc = false
616no_tfa = true
617"#,
618        )
619        .await
620        .unwrap();
621
622        let workspace = Workspace::from(&workspace_root).unwrap().unwrap();
623        let workspace_config = workspace.config().unwrap().unwrap();
624
625        let config = ConfigBuilder::default()
626            .lua_version(Some(LuaVersion::Lua53))
627            .merge(workspace_config)
628            .build()
629            .unwrap();
630
631        // The workspace-local config takes precedence over the base config,
632        assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
633        assert!(!config.generate_luarc());
634        assert!(config.no_tfa());
635
636        // ... and unspecified keys keep the base/default values.
637        assert_eq!(config.server().as_str(), "https://luarocks.org/");
638    }
639}