cargo-upgrades 3.0.0

Checks if dependencies in Cargo.toml are up to date. Compatible with workspaces and path dependencies.
Documentation
//! Install with:
//!
//! ```bash
//! cargo install cargo-upgrades
//! ```
pub use cargo_metadata::Error as MetadataError;
use cargo_metadata::{CargoOpt, Dependency, Metadata, MetadataCommand, Package};
pub use crates_index::Error as IndexError;
pub use crates_index::sparse::URL as CRATES_IO;
use crates_index::{Crate, SparseIndex};
use quick_error::quick_error;
use std::collections::hash_map::Entry;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex, RwLock};
use semver::Version;
use std::sync::atomic::{AtomicU32, Ordering};
use std::thread;
use std::time::Duration;

quick_error! {
    #[derive(Debug)]
    pub enum Error {
        Index(err: IndexError, reg: Box<str>) {
            display("can't fetch index {reg}")
            source(err)
        }
        PackageNotFound {
            display("package not found in the metadata")
        }
        Metadata(err: MetadataError) {
            from()
            display("can't get crate metadata")
            source(err)
        }
        Network(err: reqwest::Error) {
            from()
            display("could not access the crates-io registry")
            source(err)
        }
    }
}

#[cold]
fn index_err(err: IndexError, reg: &str) -> Error {
    Error::Index(err, reg.into())
}

pub struct UpgradesChecker {
    index_per_source: RwLock<HashMap<Box<str>, Arc<SparseIndex>>>,
    refreshed_crates: Mutex<HashSet<String>>,
    retries: AtomicU32,
    client: reqwest::blocking::Client,
}

impl UpgradesChecker {
    pub fn new() -> Result<Self, Error> {
        let client = reqwest::blocking::Client::builder()
            .https_only(true)
            .user_agent(format!(
                "cargo-upgrades/{} reqwest",
                env!("CARGO_PKG_VERSION")
            ))
            .timeout(Duration::from_secs(5))
            .build()?;

        let index = SparseIndex::from_url_with_hash_kind(CRATES_IO, &crates_index::HashKind::Stable).map_err(|e| index_err(e, CRATES_IO))?;

        Ok(Self {
            index_per_source: RwLock::new([(CRATES_IO.into(), Arc::new(index))].into_iter().collect()),
            client,
            refreshed_crates: Default::default(),
            retries: AtomicU32::new(0),
        })
    }

    fn get_index(&self, registry_url: &str) -> Result<Arc<SparseIndex>, Error> {
        if let Some(index) = self.index_per_source.read().unwrap().get(registry_url) {
            return Ok(Arc::clone(index));
        }

        let mut locked = self.index_per_source.write().unwrap();
        Ok(match locked.entry(registry_url.into()) {
            Entry::Vacant(e) => Arc::clone(e.insert(Arc::new(SparseIndex::from_url_with_hash_kind(registry_url, &crates_index::HashKind::Stable).map_err(|e| index_err(e, registry_url))?))),
            Entry::Occupied(e) => Arc::clone(e.get()),
        })
    }
}

pub struct Workspace {
    packages: Vec<Package>,
}

pub struct Match {
    pub matches: Option<Version>,
    pub latest: Version,
}

impl Workspace {
    pub fn new(manifest_path: Option<&str>) -> Result<Self, Error> {
        let mut metadata = Self::new_metadata(manifest_path, CargoOpt::AllFeatures)
        .or_else(move |e| {
            Self::new_metadata(manifest_path, CargoOpt::SomeFeatures(vec![]))
                .or_else(move |_| Self::new_metadata(manifest_path, CargoOpt::NoDefaultFeatures))
                .map_err(|_| e)
        })?;
        let members: HashSet<_> = metadata.workspace_members.into_iter().collect();
        metadata.packages.retain(|p| members.contains(&p.id));
        Ok(Self {
            packages: metadata.packages,
        })
    }

    fn new_metadata(manifest_path: Option<&str>, features: CargoOpt) -> Result<Metadata, MetadataError> {
        let mut cmd = MetadataCommand::new();
        if let Some(path) = manifest_path {
            cmd.manifest_path(path);
        }
        cmd.features(features);
        cmd.exec()
    }
}

impl UpgradesChecker {
    pub fn check_package<'p>(&self, package: &'p Package, include_prerelease: bool) -> Option<Vec<(&'p Dependency, Result<Match, Error>)>> {
        thread::scope(move |s| {
            let spawned = package.dependencies.iter()
                .filter_map(move |dep| {
                    let registry_url = match &dep.source {
                        Some(s) if s.is_crates_io() => CRATES_IO,
                        Some(s) if s.repr.starts_with("sparse+http") => &s.repr,
                        Some(_) => return None,
                        None if dep.path.is_some() => return None,
                        None => dep.registry.as_deref().unwrap_or(CRATES_IO),
                    };
                    Some((dep, thread::Builder::new().spawn_scoped(s, move || self.get_crate(&dep.name, registry_url)).ok()?))
                })
                .collect::<Vec<_>>();

            let deps = spawned.into_iter().filter_map(|(dep, res)| Some((dep, res.join().expect("panic").and_then(|(c, fetch_err)| {
                let fetch_err = fetch_err.map(Err).unwrap_or(Ok(()));

                let (matching, non_matching): (Vec<_>, Vec<_>) = c.versions().iter()
                    .filter(|v| !v.is_yanked())
                    .filter_map(|v| Version::parse(v.version()).ok())
                    .partition(move |v| dep.req.matches(v));

                let latest_stable = matching.iter().chain(&non_matching).filter(|v| v.pre.is_empty()).max();
                let matches_latest_stable = latest_stable.is_some_and(move |v| dep.req.matches(v));
                if !include_prerelease && matches_latest_stable {
                    fetch_err?;
                    return Ok(None);
                }

                let Some(latest_any) = matching.iter().chain(&non_matching).max() else {
                    fetch_err?;
                    return Ok(None)
                };

                // Using an unstable req is an opt-in to picking any latest version, even if unstable
                let matches_any_unstable = matching.iter().any(|v| !v.pre.is_empty());
                let latest = if include_prerelease || matches_any_unstable {
                    latest_any
                } else {
                    latest_stable.unwrap_or(latest_any)
                };

                if dep.req.matches(latest) {
                    fetch_err?;
                    return Ok(None);
                }

                Ok(Some(Match {
                    latest: latest.clone(),
                    matches: matching.into_iter().max(),
                }))
            }).transpose()?)))
            .collect::<Vec<_>>();

            if deps.is_empty() {
                return None;
            }
            Some(deps)
        })
    }

    pub fn outdated_dependencies<'ws>(&self, workspace: &'ws Workspace, include_prerelease: bool) -> impl Iterator<Item=(&'ws Package, Vec<(&'ws Dependency, Result<Match, Error>)>)> {
        workspace.packages.iter().filter_map(move |package| {
            Some((package, self.check_package(package, include_prerelease)?))
        })
    }

    pub fn get_crate(&self, crate_name: &str, registry_url: &str) -> Result<(Crate, Option<Error>), Error> {
        let index = self.get_index(registry_url)?;
        let not_updated_yet = self.refreshed_crates.lock().unwrap().insert(format!("{registry_url}:{crate_name}"));
        let fetch_err = if not_updated_yet {
            match self.fetch_crate(crate_name, &index) {
                Ok(Some(c)) => return Ok((c, None)),
                Ok(None) => None,
                Err(e) => Some(e),
            }
        } else {
            None
        };

        Ok((index.crate_from_cache(crate_name).map_err(|e| index_err(e, registry_url))?, fetch_err))
    }

    fn fetch_crate(&self, crate_name: &str, index: &SparseIndex) -> Result<Option<Crate>, Error> {
        let index_err = |e| index_err(e, index.url());
        let builder = index.make_cache_request(crate_name).map_err(index_err)?;
        let body = builder.body(Vec::<u8>::new()).map_err(|e| IndexError::Io(std::io::Error::other(e))).map_err(index_err)?;
        let reqwest_request = reqwest::blocking::Request::try_from(body)?;

        let mut response = loop {
            match self.client.execute(reqwest_request.try_clone().ok_or_else(|| IndexError::Io(std::io::Error::other("try_clone"))).map_err(index_err)?) {
                Ok(response) => break response,
                Err(e) => {
                    let r = self.retries.fetch_add(1, Ordering::Relaxed);
                    if r < 5 {
                        thread::sleep(Duration::from_millis(50 << r));
                        continue;
                    }
                    return Err(Error::Network(e));
                },
            }
        };

        let mut builder = http::Response::builder()
            .status(response.status())
            .version(response.version());

        if let Some(headers) = builder.headers_mut() {
            headers.extend(response.headers_mut().drain());
        }

        let body = response.bytes()?.to_vec();
        let http_response = builder.body(body).map_err(|e| IndexError::Io(std::io::Error::other(e))).map_err(index_err)?;

        index.parse_cache_response(crate_name, http_response, true).map_err(index_err)
    }
}

#[test]
fn beta_vs_stable() {
    let beta11 = Version::parse("1.0.1-beta.1").unwrap();
    let beta1 = Version::parse("1.0.0-beta.1").unwrap();
    let v100 = Version::parse("1.0.0").unwrap();
    assert!(v100 > beta1);
    assert!(beta11 > beta1);
    assert!(beta11 > v100);
}

#[test]
fn test_self() {
    let u = UpgradesChecker::new().unwrap();
    let ws = Workspace::new(None).unwrap();
    assert_eq!(0, u.outdated_dependencies(&ws, false).count());
}