Skip to main content

lux_lib/operations/
sync.rs

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