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