Skip to main content

cargo_resolvediff/
major_updates.rs

1// Copyright (C) 2026 by GiGa infosystems
2
3//! Handle major updates & related tasks
4
5use crate::{
6    indexed::IndexedMetadata,
7    toml_edit::{MutableTomlFile, TomlPathLookup},
8};
9use color_eyre::{Result, eyre::eyre};
10use crates_io_api::SyncClient;
11use itertools::Itertools;
12use semver::{Version, VersionReq};
13use std::{borrow::Borrow, collections::BTreeMap, fs, iter, path::PathBuf};
14use tinyvec::{ArrayVec, array_vec};
15
16/// Check whether a [`Version`] is considered a major update for a given [`VersionReq`].
17///
18/// Major updates are defined as:
19/// * Versions that don't match the requirement,
20/// * which are not pre-releases,
21/// * which aren't explicitly matched against using `<` or `<=`,
22/// * for which no equal or later version is mentioned in any semver operation
23pub fn is_major_update_for(requirement: &VersionReq, version: &Version) -> bool {
24    if requirement.matches(version) {
25        return false;
26    }
27
28    // NOTE: Don't automatically update pre-releases
29    if !version.pre.is_empty() {
30        return false;
31    }
32
33    let stripped_version = Version {
34        build: semver::BuildMetadata::EMPTY,
35        pre: semver::Prerelease::EMPTY,
36        ..*version
37    };
38
39    for i in &requirement.comparators {
40        let i_version = Version {
41            major: i.major,
42            minor: i.minor.unwrap_or(version.minor),
43            patch: i.patch.unwrap_or(version.patch),
44            pre: semver::Prerelease::EMPTY,
45            build: semver::BuildMetadata::EMPTY,
46        };
47
48        match i.op {
49            semver::Op::Less | semver::Op::LessEq => {
50                if i_version == stripped_version {
51                    // This version was explicitly not matched against
52                    return false;
53                }
54            }
55            semver::Op::Exact
56            | semver::Op::Greater
57            | semver::Op::GreaterEq
58            | semver::Op::Tilde
59            | semver::Op::Caret => {
60                if i_version >= stripped_version {
61                    return false;
62                }
63            }
64            semver::Op::Wildcard => unreachable!("Should've matched this version already"),
65            op => panic!("Unknown semver operation: {op:?}"),
66        }
67    }
68
69    true
70}
71
72/// Fetch all versions for a crate that have not been yanked.
73pub fn fetch_versions_for(
74    client: &SyncClient,
75    package: &str,
76) -> Result<Option<impl Iterator<Item = Version>>> {
77    let info = match client.get_crate(package) {
78        Ok(info) => info,
79        Err(crates_io_api::Error::NotFound(_)) => return Ok(None),
80        Err(err) => return Err(err.into()),
81    };
82    let versions = info
83        .versions
84        .into_iter()
85        .filter(|version| !version.yanked)
86        .map(|version| {
87            version
88                .num
89                .parse::<Version>()
90                .expect("Published crate version should be a valid `semver` version")
91        });
92    Ok(Some(versions))
93}
94
95/// Fetch all versions of a crate that are considered major updates for _any_ of the given
96/// [`VersionReq`]s and have not been yanked
97pub fn fetch_major_updates_for(
98    client: &SyncClient,
99    package: &str,
100    reqs: impl Iterator<Item: Borrow<VersionReq>> + Clone,
101) -> Result<Option<impl Iterator<Item = Version>>> {
102    let Some(versions) = fetch_versions_for(client, package)? else {
103        return Ok(None);
104    };
105    let versions = versions.filter(move |version| {
106        reqs.clone()
107            .any(|version_req| is_major_update_for(version_req.borrow(), version))
108    });
109    Ok(Some(versions))
110}
111
112/// The result of [`fetch_latest_major_update_for`]
113pub enum LatestVersion {
114    CrateNotFound,
115    NoMajorUpdates,
116    NewestUpdate(Version),
117}
118
119/// Fetch the latest versions of a crate that is considered a major update for _any_ of the given
120/// [`VersionReq`]s and has not been yanked
121pub fn fetch_latest_major_update_for(
122    client: &SyncClient,
123    package: &str,
124    reqs: impl Iterator<Item: Borrow<VersionReq>> + Clone,
125) -> Result<LatestVersion> {
126    let Some(versions) = fetch_major_updates_for(client, package, reqs)? else {
127        return Ok(LatestVersion::CrateNotFound);
128    };
129    let newest = versions.max();
130    Ok(newest.map_or(LatestVersion::NoMajorUpdates, LatestVersion::NewestUpdate))
131}
132
133/// A reference to a [crates.io] dependency version, part of [`ManifestDependencySet`]
134pub struct DependencyMention {
135    manifest_idx: usize,
136    /// The TOML path to the version specification
137    toml_path: Vec<String>,
138    version: VersionReq,
139}
140
141impl DependencyMention {
142    pub fn toml_path(&self) -> &[String] {
143        &self.toml_path
144    }
145
146    pub fn version(&self) -> &VersionReq {
147        &self.version
148    }
149}
150
151/// A set of manifests with the associated direct dependencies from [crates.io], with all instances
152/// of their version being requested
153pub struct ManifestDependencySet {
154    pub manifests: ManifestSet,
155    /// Maps crate names to [`DependencyMention`]s
156    pub dependencies: BTreeMap<String, Vec<DependencyMention>>,
157}
158
159impl ManifestDependencySet {
160    /// The paths in which dependencies can be listed in a given manifest
161    fn dependency_toml_paths(
162        manifest: &MutableTomlFile,
163    ) -> Result<impl Iterator<Item = ArrayVec<[&str; 3]>>> {
164        let targets = manifest
165            .document()
166            .as_table()
167            .get("target")
168            .map(|target| {
169                target.as_table_like().ok_or_else(|| {
170                    eyre!("Invalid target table in {:?} at `target`", manifest.path())
171                })
172            })
173            .transpose()?
174            .into_iter()
175            .flat_map(|target| target.iter().map(|(key, _)| key));
176
177        let dep_paths = iter::once(None)
178            .chain(targets.map(Some))
179            .cartesian_product(["dependencies", "build-dependencies", "dev-dependencies"])
180            .map(|(target, dep_kind)| {
181                target.map_or(
182                    array_vec!(_ => dep_kind),
183                    |target| array_vec!(_ => "target", target, dep_kind),
184                )
185            });
186
187        Ok(dep_paths)
188    }
189
190    /// Read a version from a given TOML path
191    fn read_version(manifest: &MutableTomlFile, path: &[String]) -> Result<VersionReq> {
192        let version = manifest
193            .path_lookup(path)
194            .expect("Version path lookup failed (maybe the `MutableTomlFile` changed?)")
195            .as_str()
196            .ok_or_else(|| {
197                eyre!(
198                    "Invalid `version`/immediate value in {path:?} at {:?}",
199                    manifest.path()
200                )
201            })?
202            .parse::<VersionReq>()?;
203        Ok(version)
204    }
205
206    /// Collect all dependencies from a set of manifests
207    fn collect_dependencies(
208        manifest_idx: usize,
209        manifest: &MutableTomlFile,
210        direct_dependencies: &mut BTreeMap<String, Vec<DependencyMention>>,
211    ) -> Result<()> {
212        for dep_path in Self::dependency_toml_paths(manifest)? {
213            let Some(dependencies) = manifest.path_lookup(dep_path) else {
214                continue;
215            };
216
217            let dependencies = dependencies.as_table_like().ok_or_else(|| {
218                eyre!(
219                    "Invalid dependency table in {:?} at {dep_path}",
220                    manifest.path()
221                )
222            })?;
223
224            for (name, dependency) in dependencies.iter() {
225                let (package, version_path_segment) =
226                    if let Some(dependency) = dependency.as_table_like() {
227                        let package = match dependency.get("package") {
228                            None => name,
229                            Some(package) => package.as_str().ok_or_else(|| {
230                                eyre!(
231                                    "Invalid `package` value in {:?} at {dep_path}.{name:?}",
232                                    manifest.path()
233                                )
234                            })?,
235                        };
236
237                        if dependency.contains_key("registry")
238                            || !dependency.contains_key("version")
239                            || dependency.contains_key("git")
240                        {
241                            continue;
242                        }
243
244                        (package, Some("version"))
245                    } else {
246                        (name, None)
247                    };
248
249                let version_path = dep_path
250                    .into_iter()
251                    .chain(iter::once(name))
252                    .chain(version_path_segment)
253                    .map(|s| s.to_owned())
254                    .collect::<Vec<_>>();
255
256                let version = Self::read_version(manifest, &version_path)?;
257
258                direct_dependencies
259                    .entry(package.to_owned())
260                    .or_default()
261                    .push(DependencyMention {
262                        manifest_idx,
263                        toml_path: version_path,
264                        version,
265                    })
266            }
267        }
268
269        Ok(())
270    }
271
272    /// Collect all direct dependencies from all workspace manifests which are part of an
273    /// [`IndexedMetadata`]
274    pub fn collect(metadata: &IndexedMetadata) -> Result<Self> {
275        let manifests = ManifestSet::collect(metadata)?;
276
277        let mut dependencies = BTreeMap::new();
278        for (idx, manifest) in manifests.manifests.iter().enumerate() {
279            Self::collect_dependencies(idx, manifest, &mut dependencies)?;
280        }
281
282        Ok(ManifestDependencySet {
283            manifests,
284            dependencies,
285        })
286    }
287
288    /// Commit all changes made to the [`ManifestSet`] (see [`MutableTomlFile::commit`])
289    pub fn commit(&mut self) -> Result<()> {
290        self.manifests.write_back()?;
291        self.manifests.commit_lock_contents()?;
292
293        // NOTE: Writing all back before committing allows rolling back if any of the write backs
294        // failed
295        for manifest in &mut self.manifests.manifests {
296            // NOTE: Should now be infallible since it's already been written back
297            manifest.commit()?;
298        }
299
300        Ok(())
301    }
302
303    /// Roll back all changes made to the [`ManifestSet`] (see [`MutableTomlFile::roll_back`]), and
304    /// reset the parsed dependency versions to the original values
305    pub fn roll_back(&mut self) -> Result<()> {
306        let mut errors = Vec::new();
307
308        if let Err(error) = self.manifests.roll_back_lock_contents() {
309            errors.push(error);
310        }
311
312        for manifest in &mut self.manifests.manifests {
313            if let Err(error) = manifest.roll_back() {
314                errors.push(error);
315            }
316        }
317
318        for mention in self.dependencies.values_mut().flatten() {
319            mention.version = Self::read_version(
320                &self.manifests.manifests[mention.manifest_idx],
321                &mention.toml_path,
322            )?;
323        }
324
325        if errors.is_empty() {
326            Ok(())
327        } else {
328            Err(eyre!("Failed to roll back:\n{errors:?}"))
329        }
330    }
331}
332
333/// A set of manifests for a workspace
334pub struct ManifestSet {
335    manifests: Vec<MutableTomlFile>,
336    lock_path: PathBuf,
337    last_lock_contents: String,
338}
339
340impl ManifestSet {
341    /// Collect all manifests from an [`IndexedMetadata`]
342    pub fn collect(metadata: &IndexedMetadata) -> Result<Self> {
343        let workspace_manifest = metadata.workspace_root.join("Cargo.toml");
344        let lock_path = workspace_manifest.with_extension("lock").into();
345
346        let mut member_manifests = metadata
347            .packages
348            .iter()
349            .filter(|(pkg_id, _)| metadata.workspace_members.contains(pkg_id))
350            .map(|(_, pkg)| &pkg.manifest_path)
351            .collect::<Vec<_>>();
352
353        let isnt_workspace = matches!(*member_manifests, [single] if *single == workspace_manifest);
354
355        if isnt_workspace {
356            member_manifests.clear();
357        }
358
359        let manifests = iter::once(&workspace_manifest)
360            .chain(member_manifests)
361            .map(MutableTomlFile::open)
362            .collect::<Result<Vec<_>>>()?;
363
364        let last_lock_contents = fs::read_to_string(&lock_path)?;
365
366        Ok(ManifestSet {
367            manifests,
368            lock_path,
369            last_lock_contents,
370        })
371    }
372
373    pub fn as_slice(&self) -> &[MutableTomlFile] {
374        &self.manifests
375    }
376
377    pub fn as_slice_mut(&mut self) -> &mut [MutableTomlFile] {
378        &mut self.manifests
379    }
380
381    /// Write back all manifests to the underlying files (see [`MutableTomlFile::write_back`])
382    pub fn write_back(&mut self) -> Result<()> {
383        for manifest in &mut self.manifests {
384            manifest.write_back()?;
385        }
386
387        Ok(())
388    }
389
390    /// Return a reference to  the manifest file associated with a given mention of a dependency
391    /// version
392    pub fn manifest_for(&self, mention: &DependencyMention) -> &MutableTomlFile {
393        &self.manifests[mention.manifest_idx]
394    }
395
396    /// Return a mutable reference to the manifest file associated with a given mention of a
397    /// dependency version
398    pub fn manifest_mut_for(&mut self, mention: &DependencyMention) -> &mut MutableTomlFile {
399        &mut self.manifests[mention.manifest_idx]
400    }
401
402    /// Write back the changes made to the manifest file associated with a given mention of a
403    /// dependency version
404    pub fn write_back_for(&mut self, mention: &DependencyMention) -> Result<()> {
405        self.manifest_mut_for(mention).write_back()?;
406        Ok(())
407    }
408
409    /// Write back the changes made to all manifest file associated with any of the given mentions
410    /// dependency versions
411    pub fn write_back_for_all(&mut self, mentions: &[DependencyMention]) -> Result<()> {
412        for mention in mentions {
413            self.write_back_for(mention)?;
414        }
415        Ok(())
416    }
417
418    /// Change a dependency version in memory only (requires calling a `write_back` or `commit`
419    /// method to actually change the underlying file)
420    pub fn write_version_to_memory(
421        &mut self,
422        mention: &mut DependencyMention,
423        version: VersionReq,
424    ) {
425        let Some(toml_edit::Value::String(toml_version)) = self
426            .manifest_mut_for(mention)
427            .path_lookup_mut(&mention.toml_path)
428            .and_then(toml_edit::Item::as_value_mut)
429        else {
430            panic!("Version path lookup failed (maybe the `MutableTomlFile` changed?)");
431        };
432        let decor = toml_version.decor().clone();
433
434        let as_string = match *version.comparators {
435            [ref single] if single.op == semver::Op::Caret => {
436                let mut out = version.to_string();
437                if out.starts_with('^') {
438                    out.remove(0); // Remove the caret
439                }
440                out
441            }
442            _ => version.to_string(),
443        };
444
445        *toml_version = toml_edit::Formatted::new(as_string);
446        *toml_version.decor_mut() = decor;
447
448        mention.version = version;
449    }
450
451    /// Change a dependency version in memory only (requires calling a `write_back` or `commit`
452    /// method to actually change the underlying file) for multiple mentions
453    pub fn write_versions_to_memory(
454        &mut self,
455        mentions: &mut [DependencyMention],
456        version: &VersionReq,
457    ) {
458        for mention in mentions {
459            self.write_version_to_memory(mention, version.clone());
460        }
461    }
462
463    /// Change a dependency version
464    pub fn write_version_to_file(
465        &mut self,
466        mention: &mut DependencyMention,
467        version: VersionReq,
468    ) -> Result<()> {
469        self.write_version_to_memory(mention, version);
470        self.write_back_for(mention)?;
471        Ok(())
472    }
473
474    /// Change a dependency version for multiple mentions
475    pub fn write_versions_to_file(
476        &mut self,
477        mentions: &mut [DependencyMention],
478        version: &VersionReq,
479    ) -> Result<()> {
480        self.write_versions_to_memory(mentions, version);
481        self.write_back_for_all(mentions)?;
482        Ok(())
483    }
484
485    /// Change a dependency version in memory if it is considered a major update
486    pub fn update_version_in_memory(&mut self, mention: &mut DependencyMention, version: &Version) {
487        if is_major_update_for(&mention.version, version) {
488            self.write_version_to_memory(
489                mention,
490                VersionReq {
491                    comparators: vec![semver::Comparator {
492                        op: semver::Op::Caret,
493                        major: version.major,
494                        minor: Some(version.minor),
495                        patch: Some(version.patch),
496                        pre: version.pre.clone(),
497                    }],
498                },
499            );
500        }
501    }
502
503    /// Change dependency versions in memory for each mention for which it is considered a major
504    /// update
505    pub fn update_versions_in_memory(
506        &mut self,
507        mentions: &mut [DependencyMention],
508        version: &Version,
509    ) {
510        for mention in mentions {
511            self.update_version_in_memory(mention, version);
512        }
513    }
514
515    /// Change a dependency version if it is considered a major update
516    pub fn update_version_in_file(
517        &mut self,
518        mention: &mut DependencyMention,
519        version: &Version,
520    ) -> Result<()> {
521        self.update_version_in_memory(mention, version);
522        self.write_back_for(mention)?;
523        Ok(())
524    }
525
526    /// Change dependency versions for each mention for which it is considered a major update
527    pub fn update_versions_in_file(
528        &mut self,
529        mentions: &mut [DependencyMention],
530        version: &Version,
531    ) -> Result<()> {
532        self.update_versions_in_memory(mentions, version);
533        self.write_back_for_all(mentions)?;
534        Ok(())
535    }
536
537    pub fn commit_lock_contents(&mut self) -> Result<()> {
538        self.last_lock_contents = fs::read_to_string(&self.lock_path)?;
539        Ok(())
540    }
541
542    pub fn roll_back_lock_contents(&mut self) -> Result<()> {
543        fs::write(&self.lock_path, &self.last_lock_contents)?;
544        Ok(())
545    }
546}