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