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#[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#[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
134impl 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 pub fn root(&self) -> &WorkspaceRoot {
149 &self.root
150 }
151
152 pub fn members(&self) -> &NonEmpty<Project> {
154 &self.members
155 }
156
157 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
159 &mut self.members
160 }
161
162 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 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 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 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 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 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 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 pub fn lockfile_path(&self) -> PathBuf {
242 self.root.join("lux.lock")
243 }
244
245 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
247 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
248 }
249
250 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 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 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 pub fn config_file(&self) -> PathBuf {
300 self.workspace_dir().join(CONFIG_TOML)
301 }
302
303 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 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 _ => 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() .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 assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
641 assert!(!config.generate_luarc());
642 assert!(config.no_tfa());
643
644 assert_eq!(config.server().as_str(), "https://luarocks.org/");
646 }
647}