1use std::{
2 io,
3 ops::Deref,
4 path::{Path, PathBuf},
5};
6
7use crate::{
8 config::Config,
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";
31
32#[derive(Clone, Debug)]
35#[cfg_attr(test, derive(Default))]
36pub struct WorkspaceRoot(PathBuf);
37
38impl AsRef<Path> for WorkspaceRoot {
39 fn as_ref(&self) -> &Path {
40 self.0.as_ref()
41 }
42}
43
44impl Deref for WorkspaceRoot {
45 type Target = PathBuf;
46
47 fn deref(&self) -> &Self::Target {
48 &self.0
49 }
50}
51
52#[derive(Debug, Error, Diagnostic)]
53pub enum WorkspaceError {
54 #[error("cannot read the current working directory")]
55 #[diagnostic(help("make sure Lux has permissions to read the current working directory"))]
56 GetCwd(io::Error),
57 #[error("error reading workspace TOML at '{toml_path}'")]
58 #[diagnostic(help("make sure the file exists and contains valid UTF-8"))]
59 ReadLuxTOML {
60 toml_path: String,
61 source: io::Error,
62 },
63 #[error("error deserializing {WORKSPACE_TOML}")]
64 #[diagnostic(transparent)]
65 TOML { source: TomlDeError },
66 #[error("no workspace found at '{0}'")]
67 #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
68 WorkspaceNotFound(PathBuf),
69 #[error("glob error: '{0}'")]
70 #[diagnostic(help("check the glob pattern in your {WORKSPACE_TOML}'s '[workspace.members]'"))]
71 Glob(String),
72 #[error(transparent)]
73 #[diagnostic(forward(0))]
74 Project(#[from] ProjectError),
75 #[error("no project or workspace found at '{0}'")]
76 #[diagnostic(help("make sure the directory contains a '{WORKSPACE_TOML}'"))]
77 NoWorkspaceOrProject(PathBuf),
78 #[error("empty workspace at '{0}'")]
79 #[diagnostic(
80 help(
81 "a Lux workspace must have at least one project, declared using '[workspace.members]'"
82 ),
83 url("https://lux.lumen-labs.org/reference/lux-toml")
84 )]
85 EmptyWorkspace(PathBuf),
86 #[error(transparent)]
87 #[diagnostic(transparent)]
88 Lockfile(#[from] LockfileError),
89 #[error(transparent)]
90 #[diagnostic(transparent)]
91 Fs(#[from] fs::FsError),
92 #[error("package must be specified in a multi-project workspace")]
93 #[diagnostic(help(
94 r#"this workspace contains multiple projects.
95specify the package with '--package=[PACKAGE_NAME]'
96 "#
97 ))]
98 NoPackageSpecified,
99 #[error("package '{0}' not found in workspace '{1}'")]
100 #[diagnostic(help(
101 "make sure it is declared in your {WORKSPACE_TOML}'s '[workspace.members]'"
102 ))]
103 PackageNotFound(PackageName, WorkspaceRoot),
104}
105
106#[derive(Error, Debug, Diagnostic)]
107#[non_exhaustive]
108pub enum WorkspaceTreeError {
109 #[error(transparent)]
110 #[diagnostic(transparent)]
111 Tree(#[from] TreeError),
112 #[error(transparent)]
113 #[diagnostic(transparent)]
114 LuaVersionError(#[from] LuaVersionError),
115}
116
117#[derive(Clone, Debug)]
119pub struct Workspace {
120 root: WorkspaceRoot,
121 members: NonEmpty<Project>,
122}
123
124impl Workspace {
127 pub fn current() -> Result<Option<Self>, WorkspaceError> {
128 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
129 Self::from(&cwd)
130 }
131
132 pub fn current_or_err() -> Result<Self, WorkspaceError> {
133 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
134 Self::current()?.ok_or(WorkspaceError::NoWorkspaceOrProject(cwd))
135 }
136
137 pub fn root(&self) -> &WorkspaceRoot {
139 &self.root
140 }
141
142 pub fn members(&self) -> &NonEmpty<Project> {
144 &self.members
145 }
146
147 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
149 &mut self.members
150 }
151
152 pub fn single_member_or_select(
155 &self,
156 name: &Option<PackageName>,
157 ) -> Result<&Project, WorkspaceError> {
158 match name {
159 Some(name) => self
160 .members()
161 .iter()
162 .find(|project| &project.toml().package == name)
163 .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
164 None => Ok(self.members().first()),
165 }
166 }
167
168 pub fn single_member_or_select_mut(
171 &mut self,
172 package: &Option<PackageName>,
173 ) -> Result<&mut Project, WorkspaceError> {
174 match package.as_ref() {
175 Some(package) => self.select_member_mut(package),
176 None => self.single_member_mut(),
177 }
178 }
179
180 pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
182 if self.members().len() == 1 {
183 Ok(self.members().first())
184 } else {
185 Err(WorkspaceError::NoPackageSpecified)
186 }
187 }
188
189 pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
191 if self.members().len() == 1 {
192 Ok(self.members_mut().first_mut())
193 } else {
194 Err(WorkspaceError::NoPackageSpecified)
195 }
196 }
197
198 pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
200 let workspace_root = self.root.clone();
201 self.members()
202 .iter()
203 .find(|project| &project.toml().package == package)
204 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
205 }
206
207 pub fn select_member_mut(
209 &mut self,
210 package: &PackageName,
211 ) -> Result<&mut Project, WorkspaceError> {
212 let workspace_root = self.root.clone();
213 self.members_mut()
214 .iter_mut()
215 .find(|project| &project.toml().package == package)
216 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
217 }
218
219 pub fn lockfile_path(&self) -> PathBuf {
221 self.root.join("lux.lock")
222 }
223
224 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
226 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
227 }
228
229 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
231 let path = self.lockfile_path();
232 if path.is_file() {
233 Ok(Some(WorkspaceLockfile::load(path)?))
234 } else {
235 Ok(None)
236 }
237 }
238
239 pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
240 self.lua_version_tree(self.lua_version(config)?, config)
241 }
242
243 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
244 let mut lua_version = self.members().first().lua_version(config)?;
245 for project in self.members() {
247 lua_version = project.lua_version(config)?;
248 }
249 Ok(lua_version)
250 }
251
252 pub(crate) fn lua_version_tree(
253 &self,
254 lua_version: LuaVersion,
255 config: &Config,
256 ) -> Result<Tree, WorkspaceTreeError> {
257 Ok(Tree::new(
258 self.default_tree_root_dir(),
259 lua_version,
260 config,
261 )?)
262 }
263
264 pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
265 self.root.join(LUX_DIR_NAME)
266 }
267
268 pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
269 Ok(self.tree(config)?.test_tree(config)?)
270 }
271
272 pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
273 Ok(self.tree(config)?.build_tree(config)?)
274 }
275
276 pub fn luarc_path(&self, config: &Config) -> PathBuf {
278 let configured_name = config.luarc_file_name();
279 let file_path = self.root.join(configured_name);
280
281 if file_path.is_file() {
282 file_path
283 } else {
284 let emmy_path = self.root.join(EMMYRC);
285 if emmy_path.is_file() {
286 emmy_path
287 } else {
288 file_path
289 }
290 }
291 }
292
293 #[tracing::instrument(level = "trace", skip_all)]
294 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
295 if !start.as_ref().exists() {
296 return Ok(None);
297 }
298 if start.as_ref().join(WORKSPACE_TOML).exists() {
299 let toml_path = start.as_ref().join(WORKSPACE_TOML);
300 let toml_content = fs::sync::read_to_string(&toml_path)?;
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 = fs::sync::read_to_string(&path)?;
333 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
334 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
335 Ok(Some(Self::from_toml(&toml_content, root)?))
336 } else {
337 if let Some(parent) = root.parent() {
338 match Self::from(parent)? {
339 Some(workspace)
340 if workspace
341 .members
342 .iter()
343 .any(|project| project.root().as_ref() == root) =>
344 {
345 Ok(Some(workspace))
346 }
347 _ => {
348 let project = Project::from_exact(root)?.ok_or_else(|| {
349 WorkspaceError::NoWorkspaceOrProject(root.to_path_buf())
350 })?;
351 Ok(Some(Workspace {
352 root: WorkspaceRoot(root.to_path_buf()),
353 members: NonEmpty::new(project),
354 }))
355 }
356 }
357 } else {
358 Ok(None)
359 }
360 }
361 } else {
362 Ok(None)
363 }
364 }
365 _ => Ok(None),
369 }
370 }
371
372 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
373 let toml = WorkspaceToml::new(WORKSPACE_TOML, toml_content)
374 .map_err(|source| WorkspaceError::TOML { source })?;
375 let mut members = Vec::new();
376 for member in toml.workspace.members {
377 match member {
378 WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
379 let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
380 .ok() .into_iter()
382 .flat_map(|paths| {
383 paths.map(|path| {
384 path.map_err(|err| WorkspaceError::Glob(err.to_string()))
385 })
386 })
387 .try_collect::<_, Vec<_>, _>()?;
388 for project_path in potential_paths {
389 if let Some(project) = Project::from_exact(&project_path)? {
390 members.push(project)
391 }
392 }
393 }
394 WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
395 let project_path = root.join(relative_project_path);
396 match Project::from_exact(&project_path)? {
397 Some(project) => members.push(project),
398 None => return Err(WorkspaceError::WorkspaceNotFound(project_path)),
399 }
400 }
401 }
402 }
403 match NonEmpty::from_vec(members) {
404 Some(members) => Ok(Workspace {
405 root: WorkspaceRoot(root.to_path_buf()),
406 members,
407 }),
408 None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
409 }
410 }
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use crate::{config::ConfigBuilder, fs};
417 use std::path::PathBuf;
418
419 use assert_fs::prelude::*;
420
421 #[tokio::test]
422 async fn find_single_project_workspace() {
423 let sample_project =
424 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
425 let project_root = assert_fs::TempDir::new().unwrap();
426 project_root.copy_from(&sample_project, &["**"]).unwrap();
427 let work_dir: PathBuf = project_root.join("src");
428 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
429 assert_eq!(workspace.members.len(), 1);
430 let project = workspace.members.first();
431 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
432 }
433
434 #[tokio::test]
435 async fn find_nested_single_project_workspace() {
436 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
437 .join("resources/test/sample-projects/nested/");
438 let project_root = assert_fs::TempDir::new().unwrap();
439 project_root.copy_from(&sample_project, &["**"]).unwrap();
440 let nested_project_root: PathBuf = project_root.join("nested");
441 let workspace = Workspace::from(&nested_project_root).unwrap().unwrap();
442 assert_eq!(workspace.members.len(), 1);
443 let project = workspace.members.first();
444 assert_eq!(
445 project.root().to_path_buf(),
446 nested_project_root.to_path_buf()
447 );
448 }
449
450 #[tokio::test]
451 async fn find_multi_project_workspace() {
452 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
453 .join("resources/test/sample-projects/multi-project/");
454 let workspace_root = assert_fs::TempDir::new().unwrap();
455 workspace_root
456 .copy_from(&sample_workspace, &["**"])
457 .unwrap();
458 let work_dir: PathBuf = workspace_root.join("projects");
459 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
460 assert_eq!(workspace.members.len(), 2);
461 let foo = workspace.select_member(&"foo".into()).unwrap();
462 assert_eq!(
463 foo.root().to_path_buf(),
464 workspace_root.join("projects/foo").to_path_buf()
465 );
466 let bar = workspace.select_member(&"bar".into()).unwrap();
467 assert_eq!(
468 bar.root().to_path_buf(),
469 workspace_root.join("projects/bar").to_path_buf()
470 );
471 }
472
473 #[tokio::test]
474 async fn find_multi_project_workspace_members_glob() {
475 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
476 .join("resources/test/sample-projects/multi-project/");
477 let workspace_root = assert_fs::TempDir::new().unwrap();
478 workspace_root
479 .copy_from(&sample_workspace, &["**"])
480 .unwrap();
481 let work_dir: PathBuf = workspace_root.join("projects");
482 let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
483 let workspace_toml_content = r#"
484[workspace]
485members = [ "glob:projects/*" ]
486"#;
487 fs::tokio::write(&workspace_toml_file, workspace_toml_content)
488 .await
489 .unwrap();
490
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 test_no_find_workspace_upwards() {
507 let work_dir = assert_fs::TempDir::new().unwrap();
508 assert!(Workspace::from(&work_dir).unwrap().is_none())
509 }
510
511 #[tokio::test]
512 async fn test_luarc_path_custom_config() {
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 workspace = Workspace::from(&project_root).unwrap().unwrap();
519
520 let config = ConfigBuilder::default()
521 .luarc_file_name(Some("custom_config.json".to_string()))
522 .build()
523 .unwrap();
524
525 let path = workspace.luarc_path(&config);
526 assert_eq!(path, workspace.root().join("custom_config.json"));
527 }
528
529 #[tokio::test]
530 async fn test_luarc_path_fallback_luarc() {
531 let sample_project =
532 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
533 let project_root = assert_fs::TempDir::new().unwrap();
534 project_root.copy_from(&sample_project, &["**"]).unwrap();
535
536 let luarc_file = project_root.child(".luarc.json");
537 luarc_file.touch().unwrap();
538
539 let workspace = Workspace::from(&project_root).unwrap().unwrap();
540 let config = ConfigBuilder::default().build().unwrap();
541
542 let path = workspace.luarc_path(&config);
543 assert_eq!(path, luarc_file.path().to_path_buf());
544 }
545
546 #[tokio::test]
547 async fn test_luarc_path_fallback_emmyrc() {
548 let sample_project =
549 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
550 let project_root = assert_fs::TempDir::new().unwrap();
551 project_root.copy_from(&sample_project, &["**"]).unwrap();
552
553 let emmyrc_file = project_root.child(".emmyrc.json");
554 emmyrc_file.touch().unwrap();
555
556 let workspace = Workspace::from(&project_root).unwrap().unwrap();
557 let config = ConfigBuilder::default().build().unwrap();
558
559 let path = workspace.luarc_path(&config);
560 assert_eq!(path, emmyrc_file.path().to_path_buf());
561 }
562}