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