Skip to main content

lux_lib/workspace/
mod.rs

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