1use std::{
2 io,
3 ops::Deref,
4 path::{Path, PathBuf},
5};
6
7use lets_find_up::{find_up_with, FindUpKind, FindUpOptions};
8use nonempty::NonEmpty;
9use thiserror::Error;
10
11use crate::{
12 config::Config,
13 lockfile::{LockfileError, ReadOnly, WorkspaceLockfile},
14 lua_rockspec::LuaVersionError,
15 lua_version::LuaVersion,
16 package::PackageName,
17 project::{Project, ProjectError, PROJECT_TOML},
18 tree::{Tree, TreeError},
19 workspace::workspace_toml::WorkspaceToml,
20};
21
22pub mod workspace_toml;
23
24pub const WORKSPACE_TOML: &str = PROJECT_TOML;
25pub(crate) const LUX_DIR_NAME: &str = ".lux";
26const LUARC: &str = ".luarc.json";
27const EMMYRC: &str = ".emmyrc.json";
28
29#[derive(Clone, Debug)]
32#[cfg_attr(test, derive(Default))]
33pub struct WorkspaceRoot(PathBuf);
34
35impl AsRef<Path> for WorkspaceRoot {
36 fn as_ref(&self) -> &Path {
37 self.0.as_ref()
38 }
39}
40
41impl Deref for WorkspaceRoot {
42 type Target = PathBuf;
43
44 fn deref(&self) -> &Self::Target {
45 &self.0
46 }
47}
48
49#[derive(Debug, Error)]
50pub enum WorkspaceError {
51 #[error("cannot get current directory: {0}")]
52 GetCwd(io::Error),
53 #[error("error reading lux.toml at {0}:\n{1}")]
54 ReadLuxTOML(String, io::Error),
55 #[error("error deserializing workspace TOML:\n{0}")]
56 TOML(String),
57 #[error("no project found at `{0}`")]
58 ProjectNotFound(PathBuf),
59 #[error("error deserializing project TOML:\n{0}")]
60 Project(#[from] ProjectError),
61 #[error("no project or workspace found")]
62 NoWorkspaceOrProject,
63 #[error("empty workspace at `{0}`")]
64 EmptyWorkspace(PathBuf),
65 #[error(transparent)]
66 Lockfile(#[from] LockfileError),
67 #[error("not a lux project or workspace directory:\n`{0}`")]
68 NotAWorkspaceDir(PathBuf),
69 #[error("package must be specified in a multi-project workspace")]
70 NoPackageSpecified,
71 #[error("package `{0}` not found in workspace `{1}`")]
72 PackageNotFound(PackageName, WorkspaceRoot),
73}
74
75#[derive(Error, Debug)]
76pub enum WorkspaceTreeError {
77 #[error(transparent)]
78 Tree(#[from] TreeError),
79 #[error(transparent)]
80 LuaVersionError(#[from] LuaVersionError),
81}
82
83#[derive(Clone, Debug)]
84pub struct Workspace {
85 root: WorkspaceRoot,
86 members: NonEmpty<Project>,
87}
88
89impl Workspace {
92 pub fn current() -> Result<Option<Self>, WorkspaceError> {
93 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
94 Self::from(&cwd)
95 }
96
97 pub fn current_or_err() -> Result<Self, WorkspaceError> {
98 let cwd = std::env::current_dir().map_err(WorkspaceError::GetCwd)?;
99 Self::current()?.ok_or(WorkspaceError::NotAWorkspaceDir(cwd))
100 }
101
102 pub fn root(&self) -> &WorkspaceRoot {
104 &self.root
105 }
106
107 pub fn members(&self) -> &NonEmpty<Project> {
109 &self.members
110 }
111
112 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
114 &mut self.members
115 }
116
117 pub fn single_member_or_select(
120 &self,
121 name: &Option<PackageName>,
122 ) -> Result<&Project, WorkspaceError> {
123 match name {
124 Some(name) => self
125 .members()
126 .iter()
127 .find(|project| &project.toml().package == name)
128 .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
129 None => Ok(self.members().first()),
130 }
131 }
132
133 pub fn single_member_or_select_mut(
136 &mut self,
137 package: &Option<PackageName>,
138 ) -> Result<&mut Project, WorkspaceError> {
139 match package.as_ref() {
140 Some(package) => self.select_member_mut(package),
141 None => self.single_member_mut(),
142 }
143 }
144
145 pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
147 if self.members().len() == 1 {
148 Ok(self.members().first())
149 } else {
150 Err(WorkspaceError::NoPackageSpecified)
151 }
152 }
153
154 pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
156 if self.members().len() == 1 {
157 Ok(self.members_mut().first_mut())
158 } else {
159 Err(WorkspaceError::NoPackageSpecified)
160 }
161 }
162
163 pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
165 let workspace_root = self.root.clone();
166 self.members()
167 .iter()
168 .find(|project| &project.toml().package == package)
169 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
170 }
171
172 pub fn select_member_mut(
174 &mut self,
175 package: &PackageName,
176 ) -> Result<&mut Project, WorkspaceError> {
177 let workspace_root = self.root.clone();
178 self.members_mut()
179 .iter_mut()
180 .find(|project| &project.toml().package == package)
181 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
182 }
183
184 pub fn lockfile_path(&self) -> PathBuf {
186 self.root.join("lux.lock")
187 }
188
189 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
191 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
192 }
193
194 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
196 let path = self.lockfile_path();
197 if path.is_file() {
198 Ok(Some(WorkspaceLockfile::load(path)?))
199 } else {
200 Ok(None)
201 }
202 }
203
204 pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
205 self.lua_version_tree(self.lua_version(config)?, config)
206 }
207
208 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
209 let mut lua_version = self.members().first().lua_version(config)?;
210 for project in self.members() {
212 lua_version = project.lua_version(config)?;
213 }
214 Ok(lua_version)
215 }
216
217 pub(crate) fn lua_version_tree(
218 &self,
219 lua_version: LuaVersion,
220 config: &Config,
221 ) -> Result<Tree, WorkspaceTreeError> {
222 Ok(Tree::new(
223 self.default_tree_root_dir(),
224 lua_version,
225 config,
226 )?)
227 }
228
229 pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
230 self.root.join(LUX_DIR_NAME)
231 }
232
233 pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
234 Ok(self.tree(config)?.test_tree(config)?)
235 }
236
237 pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
238 Ok(self.tree(config)?.build_tree(config)?)
239 }
240
241 pub fn luarc_path(&self) -> PathBuf {
243 let luarc_path = self.root.join(LUARC);
244 if luarc_path.is_file() {
245 luarc_path
246 } else {
247 let emmy_path = self.root.join(EMMYRC);
248 if emmy_path.is_file() {
249 emmy_path
250 } else {
251 luarc_path
252 }
253 }
254 }
255
256 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
257 if !start.as_ref().exists() {
258 return Ok(None);
259 }
260 if start.as_ref().join(WORKSPACE_TOML).exists() {
261 let toml_path = start.as_ref().join(WORKSPACE_TOML);
262 let toml_content = std::fs::read_to_string(&toml_path).map_err(|err| {
263 WorkspaceError::ReadLuxTOML(toml_path.to_string_lossy().to_string(), err)
264 })?;
265 let root = start.as_ref();
266 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
267 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
268 Ok(Some(Self::from_toml(&toml_content, root)?))
269 } else {
270 let project =
271 Project::from_exact(root)?.ok_or(WorkspaceError::NoWorkspaceOrProject)?;
272 Ok(Some(Workspace {
273 root: WorkspaceRoot(root.to_path_buf()),
274 members: NonEmpty::new(project),
275 }))
276 }
277 } else {
278 Ok(None)
279 }
280 }
281
282 pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
283 if !start.as_ref().exists() {
284 return Ok(None);
285 }
286 match find_up_with(
287 WORKSPACE_TOML,
288 FindUpOptions {
289 cwd: start.as_ref(),
290 kind: FindUpKind::File,
291 },
292 ) {
293 Ok(Some(path)) => {
294 if let Some(root) = path.parent() {
295 let toml_content = std::fs::read_to_string(&path).map_err(|err| {
296 WorkspaceError::ReadLuxTOML(path.to_string_lossy().to_string(), err)
297 })?;
298 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
299 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
300 Ok(Some(Self::from_toml(&toml_content, root)?))
301 } else {
302 if let Some(parent) = root.parent() {
303 match Self::from(parent)? {
304 Some(workspace) => Ok(Some(workspace)),
305 None => {
306 let project = Project::from_exact(root)?
307 .ok_or(WorkspaceError::NoWorkspaceOrProject)?;
308 Ok(Some(Workspace {
309 root: WorkspaceRoot(root.to_path_buf()),
310 members: NonEmpty::new(project),
311 }))
312 }
313 }
314 } else {
315 Ok(None)
316 }
317 }
318 } else {
319 Ok(None)
320 }
321 }
322 _ => Ok(None),
326 }
327 }
328
329 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
330 let toml = WorkspaceToml::new(toml_content)
331 .map_err(|err| WorkspaceError::TOML(err.to_string()))?;
332 let mut members = Vec::new();
333 for relative_project_path in toml.workspace.members {
334 let project_path = root.join(relative_project_path);
335 match Project::from_exact(&project_path)? {
336 Some(project) => members.push(project),
337 None => return Err(WorkspaceError::ProjectNotFound(project_path)),
338 }
339 }
340 match NonEmpty::from_vec(members) {
341 Some(members) => Ok(Workspace {
342 root: WorkspaceRoot(root.to_path_buf()),
343 members,
344 }),
345 None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
346 }
347 }
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use std::path::PathBuf;
354
355 use assert_fs::prelude::PathCopy;
356
357 #[tokio::test]
358 async fn find_single_project_workspace() {
359 let sample_project: PathBuf = "resources/test/sample-projects/init/".into();
360 let project_root = assert_fs::TempDir::new().unwrap();
361 project_root.copy_from(&sample_project, &["**"]).unwrap();
362 let work_dir: PathBuf = project_root.join("src");
363 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
364 assert_eq!(workspace.members.len(), 1);
365 let project = workspace.members.first();
366 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
367 }
368
369 #[tokio::test]
370 async fn find_multi_project_workspace() {
371 let sample_workspace: PathBuf = "resources/test/sample-projects/multi-project/".into();
372 let workspace_root = assert_fs::TempDir::new().unwrap();
373 workspace_root
374 .copy_from(&sample_workspace, &["**"])
375 .unwrap();
376 let work_dir: PathBuf = workspace_root.join("projects");
377 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
378 assert_eq!(workspace.members.len(), 2);
379 let foo = workspace.select_member(&"foo".into()).unwrap();
380 assert_eq!(
381 foo.root().to_path_buf(),
382 workspace_root.join("projects/foo").to_path_buf()
383 );
384 let bar = workspace.select_member(&"bar".into()).unwrap();
385 assert_eq!(
386 bar.root().to_path_buf(),
387 workspace_root.join("projects/bar").to_path_buf()
388 );
389 }
390
391 #[tokio::test]
392 async fn test_no_find_workspace_upwards() {
393 let work_dir = assert_fs::TempDir::new().unwrap();
394 assert!(Workspace::from(&work_dir).unwrap().is_none())
395 }
396}