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