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