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