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