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 in a lux project or workspace directory")]
68 NotAWorkspaceDir,
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 Self::current()?.ok_or(WorkspaceError::NotAWorkspaceDir)
99 }
100
101 pub fn root(&self) -> &WorkspaceRoot {
103 &self.root
104 }
105
106 pub fn members(&self) -> &NonEmpty<Project> {
108 &self.members
109 }
110
111 pub fn members_mut(&mut self) -> &mut NonEmpty<Project> {
113 &mut self.members
114 }
115
116 pub fn single_member_or_select(
119 &self,
120 name: &Option<PackageName>,
121 ) -> Result<&Project, WorkspaceError> {
122 match name {
123 Some(name) => self
124 .members()
125 .iter()
126 .find(|project| &project.toml().package == name)
127 .ok_or_else(|| WorkspaceError::PackageNotFound(name.clone(), self.root.clone())),
128 None => Ok(self.members().first()),
129 }
130 }
131
132 pub fn single_member_or_select_mut(
135 &mut self,
136 package: &Option<PackageName>,
137 ) -> Result<&mut Project, WorkspaceError> {
138 match package.as_ref() {
139 Some(package) => self.select_member_mut(package),
140 None => self.single_member_mut(),
141 }
142 }
143
144 pub fn single_member(&self) -> Result<&Project, WorkspaceError> {
146 if self.members().len() == 1 {
147 Ok(self.members().first())
148 } else {
149 Err(WorkspaceError::NoPackageSpecified)
150 }
151 }
152
153 pub fn single_member_mut(&mut self) -> Result<&mut Project, WorkspaceError> {
155 if self.members().len() == 1 {
156 Ok(self.members_mut().first_mut())
157 } else {
158 Err(WorkspaceError::NoPackageSpecified)
159 }
160 }
161
162 pub fn select_member(&self, package: &PackageName) -> Result<&Project, WorkspaceError> {
164 let workspace_root = self.root.clone();
165 self.members()
166 .iter()
167 .find(|project| &project.toml().package == package)
168 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
169 }
170
171 pub fn select_member_mut(
173 &mut self,
174 package: &PackageName,
175 ) -> Result<&mut Project, WorkspaceError> {
176 let workspace_root = self.root.clone();
177 self.members_mut()
178 .iter_mut()
179 .find(|project| &project.toml().package == package)
180 .ok_or_else(|| WorkspaceError::PackageNotFound(package.clone(), workspace_root))
181 }
182
183 pub fn lockfile_path(&self) -> PathBuf {
185 self.root.join("lux.lock")
186 }
187
188 pub fn lockfile(&self) -> Result<WorkspaceLockfile<ReadOnly>, WorkspaceError> {
190 Ok(WorkspaceLockfile::new(self.lockfile_path())?)
191 }
192
193 pub fn try_lockfile(&self) -> Result<Option<WorkspaceLockfile<ReadOnly>>, WorkspaceError> {
195 let path = self.lockfile_path();
196 if path.is_file() {
197 Ok(Some(WorkspaceLockfile::load(path)?))
198 } else {
199 Ok(None)
200 }
201 }
202
203 pub fn tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
204 self.lua_version_tree(self.lua_version(config)?, config)
205 }
206
207 pub fn lua_version(&self, config: &Config) -> Result<LuaVersion, LuaVersionError> {
208 let mut lua_version = self.members().first().lua_version(config)?;
209 for project in self.members() {
211 lua_version = project.lua_version(config)?;
212 }
213 Ok(lua_version)
214 }
215
216 pub(crate) fn lua_version_tree(
217 &self,
218 lua_version: LuaVersion,
219 config: &Config,
220 ) -> Result<Tree, WorkspaceTreeError> {
221 Ok(Tree::new(
222 self.default_tree_root_dir(),
223 lua_version,
224 config,
225 )?)
226 }
227
228 pub(crate) fn default_tree_root_dir(&self) -> PathBuf {
229 self.root.join(LUX_DIR_NAME)
230 }
231
232 pub fn test_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
233 Ok(self.tree(config)?.test_tree(config)?)
234 }
235
236 pub fn build_tree(&self, config: &Config) -> Result<Tree, WorkspaceTreeError> {
237 Ok(self.tree(config)?.build_tree(config)?)
238 }
239
240 pub fn luarc_path(&self) -> PathBuf {
242 let luarc_path = self.root.join(LUARC);
243 if luarc_path.is_file() {
244 luarc_path
245 } else {
246 let emmy_path = self.root.join(EMMYRC);
247 if emmy_path.is_file() {
248 emmy_path
249 } else {
250 luarc_path
251 }
252 }
253 }
254
255 pub fn from_exact(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
256 if !start.as_ref().exists() {
257 return Ok(None);
258 }
259 if start.as_ref().join(WORKSPACE_TOML).exists() {
260 let toml_path = start.as_ref().join(WORKSPACE_TOML);
261 let toml_content = std::fs::read_to_string(&toml_path).map_err(|err| {
262 WorkspaceError::ReadLuxTOML(toml_path.to_string_lossy().to_string(), err)
263 })?;
264 let root = start.as_ref();
265 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
266 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
267 Ok(Some(Self::from_toml(&toml_content, root)?))
268 } else {
269 let project =
270 Project::from_exact(root)?.ok_or(WorkspaceError::NoWorkspaceOrProject)?;
271 Ok(Some(Workspace {
272 root: WorkspaceRoot(root.to_path_buf()),
273 members: NonEmpty::new(project),
274 }))
275 }
276 } else {
277 Ok(None)
278 }
279 }
280
281 pub fn from(start: impl AsRef<Path>) -> Result<Option<Self>, WorkspaceError> {
282 if !start.as_ref().exists() {
283 return Ok(None);
284 }
285 match find_up_with(
286 WORKSPACE_TOML,
287 FindUpOptions {
288 cwd: start.as_ref(),
289 kind: FindUpKind::File,
290 },
291 ) {
292 Ok(Some(path)) => {
293 if let Some(root) = path.parent() {
294 let toml_content = std::fs::read_to_string(&path).map_err(|err| {
295 WorkspaceError::ReadLuxTOML(path.to_string_lossy().to_string(), err)
296 })?;
297 let toml_obj: Option<toml::Table> = toml::from_str(&toml_content).ok();
298 if toml_obj.is_some_and(|toml| toml.contains_key("workspace")) {
299 Ok(Some(Self::from_toml(&toml_content, root)?))
300 } else {
301 if let Some(parent) = root.parent() {
302 match Self::from(parent)? {
303 Some(workspace) => Ok(Some(workspace)),
304 None => {
305 let project = Project::from_exact(root)?
306 .ok_or(WorkspaceError::NoWorkspaceOrProject)?;
307 Ok(Some(Workspace {
308 root: WorkspaceRoot(root.to_path_buf()),
309 members: NonEmpty::new(project),
310 }))
311 }
312 }
313 } else {
314 Ok(None)
315 }
316 }
317 } else {
318 Ok(None)
319 }
320 }
321 _ => Ok(None),
325 }
326 }
327
328 fn from_toml(toml_content: &str, root: &Path) -> Result<Self, WorkspaceError> {
329 let toml = WorkspaceToml::new(toml_content)
330 .map_err(|err| WorkspaceError::TOML(err.to_string()))?;
331 let mut members = Vec::new();
332 for relative_project_path in toml.workspace.members {
333 let project_path = root.join(relative_project_path);
334 match Project::from_exact(&project_path)? {
335 Some(project) => members.push(project),
336 None => return Err(WorkspaceError::ProjectNotFound(project_path)),
337 }
338 }
339 match NonEmpty::from_vec(members) {
340 Some(members) => Ok(Workspace {
341 root: WorkspaceRoot(root.to_path_buf()),
342 members,
343 }),
344 None => Err(WorkspaceError::EmptyWorkspace(root.to_path_buf())),
345 }
346 }
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use std::path::PathBuf;
353
354 use assert_fs::prelude::PathCopy;
355
356 #[tokio::test]
357 async fn find_single_project_workspace() {
358 let sample_project: PathBuf = "resources/test/sample-projects/init/".into();
359 let project_root = assert_fs::TempDir::new().unwrap();
360 project_root.copy_from(&sample_project, &["**"]).unwrap();
361 let work_dir: PathBuf = project_root.join("src");
362 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
363 assert_eq!(workspace.members.len(), 1);
364 let project = workspace.members.first();
365 assert_eq!(project.root().to_path_buf(), project_root.to_path_buf());
366 }
367
368 #[tokio::test]
369 async fn find_multi_project_workspace() {
370 let sample_workspace: PathBuf = "resources/test/sample-projects/multi-project/".into();
371 let workspace_root = assert_fs::TempDir::new().unwrap();
372 workspace_root
373 .copy_from(&sample_workspace, &["**"])
374 .unwrap();
375 let work_dir: PathBuf = workspace_root.join("projects");
376 let workspace = Workspace::from(&work_dir).unwrap().unwrap();
377 assert_eq!(workspace.members.len(), 2);
378 let foo = workspace.select_member(&"foo".into()).unwrap();
379 assert_eq!(
380 foo.root().to_path_buf(),
381 workspace_root.join("projects/foo").to_path_buf()
382 );
383 let bar = workspace.select_member(&"bar".into()).unwrap();
384 assert_eq!(
385 bar.root().to_path_buf(),
386 workspace_root.join("projects/bar").to_path_buf()
387 );
388 }
389
390 #[tokio::test]
391 async fn test_no_find_workspace_upwards() {
392 let work_dir = assert_fs::TempDir::new().unwrap();
393 assert!(Workspace::from(&work_dir).unwrap().is_none())
394 }
395}