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