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] 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
175#[tracing::instrument(name = "Syncing dependencies", skip_all)]
176async fn do_sync(
177    args: &Sync<'_>,
178    lock_type: &LocalPackageLockType,
179) -> Result<SyncReport, SyncError> {
180    // NOTE(vhyrro): tools like cc and pkg-config leak cargo:rerun-if-env-changed
181    // stdout calls, therefore gag all standard output during sync.
182    let _stdout_gag = gag::Gag::stdout();
183
184    let tree = match lock_type {
185        LocalPackageLockType::Regular => args.workspace.tree(args.config)?,
186        LocalPackageLockType::Test => args.workspace.test_tree(args.config)?,
187        LocalPackageLockType::Build => args.workspace.build_tree(args.config)?,
188    };
189    fs::sync::create_dir_all(tree.root())?;
190
191    let mut workspace_lockfile = args.workspace.lockfile()?.write_guard();
192    let dest_lockfile = tree.lockfile()?;
193
194    let mut packages = Vec::new();
195    for project in args.workspace.members() {
196        match lock_type {
197            LocalPackageLockType::Regular => packages.extend(
198                project
199                    .toml()
200                    .into_local()?
201                    .dependencies()
202                    .current_platform()
203                    .clone(),
204            ),
205            LocalPackageLockType::Build => packages.extend(
206                project
207                    .toml()
208                    .into_local()?
209                    .build_dependencies()
210                    .current_platform()
211                    .clone(),
212            ),
213            LocalPackageLockType::Test => packages.extend(
214                project
215                    .toml()
216                    .into_local()?
217                    .test_dependencies()
218                    .current_platform()
219                    .clone(),
220            ),
221        }
222    }
223
224    let mut extra_packages = args.extra_packages.iter().cloned().collect_vec();
225    if lock_type == &LocalPackageLockType::Build {
226        for project in args.workspace.members() {
227            let toml = project.toml().into_local()?;
228            if let Some(backend) = operations::resolve::luarocks_build_backend_name(&toml) {
229                extra_packages.push(backend.into());
230                if cfg!(target_family = "unix") {
231                    let luarocks = unsafe {
232                        PackageReq::new_unchecked("luarocks".into(), Some(LUAROCKS_VERSION.into()))
233                    };
234                    extra_packages.push(luarocks);
235                }
236            }
237        }
238    } else if lock_type == &LocalPackageLockType::Test {
239        for project in args.workspace.members() {
240            let toml = project.toml().into_local()?;
241            for test_dep in toml
242                .test()
243                .current_platform()
244                .test_dependencies(project)
245                .iter()
246                .filter(|test_dep| {
247                    !toml
248                        .test_dependencies()
249                        .current_platform()
250                        .iter()
251                        .any(|dep| dep.name() == test_dep.name())
252                })
253                .cloned()
254            {
255                extra_packages.push(test_dep);
256            }
257        }
258    }
259
260    let packages = packages
261        .into_iter()
262        .chain(extra_packages.into_iter().unique().map_into())
263        .collect_vec();
264
265    let strategy = if args.fast.unwrap_or(false) {
266        SyncStrategy::LockfileOnly
267    } else {
268        SyncStrategy::EnsureInstalled(&tree)
269    };
270    let package_sync_spec = workspace_lockfile.package_sync_spec(&packages, lock_type, &strategy);
271
272    package_sync_spec
273        .to_remove
274        .iter()
275        .for_each(|pkg| workspace_lockfile.remove(pkg, lock_type));
276
277    let mut to_add: Vec<(tree::EntryType, LocalPackage)> = Vec::new();
278
279    let mut report = SyncReport {
280        added: Vec::new(),
281        removed: Vec::new(),
282    };
283    for (id, local_package) in workspace_lockfile.rocks(lock_type) {
284        if dest_lockfile.get(id).is_none() {
285            let entry_type = if workspace_lockfile.is_entrypoint(&local_package.id(), lock_type) {
286                tree::EntryType::Entrypoint
287            } else {
288                tree::EntryType::DependencyOnly
289            };
290            to_add.push((entry_type, local_package.clone()));
291        }
292    }
293    for (id, local_package) in dest_lockfile.rocks() {
294        if workspace_lockfile.get(id, lock_type).is_none() {
295            report.removed.push(local_package.clone());
296        }
297    }
298
299    let packages_to_install = to_add
300        .iter()
301        .map(|(entry_type, pkg)| {
302            PackageInstallSpec::new(pkg.clone().into_package_req(), *entry_type)
303                .build_behaviour(BuildBehaviour::Force)
304                .pin(pkg.pinned())
305                .opt(pkg.opt())
306                .constraint(pkg.constraint())
307                .build()
308        })
309        .unique()
310        .collect_vec();
311    report
312        .added
313        .extend(to_add.iter().map(|(_, pkg)| pkg).cloned());
314
315    let package_db = workspace_lockfile.local_pkg_locks().into();
316
317    Install::new(args.config)
318        .package_db(package_db)
319        .packages(packages_to_install)
320        .tree(tree.clone())
321        .install()
322        .await?;
323
324    // Read the destination lockfile after installing
325    let install_tree_lockfile = tree.lockfile()?;
326
327    if args.validate_integrity.unwrap_or(true) {
328        for (_, package) in &to_add {
329            install_tree_lockfile
330                .validate_integrity(package)
331                .map_err(|source| SyncError::Integrity {
332                    package: package.name().clone(),
333                    source,
334                })?;
335        }
336    }
337
338    let packages_to_remove = report.removed.iter().map(|pkg| pkg.id()).collect_vec();
339
340    Uninstall::new()
341        .config(args.config)
342        .packages(packages_to_remove)
343        .tree(tree.clone())
344        .remove()
345        .await?;
346
347    install_tree_lockfile.map_then_flush(|lockfile| {
348        lockfile.sync(workspace_lockfile.local_pkg_lock(lock_type));
349        Ok::<_, io::Error>(())
350    })?;
351
352    if !package_sync_spec.to_add.is_empty() {
353        // Install missing packages using the default package_db.
354        let missing_packages = package_sync_spec
355            .to_add
356            .into_iter()
357            .map(|dep| {
358                PackageInstallSpec::new(dep.package_req().clone(), tree::EntryType::Entrypoint)
359                    .build_behaviour(BuildBehaviour::Force)
360                    .pin(*dep.pin())
361                    .opt(*dep.opt())
362                    .maybe_source(dep.source.clone())
363                    .build()
364            })
365            .unique()
366            .collect();
367
368        let added = Install::new(args.config)
369            .packages(missing_packages)
370            .tree(tree.clone())
371            .install()
372            .await?;
373
374        report.added.extend(added);
375
376        // Sync the newly added packages back to the workspace lockfile
377        let dest_lockfile = tree.lockfile()?;
378        workspace_lockfile.sync(dest_lockfile.local_pkg_lock(), lock_type);
379    }
380
381    Ok(report)
382}
383
384#[cfg(test)]
385mod tests {
386    use super::Sync;
387    use crate::{
388        config::ConfigBuilder, lockfile::LocalPackageLockType, package::PackageReq,
389        workspace::Workspace,
390    };
391    use assert_fs::{prelude::PathCopy, TempDir};
392    use std::path::PathBuf;
393
394    #[tokio::test]
395    async fn test_sync_add_rocks() {
396        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
397            println!("Skipping impure test");
398            return;
399        }
400        let temp_dir = TempDir::new().unwrap();
401        temp_dir
402            .copy_from(
403                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
404                    .join("resources/test/sample-projects/dependencies/"),
405                &["**"],
406            )
407            .unwrap();
408        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
409        let config = ConfigBuilder::new().unwrap().build().unwrap();
410        let report = Sync::new(&workspace, &config).sync().await.unwrap();
411        assert!(report.removed.is_empty());
412        assert!(!report.added.is_empty());
413
414        let lockfile_after_sync = workspace.lockfile().unwrap();
415        assert!(!lockfile_after_sync
416            .rocks(&LocalPackageLockType::Regular)
417            .is_empty());
418    }
419
420    #[tokio::test]
421    async fn test_sync_add_rocks_with_new_package() {
422        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
423            println!("Skipping impure test");
424            return;
425        }
426        let temp_dir = TempDir::new().unwrap();
427        temp_dir
428            .copy_from(
429                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
430                    .join("resources/test/sample-projects/dependencies/"),
431                &["**"],
432            )
433            .unwrap();
434        let temp_dir = temp_dir.into_persistent();
435        let config = ConfigBuilder::new().unwrap().build().unwrap();
436        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
437        {
438            let report = Sync::new(&workspace, &config)
439                .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
440                .sync()
441                .await
442                .unwrap();
443            assert!(report.removed.is_empty());
444            assert!(!report.added.is_empty());
445            assert!(report
446                .added
447                .iter()
448                .any(|pkg| pkg.name().to_string() == "toml-edit"));
449        }
450        let lockfile_after_sync = workspace.lockfile().unwrap();
451        assert!(!lockfile_after_sync
452            .rocks(&LocalPackageLockType::Regular)
453            .is_empty());
454    }
455
456    #[tokio::test]
457    async fn regression_sync_nonexistent_lock() {
458        // This test checks that we can sync a lockfile that doesn't exist yet, and whether
459        // the sync report is valid.
460        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
461            println!("Skipping impure test");
462            return;
463        }
464        let temp_dir = TempDir::new().unwrap();
465        temp_dir
466            .copy_from(
467                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
468                    .join("resources/test/sample-projects/dependencies/"),
469                &["**"],
470            )
471            .unwrap();
472        let config = ConfigBuilder::new().unwrap().build().unwrap();
473        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
474        {
475            let report = Sync::new(&workspace, &config)
476                .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
477                .sync()
478                .await
479                .unwrap();
480            assert!(report.removed.is_empty());
481            assert!(!report.added.is_empty());
482            assert!(report
483                .added
484                .iter()
485                .any(|pkg| pkg.name().to_string() == "toml-edit"));
486        }
487        let lockfile_after_sync = workspace.lockfile().unwrap();
488        assert!(!lockfile_after_sync
489            .rocks(&LocalPackageLockType::Regular)
490            .is_empty());
491    }
492
493    #[tokio::test]
494    async fn test_sync_remove_rocks() {
495        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
496            println!("Skipping impure test");
497            return;
498        }
499        let temp_dir = TempDir::new().unwrap();
500        temp_dir
501            .copy_from(
502                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
503                    .join("resources/test/sample-projects/dependencies/"),
504                &["**"],
505            )
506            .unwrap();
507        let config = ConfigBuilder::new().unwrap().build().unwrap();
508        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
509        // First sync to create the tree and lockfile
510        Sync::new(&workspace, &config)
511            .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
512            .sync()
513            .await
514            .unwrap();
515        let report = Sync::new(&workspace, &config).sync().await.unwrap();
516        assert!(!report.removed.is_empty());
517        assert!(report.added.is_empty());
518
519        let lockfile_after_sync = workspace.lockfile().unwrap();
520        assert!(!lockfile_after_sync
521            .rocks(&LocalPackageLockType::Regular)
522            .is_empty());
523    }
524}