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