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    /// The package names of all workspace members.
163    pub fn member_names(&self) -> Result<NonEmpty<PackageName>, WorkspaceError> {
164        NonEmpty::from_vec(
165            self.members()
166                .iter()
167                .map(|project| project.toml().package.clone())
168                .collect(),
169        )
170        .ok_or_else(|| WorkspaceError::EmptyWorkspace(self.root.0.clone()))
171    }
172
173    /// Get a workspace member, defaulting to the first one if none is specified.
174    /// Fails if a package name is specified, but not found.
175    pub fn single_member_or_select(
176        &self,
177        name: &Option<PackageName>,
178    ) -> Result<&Project, WorkspaceError> {
179        match name {
180            Some(name) => self
181                .members()
182                .iter()
183                .find(|project| &project.toml().package == name)
184                .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
185            None => Ok(self.members().first()),
186        }
187    }
188
189    /// Get a mutable workspace member, defaulting to the first one if none is specified.
190    /// Fails if a package name is specified, but not found.
191    pub fn single_member_or_select_mut(
192        &mut self,
193        package: &Option<PackageName>,
194    ) -> Result<&mut Project, WorkspaceError> {
195        match package.as_ref() {
196            Some(package) => self.select_member_mut(package),
197            None => self.single_member_mut(),
198        }
199    }
200
201    /// Get the single member of this workspace, failing if it has multiple members.
202    pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
203        if self.members().len() == 1 {
204            Ok(self.members().first())
205        } else {
206            Err(WorkspaceError::NoPackageSpecified)
207        }
208    }
209
210    /// Get the single mutable member of this workspace, failing if it has multiple members.
211    pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
212        if self.members().len() == 1 {
213            Ok(self.members_mut().first_mut())
214        } else {
215            Err(WorkspaceError::NoPackageSpecified)
216        }
217    }
218
219    /// Select a member of this workspace, failing if it is not found.
220    pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
221        let workspace_root = self.root.clone();
222        self.members()
223            .iter()
224            .find(|project| &project.toml().package == package)
225            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
226    }
227
228    /// Select a mutable member of this workspace, failing if it is not found.
229    pub fn select_member_mut(
230        &mut self,
231        package: &PackageName,
232    ) -> Result<&mut Project, WorkspaceError> {
233        let workspace_root = self.root.clone();
234        self.members_mut()
235            .iter_mut()
236            .find(|project| &project.toml().package == package)
237            .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
238    }
239
240    /// Get the `lux.lock` lockfile path.
241    pub fn lockfile_path(&self) -> PathBuf {
242        self.root.join("lux.lock")
243    }
244
245    /// Get the `lux.lock` lockfile in the project root.
246    pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
247        Ok(WorkspaceLockfile::new(self.lockfile_path())?)
248    }
249
250    /// Get the `lux.lock` lockfile in the project root, if present.
251    pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
252        let path = self.lockfile_path();
253        if path.is_file() {
254            Ok(Some(WorkspaceLockfile::load(path)?))
255        } else {
256            Ok(None)
257        }
258    }
259
260    pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
261        self.lua_version_tree(self.lua_version(config)?, config)
262    }
263
264    pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
265        let mut lua_version = self.members().first().lua_version(config)?;
266        // Ensure the lua version specified by the config matches all projects
267        for project in self.members() {
268            lua_version = project.lua_version(config)?;
269        }
270        Ok(lua_version)
271    }
272
273    pub(crate) fn lua_version_tree(
274        &self,
275        lua_version: LuaVersion,
276        config: &Config,
277    ) -> Result<Tree, WorkspaceTreeError> {
278        let root_dir = config
279            .workspace_tree()
280            .map(|p| p.to_path_buf())
281            .unwrap_or(self.workspace_dir());
282        Ok(Tree::new(root_dir, lua_version, config)?)
283    }
284
285    /// This workspace's .lux directory.
286    pub(crate) fn workspace_dir(&self) -> PathBuf {
287        self.root.join(LUX_DIR_NAME)
288    }
289
290    pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
291        Ok(self.tree(config)?.test_tree(config)?)
292    }
293
294    pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
295        Ok(self.tree(config)?.build_tree(config)?)
296    }
297
298    /// The path to this workspace's local config file.
299    pub fn config_file(&self) -> PathBuf {
300        self.workspace_dir().join(CONFIG_TOML)
301    }
302
303    /// Load a [`ConfigBuilder`] from a config.toml that resides in [`Self::workspace_dir`],
304    /// if present.
305    pub fn config(&self) -> Result<Option<ConfigBuilder>, WorkspaceConfigError> {
306        let config_file = self.config_file();
307        if config_file.is_file() {
308            Ok(Some(ConfigBuilder::from_file(&config_file).map_err(
309                |err| WorkspaceConfigError {
310                    path: config_file.to_path_buf(),
311                    source: Box::new(err),
312                },
313            )?))
314        } else {
315            Ok(None)
316        }
317    }
318
319    /// Get the `.luarc.json` or `.emmyrc.json` path.
320    pub fn luarc_path(&self, config: &Config) -> PathBuf {
321        let configured_name = config.luarc_file_name();
322        let file_path = self.root.join(configured_name);
323
324        if file_path.is_file() {
325            file_path
326        } else {
327            let emmy_path = self.root.join(EMMYRC);
328            if emmy_path.is_file() {
329                emmy_path
330            } else {
331                file_path
332            }
333        }
334    }
335
336    #[tracing::instrument(level = "trace", skip_all)]
337    pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
338        if !start.as_ref().exists() {
339            return Ok(None);
340        }
341        if start.as_ref().join(WORKSPACE_TOML).exists() {
342            let toml_path = start.as_ref().join(WORKSPACE_TOML);
343            let toml_content = fs::sync::read_to_string(&toml_path)?;
344            let root = start.as_ref();
345            let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
346            if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
347                Ok(Some(Self::from_toml(&toml_content, root)?))
348            } else {
349                let project = Project::from_exact(root)?
350                    .ok_or_else(|| WorkspaceError::NoWorkspaceOrProject(root.to_path_buf()))?;
351                Ok(Some(Workspace {
352                    root: WorkspaceRoot(root.to_path_buf()),
353                    members: NonEmpty::new(project),
354                }))
355            }
356        } else {
357            Ok(None)
358        }
359    }
360
361    #[tracing::instrument(level = "trace", skip(start))]
362    pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
363        if !start.as_ref().exists() {
364            return Ok(None);
365        }
366        match find_up_with(
367            WORKSPACE_TOML,
368            FindUpOptions {
369                cwd: start.as_ref(),
370                kind: FindUpKind::File,
371            },
372        ) {
373            Ok(Some(path)) => {
374                if let Some(root) = path.parent() {
375                    let toml_content = fs::sync::read_to_string(&path)?;
376                    let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
377                    if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
378                        Ok(Some(Self::from_toml(&toml_content, root)?))
379                    } else {
380                        if let Some(parent) = root.parent() {
381                            match Self::from(parent)? {
382                                Some(workspace)
383                                    if workspace
384                                        .members
385                                        .iter()
386                                        .any(|project| project.root().as_ref() == root) =>
387                                {
388                                    Ok(Some(workspace))
389                                }
390                                _ => {
391                                    let project = Project::from_exact(root)?.ok_or_else(|| {
392                                        WorkspaceError::NoWorkspaceOrProject(root.to_path_buf())
393                                    })?;
394                                    Ok(Some(Workspace {
395                                        root: WorkspaceRoot(root.to_path_buf()),
396                                        members: NonEmpty::new(project),
397                                    }))
398                                }
399                            }
400                        } else {
401                            Ok(None)
402                        }
403                    }
404                } else {
405                    Ok(None)
406                }
407            }
408            // NOTE: If we hit a read error, it could be because we haven't found a PROJECT_TOML
409            // or WORKSPACE_TOML and have started searching too far upwards.
410            // See for example https://github.com/lumen-oss/lux/issues/532
411            _ => Ok(None),
412        }
413    }
414
415    fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
416        let toml = WorkspaceToml::new(WORKSPACE_TOML, toml_content)
417            .map_err(|source| WorkspaceError::TOML { source })?;
418        let mut members = Vec::new();
419        for member in toml.workspace.members {
420            match member {
421                WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
422                    let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
423                        .ok() // This is fine because we fail to deserialize invalid globs
424                        .into_iter()
425                        .flat_map(|paths| {
426                            paths.map(|path| {
427                                path.map_err(|err| WorkspaceError::Glob(err.to_string()))
428                            })
429                        })
430                        .try_collect::<_, Vec<_>, _>()?;
431                    for project_path in potential_paths {
432                        if let Some(project) = Project::from_exact(&project_path)? {
433                            members.push(project)
434                        }
435                    }
436                }
437                WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
438                    let project_path = root.join(relative_project_path);
439                    match Project::from_exact(&project_path)? {
440                        Some(project) => members.push(project),
441                        None => return Err(WorkspaceError::WorkspaceNotFound(project_path)),
442                    }
443                }
444            }
445        }
446        match NonEmpty::from_vec(members) {
447            Some(members) => Ok(Workspace {
448                root: WorkspaceRoot(root.to_path_buf()),
449                members,
450            }),
451            None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
452        }
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::{config::ConfigBuilder, fs, lua_version::LuaVersion};
460    use std::path::PathBuf;
461
462    use assert_fs::prelude::*;
463
464    #[tokio::test]
465    async fn find_single_project_workspace() {
466        let sample_project =
467            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
468        let project_root = assert_fs::TempDir::new().unwrap();
469        project_root.copy_from(&sample_project, &["**"]).unwrap();
470        let work_dir: PathBuf = project_root.join("src");
471        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
472        assert_eq!(workspace.members.len(), 1);
473        let project = workspace.members.first();
474        assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
475    }
476
477    #[tokio::test]
478    async fn find_nested_single_project_workspace() {
479        let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
480            .join("resources/test/sample-projects/nested/");
481        let project_root = assert_fs::TempDir::new().unwrap();
482        project_root.copy_from(&sample_project, &["**"]).unwrap();
483        let nested_project_root: PathBuf = project_root.join("nested");
484        let workspace = Workspace::from(&nested_project_root).unwrap().unwrap();
485        assert_eq!(workspace.members.len(), 1);
486        let project = workspace.members.first();
487        assert_eq!(
488            project.root().to_path_buf(),
489            nested_project_root.to_path_buf()
490        );
491    }
492
493    #[tokio::test]
494    async fn find_multi_project_workspace() {
495        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
496            .join("resources/test/sample-projects/multi-project/");
497        let workspace_root = assert_fs::TempDir::new().unwrap();
498        workspace_root
499            .copy_from(&sample_workspace, &["**"])
500            .unwrap();
501        let work_dir: PathBuf = workspace_root.join("projects");
502        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
503        assert_eq!(workspace.members.len(), 2);
504        let foo = workspace.select_member(&"foo".into()).unwrap();
505        assert_eq!(
506            foo.root().to_path_buf(),
507            workspace_root.join("projects/foo").to_path_buf()
508        );
509        let bar = workspace.select_member(&"bar".into()).unwrap();
510        assert_eq!(
511            bar.root().to_path_buf(),
512            workspace_root.join("projects/bar").to_path_buf()
513        );
514    }
515
516    #[tokio::test]
517    async fn find_multi_project_workspace_members_glob() {
518        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
519            .join("resources/test/sample-projects/multi-project/");
520        let workspace_root = assert_fs::TempDir::new().unwrap();
521        workspace_root
522            .copy_from(&sample_workspace, &["**"])
523            .unwrap();
524        let work_dir: PathBuf = workspace_root.join("projects");
525        let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
526        let workspace_toml_content = r#"
527[workspace]
528members = [ "glob:projects/*" ]
529"#;
530        fs::tokio::write(&workspace_toml_file, workspace_toml_content)
531            .await
532            .unwrap();
533
534        let workspace = Workspace::from(&work_dir).unwrap().unwrap();
535        assert_eq!(workspace.members.len(), 2);
536        let foo = workspace.select_member(&"foo".into()).unwrap();
537        assert_eq!(
538            foo.root().to_path_buf(),
539            workspace_root.join("projects/foo").to_path_buf()
540        );
541        let bar = workspace.select_member(&"bar".into()).unwrap();
542        assert_eq!(
543            bar.root().to_path_buf(),
544            workspace_root.join("projects/bar").to_path_buf()
545        );
546    }
547
548    #[tokio::test]
549    async fn test_no_find_workspace_upwards() {
550        let work_dir = assert_fs::TempDir::new().unwrap();
551        assert!(Workspace::from(&work_dir).unwrap().is_none())
552    }
553
554    #[tokio::test]
555    async fn test_luarc_path_custom_config() {
556        let sample_project =
557            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
558        let project_root = assert_fs::TempDir::new().unwrap();
559        project_root.copy_from(&sample_project, &["**"]).unwrap();
560
561        let workspace = Workspace::from(&project_root).unwrap().unwrap();
562
563        let config = ConfigBuilder::default()
564            .luarc_file_name(Some("custom_config.json".to_string()))
565            .build()
566            .unwrap();
567
568        let path = workspace.luarc_path(&config);
569        assert_eq!(path, workspace.root().join("custom_config.json"));
570    }
571
572    #[tokio::test]
573    async fn test_luarc_path_fallback_luarc() {
574        let sample_project =
575            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
576        let project_root = assert_fs::TempDir::new().unwrap();
577        project_root.copy_from(&sample_project, &["**"]).unwrap();
578
579        let luarc_file = project_root.child(".luarc.json");
580        luarc_file.touch().unwrap();
581
582        let workspace = Workspace::from(&project_root).unwrap().unwrap();
583        let config = ConfigBuilder::default().build().unwrap();
584
585        let path = workspace.luarc_path(&config);
586        assert_eq!(path, luarc_file.path().to_path_buf());
587    }
588
589    #[tokio::test]
590    async fn test_luarc_path_fallback_emmyrc() {
591        let sample_project =
592            PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
593        let project_root = assert_fs::TempDir::new().unwrap();
594        project_root.copy_from(&sample_project, &["**"]).unwrap();
595
596        let emmyrc_file = project_root.child(".emmyrc.json");
597        emmyrc_file.touch().unwrap();
598
599        let workspace = Workspace::from(&project_root).unwrap().unwrap();
600        let config = ConfigBuilder::default().build().unwrap();
601
602        let path = workspace.luarc_path(&config);
603        assert_eq!(path, emmyrc_file.path().to_path_buf());
604    }
605
606    #[tokio::test]
607    async fn test_workspace_local_config() {
608        let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
609            .join("resources/test/sample-projects/multi-project/");
610        let workspace_root = assert_fs::TempDir::new().unwrap();
611        workspace_root
612            .copy_from(&sample_workspace, &["**"])
613            .unwrap();
614
615        let workspace_config_dir = workspace_root.join(LUX_DIR_NAME);
616        fs::tokio::create_dir_all(&workspace_config_dir)
617            .await
618            .unwrap();
619        fs::tokio::write(
620            workspace_config_dir.join(CONFIG_TOML),
621            r#"
622lua_version = "5.4"
623generate_luarc = false
624no_tfa = true
625"#,
626        )
627        .await
628        .unwrap();
629
630        let workspace = Workspace::from(&workspace_root).unwrap().unwrap();
631        let workspace_config = workspace.config().unwrap().unwrap();
632
633        let config = ConfigBuilder::default()
634            .lua_version(Some(LuaVersion::Lua53))
635            .merge(workspace_config)
636            .build()
637            .unwrap();
638
639        // The workspace-local config takes precedence over the base config,
640        assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
641        assert!(!config.generate_luarc());
642        assert!(config.no_tfa());
643
644        // ... and unspecified keys keep the base/default values.
645        assert_eq!(config.server().as_str(), "https://luarocks.org/");
646    }
647}