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::{self, InstallPackages},
14    lockfile::LockedPackageLockType,
15    operations::{
16        GenLuaRc, GenLuaRcError, GenLuauRc, GenLuauRcError, InstallProject, InstallProjectError,
17        PackageInstallSpec,
18    },
19    package::PackageName,
20    package_db::{self, PackageDB},
21    project::project_toml::LocalProjectTomlValidationError,
22    rockspec::Rockspec,
23    tree::{self, EntryType, InstallTree},
24    workspace::{self, Workspace, 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 Error {
63    #[error(transparent)]
64    #[diagnostic(transparent)]
65    InstallPackages(#[from] Box<install_packages::Error>),
66    #[error(transparent)]
67    #[diagnostic(transparent)]
68    InstallProject(Box<InstallProjectError>),
69    #[error(transparent)]
70    #[diagnostic(transparent)]
71    Workspace(#[from] workspace::Error),
72    #[error(transparent)]
73    #[diagnostic(transparent)]
74    WorkspaceTree(#[from] WorkspaceTreeError),
75    #[error(transparent)]
76    #[diagnostic(transparent)]
77    Tree(#[from] tree::Error),
78    #[error(transparent)]
79    #[diagnostic(transparent)]
80    Project(#[from] LocalProjectTomlValidationError),
81    #[error(transparent)]
82    #[diagnostic(transparent)]
83    PackageDB(#[from] package_db::Error),
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<install_packages::Error> for Error {
96    fn from(source: install_packages::Error) -> Self {
97        Self::InstallPackages(Box::new(source))
98    }
99}
100
101impl From<InstallProjectError> for Error {
102    fn from(source: InstallProjectError) -> Self {
103        Self::InstallProject(Box::new(source))
104    }
105}
106
107impl From<frozen::SyncError> for Error {
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, Error> {
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, Error> {
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>, Error> {
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                    .pin(*dependency.pin())
328                    .opt(*dependency.opt())
329                    .maybe_source(dependency.source().clone())
330                    .build(),
331                );
332            }
333        }
334    }
335    Ok(packages)
336}
337
338async fn sync_frozen(args: &Sync<'_>) -> Result<SyncReport, Error> {
339    let report = frozen::Sync::new(args.workspace, args.config)
340        .validate_integrity(args.validate_integrity.unwrap_or(true))
341        .test(args.targets.test)
342        .sync()
343        .await?;
344    Ok(report)
345}
346
347#[cfg(all(test, feature = "impure_tests"))]
348mod tests {
349    use std::path::PathBuf;
350
351    use assert_fs::prelude::{FileWriteStr, PathChild, PathCopy};
352
353    use crate::{
354        config::ConfigBuilder, lockfile::LockedPackageLockType, lua_version::LuaVersion,
355        package::PackageName, package_db::PackageDB, tree::InstallTree, workspace::Workspace,
356    };
357
358    use super::{Sync, SyncMode, TargetSet};
359
360    #[tokio::test]
361    async fn installs_workspace_dependencies() {
362        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
363            .join("resources/test/sample-projects/dependencies/");
364        let temp = assert_fs::TempDir::new().unwrap();
365        temp.copy_from(sample, &["**"]).unwrap();
366
367        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
368        let config = ConfigBuilder::new()
369            .unwrap()
370            .lua_version(Some(LuaVersion::Lua51))
371            .build()
372            .unwrap();
373
374        Sync::new(&workspace, &config)
375            .mode(SyncMode::Open)
376            .targets(TargetSet::all(&workspace, false))
377            .only_deps(true)
378            .sync()
379            .await
380            .unwrap();
381
382        let lockfile = workspace.lockfile().unwrap();
383        let rocks = lockfile.rocks(&LockedPackageLockType::Regular);
384        assert!(rocks
385            .values()
386            .any(|pkg| pkg.name().to_string() == "lua-cjson"));
387        assert!(rocks
388            .values()
389            .any(|pkg| pkg.name().to_string() == "plenary.nvim"));
390    }
391
392    #[tokio::test]
393    async fn install_honors_locked_version() {
394        let temp = assert_fs::TempDir::new().unwrap();
395        let toml = temp.child("lux.toml");
396        toml.write_str(
397            r#"
398            package = "locktest"
399            version = "0.1.0"
400            lua = ">=5.1"
401
402            [build]
403            type = "builtin"
404
405            [dependencies]
406            argparse = "==0.7.1-1"
407            "#,
408        )
409        .unwrap();
410
411        let config = ConfigBuilder::new()
412            .unwrap()
413            .lua_version(Some(LuaVersion::Lua51))
414            .build()
415            .unwrap();
416        let name = PackageName::from("argparse");
417
418        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
419        Sync::new(&workspace, &config)
420            .mode(SyncMode::Open)
421            .targets(TargetSet::all(&workspace, false))
422            .only_deps(true)
423            .sync()
424            .await
425            .unwrap();
426        let tree = workspace.tree(&config).unwrap();
427        assert_eq!(
428            tree.lockfile()
429                .unwrap()
430                .entrypoint(&name)
431                .unwrap()
432                .version()
433                .to_string(),
434            "0.7.1-1"
435        );
436
437        toml.write_str(
438            r#"
439            package = "locktest"
440            version = "0.1.0"
441            lua = ">=5.1"
442
443            [build]
444            type = "builtin"
445
446            [dependencies]
447            argparse = ">=0.7.1"
448            "#,
449        )
450        .unwrap();
451        std::fs::remove_dir_all(tree.root()).unwrap();
452
453        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
454        Sync::new(&workspace, &config)
455            .mode(SyncMode::Open)
456            .targets(TargetSet::all(&workspace, false))
457            .only_deps(true)
458            .sync()
459            .await
460            .unwrap();
461        let tree = workspace.tree(&config).unwrap();
462        assert_eq!(
463            tree.lockfile()
464                .unwrap()
465                .entrypoint(&name)
466                .unwrap()
467                .version()
468                .to_string(),
469            "0.7.1-1"
470        );
471    }
472
473    #[tokio::test]
474    async fn install_without_lock_resolves_latest() {
475        let temp = assert_fs::TempDir::new().unwrap();
476        temp.child("lux.toml")
477            .write_str(
478                r#"
479                package = "locktest"
480                version = "0.1.0"
481                lua = ">=5.1"
482
483                [build]
484                type = "builtin"
485
486                [dependencies]
487                argparse = ">=0.7.1"
488                "#,
489            )
490            .unwrap();
491
492        let config = ConfigBuilder::new()
493            .unwrap()
494            .lua_version(Some(LuaVersion::Lua51))
495            .build()
496            .unwrap();
497        let name = PackageName::from("argparse");
498
499        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
500        Sync::new(&workspace, &config)
501            .mode(SyncMode::Open)
502            .targets(TargetSet::all(&workspace, false))
503            .only_deps(true)
504            .sync()
505            .await
506            .unwrap();
507        let tree = workspace.tree(&config).unwrap();
508        let installed = tree
509            .lockfile()
510            .unwrap()
511            .entrypoint(&name)
512            .unwrap()
513            .version()
514            .clone();
515        let latest = PackageDB::from_config(&config)
516            .await
517            .unwrap()
518            .latest_version(&name)
519            .await
520            .unwrap();
521        assert_eq!(installed, latest);
522        assert_ne!(installed.to_string(), "0.7.1-1");
523    }
524
525    #[tokio::test]
526    async fn replaces_dependency_on_version_change() {
527        let temp = assert_fs::TempDir::new().unwrap();
528        let toml = temp.child("lux.toml");
529        toml.write_str(
530            r#"
531            package = "versionchange"
532            version = "0.1.0"
533            lua = ">=5.1"
534
535            [build]
536            type = "builtin"
537
538            [dependencies]
539            argparse = "==0.7.1-1"
540            "#,
541        )
542        .unwrap();
543
544        let config = ConfigBuilder::new()
545            .unwrap()
546            .lua_version(Some(LuaVersion::Lua51))
547            .build()
548            .unwrap();
549        let name = PackageName::from("argparse");
550
551        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
552        Sync::new(&workspace, &config)
553            .mode(SyncMode::Open)
554            .targets(TargetSet::all(&workspace, false))
555            .only_deps(true)
556            .sync()
557            .await
558            .unwrap();
559        let tree = workspace.tree(&config).unwrap();
560        assert_eq!(
561            tree.lockfile()
562                .unwrap()
563                .entrypoint(&name)
564                .unwrap()
565                .version()
566                .to_string(),
567            "0.7.1-1"
568        );
569
570        toml.write_str(
571            r#"
572            package = "versionchange"
573            version = "0.1.0"
574            lua = ">=5.1"
575
576            [build]
577            type = "builtin"
578
579            [dependencies]
580            argparse = "==0.7.2-1"
581            "#,
582        )
583        .unwrap();
584
585        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
586        Sync::new(&workspace, &config)
587            .mode(SyncMode::Open)
588            .targets(TargetSet::all(&workspace, false))
589            .only_deps(true)
590            .sync()
591            .await
592            .unwrap();
593        let tree = workspace.tree(&config).unwrap();
594        assert_eq!(
595            tree.lockfile()
596                .unwrap()
597                .entrypoint(&name)
598                .unwrap()
599                .version()
600                .to_string(),
601            "0.7.2-1"
602        );
603    }
604}