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