Skip to main content

lux_lib/operations/
sync.rs

1use std::{io, sync::Arc};
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    progress::{MultiProgress, Progress},
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    progress: Option<Arc<Progress<MultiProgress>>>,
38    /// Whether to validate the integrity of installed packages.
39    validate_integrity: Option<bool>,
40    /// When `true`, skip filesystem existence checks and rely on the install tree's lockfile
41    /// alone.
42    fast: Option<bool>,
43}
44
45impl<State> SyncBuilder<'_, State>
46where
47    State: sync_builder::State,
48{
49    pub fn add_package(mut self, package: PackageReq) -> Self {
50        self.extra_packages.push(package);
51        self
52    }
53}
54
55impl<State> SyncBuilder<'_, State>
56where
57    State: sync_builder::State + sync_builder::IsComplete,
58{
59    pub async fn sync_dependencies(self) -> Result<SyncReport, SyncError> {
60        do_sync(self._build(), &LocalPackageLockType::Regular).await
61    }
62
63    pub async fn sync_test_dependencies(mut self) -> Result<SyncReport, SyncError> {
64        for project in self.workspace.members() {
65            let toml = project.toml().into_local()?;
66            for test_dep in toml
67                .test()
68                .current_platform()
69                .test_dependencies(project)
70                .iter()
71                .filter(|test_dep| {
72                    !toml
73                        .test_dependencies()
74                        .current_platform()
75                        .iter()
76                        .any(|dep| dep.name() == test_dep.name())
77                })
78                .cloned()
79            {
80                self.extra_packages.push(test_dep);
81            }
82        }
83        do_sync(self._build(), &LocalPackageLockType::Test).await
84    }
85
86    pub async fn sync_build_dependencies(mut self) -> Result<SyncReport, SyncError> {
87        if cfg!(target_family = "unix") && !self.extra_packages.is_empty() {
88            for project in self.workspace.members() {
89                let toml = project.toml().into_local()?;
90                if toml
91                    .build()
92                    .current_platform()
93                    .build_backend
94                    .as_ref()
95                    .is_some_and(|build_backend| {
96                        matches!(
97                            build_backend,
98                            crate::lua_rockspec::BuildBackendSpec::LuaRock(_)
99                        )
100                    })
101                {
102                    let luarocks = unsafe {
103                        PackageReq::new_unchecked("luarocks".into(), Some(LUAROCKS_VERSION.into()))
104                    };
105                    self = self.add_package(luarocks);
106                }
107            }
108        }
109        do_sync(self._build(), &LocalPackageLockType::Build).await
110    }
111}
112
113#[derive(Debug)]
114pub struct SyncReport {
115    pub(crate) added: Vec<LocalPackage>,
116    pub(crate) removed: Vec<LocalPackage>,
117}
118
119impl SyncReport {
120    pub fn added(&self) -> &[LocalPackage] {
121        &self.added
122    }
123    pub fn removed(&self) -> &[LocalPackage] {
124        &self.removed
125    }
126}
127
128#[derive(Error, Debug, Diagnostic)]
129pub enum SyncError {
130    #[error(transparent)]
131    #[diagnostic(transparent)]
132    FlushLockfile(#[from] FlushLockfileError),
133    #[error("failed to create install tree at {0}:\n{1}")]
134    FailedToCreateDirectory(String, io::Error),
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
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 progress = args.progress.unwrap_or(MultiProgress::new_arc(args.config));
184
185    let mut packages = Vec::new();
186    for project in args.workspace.members() {
187        match lock_type {
188            LocalPackageLockType::Regular => packages.extend(
189                project
190                    .toml()
191                    .into_local()?
192                    .dependencies()
193                    .current_platform()
194                    .clone(),
195            ),
196            LocalPackageLockType::Build => packages.extend(
197                project
198                    .toml()
199                    .into_local()?
200                    .build_dependencies()
201                    .current_platform()
202                    .clone(),
203            ),
204            LocalPackageLockType::Test => packages.extend(
205                project
206                    .toml()
207                    .into_local()?
208                    .test_dependencies()
209                    .current_platform()
210                    .clone(),
211            ),
212        }
213    }
214    let packages = packages
215        .into_iter()
216        .chain(args.extra_packages.into_iter().map_into())
217        .collect_vec();
218
219    let strategy = if args.fast.unwrap_or(false) {
220        SyncStrategy::LockfileOnly
221    } else {
222        SyncStrategy::EnsureInstalled(&tree)
223    };
224    let package_sync_spec = workspace_lockfile.package_sync_spec(&packages, lock_type, &strategy);
225
226    package_sync_spec
227        .to_remove
228        .iter()
229        .for_each(|pkg| workspace_lockfile.remove(pkg, lock_type));
230
231    let mut to_add: Vec<(tree::EntryType, LocalPackage)> = Vec::new();
232
233    let mut report = SyncReport {
234        added: Vec::new(),
235        removed: Vec::new(),
236    };
237    for (id, local_package) in workspace_lockfile.rocks(lock_type) {
238        if dest_lockfile.get(id).is_none() {
239            let entry_type = if workspace_lockfile.is_entrypoint(&local_package.id(), lock_type) {
240                tree::EntryType::Entrypoint
241            } else {
242                tree::EntryType::DependencyOnly
243            };
244            to_add.push((entry_type, local_package.clone()));
245        }
246    }
247    for (id, local_package) in dest_lockfile.rocks() {
248        if workspace_lockfile.get(id, lock_type).is_none() {
249            report.removed.push(local_package.clone());
250        }
251    }
252
253    let packages_to_install = to_add
254        .iter()
255        .map(|(entry_type, pkg)| {
256            PackageInstallSpec::new(pkg.clone().into_package_req(), *entry_type)
257                .build_behaviour(BuildBehaviour::Force)
258                .pin(pkg.pinned())
259                .opt(pkg.opt())
260                .constraint(pkg.constraint())
261                .build()
262        })
263        .unique()
264        .collect_vec();
265    report
266        .added
267        .extend(to_add.iter().map(|(_, pkg)| pkg).cloned());
268
269    let package_db = workspace_lockfile.local_pkg_lock(lock_type).clone().into();
270
271    Install::new(args.config)
272        .package_db(package_db)
273        .packages(packages_to_install)
274        .tree(tree.clone())
275        .progress(progress.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        .progress(progress.clone())
299        .tree(tree.clone())
300        .remove()
301        .await?;
302
303    install_tree_lockfile.map_then_flush(|lockfile| {
304        lockfile.sync(workspace_lockfile.local_pkg_lock(lock_type));
305        Ok::<_, io::Error>(())
306    })?;
307
308    if !package_sync_spec.to_add.is_empty() {
309        // Install missing packages using the default package_db.
310        let missing_packages = package_sync_spec
311            .to_add
312            .into_iter()
313            .map(|dep| {
314                PackageInstallSpec::new(dep.package_req().clone(), tree::EntryType::Entrypoint)
315                    .build_behaviour(BuildBehaviour::Force)
316                    .pin(*dep.pin())
317                    .opt(*dep.opt())
318                    .maybe_source(dep.source.clone())
319                    .build()
320            })
321            .unique()
322            .collect();
323
324        let added = Install::new(args.config)
325            .packages(missing_packages)
326            .tree(tree.clone())
327            .progress(progress.clone())
328            .install()
329            .await?;
330
331        report.added.extend(added);
332
333        // Sync the newly added packages back to the workspace lockfile
334        let dest_lockfile = tree.lockfile()?;
335        workspace_lockfile.sync(dest_lockfile.local_pkg_lock(), lock_type);
336    }
337
338    operations::GenLuaRc::new()
339        .config(args.config)
340        .workspace(args.workspace)
341        .generate_luarc()
342        .await?;
343
344    Ok(report)
345}
346
347#[cfg(test)]
348mod tests {
349    use super::Sync;
350    use crate::{
351        config::ConfigBuilder, lockfile::LocalPackageLockType, package::PackageReq,
352        workspace::Workspace,
353    };
354    use assert_fs::{prelude::PathCopy, TempDir};
355    use std::path::PathBuf;
356
357    #[tokio::test]
358    async fn test_sync_add_rocks() {
359        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
360            println!("Skipping impure test");
361            return;
362        }
363        let temp_dir = TempDir::new().unwrap();
364        temp_dir
365            .copy_from(
366                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
367                    .join("resources/test/sample-projects/dependencies/"),
368                &["**"],
369            )
370            .unwrap();
371        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
372        let config = ConfigBuilder::new().unwrap().build().unwrap();
373        let report = Sync::new(&workspace, &config)
374            .sync_dependencies()
375            .await
376            .unwrap();
377        assert!(report.removed.is_empty());
378        assert!(!report.added.is_empty());
379
380        let lockfile_after_sync = workspace.lockfile().unwrap();
381        assert!(!lockfile_after_sync
382            .rocks(&LocalPackageLockType::Regular)
383            .is_empty());
384    }
385
386    #[tokio::test]
387    async fn test_sync_add_rocks_with_new_package() {
388        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
389            println!("Skipping impure test");
390            return;
391        }
392        let temp_dir = TempDir::new().unwrap();
393        temp_dir
394            .copy_from(
395                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
396                    .join("resources/test/sample-projects/dependencies/"),
397                &["**"],
398            )
399            .unwrap();
400        let temp_dir = temp_dir.into_persistent();
401        let config = ConfigBuilder::new().unwrap().build().unwrap();
402        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
403        {
404            let report = Sync::new(&workspace, &config)
405                .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
406                .sync_dependencies()
407                .await
408                .unwrap();
409            assert!(report.removed.is_empty());
410            assert!(!report.added.is_empty());
411            assert!(report
412                .added
413                .iter()
414                .any(|pkg| pkg.name().to_string() == "toml-edit"));
415        }
416        let lockfile_after_sync = workspace.lockfile().unwrap();
417        assert!(!lockfile_after_sync
418            .rocks(&LocalPackageLockType::Regular)
419            .is_empty());
420    }
421
422    #[tokio::test]
423    async fn regression_sync_nonexistent_lock() {
424        // This test checks that we can sync a lockfile that doesn't exist yet, and whether
425        // the sync report is valid.
426        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
427            println!("Skipping impure test");
428            return;
429        }
430        let temp_dir = TempDir::new().unwrap();
431        temp_dir
432            .copy_from(
433                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
434                    .join("resources/test/sample-projects/dependencies/"),
435                &["**"],
436            )
437            .unwrap();
438        let config = ConfigBuilder::new().unwrap().build().unwrap();
439        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
440        {
441            let report = Sync::new(&workspace, &config)
442                .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
443                .sync_dependencies()
444                .await
445                .unwrap();
446            assert!(report.removed.is_empty());
447            assert!(!report.added.is_empty());
448            assert!(report
449                .added
450                .iter()
451                .any(|pkg| pkg.name().to_string() == "toml-edit"));
452        }
453        let lockfile_after_sync = workspace.lockfile().unwrap();
454        assert!(!lockfile_after_sync
455            .rocks(&LocalPackageLockType::Regular)
456            .is_empty());
457    }
458
459    #[tokio::test]
460    async fn test_sync_remove_rocks() {
461        if std::env::var("LUX_SKIP_IMPURE_TESTS").unwrap_or("0".into()) == "1" {
462            println!("Skipping impure test");
463            return;
464        }
465        let temp_dir = TempDir::new().unwrap();
466        temp_dir
467            .copy_from(
468                PathBuf::from(env!("CARGO_MANIFEST_DIR"))
469                    .join("resources/test/sample-projects/dependencies/"),
470                &["**"],
471            )
472            .unwrap();
473        let config = ConfigBuilder::new().unwrap().build().unwrap();
474        let workspace = Workspace::from_exact(temp_dir.path()).unwrap().unwrap();
475        // First sync to create the tree and lockfile
476        Sync::new(&workspace, &config)
477            .add_package(PackageReq::new("toml-edit".into(), None).unwrap())
478            .sync_dependencies()
479            .await
480            .unwrap();
481        let report = Sync::new(&workspace, &config)
482            .sync_dependencies()
483            .await
484            .unwrap();
485        assert!(!report.removed.is_empty());
486        assert!(report.added.is_empty());
487
488        let lockfile_after_sync = workspace.lockfile().unwrap();
489        assert!(!lockfile_after_sync
490            .rocks(&LocalPackageLockType::Regular)
491            .is_empty());
492    }
493}