Skip to main content

lux_lib/operations/
update.rs

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