1use std::{io, sync::Arc};
2
3use super::{Install, InstallError, PackageInstallSpec, RemoveError, Uninstall};
4use crate::{
5 build::BuildBehaviour,
6 config::Config,
7 lockfile::{
8 FlushLockfileError, LocalPackage, LocalPackageLockType, LockfileIntegrityError,
9 SyncStrategy,
10 },
11 luarocks::luarocks_installation::LUAROCKS_VERSION,
12 operations::{self, GenLuaRcError},
13 package::{PackageName, PackageReq},
14 progress::{MultiProgress, Progress},
15 project::{project_toml::LocalProjectTomlValidationError, ProjectError},
16 rockspec::Rockspec,
17 tree::{self, InstallTree, TreeError},
18 workspace::{Workspace, WorkspaceError, WorkspaceTreeError},
19};
20use bon::Builder;
21use itertools::Itertools;
22use miette::Diagnostic;
23use thiserror::Error;
24
25#[derive(Builder)]
27#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
28pub struct Sync<'a> {
29 #[builder(start_fn)]
30 workspace: &'a Workspace,
31 #[builder(start_fn)]
32 config: &'a Config,
33
34 #[builder(field)]
35 extra_packages: Vec<PackageReq>,
36
37 progress: Option<Arc<Progress<MultiProgress>>>,
38 validate_integrity: Option<bool>,
40 fast: Option<bool>,
43}
44
45impl<State> SyncBuilder<'_, State>
46where
47 State: sync_builder::State,
48{
49 pub fn add_package(mut self, package: PackageReq) -> Self {
50 self.extra_packages.push(package);
51 self
52 }
53}
54
55impl<State> SyncBuilder<'_, State>
56where
57 State: sync_builder::State + sync_builder::IsComplete,
58{
59 pub async fn sync_dependencies(self) -> Result<SyncReport, SyncError> {
60 do_sync(self._build(), &LocalPackageLockType::Regular).await
61 }
62
63 pub async fn sync_test_dependencies(mut self) -> Result<SyncReport, SyncError> {
64 for project in self.workspace.members() {
65 let toml = project.toml().into_local()?;
66 for test_dep in toml
67 .test()
68 .current_platform()
69 .test_dependencies(project)
70 .iter()
71 .filter(|test_dep| {
72 !toml
73 .test_dependencies()
74 .current_platform()
75 .iter()
76 .any(|dep| dep.name() == test_dep.name())
77 })
78 .cloned()
79 {
80 self.extra_packages.push(test_dep);
81 }
82 }
83 do_sync(self._build(), &LocalPackageLockType::Test).await
84 }
85
86 pub async fn sync_build_dependencies(mut self) -> Result<SyncReport, SyncError> {
87 if cfg!(target_family = "unix") && !self.extra_packages.is_empty() {
88 for project in self.workspace.members() {
89 let toml = project.toml().into_local()?;
90 if toml
91 .build()
92 .current_platform()
93 .build_backend
94 .as_ref()
95 .is_some_and(|build_backend| {
96 matches!(
97 build_backend,
98 crate::lua_rockspec::BuildBackendSpec::LuaRock(_)
99 )
100 })
101 {
102 let luarocks = unsafe {
103 PackageReq::new_unchecked("luarocks".into(), Some(LUAROCKS_VERSION.into()))
104 };
105 self = self.add_package(luarocks);
106 }
107 }
108 }
109 do_sync(self._build(), &LocalPackageLockType::Build).await
110 }
111}
112
113#[derive(Debug)]
114pub struct SyncReport {
115 pub(crate) added: Vec<LocalPackage>,
116 pub(crate) removed: Vec<LocalPackage>,
117}
118
119impl SyncReport {
120 pub fn added(&self) -> &[LocalPackage] {
121 &self.added
122 }
123 pub fn removed(&self) -> &[LocalPackage] {
124 &self.removed
125 }
126}
127
128#[derive(Error, Debug, Diagnostic)]
129pub enum SyncError {
130 #[error(transparent)]
131 #[diagnostic(transparent)]
132 FlushLockfile(#[from] FlushLockfileError),
133 #[error("failed to create install tree at {0}:\n{1}")]
134 FailedToCreateDirectory(String, io::Error),
135 #[error(transparent)]
136 #[diagnostic(transparent)]
137 Tree(#[from] TreeError),
138 #[error(transparent)]
139 #[diagnostic(transparent)]
140 Install(#[from] InstallError),
141 #[error(transparent)]
142 #[diagnostic(transparent)]
143 Remove(#[from] RemoveError),
144 #[error("integrity error for package '{package}'")]
145 Integrity {
146 package: PackageName,
147 #[diagnostic_source]
148 err: LockfileIntegrityError,
149 },
150 #[error(transparent)]
151 #[diagnostic(transparent)]
152 WorkspaceTree(#[from] WorkspaceTreeError),
153 #[error(transparent)]
154 #[diagnostic(transparent)]
155 Workspace(#[from] WorkspaceError),
156 #[error(transparent)]
157 #[diagnostic(transparent)]
158 Project(#[from] ProjectError),
159 #[error(transparent)]
160 #[diagnostic(transparent)]
161 LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
162 #[error("failed to generate `.luarc.json`:\n{0}")]
163 #[diagnostic(forward(0))]
164 GenLuaRc(#[from] GenLuaRcError),
165}
166
167async fn do_sync(
168 args: Sync<'_>,
169 lock_type: &LocalPackageLockType,
170) -> Result<SyncReport, SyncError> {
171 let tree = match lock_type {
172 LocalPackageLockType::Regular => args.workspace.tree(args.config)?,
173 LocalPackageLockType::Test => args.workspace.test_tree(args.config)?,
174 LocalPackageLockType::Build => args.workspace.build_tree(args.config)?,
175 };
176 std::fs::create_dir_all(tree.root()).map_err(|err| {
177 SyncError::FailedToCreateDirectory(tree.root().to_string_lossy().to_string(), err)
178 })?;
179
180 let mut workspace_lockfile = args.workspace.lockfile()?.write_guard();
181 let dest_lockfile = tree.lockfile()?;
182
183 let progress = args.progress.unwrap_or(MultiProgress::new_arc(args.config));
184
185 let mut packages = Vec::new();
186 for project in args.workspace.members() {
187 match lock_type {
188 LocalPackageLockType::Regular => packages.extend(
189 project
190 .toml()
191 .into_local()?
192 .dependencies()
193 .current_platform()
194 .clone(),
195 ),
196 LocalPackageLockType::Build => packages.extend(
197 project
198 .toml()
199 .into_local()?
200 .build_dependencies()
201 .current_platform()
202 .clone(),
203 ),
204 LocalPackageLockType::Test => packages.extend(
205 project
206 .toml()
207 .into_local()?
208 .test_dependencies()
209 .current_platform()
210 .clone(),
211 ),
212 }
213 }
214 let packages = packages
215 .into_iter()
216 .chain(args.extra_packages.into_iter().map_into())
217 .collect_vec();
218
219 let strategy = if args.fast.unwrap_or(false) {
220 SyncStrategy::LockfileOnly
221 } else {
222 SyncStrategy::EnsureInstalled(&tree)
223 };
224 let package_sync_spec = workspace_lockfile.package_sync_spec(&packages, lock_type, &strategy);
225
226 package_sync_spec
227 .to_remove
228 .iter()
229 .for_each(|pkg| workspace_lockfile.remove(pkg, lock_type));
230
231 let mut to_add: Vec<(tree::EntryType, LocalPackage)> = Vec::new();
232
233 let mut report = SyncReport {
234 added: Vec::new(),
235 removed: Vec::new(),
236 };
237 for (id, local_package) in workspace_lockfile.rocks(lock_type) {
238 if dest_lockfile.get(id).is_none() {
239 let entry_type = if workspace_lockfile.is_entrypoint(&local_package.id(), lock_type) {
240 tree::EntryType::Entrypoint
241 } else {
242 tree::EntryType::DependencyOnly
243 };
244 to_add.push((entry_type, local_package.clone()));
245 }
246 }
247 for (id, local_package) in dest_lockfile.rocks() {
248 if workspace_lockfile.get(id, lock_type).is_none() {
249 report.removed.push(local_package.clone());
250 }
251 }
252
253 let packages_to_install = to_add
254 .iter()
255 .map(|(entry_type, pkg)| {
256 PackageInstallSpec::new(pkg.clone().into_package_req(), *entry_type)
257 .build_behaviour(BuildBehaviour::Force)
258 .pin(pkg.pinned())
259 .opt(pkg.opt())
260 .constraint(pkg.constraint())
261 .build()
262 })
263 .unique()
264 .collect_vec();
265 report
266 .added
267 .extend(to_add.iter().map(|(_, pkg)| pkg).cloned());
268
269 let package_db = workspace_lockfile.local_pkg_lock(lock_type).clone().into();
270
271 Install::new(args.config)
272 .package_db(package_db)
273 .packages(packages_to_install)
274 .tree(tree.clone())
275 .progress(progress.clone())
276 .install()
277 .await?;
278
279 let install_tree_lockfile = tree.lockfile()?;
281
282 if args.validate_integrity.unwrap_or(true) {
283 for (_, package) in &to_add {
284 install_tree_lockfile
285 .validate_integrity(package)
286 .map_err(|err| SyncError::Integrity {
287 package: package.name().clone(),
288 err,
289 })?;
290 }
291 }
292
293 let packages_to_remove = report.removed.iter().map(|pkg| pkg.id()).collect_vec();
294
295 Uninstall::new()
296 .config(args.config)
297 .packages(packages_to_remove)
298 .progress(progress.clone())
299 .tree(tree.clone())
300 .remove()
301 .await?;
302
303 install_tree_lockfile.map_then_flush(|lockfile| {
304 lockfile.sync(workspace_lockfile.local_pkg_lock(lock_type));
305 Ok::<_, io::Error>(())
306 })?;
307
308 if !package_sync_spec.to_add.is_empty() {
309 let missing_packages = package_sync_spec
311 .to_add
312 .into_iter()
313 .map(|dep| {
314 PackageInstallSpec::new(dep.package_req().clone(), tree::EntryType::Entrypoint)
315 .build_behaviour(BuildBehaviour::Force)
316 .pin(*dep.pin())
317 .opt(*dep.opt())
318 .maybe_source(dep.source.clone())
319 .build()
320 })
321 .unique()
322 .collect();
323
324 let added = Install::new(args.config)
325 .packages(missing_packages)
326 .tree(tree.clone())
327 .progress(progress.clone())
328 .install()
329 .await?;
330
331 report.added.extend(added);
332
333 let dest_lockfile = tree.lockfile()?;
335 workspace_lockfile.sync(dest_lockfile.local_pkg_lock(), lock_type);
336 }
337
338 operations::GenLuaRc::new()
339 .config(args.config)
340 .workspace(args.workspace)
341 .generate_luarc()
342 .await?;
343
344 Ok(report)
345}
346
347#[cfg(test)]
348mod tests {
349 use super::Sync;
350 use crate::{
351 config::ConfigBuilder, lockfile::LocalPackageLockType, package::PackageReq,
352 workspace::Workspace,
353 };
354 use assert_fs::{prelude::PathCopy, TempDir};
355 use std::path::PathBuf;
356
357 #[tokio::test]
358 async fn test_sync_add_rocks() {
359 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
360 println!("Skipping impure test");
361 return;
362 }
363 let temp_dir = TempDir::new().unwrap();
364 temp_dir
365 .copy_from(
366 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
367 .join("resources/test/sample-projects/dependencies/"),
368 &["**"],
369 )
370 .unwrap();
371 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
372 let config = ConfigBuilder::new().unwrap().build().unwrap();
373 let report = Sync::new(&workspace, &config)
374 .sync_dependencies()
375 .await
376 .unwrap();
377 assert!(report.removed.is_empty());
378 assert!(!report.added.is_empty());
379
380 let lockfile_after_sync = workspace.lockfile().unwrap();
381 assert!(!lockfile_after_sync
382 .rocks(&LocalPackageLockType::Regular)
383 .is_empty());
384 }
385
386 #[tokio::test]
387 async fn test_sync_add_rocks_with_new_package() {
388 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
389 println!("Skipping impure test");
390 return;
391 }
392 let temp_dir = TempDir::new().unwrap();
393 temp_dir
394 .copy_from(
395 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
396 .join("resources/test/sample-projects/dependencies/"),
397 &["**"],
398 )
399 .unwrap();
400 let temp_dir = temp_dir.into_persistent();
401 let config = ConfigBuilder::new().unwrap().build().unwrap();
402 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
403 {
404 let report = Sync::new(&workspace, &config)
405 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
406 .sync_dependencies()
407 .await
408 .unwrap();
409 assert!(report.removed.is_empty());
410 assert!(!report.added.is_empty());
411 assert!(report
412 .added
413 .iter()
414 .any(|pkg| pkg.name().to_string() == "toml-edit"));
415 }
416 let lockfile_after_sync = workspace.lockfile().unwrap();
417 assert!(!lockfile_after_sync
418 .rocks(&LocalPackageLockType::Regular)
419 .is_empty());
420 }
421
422 #[tokio::test]
423 async fn regression_sync_nonexistent_lock() {
424 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
427 println!("Skipping impure test");
428 return;
429 }
430 let temp_dir = TempDir::new().unwrap();
431 temp_dir
432 .copy_from(
433 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
434 .join("resources/test/sample-projects/dependencies/"),
435 &["**"],
436 )
437 .unwrap();
438 let config = ConfigBuilder::new().unwrap().build().unwrap();
439 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
440 {
441 let report = Sync::new(&workspace, &config)
442 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
443 .sync_dependencies()
444 .await
445 .unwrap();
446 assert!(report.removed.is_empty());
447 assert!(!report.added.is_empty());
448 assert!(report
449 .added
450 .iter()
451 .any(|pkg| pkg.name().to_string() == "toml-edit"));
452 }
453 let lockfile_after_sync = workspace.lockfile().unwrap();
454 assert!(!lockfile_after_sync
455 .rocks(&LocalPackageLockType::Regular)
456 .is_empty());
457 }
458
459 #[tokio::test]
460 async fn test_sync_remove_rocks() {
461 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
462 println!("Skipping impure test");
463 return;
464 }
465 let temp_dir = TempDir::new().unwrap();
466 temp_dir
467 .copy_from(
468 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
469 .join("resources/test/sample-projects/dependencies/"),
470 &["**"],
471 )
472 .unwrap();
473 let config = ConfigBuilder::new().unwrap().build().unwrap();
474 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
475 Sync::new(&workspace, &config)
477 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
478 .sync_dependencies()
479 .await
480 .unwrap();
481 let report = Sync::new(&workspace, &config)
482 .sync_dependencies()
483 .await
484 .unwrap();
485 assert!(!report.removed.is_empty());
486 assert!(report.added.is_empty());
487
488 let lockfile_after_sync = workspace.lockfile().unwrap();
489 assert!(!lockfile_after_sync
490 .rocks(&LocalPackageLockType::Regular)
491 .is_empty());
492 }
493}