Skip to main content

lux_lib/operations/
sync.rs

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/// A rocks sync builder, for synchronising a tree with a lockfile.
24#[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    /// Whether to validate the integrity of installed packages.
37    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    // Read the destination lockfile after installing
234    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        // Install missing packages using the default package_db.
261        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        // Sync the newly added packages back to the project lockfile
284        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        // This test checks that we can sync a lockfile that doesn't exist yet, and whether
375        // the sync report is valid.
376        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        // First sync to create the tree and lockfile
426        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}