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 .collect_vec();
251 report
252 .added
253 .extend(to_add.iter().map(|(_, pkg)| pkg).cloned());
254
255 let package_db = workspace_lockfile.local_pkg_lock(lock_type).clone().into();
256
257 Install::new(args.config)
258 .package_db(package_db)
259 .packages(packages_to_install)
260 .tree(tree.clone())
261 .progress(progress.clone())
262 .install()
263 .await?;
264
265 let install_tree_lockfile = tree.lockfile()?;
267
268 if args.validate_integrity.unwrap_or(true) {
269 for (_, package) in &to_add {
270 install_tree_lockfile
271 .validate_integrity(package)
272 .map_err(|err| SyncError::Integrity(package.name().clone(), err))?;
273 }
274 }
275
276 let packages_to_remove = report.removed.iter().map(|pkg| pkg.id()).collect_vec();
277
278 Uninstall::new()
279 .config(args.config)
280 .packages(packages_to_remove)
281 .progress(progress.clone())
282 .tree(tree.clone())
283 .remove()
284 .await?;
285
286 install_tree_lockfile.map_then_flush(|lockfile| {
287 lockfile.sync(workspace_lockfile.local_pkg_lock(lock_type));
288 Ok::<_, io::Error>(())
289 })?;
290
291 if !package_sync_spec.to_add.is_empty() {
292 let missing_packages = package_sync_spec
294 .to_add
295 .into_iter()
296 .map(|dep| {
297 PackageInstallSpec::new(dep.package_req().clone(), tree::EntryType::Entrypoint)
298 .build_behaviour(BuildBehaviour::Force)
299 .pin(*dep.pin())
300 .opt(*dep.opt())
301 .maybe_source(dep.source.clone())
302 .build()
303 })
304 .collect();
305
306 let added = Install::new(args.config)
307 .packages(missing_packages)
308 .tree(tree.clone())
309 .progress(progress.clone())
310 .install()
311 .await?;
312
313 report.added.extend(added);
314
315 let dest_lockfile = tree.lockfile()?;
317 workspace_lockfile.sync(dest_lockfile.local_pkg_lock(), lock_type);
318 }
319
320 operations::GenLuaRc::new()
321 .config(args.config)
322 .workspace(args.workspace)
323 .generate_luarc()
324 .await?;
325
326 Ok(report)
327}
328
329#[cfg(test)]
330mod tests {
331 use super::Sync;
332 use crate::{
333 config::ConfigBuilder, lockfile::LocalPackageLockType, package::PackageReq,
334 workspace::Workspace,
335 };
336 use assert_fs::{prelude::PathCopy, TempDir};
337 use std::path::PathBuf;
338
339 #[tokio::test]
340 async fn test_sync_add_rocks() {
341 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
342 println!("Skipping impure test");
343 return;
344 }
345 let temp_dir = TempDir::new().unwrap();
346 temp_dir
347 .copy_from(
348 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
349 .join("resources/test/sample-projects/dependencies/"),
350 &["**"],
351 )
352 .unwrap();
353 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
354 let config = ConfigBuilder::new().unwrap().build().unwrap();
355 let report = Sync::new(&workspace, &config)
356 .sync_dependencies()
357 .await
358 .unwrap();
359 assert!(report.removed.is_empty());
360 assert!(!report.added.is_empty());
361
362 let lockfile_after_sync = workspace.lockfile().unwrap();
363 assert!(!lockfile_after_sync
364 .rocks(&LocalPackageLockType::Regular)
365 .is_empty());
366 }
367
368 #[tokio::test]
369 async fn test_sync_add_rocks_with_new_package() {
370 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
371 println!("Skipping impure test");
372 return;
373 }
374 let temp_dir = TempDir::new().unwrap();
375 temp_dir
376 .copy_from(
377 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
378 .join("resources/test/sample-projects/dependencies/"),
379 &["**"],
380 )
381 .unwrap();
382 let temp_dir = temp_dir.into_persistent();
383 let config = ConfigBuilder::new().unwrap().build().unwrap();
384 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
385 {
386 let report = Sync::new(&workspace, &config)
387 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
388 .sync_dependencies()
389 .await
390 .unwrap();
391 assert!(report.removed.is_empty());
392 assert!(!report.added.is_empty());
393 assert!(report
394 .added
395 .iter()
396 .any(|pkg| pkg.name().to_string() == "toml-edit"));
397 }
398 let lockfile_after_sync = workspace.lockfile().unwrap();
399 assert!(!lockfile_after_sync
400 .rocks(&LocalPackageLockType::Regular)
401 .is_empty());
402 }
403
404 #[tokio::test]
405 async fn regression_sync_nonexistent_lock() {
406 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
409 println!("Skipping impure test");
410 return;
411 }
412 let temp_dir = TempDir::new().unwrap();
413 temp_dir
414 .copy_from(
415 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
416 .join("resources/test/sample-projects/dependencies/"),
417 &["**"],
418 )
419 .unwrap();
420 let config = ConfigBuilder::new().unwrap().build().unwrap();
421 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
422 {
423 let report = Sync::new(&workspace, &config)
424 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
425 .sync_dependencies()
426 .await
427 .unwrap();
428 assert!(report.removed.is_empty());
429 assert!(!report.added.is_empty());
430 assert!(report
431 .added
432 .iter()
433 .any(|pkg| pkg.name().to_string() == "toml-edit"));
434 }
435 let lockfile_after_sync = workspace.lockfile().unwrap();
436 assert!(!lockfile_after_sync
437 .rocks(&LocalPackageLockType::Regular)
438 .is_empty());
439 }
440
441 #[tokio::test]
442 async fn test_sync_remove_rocks() {
443 if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
444 println!("Skipping impure test");
445 return;
446 }
447 let temp_dir = TempDir::new().unwrap();
448 temp_dir
449 .copy_from(
450 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
451 .join("resources/test/sample-projects/dependencies/"),
452 &["**"],
453 )
454 .unwrap();
455 let config = ConfigBuilder::new().unwrap().build().unwrap();
456 let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
457 Sync::new(&workspace, &config)
459 .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
460 .sync_dependencies()
461 .await
462 .unwrap();
463 let report = Sync::new(&workspace, &config)
464 .sync_dependencies()
465 .await
466 .unwrap();
467 assert!(!report.removed.is_empty());
468 assert!(report.added.is_empty());
469
470 let lockfile_after_sync = workspace.lockfile().unwrap();
471 assert!(!lockfile_after_sync
472 .rocks(&LocalPackageLockType::Regular)
473 .is_empty());
474 }
475}