Skip to main content

lux_lib/drivers/sync/
mod.rs

1use self::reconcile::SyncReport;
2use crate::package;
3use bon::Builder;
4use miette::Diagnostic;
5use nonempty::NonEmpty;
6use thiserror::Error;
7
8mod frozen;
9pub(crate) mod reconcile;
10
11use crate::{
12    build::BuildBehaviour,
13    config::Config,
14    drivers::install_packages::{self, InstallPackages},
15    lockfile::LockedPackageLockType,
16    operations::{
17        GenLuaRc, GenLuaRcError, GenLuauRc, GenLuauRcError, InstallProject, InstallProjectError,
18        PackageInstallSpec,
19    },
20    package_db::{self, PackageDB},
21    project::project_toml::LocalProjectTomlValidationError,
22    rockspec::Rockspec,
23    tree::{self, EntryType, InstallTree},
24    workspace::{self, TreeError, Workspace},
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<package::Name>,
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: package::Name, 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] TreeError),
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<package::Name>,
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(&package::Name::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 crate::package;
350    use std::path::PathBuf;
351
352    use assert_fs::prelude::{FileWriteStr, PathChild, PathCopy};
353
354    use crate::{
355        config::ConfigBuilder, lockfile::LockedPackageLockType, lua_version::LuaVersion,
356        package_db::PackageDB, tree::InstallTree, workspace::Workspace,
357    };
358
359    use super::{Sync, SyncMode, TargetSet};
360
361    #[tokio::test]
362    async fn installs_workspace_dependencies() {
363        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
364            .join("resources/test/sample-projects/dependencies/");
365        let temp = assert_fs::TempDir::new().unwrap();
366        temp.copy_from(sample, &["**"]).unwrap();
367
368        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
369        let config = ConfigBuilder::new()
370            .unwrap()
371            .lua_version(Some(LuaVersion::Lua51))
372            .build()
373            .unwrap();
374
375        Sync::new(&workspace, &config)
376            .mode(SyncMode::Open)
377            .targets(TargetSet::all(&workspace, false))
378            .only_deps(true)
379            .sync()
380            .await
381            .unwrap();
382
383        let lockfile = workspace.lockfile().unwrap();
384        let rocks = lockfile.rocks(&LockedPackageLockType::Regular);
385        assert!(rocks
386            .values()
387            .any(|pkg| pkg.name().to_string() == "lua-cjson"));
388        assert!(rocks
389            .values()
390            .any(|pkg| pkg.name().to_string() == "plenary.nvim"));
391    }
392
393    #[tokio::test]
394    async fn install_honors_locked_version() {
395        let temp = assert_fs::TempDir::new().unwrap();
396        let toml = temp.child("lux.toml");
397        toml.write_str(
398            r#"
399            package = "locktest"
400            version = "0.1.0"
401            lua = ">=5.1"
402
403            [build]
404            type = "builtin"
405
406            [dependencies]
407            argparse = "==0.7.1-1"
408            "#,
409        )
410        .unwrap();
411
412        let config = ConfigBuilder::new()
413            .unwrap()
414            .lua_version(Some(LuaVersion::Lua51))
415            .build()
416            .unwrap();
417        let name = package::Name::from("argparse");
418
419        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
420        Sync::new(&workspace, &config)
421            .mode(SyncMode::Open)
422            .targets(TargetSet::all(&workspace, false))
423            .only_deps(true)
424            .sync()
425            .await
426            .unwrap();
427        let tree = workspace.tree(&config).unwrap();
428        assert_eq!(
429            tree.lockfile()
430                .unwrap()
431                .entrypoint(&name)
432                .unwrap()
433                .version()
434                .to_string(),
435            "0.7.1-1"
436        );
437
438        toml.write_str(
439            r#"
440            package = "locktest"
441            version = "0.1.0"
442            lua = ">=5.1"
443
444            [build]
445            type = "builtin"
446
447            [dependencies]
448            argparse = ">=0.7.1"
449            "#,
450        )
451        .unwrap();
452        std::fs::remove_dir_all(tree.root()).unwrap();
453
454        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
455        Sync::new(&workspace, &config)
456            .mode(SyncMode::Open)
457            .targets(TargetSet::all(&workspace, false))
458            .only_deps(true)
459            .sync()
460            .await
461            .unwrap();
462        let tree = workspace.tree(&config).unwrap();
463        assert_eq!(
464            tree.lockfile()
465                .unwrap()
466                .entrypoint(&name)
467                .unwrap()
468                .version()
469                .to_string(),
470            "0.7.1-1"
471        );
472    }
473
474    #[tokio::test]
475    async fn install_without_lock_resolves_latest() {
476        let temp = assert_fs::TempDir::new().unwrap();
477        temp.child("lux.toml")
478            .write_str(
479                r#"
480                package = "locktest"
481                version = "0.1.0"
482                lua = ">=5.1"
483
484                [build]
485                type = "builtin"
486
487                [dependencies]
488                argparse = ">=0.7.1"
489                "#,
490            )
491            .unwrap();
492
493        let config = ConfigBuilder::new()
494            .unwrap()
495            .lua_version(Some(LuaVersion::Lua51))
496            .build()
497            .unwrap();
498        let name = package::Name::from("argparse");
499
500        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
501        Sync::new(&workspace, &config)
502            .mode(SyncMode::Open)
503            .targets(TargetSet::all(&workspace, false))
504            .only_deps(true)
505            .sync()
506            .await
507            .unwrap();
508        let tree = workspace.tree(&config).unwrap();
509        let installed = tree
510            .lockfile()
511            .unwrap()
512            .entrypoint(&name)
513            .unwrap()
514            .version()
515            .clone();
516        let latest = PackageDB::from_config(&config)
517            .await
518            .unwrap()
519            .latest_version(&name)
520            .await
521            .unwrap();
522        assert_eq!(installed, latest);
523        assert_ne!(installed.to_string(), "0.7.1-1");
524    }
525
526    #[tokio::test]
527    async fn replaces_dependency_on_version_change() {
528        let temp = assert_fs::TempDir::new().unwrap();
529        let toml = temp.child("lux.toml");
530        toml.write_str(
531            r#"
532            package = "versionchange"
533            version = "0.1.0"
534            lua = ">=5.1"
535
536            [build]
537            type = "builtin"
538
539            [dependencies]
540            argparse = "==0.7.1-1"
541            "#,
542        )
543        .unwrap();
544
545        let config = ConfigBuilder::new()
546            .unwrap()
547            .lua_version(Some(LuaVersion::Lua51))
548            .build()
549            .unwrap();
550        let name = package::Name::from("argparse");
551
552        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
553        Sync::new(&workspace, &config)
554            .mode(SyncMode::Open)
555            .targets(TargetSet::all(&workspace, false))
556            .only_deps(true)
557            .sync()
558            .await
559            .unwrap();
560        let tree = workspace.tree(&config).unwrap();
561        assert_eq!(
562            tree.lockfile()
563                .unwrap()
564                .entrypoint(&name)
565                .unwrap()
566                .version()
567                .to_string(),
568            "0.7.1-1"
569        );
570
571        toml.write_str(
572            r#"
573            package = "versionchange"
574            version = "0.1.0"
575            lua = ">=5.1"
576
577            [build]
578            type = "builtin"
579
580            [dependencies]
581            argparse = "==0.7.2-1"
582            "#,
583        )
584        .unwrap();
585
586        let workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
587        Sync::new(&workspace, &config)
588            .mode(SyncMode::Open)
589            .targets(TargetSet::all(&workspace, false))
590            .only_deps(true)
591            .sync()
592            .await
593            .unwrap();
594        let tree = workspace.tree(&config).unwrap();
595        assert_eq!(
596            tree.lockfile()
597                .unwrap()
598                .entrypoint(&name)
599                .unwrap()
600                .version()
601                .to_string(),
602            "0.7.2-1"
603        );
604    }
605}