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