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 get current directory: {0}")]
54 GetCwd(io::Error),
55 #[error("error reading lux.toml at {0}:\n{1}")]
56 ReadLuxTOML(String, io::Error),
57 #[error("error deserializing workspace TOML:\n{0}")]
58 #[diagnostic(transparent)]
59 TOML(TomlDeError),
60 #[error("no project found at '{0}'")]
61 ProjectNotFound(PathBuf),
62 #[error("glob error: '{0}'")]
63 Glob(String),
64 #[error("error deserializing project TOML:\n{0}")]
65 #[diagnostic(forward(0))]
66 Project(#[from] ProjectError),
67 #[error("no project or workspace found")]
68 NoWorkspaceOrProject,
69 #[error("empty workspace at '{0}'")]
70 EmptyWorkspace(PathBuf),
71 #[error(transparent)]
72 #[diagnostic(transparent)]
73 Lockfile(#[from] LockfileError),
74 #[error("not a lux project or workspace directory:\n'{0}'")]
75 NotAWorkspaceDir(PathBuf),
76 #[error("package must be specified in a multi-project workspace")]
77 NoPackageSpecified,
78 #[error("package '{0}' not found in workspace '{1}'")]
79 PackageNotFound(PackageName, WorkspaceRoot),
80}
81
82#[derive(Error, Debug, Diagnostic)]
83#[non_exhaustive]
84pub enum WorkspaceTreeError {
85 #[error(transparent)]
86 #[diagnostic(transparent)]
87 Tree(#[from] TreeError),
88 #[error(transparent)]
89 #[diagnostic(transparent)]
90 LuaVersionError(#[from] LuaVersionError),
91}
92
93#[derive(Clone, Debug)]
95pub struct Workspace {
96 root: WorkspaceRoot,
97 members: NonEmpty<Project>,
98}
99
100impl Workspace {
103 pub fn current() -> Result<Option<Self>, WorkspaceError> {
104 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
105 Self::from(&cwd)
106 }
107
108 pub fn current_or_err() -> Result<Self, WorkspaceError> {
109 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
110 Self::current()?.ok_or(WorkspaceError::NotAWorkspaceDir(cwd))
111 }
112
113 pub fn root(&self) -> &WorkspaceRoot {
115 &self.root
116 }
117
118 pub fn members(&self) -> &NonEmpty<Project> {
120 &self.members
121 }
122
123 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
125 &mut self.members
126 }
127
128 pub fn single_member_or_select(
131 &self,
132 name: &Option<PackageName>,
133 ) -> Result<&Project, WorkspaceError> {
134 match name {
135 Some(name) => self
136 .members()
137 .iter()
138 .find(|project| &project.toml().package == name)
139 .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
140 None => Ok(self.members().first()),
141 }
142 }
143
144 pub fn single_member_or_select_mut(
147 &mut self,
148 package: &Option<PackageName>,
149 ) -> Result<&mut Project, WorkspaceError> {
150 match package.as_ref() {
151 Some(package) => self.select_member_mut(package),
152 None => self.single_member_mut(),
153 }
154 }
155
156 pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
158 if self.members().len() == 1 {
159 Ok(self.members().first())
160 } else {
161 Err(WorkspaceError::NoPackageSpecified)
162 }
163 }
164
165 pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
167 if self.members().len() == 1 {
168 Ok(self.members_mut().first_mut())
169 } else {
170 Err(WorkspaceError::NoPackageSpecified)
171 }
172 }
173
174 pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
176 let workspace_root = self.root.clone();
177 self.members()
178 .iter()
179 .find(|project| &project.toml().package == package)
180 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
181 }
182
183 pub fn select_member_mut(
185 &mut self,
186 package: &PackageName,
187 ) -> Result<&mut Project, WorkspaceError> {
188 let workspace_root = self.root.clone();
189 self.members_mut()
190 .iter_mut()
191 .find(|project| &project.toml().package == package)
192 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
193 }
194
195 pub fn lockfile_path(&self) -> PathBuf {
197 self.root.join("lux.lock")
198 }
199
200 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
202 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
203 }
204
205 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
207 let path = self.lockfile_path();
208 if path.is_file() {
209 Ok(Some(WorkspaceLockfile::load(path)?))
210 } else {
211 Ok(None)
212 }
213 }
214
215 pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
216 self.lua_version_tree(self.lua_version(config)?, config)
217 }
218
219 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
220 let mut lua_version = self.members().first().lua_version(config)?;
221 for project in self.members() {
223 lua_version = project.lua_version(config)?;
224 }
225 Ok(lua_version)
226 }
227
228 pub(crate) fn lua_version_tree(
229 &self,
230 lua_version: LuaVersion,
231 config: &Config,
232 ) -> Result<Tree, WorkspaceTreeError> {
233 Ok(Tree::new(
234 self.default_tree_root_dir(),
235 lua_version,
236 config,
237 )?)
238 }
239
240 pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
241 self.root.join(LUX_DIR_NAME)
242 }
243
244 pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
245 Ok(self.tree(config)?.test_tree(config)?)
246 }
247
248 pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
249 Ok(self.tree(config)?.build_tree(config)?)
250 }
251
252 pub fn luarc_path(&self, config: &Config) -> PathBuf {
254 let configured_name = config.luarc_file_name();
255 let file_path = self.root.join(configured_name);
256
257 if file_path.is_file() {
258 file_path
259 } else {
260 let emmy_path = self.root.join(EMMYRC);
261 if emmy_path.is_file() {
262 emmy_path
263 } else {
264 file_path
265 }
266 }
267 }
268
269 #[tracing::instrument(level = "trace", skip_all)]
270 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
271 if !start.as_ref().exists() {
272 return Ok(None);
273 }
274 if start.as_ref().join(WORKSPACE_TOML).exists() {
275 let toml_path = start.as_ref().join(WORKSPACE_TOML);
276 let toml_content = std::fs::read_to_string(&toml_path).map_err(|err| {
277 WorkspaceError::ReadLuxTOML(toml_path.to_string_lossy().to_string(), err)
278 })?;
279 let root = start.as_ref();
280 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
281 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
282 Ok(Some(Self::from_toml(&toml_content, root)?))
283 } else {
284 let project =
285 Project::from_exact(root)?.ok_or(WorkspaceError::NoWorkspaceOrProject)?;
286 Ok(Some(Workspace {
287 root: WorkspaceRoot(root.to_path_buf()),
288 members: NonEmpty::new(project),
289 }))
290 }
291 } else {
292 Ok(None)
293 }
294 }
295
296 #[tracing::instrument(level = "trace", skip(start))]
297 pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
298 if !start.as_ref().exists() {
299 return Ok(None);
300 }
301 match find_up_with(
302 WORKSPACE_TOML,
303 FindUpOptions {
304 cwd: start.as_ref(),
305 kind: FindUpKind::File,
306 },
307 ) {
308 Ok(Some(path)) => {
309 if let Some(root) = path.parent() {
310 let toml_content = std::fs::read_to_string(&path).map_err(|err| {
311 WorkspaceError::ReadLuxTOML(path.to_string_lossy().to_string(), err)
312 })?;
313 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
314 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
315 Ok(Some(Self::from_toml(&toml_content, root)?))
316 } else {
317 if let Some(parent) = root.parent() {
318 match Self::from(parent)? {
319 Some(workspace) => Ok(Some(workspace)),
320 None => {
321 let project = Project::from_exact(root)?
322 .ok_or(WorkspaceError::NoWorkspaceOrProject)?;
323 Ok(Some(Workspace {
324 root: WorkspaceRoot(root.to_path_buf()),
325 members: NonEmpty::new(project),
326 }))
327 }
328 }
329 } else {
330 Ok(None)
331 }
332 }
333 } else {
334 Ok(None)
335 }
336 }
337 _ => Ok(None),
341 }
342 }
343
344 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
345 let toml =
346 WorkspaceToml::new(WORKSPACE_TOML, toml_content).map_err(WorkspaceError::TOML)?;
347 let mut members = Vec::new();
348 for member in toml.workspace.members {
349 match member {
350 WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
351 let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
352 .ok() .into_iter()
354 .flat_map(|paths| {
355 paths.map(|path| {
356 path.map_err(|err| WorkspaceError::Glob(err.to_string()))
357 })
358 })
359 .try_collect::<_, Vec<_>, _>()?;
360 for project_path in potential_paths {
361 if let Some(project) = Project::from_exact(&project_path)? {
362 members.push(project)
363 }
364 }
365 }
366 WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
367 let project_path = root.join(relative_project_path);
368 match Project::from_exact(&project_path)? {
369 Some(project) => members.push(project),
370 None => return Err(WorkspaceError::ProjectNotFound(project_path)),
371 }
372 }
373 }
374 }
375 match NonEmpty::from_vec(members) {
376 Some(members) => Ok(Workspace {
377 root: WorkspaceRoot(root.to_path_buf()),
378 members,
379 }),
380 None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
381 }
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388 use crate::config::ConfigBuilder;
389 use std::path::PathBuf;
390
391 use assert_fs::prelude::*;
392
393 #[tokio::test]
394 async fn find_single_project_workspace() {
395 let sample_project =
396 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
397 let project_root = assert_fs::TempDir::new().unwrap();
398 project_root.copy_from(&sample_project, &["**"]).unwrap();
399 let work_dir: PathBuf = project_root.join("src");
400 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
401 assert_eq!(workspace.members.len(), 1);
402 let project = workspace.members.first();
403 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
404 }
405
406 #[tokio::test]
407 async fn find_multi_project_workspace() {
408 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
409 .join("resources/test/sample-projects/multi-project/");
410 let workspace_root = assert_fs::TempDir::new().unwrap();
411 workspace_root
412 .copy_from(&sample_workspace, &["**"])
413 .unwrap();
414 let work_dir: PathBuf = workspace_root.join("projects");
415 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
416 assert_eq!(workspace.members.len(), 2);
417 let foo = workspace.select_member(&"foo".into()).unwrap();
418 assert_eq!(
419 foo.root().to_path_buf(),
420 workspace_root.join("projects/foo").to_path_buf()
421 );
422 let bar = workspace.select_member(&"bar".into()).unwrap();
423 assert_eq!(
424 bar.root().to_path_buf(),
425 workspace_root.join("projects/bar").to_path_buf()
426 );
427 }
428
429 #[tokio::test]
430 async fn find_multi_project_workspace_members_glob() {
431 let sample_workspace = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
432 .join("resources/test/sample-projects/multi-project/");
433 let workspace_root = assert_fs::TempDir::new().unwrap();
434 workspace_root
435 .copy_from(&sample_workspace, &["**"])
436 .unwrap();
437 let work_dir: PathBuf = workspace_root.join("projects");
438 let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
439 let workspace_toml_content = r#"
440[workspace]
441members = [ "glob:projects/*" ]
442"#;
443 tokio::fs::write(&workspace_toml_file, workspace_toml_content)
444 .await
445 .unwrap();
446
447 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
448 assert_eq!(workspace.members.len(), 2);
449 let foo = workspace.select_member(&"foo".into()).unwrap();
450 assert_eq!(
451 foo.root().to_path_buf(),
452 workspace_root.join("projects/foo").to_path_buf()
453 );
454 let bar = workspace.select_member(&"bar".into()).unwrap();
455 assert_eq!(
456 bar.root().to_path_buf(),
457 workspace_root.join("projects/bar").to_path_buf()
458 );
459 }
460
461 #[tokio::test]
462 async fn test_no_find_workspace_upwards() {
463 let work_dir = assert_fs::TempDir::new().unwrap();
464 assert!(Workspace::from(&work_dir).unwrap().is_none())
465 }
466
467 #[tokio::test]
468 async fn test_luarc_path_custom_config() {
469 let sample_project =
470 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
471 let project_root = assert_fs::TempDir::new().unwrap();
472 project_root.copy_from(&sample_project, &["**"]).unwrap();
473
474 let workspace = Workspace::from(&project_root).unwrap().unwrap();
475
476 let config = ConfigBuilder::default()
477 .luarc_file_name(Some("custom_config.json".to_string()))
478 .build()
479 .unwrap();
480
481 let path = workspace.luarc_path(&config);
482 assert_eq!(path, workspace.root().join("custom_config.json"));
483 }
484
485 #[tokio::test]
486 async fn test_luarc_path_fallback_luarc() {
487 let sample_project =
488 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
489 let project_root = assert_fs::TempDir::new().unwrap();
490 project_root.copy_from(&sample_project, &["**"]).unwrap();
491
492 let luarc_file = project_root.child(".luarc.json");
493 luarc_file.touch().unwrap();
494
495 let workspace = Workspace::from(&project_root).unwrap().unwrap();
496 let config = ConfigBuilder::default().build().unwrap();
497
498 let path = workspace.luarc_path(&config);
499 assert_eq!(path, luarc_file.path().to_path_buf());
500 }
501
502 #[tokio::test]
503 async fn test_luarc_path_fallback_emmyrc() {
504 let sample_project =
505 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("resources/test/sample-projects/init/");
506 let project_root = assert_fs::TempDir::new().unwrap();
507 project_root.copy_from(&sample_project, &["**"]).unwrap();
508
509 let emmyrc_file = project_root.child(".emmyrc.json");
510 emmyrc_file.touch().unwrap();
511
512 let workspace = Workspace::from(&project_root).unwrap().unwrap();
513 let config = ConfigBuilder::default().build().unwrap();
514
515 let path = workspace.luarc_path(&config);
516 assert_eq!(path, emmyrc_file.path().to_path_buf());
517 }
518}