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