Skip to main content

lux_lib/workspace/
mod.rs

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