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