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 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 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 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 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 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 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 pub fn lockfile_path(&self) -> PathBuf {
231 self.root.join("lux.lock")
232 }
233
234 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
236 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
237 }
238
239 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 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 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 pub fn config_file(&self) -> PathBuf {
289 self.workspace_dir().join(CONFIG_TOML)
290 }
291
292 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 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 _ => 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() .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 assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
630 assert!(!config.generate_luarc());
631 assert!(config.no_tfa());
632
633 assert_eq!(config.server().as_str(), "https://luarocks.org/");
635 }
636}