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