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