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 LUARC: &str = ".luarc.json";
30const EMMYRC: &str = ".emmyrc.json";
31
32#[derive(Clone, Debug)]
35#[cfg_attr(test, derive(Default))]
36pub struct WorkspaceRoot(PathBuf);
37
38impl AsRef<Path> for WorkspaceRoot {
39 fn as_ref(&self) -> &Path {
40 self.0.as_ref()
41 }
42}
43
44impl Deref for WorkspaceRoot {
45 type Target = PathBuf;
46
47 fn deref(&self) -> &Self::Target {
48 &self.0
49 }
50}
51
52#[derive(Debug, Error)]
53pub enum WorkspaceError {
54 #[error("cannot get current directory: {0}")]
55 GetCwd(io::Error),
56 #[error("error reading lux.toml at {0}:\n{1}")]
57 ReadLuxTOML(String, io::Error),
58 #[error("error deserializing workspace TOML:\n{0}")]
59 TOML(String),
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 Project(#[from] ProjectError),
66 #[error("no project or workspace found")]
67 NoWorkspaceOrProject,
68 #[error("empty workspace at '{0}'")]
69 EmptyWorkspace(PathBuf),
70 #[error(transparent)]
71 Lockfile(#[from] LockfileError),
72 #[error("not a lux project or workspace directory:\n'{0}'")]
73 NotAWorkspaceDir(PathBuf),
74 #[error("package must be specified in a multi-project workspace")]
75 NoPackageSpecified,
76 #[error("package '{0}' not found in workspace '{1}'")]
77 PackageNotFound(PackageName, WorkspaceRoot),
78}
79
80#[derive(Error, Debug)]
81pub enum WorkspaceTreeError {
82 #[error(transparent)]
83 Tree(#[from] TreeError),
84 #[error(transparent)]
85 LuaVersionError(#[from] LuaVersionError),
86}
87
88#[derive(Clone, Debug)]
90pub struct Workspace {
91 root: WorkspaceRoot,
92 members: NonEmpty<Project>,
93}
94
95impl Workspace {
98 pub fn current() -> Result<Option<Self>, WorkspaceError> {
99 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
100 Self::from(&cwd)
101 }
102
103 pub fn current_or_err() -> Result<Self, WorkspaceError> {
104 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
105 Self::current()?.ok_or(WorkspaceError::NotAWorkspaceDir(cwd))
106 }
107
108 pub fn root(&self) -> &WorkspaceRoot {
110 &self.root
111 }
112
113 pub fn members(&self) -> &NonEmpty<Project> {
115 &self.members
116 }
117
118 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
120 &mut self.members
121 }
122
123 pub fn single_member_or_select(
126 &self,
127 name: &Option<PackageName>,
128 ) -> Result<&Project, WorkspaceError> {
129 match name {
130 Some(name) => self
131 .members()
132 .iter()
133 .find(|project| &project.toml().package == name)
134 .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
135 None => Ok(self.members().first()),
136 }
137 }
138
139 pub fn single_member_or_select_mut(
142 &mut self,
143 package: &Option<PackageName>,
144 ) -> Result<&mut Project, WorkspaceError> {
145 match package.as_ref() {
146 Some(package) => self.select_member_mut(package),
147 None => self.single_member_mut(),
148 }
149 }
150
151 pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
153 if self.members().len() == 1 {
154 Ok(self.members().first())
155 } else {
156 Err(WorkspaceError::NoPackageSpecified)
157 }
158 }
159
160 pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
162 if self.members().len() == 1 {
163 Ok(self.members_mut().first_mut())
164 } else {
165 Err(WorkspaceError::NoPackageSpecified)
166 }
167 }
168
169 pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
171 let workspace_root = self.root.clone();
172 self.members()
173 .iter()
174 .find(|project| &project.toml().package == package)
175 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
176 }
177
178 pub fn select_member_mut(
180 &mut self,
181 package: &PackageName,
182 ) -> Result<&mut Project, WorkspaceError> {
183 let workspace_root = self.root.clone();
184 self.members_mut()
185 .iter_mut()
186 .find(|project| &project.toml().package == package)
187 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
188 }
189
190 pub fn lockfile_path(&self) -> PathBuf {
192 self.root.join("lux.lock")
193 }
194
195 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
197 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
198 }
199
200 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
202 let path = self.lockfile_path();
203 if path.is_file() {
204 Ok(Some(WorkspaceLockfile::load(path)?))
205 } else {
206 Ok(None)
207 }
208 }
209
210 pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
211 self.lua_version_tree(self.lua_version(config)?, config)
212 }
213
214 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
215 let mut lua_version = self.members().first().lua_version(config)?;
216 for project in self.members() {
218 lua_version = project.lua_version(config)?;
219 }
220 Ok(lua_version)
221 }
222
223 pub(crate) fn lua_version_tree(
224 &self,
225 lua_version: LuaVersion,
226 config: &Config,
227 ) -> Result<Tree, WorkspaceTreeError> {
228 Ok(Tree::new(
229 self.default_tree_root_dir(),
230 lua_version,
231 config,
232 )?)
233 }
234
235 pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
236 self.root.join(LUX_DIR_NAME)
237 }
238
239 pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
240 Ok(self.tree(config)?.test_tree(config)?)
241 }
242
243 pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
244 Ok(self.tree(config)?.build_tree(config)?)
245 }
246
247 pub fn luarc_path(&self) -> PathBuf {
249 let luarc_path = self.root.join(LUARC);
250 if luarc_path.is_file() {
251 luarc_path
252 } else {
253 let emmy_path = self.root.join(EMMYRC);
254 if emmy_path.is_file() {
255 emmy_path
256 } else {
257 luarc_path
258 }
259 }
260 }
261
262 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
263 if !start.as_ref().exists() {
264 return Ok(None);
265 }
266 if start.as_ref().join(WORKSPACE_TOML).exists() {
267 let toml_path = start.as_ref().join(WORKSPACE_TOML);
268 let toml_content = std::fs::read_to_string(&toml_path).map_err(|err| {
269 WorkspaceError::ReadLuxTOML(toml_path.to_string_lossy().to_string(), err)
270 })?;
271 let root = start.as_ref();
272 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
273 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
274 Ok(Some(Self::from_toml(&toml_content, root)?))
275 } else {
276 let project =
277 Project::from_exact(root)?.ok_or(WorkspaceError::NoWorkspaceOrProject)?;
278 Ok(Some(Workspace {
279 root: WorkspaceRoot(root.to_path_buf()),
280 members: NonEmpty::new(project),
281 }))
282 }
283 } else {
284 Ok(None)
285 }
286 }
287
288 pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
289 if !start.as_ref().exists() {
290 return Ok(None);
291 }
292 match find_up_with(
293 WORKSPACE_TOML,
294 FindUpOptions {
295 cwd: start.as_ref(),
296 kind: FindUpKind::File,
297 },
298 ) {
299 Ok(Some(path)) => {
300 if let Some(root) = path.parent() {
301 let toml_content = std::fs::read_to_string(&path).map_err(|err| {
302 WorkspaceError::ReadLuxTOML(path.to_string_lossy().to_string(), err)
303 })?;
304 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
305 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
306 Ok(Some(Self::from_toml(&toml_content, root)?))
307 } else {
308 if let Some(parent) = root.parent() {
309 match Self::from(parent)? {
310 Some(workspace) => Ok(Some(workspace)),
311 None => {
312 let project = Project::from_exact(root)?
313 .ok_or(WorkspaceError::NoWorkspaceOrProject)?;
314 Ok(Some(Workspace {
315 root: WorkspaceRoot(root.to_path_buf()),
316 members: NonEmpty::new(project),
317 }))
318 }
319 }
320 } else {
321 Ok(None)
322 }
323 }
324 } else {
325 Ok(None)
326 }
327 }
328 _ => Ok(None),
332 }
333 }
334
335 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
336 let toml = WorkspaceToml::new(toml_content)
337 .map_err(|err| WorkspaceError::TOML(err.to_string()))?;
338 let mut members = Vec::new();
339 for member in toml.workspace.members {
340 match member {
341 WorkspaceMemberSpec::RelativeProjectGlob(pattern) => {
342 let potential_paths = glob(root.join(pattern).to_slash_lossy().deref())
343 .ok() .into_iter()
345 .flat_map(|paths| {
346 paths.map(|path| {
347 path.map_err(|err| WorkspaceError::Glob(err.to_string()))
348 })
349 })
350 .try_collect::<_, Vec<_>, _>()?;
351 for project_path in potential_paths {
352 if let Some(project) = Project::from_exact(&project_path)? {
353 members.push(project)
354 }
355 }
356 }
357 WorkspaceMemberSpec::RelativeProjectPath(relative_project_path) => {
358 let project_path = root.join(relative_project_path);
359 match Project::from_exact(&project_path)? {
360 Some(project) => members.push(project),
361 None => return Err(WorkspaceError::ProjectNotFound(project_path)),
362 }
363 }
364 }
365 }
366 match NonEmpty::from_vec(members) {
367 Some(members) => Ok(Workspace {
368 root: WorkspaceRoot(root.to_path_buf()),
369 members,
370 }),
371 None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
372 }
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use std::path::PathBuf;
380
381 use assert_fs::prelude::PathCopy;
382
383 #[tokio::test]
384 async fn find_single_project_workspace() {
385 let sample_project: PathBuf = "resources/test/sample-projects/init/".into();
386 let project_root = assert_fs::TempDir::new().unwrap();
387 project_root.copy_from(&sample_project, &["**"]).unwrap();
388 let work_dir: PathBuf = project_root.join("src");
389 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
390 assert_eq!(workspace.members.len(), 1);
391 let project = workspace.members.first();
392 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
393 }
394
395 #[tokio::test]
396 async fn find_multi_project_workspace() {
397 let sample_workspace: PathBuf = "resources/test/sample-projects/multi-project/".into();
398 let workspace_root = assert_fs::TempDir::new().unwrap();
399 workspace_root
400 .copy_from(&sample_workspace, &["**"])
401 .unwrap();
402 let work_dir: PathBuf = workspace_root.join("projects");
403 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
404 assert_eq!(workspace.members.len(), 2);
405 let foo = workspace.select_member(&"foo".into()).unwrap();
406 assert_eq!(
407 foo.root().to_path_buf(),
408 workspace_root.join("projects/foo").to_path_buf()
409 );
410 let bar = workspace.select_member(&"bar".into()).unwrap();
411 assert_eq!(
412 bar.root().to_path_buf(),
413 workspace_root.join("projects/bar").to_path_buf()
414 );
415 }
416
417 #[tokio::test]
418 async fn find_multi_project_workspace_members_glob() {
419 let sample_workspace: PathBuf = "resources/test/sample-projects/multi-project/".into();
420 let workspace_root = assert_fs::TempDir::new().unwrap();
421 workspace_root
422 .copy_from(&sample_workspace, &["**"])
423 .unwrap();
424 let work_dir: PathBuf = workspace_root.join("projects");
425 let workspace_toml_file = workspace_root.join(WORKSPACE_TOML);
426 let workspace_toml_content = r#"
427[workspace]
428members = [ "glob:projects/*" ]
429"#;
430 tokio::fs::write(&workspace_toml_file, workspace_toml_content)
431 .await
432 .unwrap();
433
434 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
435 assert_eq!(workspace.members.len(), 2);
436 let foo = workspace.select_member(&"foo".into()).unwrap();
437 assert_eq!(
438 foo.root().to_path_buf(),
439 workspace_root.join("projects/foo").to_path_buf()
440 );
441 let bar = workspace.select_member(&"bar".into()).unwrap();
442 assert_eq!(
443 bar.root().to_path_buf(),
444 workspace_root.join("projects/bar").to_path_buf()
445 );
446 }
447
448 #[tokio::test]
449 async fn test_no_find_workspace_upwards() {
450 let work_dir = assert_fs::TempDir::new().unwrap();
451 assert!(Workspace::from(&work_dir).unwrap().is_none())
452 }
453}