lux_lib/operations/
sync.rs

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