1use std::{io, sync::Arc};
2
3use crate::{
4 build::BuildBehaviour,
5 config::Config,
6 lockfile::{
7 FlushLockfileError, LocalPackage, LocalPackageLockType, LockfileIntegrityError,
8 SyncStrategy,
9 },
10 luarocks::luarocks_installation::LUAROCKS_VERSION,
11 operations::{self, GenLuaRcError},
12 package::{PackageName, PackageReq},
13 progress::{MultiProgress, Progress},
14 project::{project_toml::LocalProjectTomlValidationError, ProjectError},
15 rockspec::Rockspec,
16 tree::{self, TreeError},
17 workspace::{Workspace, WorkspaceError, WorkspaceTreeError},
18};
19use bon::Builder;
20use itertools::Itertools;
21use thiserror::Error;
22
23use super::{Install, InstallError, PackageInstallSpec, RemoveError, Uninstall};
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)]
129pub enum SyncError {
130 #[error(transparent)]
131 FlushLockfile(#[from] FlushLockfileError),
132 #[error("failed to create install tree at {0}:\n{1}")]
133 FailedToCreateDirectory(String, io::Error),
134 #[error(transparent)]
135 Tree(#[from] TreeError),
136 #[error(transparent)]
137 Install(#[from] InstallError),
138 #[error(transparent)]
139 Remove(#[from] RemoveError),
140 #[error("integrity error for package {0}: {1}\n")]
141 Integrity(PackageName, LockfileIntegrityError),
142 #[error(transparent)]
143 WorkspaceTree(#[from] WorkspaceTreeError),
144 #[error(transparent)]
145 Workspace(#[from] WorkspaceError),
146 #[error(transparent)]
147 Project(#[from] ProjectError),
148 #[error(transparent)]
149 LocalProjectTomlValidationError(#[from] LocalProjectTomlValidationError),
150 #[error("failed to generate `.luarc.json`:\n{0}")]
151 GenLuaRc(#[from] GenLuaRcError),
152}
153
154async fn do_sync(
155 args: Sync<'_>,
156 lock_type: &LocalPackageLockType,
157) -> Result<SyncReport, SyncError> {
158 let tree = match lock_type {
159 LocalPackageLockType::Regular => args.workspace.tree(args.config)?,
160 LocalPackageLockType::Test => args.workspace.test_tree(args.config)?,
161 LocalPackageLockType::Build => args.workspace.build_tree(args.config)?,
162 };
163 std::fs::create_dir_all(tree.root()).map_err(|err| {
164 SyncError::FailedToCreateDirectory(tree.root().to_string_lossy().to_string(), err)
165 })?;
166
167 let mut workspace_lockfile = args.workspace.lockfile()?.write_guard();
168 let dest_lockfile = tree.lockfile()?;
169
170 let progress = args.progress.unwrap_or(MultiProgress::new_arc(args.config));
171
172 let mut packages = Vec::new();
173 for project in args.workspace.members() {
174 match lock_type {
175 LocalPackageLockType::Regular => packages.extend(
176 project
177 .toml()
178 .into_local()?
179 .dependencies()
180 .current_platform()
181 .clone(),
182 ),
183 LocalPackageLockType::Build => packages.extend(
184 project
185 .toml()
186 .into_local()?
187 .build_dependencies()
188 .current_platform()
189 .clone(),
190 ),
191 LocalPackageLockType::Test => packages.extend(
192 project
193 .toml()
194 .into_local()?
195 .test_dependencies()
196 .current_platform()
197 .clone(),
198 ),
199 }
200 }
201 let packages = packages
202 .into_iter()
203 .chain(args.extra_packages.into_iter().map_into())
204 .collect_vec();
205
206 let strategy = if args.fast.unwrap_or(false) {
207 SyncStrategy::LockfileOnly
208 } else {
209 SyncStrategy::EnsureInstalled(&tree)
210 };
211 let package_sync_spec = workspace_lockfile.package_sync_spec(&packages, lock_type, &strategy);
212
213 package_sync_spec
214 .to_remove
215 .iter()
216 .for_each(|pkg| workspace_lockfile.remove(pkg, lock_type));
217
218 let mut to_add: Vec<(tree::EntryType, LocalPackage)> = Vec::new();
219
220 let mut report = SyncReport {
221 added: Vec::new(),
222 removed: Vec::new(),
223 };
224 for (id, local_package) in workspace_lockfile.rocks(lock_type) {
225 if dest_lockfile.get(id).is_none() {
226 let entry_type = if workspace_lockfile.is_entrypoint(&local_package.id(), lock_type) {
227 tree::EntryType::Entrypoint
228 } else {
229 tree::EntryType::DependencyOnly
230 };
231 to_add.push((entry_type, local_package.clone()));
232 }
233 }
234 for (id, local_package) in dest_lockfile.rocks() {
235 if workspace_lockfile.get(id, lock_type).is_none() {
236 report.removed.push(local_package.clone());
237 }
238 }
239
240 let packages_to_install = to_add
241 .iter()
242 .map(|(entry_type, pkg)| {
243 PackageInstallSpec::new(pkg.clone().into_package_req(), *entry_type)
244 .build_behaviour(BuildBehaviour::Force)
245 .pin(pkg.pinned())
246 .opt(pkg.opt())
247 .constraint(pkg.constraint())
248 .build()
249 })
250 .unique()
251 .collect_vec();
252 report
253 .added
254 .extend(to_add.iter().map(|(_, pkg)| pkg).cloned());
255
256 let package_db = workspace_lockfile.local_pkg_lock(lock_type).clone().into();
257
258 Install::new(args.config)
259 .package_db(package_db)
260 .packages(packages_to_install)
261 .tree(tree.clone())
262 .progress(progress.clone())
263 .install()
264 .await?;
265
266 let install_tree_lockfile = tree.lockfile()?;
268
269 if args.validate_integrity.unwrap_or(true) {
270 for (_, package) in &to_add {
271 install_tree_lockfile
272 .validate_integrity(package)
273 .map_err(|err| SyncError::Integrity(package.name().clone(), err))?;
274 }
275 }
276
277 let packages_to_remove = report.removed.iter().map(|pkg| pkg.id()).collect_vec();
278
279 Uninstall::new()
280 .config(args.config)
281 .packages(packages_to_remove)
282 .progress(progress.clone())
283 .tree(tree.clone())
284 .remove()
285 .await?;
286
287 install_tree_lockfile.map_then_flush(|lockfile| {
288 lockfile.sync(workspace_lockfile.local_pkg_lock(lock_type));
289 Ok::<_, io::Error>(())
290 })?;
291
292 if !package_sync_spec.to_add.is_empty() {
293 let missing_packages = package_sync_spec
295 .to_add
296 .into_iter()
297 .map(|dep| {
298 PackageInstallSpec::new(dep.package_req().clone(), tree::EntryType::Entrypoint)
299 .build_behaviour(BuildBehaviour::Force)
300 .pin(*dep.pin())
301 .opt(*dep.opt())
302 .maybe_source(dep.source.clone())
303 .build()
304 })
305 .unique()
306 .collect();
307
308 let added = Install::new(args.config)
309 .packages(missing_packages)
310 .tree(tree.clone())
311 .progress(progress.clone())
312 .install()
313 .await?;
314
315 report.added.extend(added);
316
317 let dest_lockfile = tree.lockfile()?;
319 workspace_lockfile.sync(dest_lockfile.local_pkg_lock(), lock_type);
320 }
321
322 operations::GenLuaRc::new()
323 .config(args.config)
324 .workspace(args.workspace)
325 .generate_luarc()
326 .await?;
327
328 Ok(report)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::Sync;
334 use crate::{
335 config::ConfigBuilder, lockfile::LocalPackageLockType, package::PackageReq,
336 workspace::Workspace,
337 };
338 use assert_fs::{prelude::PathCopy, TempDir};
339 use std::path::PathBuf;
340
341 #[tokio::test]
342 async fn test_sync_add_rocks() {
343 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
344 println!("Skipping impure test");
345 return;
346 }
347 let temp_dir = TempDir::new().unwrap();
348 temp_dir
349 .copy_from(
350 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
351 .join("resources/test/sample-projects/dependencies/"),
352 &["**"],
353 )
354 .unwrap();
355 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
356 let config = ConfigBuilder::new().unwrap().build().unwrap();
357 let report = Sync::new(&workspace, &config)
358 .sync_dependencies()
359 .await
360 .unwrap();
361 assert!(report.removed.is_empty());
362 assert!(!report.added.is_empty());
363
364 let lockfile_after_sync = workspace.lockfile().unwrap();
365 assert!(!lockfile_after_sync
366 .rocks(&LocalPackageLockType::Regular)
367 .is_empty());
368 }
369
370 #[tokio::test]
371 async fn test_sync_add_rocks_with_new_package() {
372 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
373 println!("Skipping impure test");
374 return;
375 }
376 let temp_dir = TempDir::new().unwrap();
377 temp_dir
378 .copy_from(
379 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
380 .join("resources/test/sample-projects/dependencies/"),
381 &["**"],
382 )
383 .unwrap();
384 let temp_dir = temp_dir.into_persistent();
385 let config = ConfigBuilder::new().unwrap().build().unwrap();
386 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
387 {
388 let report = Sync::new(&workspace, &config)
389 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
390 .sync_dependencies()
391 .await
392 .unwrap();
393 assert!(report.removed.is_empty());
394 assert!(!report.added.is_empty());
395 assert!(report
396 .added
397 .iter()
398 .any(|pkg| pkg.name().to_string() == "toml-edit"));
399 }
400 let lockfile_after_sync = workspace.lockfile().unwrap();
401 assert!(!lockfile_after_sync
402 .rocks(&LocalPackageLockType::Regular)
403 .is_empty());
404 }
405
406 #[tokio::test]
407 async fn regression_sync_nonexistent_lock() {
408 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
411 println!("Skipping impure test");
412 return;
413 }
414 let temp_dir = TempDir::new().unwrap();
415 temp_dir
416 .copy_from(
417 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
418 .join("resources/test/sample-projects/dependencies/"),
419 &["**"],
420 )
421 .unwrap();
422 let config = ConfigBuilder::new().unwrap().build().unwrap();
423 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
424 {
425 let report = Sync::new(&workspace, &config)
426 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
427 .sync_dependencies()
428 .await
429 .unwrap();
430 assert!(report.removed.is_empty());
431 assert!(!report.added.is_empty());
432 assert!(report
433 .added
434 .iter()
435 .any(|pkg| pkg.name().to_string() == "toml-edit"));
436 }
437 let lockfile_after_sync = workspace.lockfile().unwrap();
438 assert!(!lockfile_after_sync
439 .rocks(&LocalPackageLockType::Regular)
440 .is_empty());
441 }
442
443 #[tokio::test]
444 async fn test_sync_remove_rocks() {
445 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
446 println!("Skipping impure test");
447 return;
448 }
449 let temp_dir = TempDir::new().unwrap();
450 temp_dir
451 .copy_from(
452 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
453 .join("resources/test/sample-projects/dependencies/"),
454 &["**"],
455 )
456 .unwrap();
457 let config = ConfigBuilder::new().unwrap().build().unwrap();
458 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
459 Sync::new(&workspace, &config)
461 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
462 .sync_dependencies()
463 .await
464 .unwrap();
465 let report = Sync::new(&workspace, &config)
466 .sync_dependencies()
467 .await
468 .unwrap();
469 assert!(!report.removed.is_empty());
470 assert!(report.added.is_empty());
471
472 let lockfile_after_sync = workspace.lockfile().unwrap();
473 assert!(!lockfile_after_sync
474 .rocks(&LocalPackageLockType::Regular)
475 .is_empty());
476 }
477}