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