Skip to main content

lux_lib/drivers/sync/
mod.rs

1use self::reconcile::SyncReport;
2use bon::Builder;
3use miette::Diagnostic;
4use nonempty::NonEmpty;
5use thiserror::Error;
6
7mod frozen;
8pub(crate) mod reconcile;
9
10use crate::{
11    build::BuildBehaviour,
12    config::Config,
13    drivers::install_packages::{InstallPackages, InstallPackagesError},
14    lockfile::LockedPackageLockType,
15    operations::{
16        GenLuaRc, GenLuaRcError, InstallProject, InstallProjectError, PackageInstallSpec,
17    },
18    package::PackageName,
19    package_db::{PackageDB, PackageDBError},
20    project::project_toml::LocalProjectTomlValidationError,
21    rockspec::Rockspec,
22    tree::{EntryType, InstallTree, TreeError},
23    workspace::{Workspace, WorkspaceError, WorkspaceTreeError},
24};
25
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum SyncMode {
28    /// The lockfile may be rewritten. Missing dependencies are resolved from
29    /// manifests, preferring locked versions.
30    Open,
31    /// The lockfile is authoritative. Nothing is resolved from manifests.
32    Frozen,
33}
34
35#[derive(Clone, Debug)]
36pub struct TargetSet {
37    pub test: bool,
38    pub members: NonEmpty<PackageName>,
39}
40
41impl TargetSet {
42    pub fn all(workspace: &Workspace, test: bool) -> Self {
43        Self {
44            test,
45            members: workspace
46                .members()
47                .clone()
48                .map(|project| project.toml().package.clone()),
49        }
50    }
51
52    pub fn member(member: PackageName, test: bool) -> Self {
53        Self {
54            test,
55            members: NonEmpty::new(member),
56        }
57    }
58}
59
60#[derive(Error, Debug, Diagnostic)]
61pub enum SyncError {
62    #[error(transparent)]
63    #[diagnostic(transparent)]
64    InstallPackages(#[from] Box<InstallPackagesError>),
65    #[error(transparent)]
66    #[diagnostic(transparent)]
67    InstallProject(Box<InstallProjectError>),
68    #[error(transparent)]
69    #[diagnostic(transparent)]
70    Workspace(#[from] WorkspaceError),
71    #[error(transparent)]
72    #[diagnostic(transparent)]
73    WorkspaceTree(#[from] WorkspaceTreeError),
74    #[error(transparent)]
75    #[diagnostic(transparent)]
76    Tree(#[from] TreeError),
77    #[error(transparent)]
78    #[diagnostic(transparent)]
79    Project(#[from] LocalProjectTomlValidationError),
80    #[error(transparent)]
81    #[diagnostic(transparent)]
82    PackageDB(#[from] PackageDBError),
83    #[error(transparent)]
84    #[diagnostic(transparent)]
85    GenLuaRc(#[from] GenLuaRcError),
86    #[error(transparent)]
87    #[diagnostic(transparent)]
88    Frozen(Box<frozen::SyncError>),
89}
90
91impl From<InstallPackagesError> for SyncError {
92    fn from(source: InstallPackagesError) -> Self {
93        Self::InstallPackages(Box::new(source))
94    }
95}
96
97impl From<InstallProjectError> for SyncError {
98    fn from(source: InstallProjectError) -> Self {
99        Self::InstallProject(Box::new(source))
100    }
101}
102
103impl From<frozen::SyncError> for SyncError {
104    fn from(source: frozen::SyncError) -> Self {
105        Self::Frozen(Box::new(source))
106    }
107}
108
109/// Reconciles a workspace's install trees with its dependencies.
110#[derive(Builder)]
111#[builder(start_fn = new, finish_fn(name = _build, vis = ""))]
112pub struct Sync<'a> {
113    #[builder(start_fn)]
114    workspace: &'a Workspace,
115    #[builder(start_fn)]
116    config: &'a Config,
117
118    mode: SyncMode,
119
120    targets: TargetSet,
121
122    #[builder(default = BuildBehaviour::Force)]
123    behaviour: BuildBehaviour,
124
125    /// Build only the dependencies, not the workspace members.
126    #[builder(default = false)]
127    only_deps: bool,
128
129    /// Ignore the project's lockfile and don't create one.
130    #[builder(default = false)]
131    no_lock: bool,
132
133    /// Whether to validate the integrity of installed packages (`Frozen` mode).
134    validate_integrity: Option<bool>,
135}
136
137impl<State> SyncBuilder<'_, State>
138where
139    State: sync_builder::State + sync_builder::IsComplete,
140{
141    pub async fn sync(self) -> Result<SyncReport, SyncError> {
142        let args = self._build();
143        match args.mode {
144            SyncMode::Open => sync_open(&args).await,
145            SyncMode::Frozen => sync_frozen(&args).await,
146        }
147    }
148}
149
150async fn sync_open(args: &Sync<'_>) -> Result<SyncReport, SyncError> {
151    let config = args.config;
152    let workspace = args.workspace;
153
154    let tree = workspace.tree(config)?;
155    let build_tree = tree.build_tree(config)?;
156
157    let regular = gather_dependencies(
158        workspace,
159        DependencyKind::Regular,
160        &workspace.member_names()?,
161    )?
162    .into_iter()
163    .filter(|spec| !tree.match_rocks(&spec.package).is_ok_and(|m| m.is_found()))
164    .map(|spec| PackageInstallSpec {
165        build_behaviour: BuildBehaviour::Force,
166        ..spec
167    })
168    .collect::<Vec<_>>();
169
170    let build = gather_dependencies(workspace, DependencyKind::Build, &workspace.member_names()?)?
171        .into_iter()
172        .filter(|spec| {
173            !build_tree
174                .match_rocks(&spec.package)
175                .is_ok_and(|m| m.is_found())
176        })
177        .map(|spec| PackageInstallSpec {
178            build_behaviour: BuildBehaviour::Force,
179            ..spec
180        })
181        .collect::<Vec<_>>();
182
183    let test_tree = tree.test_tree(config)?;
184    let test = if args.targets.test {
185        gather_dependencies(workspace, DependencyKind::Test, &args.targets.members)?
186            .into_iter()
187            .filter(|spec| {
188                !test_tree
189                    .match_rocks(&spec.package)
190                    .is_ok_and(|m| m.is_found())
191            })
192            .map(|spec| PackageInstallSpec {
193                build_behaviour: BuildBehaviour::Force,
194                ..spec
195            })
196            .collect::<Vec<_>>()
197    } else {
198        Vec::new()
199    };
200
201    let package_db = PackageDB::open(config, workspace).await?;
202
203    let mut install = InstallPackages::new(config, &tree)
204        .packages(regular)
205        .build_packages(build)
206        .package_db(package_db);
207    if !test.is_empty() {
208        install = install.test_packages(test);
209    }
210
211    let (mut added, lockfile) = install.install().await?;
212
213    if !args.no_lock {
214        let mut workspace_lockfile = workspace.lockfile()?.write_guard();
215        lockfile.commit(&mut workspace_lockfile);
216        if args.targets.test {
217            workspace_lockfile.sync(
218                test_tree.lockfile()?.local_pkg_lock(),
219                &LockedPackageLockType::Test,
220            );
221        }
222    }
223
224    if !args.only_deps {
225        let projects = args
226            .targets
227            .members
228            .iter()
229            .map(|name| workspace.select_member(name))
230            .collect::<Result<Vec<_>, _>>()?;
231        for project in projects {
232            added.push(
233                InstallProject::new()
234                    .project(project)
235                    .config(config)
236                    .tree(&tree)
237                    .behaviour(args.behaviour)
238                    .workspace(workspace)
239                    .build()
240                    .await?,
241            );
242        }
243    }
244
245    if !args.no_lock {
246        GenLuaRc::new()
247            .config(config)
248            .workspace(workspace)
249            .generate_luarc()
250            .await?;
251    }
252
253    Ok(SyncReport {
254        added,
255        removed: Vec::new(),
256    })
257}
258
259#[derive(Clone, Copy)]
260pub(crate) enum DependencyKind {
261    Regular,
262    Build,
263    Test,
264}
265
266pub(crate) fn gather_dependencies(
267    workspace: &Workspace,
268    kind: DependencyKind,
269    members: &NonEmpty<PackageName>,
270) -> Result<Vec<PackageInstallSpec>, SyncError> {
271    let mut packages = Vec::new();
272    for project in workspace.members() {
273        if !members.contains(project.toml().package()) {
274            continue;
275        }
276        let toml = project.toml().into_local()?;
277        if let DependencyKind::Test = kind {
278            let explicit = toml.test_dependencies().current_platform();
279            for dependency in toml.test().current_platform().test_dependencies(project) {
280                if explicit.iter().any(|dep| dep.name() == dependency.name()) {
281                    continue;
282                }
283                packages.push(
284                    PackageInstallSpec::new(dependency, EntryType::Entrypoint)
285                        .build_behaviour(BuildBehaviour::Ignore)
286                        .build(),
287                );
288            }
289            for dependency in explicit {
290                if dependency.name().eq(&PackageName::new("lua".into())) {
291                    continue;
292                }
293                packages.push(
294                    PackageInstallSpec::new(
295                        dependency.package_req().clone(),
296                        EntryType::Entrypoint,
297                    )
298                    .build_behaviour(BuildBehaviour::Ignore)
299                    .pin(*dependency.pin())
300                    .opt(*dependency.opt())
301                    .maybe_source(dependency.source.clone())
302                    .build(),
303                );
304            }
305        } else {
306            let dependencies = match kind {
307                DependencyKind::Regular => toml.dependencies().current_platform().clone(),
308                _ => toml.build_dependencies().current_platform().clone(),
309            };
310            for dependency in dependencies {
311                // From the perspective of a project, dependencies are entrypoints.
312                packages.push(
313                    PackageInstallSpec::new(
314                        dependency.package_req().clone(),
315                        EntryType::Entrypoint,
316                    )
317                    .build_behaviour(BuildBehaviour::Ignore)
318                    .maybe_source(dependency.source().clone())
319                    .build(),
320                );
321            }
322        }
323    }
324    Ok(packages)
325}
326
327async fn sync_frozen(args: &Sync<'_>) -> Result<SyncReport, SyncError> {
328    let report = frozen::Sync::new(args.workspace, args.config)
329        .validate_integrity(args.validate_integrity.unwrap_or(true))
330        .test(args.targets.test)
331        .sync()
332        .await?;
333    Ok(report)
334}
335
336#[cfg(all(test, feature = "impure_tests"))]
337mod tests {
338    use std::path::PathBuf;
339
340    use assert_fs::prelude::{FileWriteStr, PathChild, PathCopy};
341
342    use crate::{
343        config::ConfigBuilder, lockfile::LockedPackageLockType, lua_version::LuaVersion,
344        package::PackageName, package_db::PackageDB, tree::InstallTree, workspace::Workspace,
345    };
346
347    use super::{Sync, SyncMode, TargetSet};
348
349    #[tokio::test]
350    async fn installs_workspace_dependencies() {
351        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
352            .join("resources/test/sample-projects/dependencies/");
353        let temp = assert_fs::TempDir::new().unwrap();
354        temp.copy_from(sample, &["**"]).unwrap();
355
356        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
357        let config = ConfigBuilder::new()
358            .unwrap()
359            .lua_version(Some(LuaVersion::Lua51))
360            .build()
361            .unwrap();
362
363        Sync::new(&workspace, &config)
364            .mode(SyncMode::Open)
365            .targets(TargetSet::all(&workspace, false))
366            .only_deps(true)
367            .sync()
368            .await
369            .unwrap();
370
371        let lockfile = workspace.lockfile().unwrap();
372        let rocks = lockfile.rocks(&LockedPackageLockType::Regular);
373        assert!(rocks
374            .values()
375            .any(|pkg| pkg.name().to_string() == "lua-cjson"));
376        assert!(rocks
377            .values()
378            .any(|pkg| pkg.name().to_string() == "plenary.nvim"));
379    }
380
381    #[tokio::test]
382    async fn install_honors_locked_version() {
383        let temp = assert_fs::TempDir::new().unwrap();
384        let toml = temp.child("lux.toml");
385        toml.write_str(
386            r#"
387            package = "locktest"
388            version = "0.1.0"
389            lua = ">=5.1"
390
391            [build]
392            type = "builtin"
393
394            [dependencies]
395            argparse = "==0.7.1-1"
396            "#,
397        )
398        .unwrap();
399
400        let config = ConfigBuilder::new()
401            .unwrap()
402            .lua_version(Some(LuaVersion::Lua51))
403            .build()
404            .unwrap();
405        let name = PackageName::from("argparse");
406
407        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
408        Sync::new(&workspace, &config)
409            .mode(SyncMode::Open)
410            .targets(TargetSet::all(&workspace, false))
411            .only_deps(true)
412            .sync()
413            .await
414            .unwrap();
415        let tree = workspace.tree(&config).unwrap();
416        assert_eq!(
417            tree.lockfile()
418                .unwrap()
419                .entrypoint(&name)
420                .unwrap()
421                .version()
422                .to_string(),
423            "0.7.1-1"
424        );
425
426        toml.write_str(
427            r#"
428            package = "locktest"
429            version = "0.1.0"
430            lua = ">=5.1"
431
432            [build]
433            type = "builtin"
434
435            [dependencies]
436            argparse = ">=0.7.1"
437            "#,
438        )
439        .unwrap();
440        std::fs::remove_dir_all(tree.root()).unwrap();
441
442        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
443        Sync::new(&workspace, &config)
444            .mode(SyncMode::Open)
445            .targets(TargetSet::all(&workspace, false))
446            .only_deps(true)
447            .sync()
448            .await
449            .unwrap();
450        let tree = workspace.tree(&config).unwrap();
451        assert_eq!(
452            tree.lockfile()
453                .unwrap()
454                .entrypoint(&name)
455                .unwrap()
456                .version()
457                .to_string(),
458            "0.7.1-1"
459        );
460    }
461
462    #[tokio::test]
463    async fn install_without_lock_resolves_latest() {
464        let temp = assert_fs::TempDir::new().unwrap();
465        temp.child("lux.toml")
466            .write_str(
467                r#"
468                package = "locktest"
469                version = "0.1.0"
470                lua = ">=5.1"
471
472                [build]
473                type = "builtin"
474
475                [dependencies]
476                argparse = ">=0.7.1"
477                "#,
478            )
479            .unwrap();
480
481        let config = ConfigBuilder::new()
482            .unwrap()
483            .lua_version(Some(LuaVersion::Lua51))
484            .build()
485            .unwrap();
486        let name = PackageName::from("argparse");
487
488        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
489        Sync::new(&workspace, &config)
490            .mode(SyncMode::Open)
491            .targets(TargetSet::all(&workspace, false))
492            .only_deps(true)
493            .sync()
494            .await
495            .unwrap();
496        let tree = workspace.tree(&config).unwrap();
497        let installed = tree
498            .lockfile()
499            .unwrap()
500            .entrypoint(&name)
501            .unwrap()
502            .version()
503            .clone();
504        let latest = PackageDB::from_config(&config)
505            .await
506            .unwrap()
507            .latest_version(&name)
508            .await
509            .unwrap();
510        assert_eq!(installed, latest);
511        assert_ne!(installed.to_string(), "0.7.1-1");
512    }
513
514    #[tokio::test]
515    async fn replaces_dependency_on_version_change() {
516        let temp = assert_fs::TempDir::new().unwrap();
517        let toml = temp.child("lux.toml");
518        toml.write_str(
519            r#"
520            package = "versionchange"
521            version = "0.1.0"
522            lua = ">=5.1"
523
524            [build]
525            type = "builtin"
526
527            [dependencies]
528            argparse = "==0.7.1-1"
529            "#,
530        )
531        .unwrap();
532
533        let config = ConfigBuilder::new()
534            .unwrap()
535            .lua_version(Some(LuaVersion::Lua51))
536            .build()
537            .unwrap();
538        let name = PackageName::from("argparse");
539
540        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
541        Sync::new(&workspace, &config)
542            .mode(SyncMode::Open)
543            .targets(TargetSet::all(&workspace, false))
544            .only_deps(true)
545            .sync()
546            .await
547            .unwrap();
548        let tree = workspace.tree(&config).unwrap();
549        assert_eq!(
550            tree.lockfile()
551                .unwrap()
552                .entrypoint(&name)
553                .unwrap()
554                .version()
555                .to_string(),
556            "0.7.1-1"
557        );
558
559        toml.write_str(
560            r#"
561            package = "versionchange"
562            version = "0.1.0"
563            lua = ">=5.1"
564
565            [build]
566            type = "builtin"
567
568            [dependencies]
569            argparse = "==0.7.2-1"
570            "#,
571        )
572        .unwrap();
573
574        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
575        Sync::new(&workspace, &config)
576            .mode(SyncMode::Open)
577            .targets(TargetSet::all(&workspace, false))
578            .only_deps(true)
579            .sync()
580            .await
581            .unwrap();
582        let tree = workspace.tree(&config).unwrap();
583        assert_eq!(
584            tree.lockfile()
585                .unwrap()
586                .entrypoint(&name)
587                .unwrap()
588                .version()
589                .to_string(),
590            "0.7.2-1"
591        );
592    }
593}