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