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