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