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#[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#[derive(Clone, Debug)]
115pub struct Workspace {
116 root: WorkspaceRoot,
117 members: NonEmpty<Project>,
118}
119
120impl 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 pub fn root(&self) -> &WorkspaceRoot {
135 &self.root
136 }
137
138 pub fn members(&self) -> &NonEmpty<Project> {
140 &self.members
141 }
142
143 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
145 &mut self.members
146 }
147
148 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 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 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 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 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 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 pub fn lockfile_path(&self) -> PathBuf {
217 self.root.join("lux.lock")
218 }
219
220 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
222 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
223 }
224
225 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 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 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 _ => 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() .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}