Skip to main content

lux_lib/workspace/
mod.rs

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