1use crate::package;
2use std::{
3 io,
4 ops::Deref,
5 path::{Path, PathBuf},
6};
7
8use crate::{
9 config::{self, Config, ConfigBuilder},
10 fs,
11 lockfile::{self, ReadOnly, WorkspaceLockfile},
12 lua_rockspec::LuaVersionError,
13 lua_version::LuaVersion,
14 project::{self, Project, TomlDeError, PROJECT_TOML},
15 tree::{self, InstallTree, Tree},
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 Error {
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] project::Error),
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] lockfile::Error),
90 #[error(transparent)]
91 #[diagnostic(transparent)]
92 Fs(#[from] fs::Error),
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(package::Name, WorkspaceRoot),
105}
106
107#[derive(Error, Debug, Diagnostic)]
108#[non_exhaustive]
109pub enum TreeError {
110 #[error(transparent)]
111 #[diagnostic(transparent)]
112 Tree(#[from] tree::Error),
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 ConfigError {
129 path: PathBuf,
130 #[diagnostic(source)]
131 source: Box<config::Error>,
132}
133
134impl Workspace {
137 pub fn current() -> Result<Option<Self>, Error> {
138 let cwd = std::env::current_dir().map_err(Error::GetCwd)?;
139 Self::from(&cwd)
140 }
141
142 pub fn current_or_err() -> Result<Self, Error> {
143 let cwd = std::env::current_dir().map_err(Error::GetCwd)?;
144 Self::current()?.ok_or(Error::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<package::Name>, Error> {
164 NonEmpty::from_vec(
165 self.members()
166 .iter()
167 .map(|project| project.toml().package.clone())
168 .collect(),
169 )
170 .ok_or_else(|| Error::EmptyWorkspace(self.root.0.clone()))
171 }
172
173 pub fn single_member_or_select(&self, name: &Option<package::Name>) -> Result<&Project, Error> {
176 match name {
177 Some(name) => self
178 .members()
179 .iter()
180 .find(|project| &project.toml().package == name)
181 .ok_or_else(|| Error::PackageNotFound(name.clone(), self.root.clone())),
182 None => Ok(self.members().first()),
183 }
184 }
185
186 pub fn single_member_or_select_mut(
189 &mut self,
190 package: &Option<package::Name>,
191 ) -> Result<&mut Project, Error> {
192 match package.as_ref() {
193 Some(package) => self.select_member_mut(package),
194 None => self.single_member_mut(),
195 }
196 }
197
198 pub fn single_member(&self) -> Result<&Project, Error> {
200 if self.members().len() == 1 {
201 Ok(self.members().first())
202 } else {
203 Err(Error::NoPackageSpecified)
204 }
205 }
206
207 pub fn single_member_mut(&mut self) -> Result<&mut Project, Error> {
209 if self.members().len() == 1 {
210 Ok(self.members_mut().first_mut())
211 } else {
212 Err(Error::NoPackageSpecified)
213 }
214 }
215
216 pub fn select_member(&self, package: &package::Name) -> Result<&Project, Error> {
218 let workspace_root = self.root.clone();
219 self.members()
220 .iter()
221 .find(|project| &project.toml().package == package)
222 .ok_or_else(|| Error::PackageNotFound(package.clone(), workspace_root))
223 }
224
225 pub fn select_member_mut(&mut self, package: &package::Name) -> Result<&mut Project, Error> {
227 let workspace_root = self.root.clone();
228 self.members_mut()
229 .iter_mut()
230 .find(|project| &project.toml().package == package)
231 .ok_or_else(|| Error::PackageNotFound(package.clone(), workspace_root))
232 }
233
234 pub fn lockfile_path(&self) -> PathBuf {
236 self.root.join("lux.lock")
237 }
238
239 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, Error> {
241 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
242 }
243
244 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, Error> {
246 let path = self.lockfile_path();
247 if path.is_file() {
248 Ok(Some(WorkspaceLockfile::load(path)?))
249 } else {
250 Ok(None)
251 }
252 }
253
254 pub fn tree(&self, config: &Config) -> Result<Tree, TreeError> {
255 self.lua_version_tree(self.lua_version(config)?, config)
256 }
257
258 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
259 let mut lua_version = self.members().first().lua_version(config)?;
260 for project in self.members() {
262 lua_version = project.lua_version(config)?;
263 }
264 Ok(lua_version)
265 }
266
267 pub(crate) fn lua_version_tree(
268 &self,
269 lua_version: LuaVersion,
270 config: &Config,
271 ) -> Result<Tree, TreeError> {
272 let root_dir = config
273 .workspace_tree()
274 .map(|p| p.to_path_buf())
275 .unwrap_or(self.workspace_dir());
276 Ok(Tree::new(root_dir, lua_version, config)?)
277 }
278
279 pub(crate) fn workspace_dir(&self) -> PathBuf {
281 self.root.join(LUX_DIR_NAME)
282 }
283
284 pub fn test_tree(&self, config: &Config) -> Result<Tree, TreeError> {
285 Ok(self.tree(config)?.test_tree(config)?)
286 }
287
288 pub fn build_tree(&self, config: &Config) -> Result<Tree, TreeError> {
289 Ok(self.tree(config)?.build_tree(config)?)
290 }
291
292 pub fn config_file(&self) -> PathBuf {
294 self.workspace_dir().join(CONFIG_TOML)
295 }
296
297 pub fn config(&self) -> Result<Option<ConfigBuilder>, ConfigError> {
300 let config_file = self.config_file();
301 if config_file.is_file() {
302 Ok(Some(ConfigBuilder::from_file(&config_file).map_err(
303 |err| ConfigError {
304 path: config_file.to_path_buf(),
305 source: Box::new(err),
306 },
307 )?))
308 } else {
309 Ok(None)
310 }
311 }
312
313 pub fn luarc_path(&self, config: &Config) -> PathBuf {
315 let configured_name = config.luarc_file_name();
316 let file_path = self.root.join(configured_name);
317
318 if file_path.is_file() {
319 file_path
320 } else {
321 let emmy_path = self.root.join(EMMYRC);
322 if emmy_path.is_file() {
323 emmy_path
324 } else {
325 file_path
326 }
327 }
328 }
329
330 #[tracing::instrument(level = "trace", skip_all)]
331 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, Error> {
332 if !start.as_ref().exists() {
333 return Ok(None);
334 }
335 if start.as_ref().join(WORKSPACE_TOML).exists() {
336 let toml_path = start.as_ref().join(WORKSPACE_TOML);
337 let toml_content = fs::sync::read_to_string(&toml_path)?;
338 let root = start.as_ref();
339 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
340 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
341 Ok(Some(Self::from_toml(&toml_content, root)?))
342 } else {
343 let project = Project::from_exact(root)?
344 .ok_or_else(|| Error::NoWorkspaceOrProject(root.to_path_buf()))?;
345 Ok(Some(Workspace {
346 root: WorkspaceRoot(root.to_path_buf()),
347 members: NonEmpty::new(project),
348 }))
349 }
350 } else {
351 Ok(None)
352 }
353 }
354
355 #[tracing::instrument(level = "trace", skip(start))]
356 pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, Error> {
357 if !start.as_ref().exists() {
358 return Ok(None);
359 }
360 match find_up_with(
361 WORKSPACE_TOML,
362 FindUpOptions {
363 cwd: start.as_ref(),
364 kind: FindUpKind::File,
365 },
366 ) {
367 Ok(Some(path)) => {
368 if let Some(root) = path.parent() {
369 let toml_content = fs::sync::read_to_string(&path)?;
370 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
371 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
372 Ok(Some(Self::from_toml(&toml_content, root)?))
373 } else {
374 if let Some(parent) = root.parent() {
375 match Self::from(parent)? {
376 Some(workspace)
377 if workspace
378 .members
379 .iter()
380 .any(|project| project.root().as_ref() == root) =>
381 {
382 Ok(Some(workspace))
383 }
384 _ => {
385 let project = Project::from_exact(root)?.ok_or_else(|| {
386 Error::NoWorkspaceOrProject(root.to_path_buf())
387 })?;
388 Ok(Some(Workspace {
389 root: WorkspaceRoot(root.to_path_buf()),
390 members: NonEmpty::new(project),
391 }))
392 }
393 }
394 } else {
395 Ok(None)
396 }
397 }
398 } else {
399 Ok(None)
400 }
401 }
402 _ => Ok(None),
406 }
407 }
408
409 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, Error> {
410 let toml = WorkspaceToml::new(WORKSPACE_TOML, toml_content)
411 .map_err(|source| Error::TOML { source })?;
412 let mut members = Vec::new();
413 for member in toml.workspace.members {
414 match member {
415 WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
416 let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
417 .ok() .into_iter()
419 .flat_map(|paths| {
420 paths.map(|path| path.map_err(|err| Error::Glob(err.to_string())))
421 })
422 .try_collect::<_, Vec<_>, _>()?;
423 for project_path in potential_paths {
424 if let Some(project) = Project::from_exact(&project_path)? {
425 members.push(project)
426 }
427 }
428 }
429 WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
430 let project_path = root.join(relative_project_path);
431 match Project::from_exact(&project_path)? {
432 Some(project) => members.push(project),
433 None => return Err(Error::WorkspaceNotFound(project_path)),
434 }
435 }
436 }
437 }
438 match NonEmpty::from_vec(members) {
439 Some(members) => Ok(Workspace {
440 root: WorkspaceRoot(root.to_path_buf()),
441 members,
442 }),
443 None => Err(Error::EmptyWorkspace(root.to_path_buf())),
444 }
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451 use crate::{config::ConfigBuilder, fs, lua_version::LuaVersion};
452 use std::path::PathBuf;
453
454 use assert_fs::prelude::*;
455
456 #[tokio::test]
457 async fn find_single_project_workspace() {
458 let sample_project =
459 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
460 let project_root = assert_fs::TempDir::new().unwrap();
461 project_root.copy_from(&sample_project, &["**"]).unwrap();
462 let work_dir: PathBuf = project_root.join("src");
463 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
464 assert_eq!(workspace.members.len(), 1);
465 let project = workspace.members.first();
466 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
467 }
468
469 #[tokio::test]
470 async fn find_nested_single_project_workspace() {
471 let sample_project = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
472 .join("resources/test/sample-projects/nested/");
473 let project_root = assert_fs::TempDir::new().unwrap();
474 project_root.copy_from(&sample_project, &["**"]).unwrap();
475 let nested_project_root: PathBuf = project_root.join("nested");
476 let workspace = Workspace::from(&nested_project_root).unwrap().unwrap();
477 assert_eq!(workspace.members.len(), 1);
478 let project = workspace.members.first();
479 assert_eq!(
480 project.root().to_path_buf(),
481 nested_project_root.to_path_buf()
482 );
483 }
484
485 #[tokio::test]
486 async fn find_multi_project_workspace() {
487 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
488 .join("resources/test/sample-projects/multi-project/");
489 let workspace_root = assert_fs::TempDir::new().unwrap();
490 workspace_root
491 .copy_from(&sample_workspace, &["**"])
492 .unwrap();
493 let work_dir: PathBuf = workspace_root.join("projects");
494 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
495 assert_eq!(workspace.members.len(), 2);
496 let foo = workspace.select_member(&"foo".into()).unwrap();
497 assert_eq!(
498 foo.root().to_path_buf(),
499 workspace_root.join("projects/foo").to_path_buf()
500 );
501 let bar = workspace.select_member(&"bar".into()).unwrap();
502 assert_eq!(
503 bar.root().to_path_buf(),
504 workspace_root.join("projects/bar").to_path_buf()
505 );
506 }
507
508 #[tokio::test]
509 async fn find_multi_project_workspace_members_glob() {
510 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
511 .join("resources/test/sample-projects/multi-project/");
512 let workspace_root = assert_fs::TempDir::new().unwrap();
513 workspace_root
514 .copy_from(&sample_workspace, &["**"])
515 .unwrap();
516 let work_dir: PathBuf = workspace_root.join("projects");
517 let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
518 let workspace_toml_content = r#"
519[workspace]
520members = [ "glob:projects/*" ]
521"#;
522 fs::tokio::write(&workspace_toml_file, workspace_toml_content)
523 .await
524 .unwrap();
525
526 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
527 assert_eq!(workspace.members.len(), 2);
528 let foo = workspace.select_member(&"foo".into()).unwrap();
529 assert_eq!(
530 foo.root().to_path_buf(),
531 workspace_root.join("projects/foo").to_path_buf()
532 );
533 let bar = workspace.select_member(&"bar".into()).unwrap();
534 assert_eq!(
535 bar.root().to_path_buf(),
536 workspace_root.join("projects/bar").to_path_buf()
537 );
538 }
539
540 #[tokio::test]
541 async fn test_no_find_workspace_upwards() {
542 let work_dir = assert_fs::TempDir::new().unwrap();
543 assert!(Workspace::from(&work_dir).unwrap().is_none())
544 }
545
546 #[tokio::test]
547 async fn test_luarc_path_custom_config() {
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 workspace = Workspace::from(&project_root).unwrap().unwrap();
554
555 let config = ConfigBuilder::default()
556 .luarc_file_name(Some("custom_config.json".to_string()))
557 .build()
558 .unwrap();
559
560 let path = workspace.luarc_path(&config);
561 assert_eq!(path, workspace.root().join("custom_config.json"));
562 }
563
564 #[tokio::test]
565 async fn test_luarc_path_fallback_luarc() {
566 let sample_project =
567 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
568 let project_root = assert_fs::TempDir::new().unwrap();
569 project_root.copy_from(&sample_project, &["**"]).unwrap();
570
571 let luarc_file = project_root.child(".luarc.json");
572 luarc_file.touch().unwrap();
573
574 let workspace = Workspace::from(&project_root).unwrap().unwrap();
575 let config = ConfigBuilder::default().build().unwrap();
576
577 let path = workspace.luarc_path(&config);
578 assert_eq!(path, luarc_file.path().to_path_buf());
579 }
580
581 #[tokio::test]
582 async fn test_luarc_path_fallback_emmyrc() {
583 let sample_project =
584 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
585 let project_root = assert_fs::TempDir::new().unwrap();
586 project_root.copy_from(&sample_project, &["**"]).unwrap();
587
588 let emmyrc_file = project_root.child(".emmyrc.json");
589 emmyrc_file.touch().unwrap();
590
591 let workspace = Workspace::from(&project_root).unwrap().unwrap();
592 let config = ConfigBuilder::default().build().unwrap();
593
594 let path = workspace.luarc_path(&config);
595 assert_eq!(path, emmyrc_file.path().to_path_buf());
596 }
597
598 #[tokio::test]
599 async fn test_workspace_local_config() {
600 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
601 .join("resources/test/sample-projects/multi-project/");
602 let workspace_root = assert_fs::TempDir::new().unwrap();
603 workspace_root
604 .copy_from(&sample_workspace, &["**"])
605 .unwrap();
606
607 let workspace_config_dir = workspace_root.join(LUX_DIR_NAME);
608 fs::tokio::create_dir_all(&workspace_config_dir)
609 .await
610 .unwrap();
611 fs::tokio::write(
612 workspace_config_dir.join(CONFIG_TOML),
613 r#"
614lua_version = "5.4"
615generate_luarc = false
616no_tfa = true
617"#,
618 )
619 .await
620 .unwrap();
621
622 let workspace = Workspace::from(&workspace_root).unwrap().unwrap();
623 let workspace_config = workspace.config().unwrap().unwrap();
624
625 let config = ConfigBuilder::default()
626 .lua_version(Some(LuaVersion::Lua53))
627 .merge(workspace_config)
628 .build()
629 .unwrap();
630
631 assert_eq!(config.lua_version(), Some(&LuaVersion::Lua54));
633 assert!(!config.generate_luarc());
634 assert!(config.no_tfa());
635
636 assert_eq!(config.server().as_str(), "https://luarocks.org/");
638 }
639}