Skip to main content

lux_lib/operations/
sync.rs

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