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 a lux project or workspace directory:\n`{0}`")]
68    NotAWorkspaceDir(PathBuf),
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        let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
99        Self::current()?.ok_or(WorkspaceError::NotAWorkspaceDir(cwd))
100    }
101
102    /// The path where the root `lux.toml` resides.
103    pub fn root(&self) -> &WorkspaceRoot {
104        &self.root
105    }
106
107    /// The members of this workspace.
108    pub fn members(&self) -> &NonEmpty<Project> {
109        &self.members
110    }
111
112    /// Mutable reference to the members of this workspace.
113    pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
114        &mut self.members
115    }
116
117    /// Get a workspace member, defaulting to the first one if none is specified.
118    /// Fails if a package name is specified, but not found.
119    pub fn single_member_or_select(
120        &self,
121        name: &Option<PackageName>,
122    ) -> Result<&Project, WorkspaceError> {
123        match name {
124            Some(name) => self
125                .members()
126                .iter()
127                .find(|project| &project.toml().package == name)
128                .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
129            None => Ok(self.members().first()),
130        }
131    }
132
133    /// Get a mutable workspace member, defaulting to the first one if none is specified.
134    /// Fails if a package name is specified, but not found.
135    pub fn single_member_or_select_mut(
136        &mut self,
137        package: &Option<PackageName>,
138    ) -> Result<&mut Project, WorkspaceError> {
139        match package.as_ref() {
140            Some(package) => self.select_member_mut(package),
141            None => self.single_member_mut(),
142        }
143    }
144
145    /// Get the single member of this workspace, failing if it has multiple members.
146    pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
147        if self.members().len() == 1 {
148            Ok(self.members().first())
149        } else {
150            Err(WorkspaceError::NoPackageSpecified)
151        }
152    }
153
154    /// Get the single mutable member of this workspace, failing if it has multiple members.
155    pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
156        if self.members().len() == 1 {
157            Ok(self.members_mut().first_mut())
158        } else {
159            Err(WorkspaceError::NoPackageSpecified)
160        }
161    }
162
163    /// Select a member of this workspace, failing if it is not found.
164    pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
165        let workspace_root = self.root.clone();
166        self.members()
167            .iter()
168            .find(|project| &project.toml().package == package)
169            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
170    }
171
172    /// Select a mutable member of this workspace, failing if it is not found.
173    pub fn select_member_mut(
174        &mut self,
175        package: &PackageName,
176    ) -> Result<&mut Project, WorkspaceError> {
177        let workspace_root = self.root.clone();
178        self.members_mut()
179            .iter_mut()
180            .find(|project| &project.toml().package == package)
181            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
182    }
183
184    /// Get the `lux.lock` lockfile path.
185    pub fn lockfile_path(&self) -> PathBuf {
186        self.root.join("lux.lock")
187    }
188
189    /// Get the `lux.lock` lockfile in the project root.
190    pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
191        Ok(WorkspaceLockfile::new(self.lockfile_path())?)
192    }
193
194    /// Get the `lux.lock` lockfile in the project root, if present.
195    pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
196        let path = self.lockfile_path();
197        if path.is_file() {
198            Ok(Some(WorkspaceLockfile::load(path)?))
199        } else {
200            Ok(None)
201        }
202    }
203
204    pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
205        self.lua_version_tree(self.lua_version(config)?, config)
206    }
207
208    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
209        let mut lua_version = self.members().first().lua_version(config)?;
210        // Ensure the lua version specified by the config matches all projects
211        for project in self.members() {
212            lua_version = project.lua_version(config)?;
213        }
214        Ok(lua_version)
215    }
216
217    pub(crate) fn lua_version_tree(
218        &self,
219        lua_version: LuaVersion,
220        config: &Config,
221    ) -> Result<Tree, WorkspaceTreeError> {
222        Ok(Tree::new(
223            self.default_tree_root_dir(),
224            lua_version,
225            config,
226        )?)
227    }
228
229    pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
230        self.root.join(LUX_DIR_NAME)
231    }
232
233    pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
234        Ok(self.tree(config)?.test_tree(config)?)
235    }
236
237    pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
238        Ok(self.tree(config)?.build_tree(config)?)
239    }
240
241    /// Get the `.luarc.json` or `.emmyrc.json` path.
242    pub fn luarc_path(&self) -> PathBuf {
243        let luarc_path = self.root.join(LUARC);
244        if luarc_path.is_file() {
245            luarc_path
246        } else {
247            let emmy_path = self.root.join(EMMYRC);
248            if emmy_path.is_file() {
249                emmy_path
250            } else {
251                luarc_path
252            }
253        }
254    }
255
256    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
257        if !start.as_ref().exists() {
258            return Ok(None);
259        }
260        if start.as_ref().join(WORKSPACE_TOML).exists() {
261            let toml_path = start.as_ref().join(WORKSPACE_TOML);
262            let toml_content = std::fs::read_to_string(&toml_path).map_err(|err| {
263                WorkspaceError::ReadLuxTOML(toml_path.to_string_lossy().to_string(), err)
264            })?;
265            let root = start.as_ref();
266            let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
267            if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
268                Ok(Some(Self::from_toml(&toml_content, root)?))
269            } else {
270                let project =
271                    Project::from_exact(root)?.ok_or(WorkspaceError::NoWorkspaceOrProject)?;
272                Ok(Some(Workspace {
273                    root: WorkspaceRoot(root.to_path_buf()),
274                    members: NonEmpty::new(project),
275                }))
276            }
277        } else {
278            Ok(None)
279        }
280    }
281
282    pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
283        if !start.as_ref().exists() {
284            return Ok(None);
285        }
286        match find_up_with(
287            WORKSPACE_TOML,
288            FindUpOptions {
289                cwd: start.as_ref(),
290                kind: FindUpKind::File,
291            },
292        ) {
293            Ok(Some(path)) => {
294                if let Some(root) = path.parent() {
295                    let toml_content = std::fs::read_to_string(&path).map_err(|err| {
296                        WorkspaceError::ReadLuxTOML(path.to_string_lossy().to_string(), err)
297                    })?;
298                    let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
299                    if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
300                        Ok(Some(Self::from_toml(&toml_content, root)?))
301                    } else {
302                        if let Some(parent) = root.parent() {
303                            match Self::from(parent)? {
304                                Some(workspace) => Ok(Some(workspace)),
305                                None => {
306                                    let project = Project::from_exact(root)?
307                                        .ok_or(WorkspaceError::NoWorkspaceOrProject)?;
308                                    Ok(Some(Workspace {
309                                        root: WorkspaceRoot(root.to_path_buf()),
310                                        members: NonEmpty::new(project),
311                                    }))
312                                }
313                            }
314                        } else {
315                            Ok(None)
316                        }
317                    }
318                } else {
319                    Ok(None)
320                }
321            }
322            // NOTE: If we hit a read error, it could be because we haven't found a PROJECT_TOML
323            // or WORKSPACE_TOML and have started searching too far upwards.
324            // See for example https://github.com/lumen-oss/lux/issues/532
325            _ => Ok(None),
326        }
327    }
328
329    fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
330        let toml = WorkspaceToml::new(toml_content)
331            .map_err(|err| WorkspaceError::TOML(err.to_string()))?;
332        let mut members = Vec::new();
333        for relative_project_path in toml.workspace.members {
334            let project_path = root.join(relative_project_path);
335            match Project::from_exact(&project_path)? {
336                Some(project) => members.push(project),
337                None => return Err(WorkspaceError::ProjectNotFound(project_path)),
338            }
339        }
340        match NonEmpty::from_vec(members) {
341            Some(members) => Ok(Workspace {
342                root: WorkspaceRoot(root.to_path_buf()),
343                members,
344            }),
345            None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
346        }
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use std::path::PathBuf;
354
355    use assert_fs::prelude::PathCopy;
356
357    #[tokio::test]
358    async fn find_single_project_workspace() {
359        let sample_project: PathBuf = "resources/test/sample-projects/init/".into();
360        let project_root = assert_fs::TempDir::new().unwrap();
361        project_root.copy_from(&sample_project, &["**"]).unwrap();
362        let work_dir: PathBuf = project_root.join("src");
363        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
364        assert_eq!(workspace.members.len(), 1);
365        let project = workspace.members.first();
366        assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
367    }
368
369    #[tokio::test]
370    async fn find_multi_project_workspace() {
371        let sample_workspace: PathBuf = "resources/test/sample-projects/multi-project/".into();
372        let workspace_root = assert_fs::TempDir::new().unwrap();
373        workspace_root
374            .copy_from(&sample_workspace, &["**"])
375            .unwrap();
376        let work_dir: PathBuf = workspace_root.join("projects");
377        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
378        assert_eq!(workspace.members.len(), 2);
379        let foo = workspace.select_member(&"foo".into()).unwrap();
380        assert_eq!(
381            foo.root().to_path_buf(),
382            workspace_root.join("projects/foo").to_path_buf()
383        );
384        let bar = workspace.select_member(&"bar".into()).unwrap();
385        assert_eq!(
386            bar.root().to_path_buf(),
387            workspace_root.join("projects/bar").to_path_buf()
388        );
389    }
390
391    #[tokio::test]
392    async fn test_no_find_workspace_upwards() {
393        let work_dir = assert_fs::TempDir::new().unwrap();
394        assert!(Workspace::from(&work_dir).unwrap().is_none())
395    }
396}