Skip to main content

lux_lib/operations/
update.rs

1use std::io;
2
3use crate::{
4    build::BuildBehaviour,
5    config::Config,
6    lockfile::{
7        LockedPackage, LockedPackageLockType, Lockfile, PinnedState, ReadOnly, ReadWrite,
8        WorkspaceLockfile,
9    },
10    lua_version::{LuaVersion, LuaVersionUnset},
11    package::{PackageReq, RockConstraintUnsatisfied},
12    package_db::{PackageDB, PackageDBError},
13    remote_package_source::RemotePackageSource,
14    tree::{self, InstallTree, Tree, TreeError},
15    workspace::{Workspace, WorkspaceError, WorkspaceTreeError},
16};
17use bon::Builder;
18use itertools::Itertools;
19use miette::Diagnostic;
20use thiserror::Error;
21
22use super::{PackageInstallSpec, RemoveError, Uninstall};
23
24use crate::drivers::install_packages::{InstallPackages, InstallPackagesError};
25use crate::drivers::sync::{Sync, SyncError, SyncMode, TargetSet};
26
27#[derive(Error, Debug, Diagnostic)]
28pub enum UpdateError {
29    #[error(transparent)]
30    #[diagnostic(transparent)]
31    RockConstraintUnsatisfied(#[from] RockConstraintUnsatisfied),
32    #[error("failed to update rock")]
33    #[diagnostic(forward(0))]
34    Install(#[from] Box<InstallPackagesError>),
35    #[error("failed to remove old rock")]
36    #[diagnostic(forward(0))]
37    Remove(#[from] RemoveError),
38    #[error("error initialising remote package DB")]
39    #[diagnostic(forward(0))]
40    PackageDB(#[from] PackageDBError),
41    #[error("error loading the workspace")]
42    #[diagnostic(forward(0))]
43    Workspace(#[from] WorkspaceError),
44    #[error(transparent)]
45    #[diagnostic(transparent)]
46    LuaVersionUnset(#[from] LuaVersionUnset),
47    #[error(transparent)]
48    Io(#[from] io::Error),
49    #[error(transparent)]
50    #[diagnostic(transparent)]
51    Tree(#[from] TreeError),
52    #[error("error initialising the workspace install tree")]
53    #[diagnostic(forward(0))]
54    WorkspaceTree(#[from] WorkspaceTreeError),
55    #[error("error syncing the workspace install tree")]
56    #[diagnostic(forward(0))]
57    Sync(#[from] Box<SyncError>),
58}
59
60impl From<InstallPackagesError> for UpdateError {
61    fn from(source: InstallPackagesError) -> Self {
62        Self::Install(Box::new(source))
63    }
64}
65
66impl From<SyncError> for UpdateError {
67    fn from(source: SyncError) -> Self {
68        Self::Sync(Box::new(source))
69    }
70}
71
72/// A rocks package updater, providing fine-grained control
73/// over how packages should be updated.
74/// Can update multiple packages in parallel.
75#[derive(Builder)]
76#[builder(start_fn = new, finish_fn(name = _update, vis = ""))]
77pub struct Update<'a> {
78    #[builder(start_fn)]
79    config: &'a Config,
80
81    /// Packages to update.
82    #[builder(field)]
83    packages: Option<Vec<PackageReq>>,
84
85    /// Test dependencies to update.
86    #[builder(field)]
87    test_dependencies: Option<Vec<PackageReq>>,
88
89    /// Build dependencies to update.
90    #[builder(field)]
91    build_dependencies: Option<Vec<PackageReq>>,
92
93    /// Workspace to use for resolving the dependency tree.
94    /// Uses the current workspace if None.
95    workspace: Option<Workspace>,
96
97    /// Whether to validate the integrity when syncing the project lockfile.
98    validate_integrity: Option<bool>,
99
100    package_db: Option<PackageDB>,
101}
102
103impl<State: update_builder::State> UpdateBuilder<'_, State> {
104    pub fn packages(mut self, packages: Option<Vec<PackageReq>>) -> Self {
105        self.packages = packages;
106        self
107    }
108    pub fn build_dependencies(mut self, packages: Option<Vec<PackageReq>>) -> Self {
109        self.build_dependencies = packages;
110        self
111    }
112    pub fn test_dependencies(mut self, packages: Option<Vec<PackageReq>>) -> Self {
113        self.test_dependencies = packages;
114        self
115    }
116}
117
118impl<State: update_builder::State> UpdateBuilder<'_, State> {
119    #[tracing::instrument(name = "Updating packages", skip_all)]
120    pub async fn update(self) -> Result<Vec<LockedPackage>, UpdateError>
121    where
122        State: update_builder::IsComplete,
123    {
124        let args = self._update();
125
126        if args
127            .packages
128            .as_ref()
129            .is_some_and(|packages| packages.is_empty())
130        {
131            return Ok(Vec::default());
132        }
133
134        let package_db = match &args.package_db {
135            Some(db) => db.clone(),
136            None => {
137                let db = PackageDB::from_config(args.config).await?;
138                db
139            }
140        };
141
142        let workspace = match args.workspace.clone() {
143            Some(ws) => Some(ws),
144            None => Workspace::current()?,
145        };
146
147        match workspace {
148            Some(workspace) => Box::pin(update_workspace(workspace, args, package_db)).await,
149            None => Box::pin(update_install_tree(args, package_db)).await,
150        }
151    }
152}
153
154async fn update_workspace(
155    workspace: Workspace,
156    args: Update<'_>,
157    package_db: PackageDB,
158) -> Result<Vec<LockedPackage>, UpdateError> {
159    let tree = workspace.tree(args.config)?;
160
161    let sync_report = Sync::new(&workspace, args.config)
162        .mode(SyncMode::Frozen)
163        .targets(TargetSet::all(&workspace, true))
164        .validate_integrity(args.validate_integrity.unwrap_or(false))
165        .sync()
166        .await?;
167
168    let mut project_lockfile = workspace.lockfile()?.write_guard();
169
170    let updated_dependencies = update_dependency_tree(
171        tree,
172        &mut project_lockfile,
173        LockedPackageLockType::Regular,
174        package_db.clone(),
175        args.config,
176        &args.packages,
177    )
178    .await?;
179
180    let test_tree = workspace.test_tree(args.config)?;
181
182    let updated_test_dependencies = update_dependency_tree(
183        test_tree,
184        &mut project_lockfile,
185        LockedPackageLockType::Test,
186        package_db.clone(),
187        args.config,
188        &args.test_dependencies,
189    )
190    .await?;
191
192    let build_tree = workspace.build_tree(args.config)?;
193
194    let updated_build_dependencies = update_dependency_tree(
195        build_tree,
196        &mut project_lockfile,
197        LockedPackageLockType::Build,
198        package_db,
199        args.config,
200        &args.build_dependencies,
201    )
202    .await?;
203
204    Ok(updated_dependencies
205        .into_iter()
206        .chain(sync_report.added)
207        .chain(sync_report.removed)
208        .chain(updated_build_dependencies)
209        .chain(updated_test_dependencies)
210        .collect_vec())
211}
212
213async fn update_dependency_tree(
214    tree: Tree,
215    project_lockfile: &mut WorkspaceLockfile<ReadWrite>,
216    lock_type: LockedPackageLockType,
217    package_db: PackageDB,
218    config: &Config,
219    packages: &Option<Vec<PackageReq>>,
220) -> Result<Vec<LockedPackage>, UpdateError> {
221    let lockfile = tree.lockfile()?;
222    let dependencies = updatable_packages(&lockfile)
223        .into_iter()
224        .filter(|pkg| is_included(pkg, packages))
225        .collect_vec();
226    let updated_dependencies = update(dependencies, package_db, &tree, &lockfile, config).await?;
227    if !updated_dependencies.is_empty() {
228        let updated_lockfile = tree.lockfile()?;
229        project_lockfile.sync(updated_lockfile.local_pkg_lock(), &lock_type);
230    }
231    Ok(updated_dependencies)
232}
233
234fn is_included(
235    (pkg, _): &(LockedPackage, PackageReq),
236    package_reqs: &Option<Vec<PackageReq>>,
237) -> bool {
238    package_reqs.is_none()
239        || package_reqs.as_ref().is_some_and(|packages| {
240            packages
241                .iter()
242                .any(|req| req.matches(&pkg.as_package_spec()))
243        })
244}
245
246async fn update_install_tree(
247    args: Update<'_>,
248    package_db: PackageDB,
249) -> Result<Vec<LockedPackage>, UpdateError> {
250    let tree = args
251        .config
252        .user_tree(LuaVersion::from(args.config)?.clone())?;
253    let lockfile = tree.lockfile()?;
254    let packages = updatable_packages(&lockfile)
255        .into_iter()
256        .filter(|pkg| is_included(pkg, &args.packages))
257        .collect_vec();
258    update(packages, package_db, &tree, &lockfile, args.config).await
259}
260
261async fn update(
262    packages: Vec<(LockedPackage, PackageReq)>,
263    package_db: PackageDB,
264    tree: &Tree,
265    lockfile: &Lockfile<ReadOnly>,
266    config: &Config,
267) -> Result<Vec<LockedPackage>, UpdateError> {
268    let mut updatable = Vec::new();
269    for (package, constraint) in packages.clone() {
270        match package
271            .to_package()
272            .has_update_with(&constraint, &package_db)
273            .await
274        {
275            Ok(Some(_)) if package.pinned() == PinnedState::Unpinned => {
276                updatable.push((package, constraint));
277            }
278            _ => {}
279        }
280    }
281    if updatable.is_empty() {
282        Ok(Vec::new())
283    } else {
284        Uninstall::new()
285            .config(config)
286            .packages(updatable.iter().map(|(package, _)| package.id()))
287            .remove()
288            .await?;
289        let (updated_packages, _lockfile) = InstallPackages::new(config, tree)
290            .packages(
291                updatable
292                    .iter()
293                    .map(|updatable| mk_install_spec(updatable, lockfile))
294                    .collect(),
295            )
296            .package_db(package_db)
297            .install()
298            .await?;
299        Ok(updated_packages)
300    }
301}
302
303fn updatable_packages(lockfile: &Lockfile<ReadOnly>) -> Vec<(LockedPackage, PackageReq)> {
304    lockfile
305        .rocks()
306        .values()
307        .filter(|package| {
308            package.pinned() == PinnedState::Unpinned
309                && match package.source() {
310                    RemotePackageSource::LuarocksRockspec(_) => true,
311                    RemotePackageSource::LuarocksSrcRock(_) => true,
312                    RemotePackageSource::LuarocksBinaryRock(_) => true,
313                    RemotePackageSource::Wally(_) => true,
314                    // We don't support updating git sources or local packages
315                    RemotePackageSource::RockspecContent(_) => false,
316                    RemotePackageSource::Local => false,
317                    #[cfg(test)]
318                    RemotePackageSource::Test => false,
319                }
320        })
321        .map(|package| (package.clone(), package.spec().as_package_req()))
322        .collect_vec()
323}
324
325fn mk_install_spec(
326    (package, req): &(LockedPackage, PackageReq),
327    lockfile: &Lockfile<ReadOnly>,
328) -> PackageInstallSpec {
329    let entry_type = if lockfile.is_entrypoint(&package.id()) {
330        tree::EntryType::Entrypoint
331    } else {
332        tree::EntryType::DependencyOnly
333    };
334    PackageInstallSpec::new(req.clone(), entry_type)
335        .build_behaviour(BuildBehaviour::Force)
336        .pin(PinnedState::Unpinned)
337        .opt(package.opt())
338        .build()
339}
340
341#[cfg(all(test, feature = "impure_tests"))]
342mod tests {
343    use std::path::PathBuf;
344
345    use assert_fs::prelude::PathCopy;
346
347    use crate::{
348        config::ConfigBuilder, lockfile::LockedPackageLockType, lua_version::LuaVersion,
349        package_db::PackageDB, workspace::Workspace,
350    };
351
352    use super::Update;
353
354    #[tokio::test]
355    async fn update_preserves_pessimistic_req_and_persists_lockfile() {
356        let sample = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
357            .join("resources/test/sample-projects/no-build-spec/");
358        let temp = assert_fs::TempDir::new().unwrap();
359        temp.copy_from(sample, &["**"]).unwrap();
360        let toml_path = temp.path().join("lux.toml");
361        let manifest = std::fs::read_to_string(&toml_path).unwrap();
362        std::fs::write(
363            &toml_path,
364            format!("{manifest}\n[dependencies]\n30log = \"~> 0.9\"\n"),
365        )
366        .unwrap();
367
368        let config = ConfigBuilder::new()
369            .unwrap()
370            .lua_version(Some(LuaVersion::Lua51))
371            .build()
372            .unwrap();
373        let package_db = PackageDB::from_config(&config).await.unwrap();
374
375        let mut workspace = Workspace::from_exact(temp.path()).unwrap().unwrap();
376        workspace
377            .members_mut()
378            .iter_mut()
379            .next()
380            .unwrap()
381            .upgrade_all(&package_db, &config)
382            .await
383            .unwrap();
384
385        let rewritten = std::fs::read_to_string(&toml_path).unwrap();
386        assert!(
387            rewritten.contains("30log = \"~>"),
388            "upgrade overwrote the pessimistic req: {rewritten}"
389        );
390        assert!(
391            !rewritten.contains("30log = \"~> 0.9\""),
392            "upgrade did not widen the pessimistic req: {rewritten}"
393        );
394
395        Update::new(&config)
396            .workspace(workspace.clone())
397            .package_db(package_db.clone())
398            .update()
399            .await
400            .unwrap();
401
402        let reloaded = Workspace::from_exact(temp.path()).unwrap().unwrap();
403        let lockfile = reloaded.lockfile().unwrap();
404        assert!(
405            lockfile
406                .rocks(&LockedPackageLockType::Regular)
407                .values()
408                .any(|pkg| pkg.name().to_string() == "30log"),
409            "lockfile was not persisted"
410        );
411    }
412}