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