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 toml_edit::{Formatted, Value};
use semver::{Version, VersionReq};
use std::path::PathBuf;
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>,
workspace_root: PathBuf,
}
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();
let workspace_root = metadata.workspace_root.into_std_path_buf();
metadata.packages.retain(|p| members.contains(&p.id));
Ok(Self {
packages: metadata.packages,
workspace_root,
})
}
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()
}
fn cargo_lock(&self) -> Result<String, Error> {
std::fs::read_to_string(self.workspace_root.join("Cargo.lock"))
.map_err(|e| Error::Metadata(e.into()))
}
}
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)
};
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)
}
pub fn save_locked(&self, workspace: &Workspace) -> Result<(), Error> {
let cargo_lock = workspace.cargo_lock()?;
let lock_versions = Self::parse_cargo_lock(cargo_lock)?;
let ws_manifest = workspace.workspace_root.join("Cargo.toml");
let mut registries = HashMap::new();
for (deps, manifest_path) in workspace.packages.iter().map(|p| (p.dependencies.as_slice(), p.manifest_path.as_std_path())).chain([(&[][..], ws_manifest.as_path())]) {
let manifest_str = std::fs::read_to_string(manifest_path)
.map_err(From::from)
.map_err(Error::Metadata)?;
let mut doc = manifest_str
.parse::<toml_edit::DocumentMut>()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))
.map_err(cargo_metadata::Error::from)
.map_err(Error::Metadata)?;
registries.extend(deps.iter().filter_map(|d| {
let reg = d.source.as_ref().filter(|s| !s.is_crates_io()).map(|s| s.repr.as_str()).or(d.registry.as_deref())?;
let key = d.rename.as_deref().unwrap_or(&d.name);
Some((key, (d.name.as_str(), reg)))
}));
let mut modified = Self::update_item(doc.as_item_mut(), &lock_versions, ®istries)?;
if let Some(ws) = doc.get_mut("workspace") {
modified |= Self::update_item(ws, &lock_versions, ®istries)?;
}
for (_, target) in doc.get_mut("target").and_then(|t| t.as_table_like_mut()).into_iter().flat_map(|t| t.iter_mut()) {
modified |= Self::update_item(target, &lock_versions, ®istries)?;
}
if modified {
std::fs::write(manifest_path, doc.to_string())
.map_err(From::from)
.map_err(Error::Metadata)?;
}
}
Ok(())
}
fn update_item(target: &mut toml_edit::Item, lock_versions: &HashMap<(Box<str>, Box<str>), Version>, registries: &HashMap<&str, (&str, &str)>) -> Result<bool, Error> {
let mut modified = false;
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = target.get_mut(section).and_then(|d| d.as_table_like_mut()) else {
continue;
};
for (key, dep) in deps.iter_mut() {
let (package, registry) = match dep.as_table_like() {
Some(t) => {
if t.get("git").is_some() || t.get("workspace").is_some() {
continue;
}
let package = t.get("package").and_then(|p| p.as_str()).unwrap_or(&*key);
let registry = if t.get("registry").is_none() {
CRATES_IO
} else if let Some(&(orig_package, reg)) = registries.get(&*key) && orig_package == package {
reg
} else {
continue;
};
(package, registry)
},
None => (&*key, CRATES_IO),
};
let Some(ver) = lock_versions.get(&(package.into(), registry.into())) else {
continue;
};
let dep_req = match dep.as_table_like_mut() {
Some(tbl) => tbl.get_mut("version").and_then(|v| v.as_value_mut()),
None => dep.as_value_mut(),
};
let Some(Value::String(s)) = dep_req else {
continue;
};
let Ok(mut req) = VersionReq::parse(s.value()) else {
continue;
};
if let [comp] = req.comparators.as_slice() && matches!(comp.op, semver::Op::Caret | semver::Op::Tilde | semver::Op::Wildcard) {
if !req.matches(ver) {
continue;
}
let mut ver = ver.to_string();
if s.value() != &ver {
if ver.ends_with(".0") {
ver.truncate(ver.len() - 2);
}
*s = Formatted::new(ver);
}
} else {
for comp in req.comparators.iter_mut() {
if comp.op == semver::Op::Exact {
continue;
}
if comp.major > ver.major || (comp.major == ver.major && comp.minor.is_some_and(|m| m > ver.minor)) {
continue;
}
comp.major = ver.major;
comp.minor = if ver.minor != 0 || ver.patch != 0 || !ver.pre.is_empty() { Some(ver.minor) } else { None };
comp.patch = if ver.patch != 0 || !ver.pre.is_empty() { Some(ver.minor) } else { None };
comp.pre = ver.pre.clone();
}
}
modified = true;
}
}
Ok(modified)
}
fn parse_cargo_lock(lockfile: String) -> Result<HashMap<(Box<str>, Box<str>), Version>, Error> {
let doc = toml_edit::Document::parse(lockfile.as_str())
.map_err(|e| Error::Metadata(std::io::Error::other(e).into()))?;
let Some(packages) = doc.get("package").and_then(|p| p.as_array_of_tables()) else {
return Ok(Default::default());
};
let mut versions = HashMap::with_capacity(packages.len());
let p = packages.iter().filter_map(|pkg| {
let source = pkg.get("source")?.as_str()?;
let url = if source == "registry+https://github.com/rust-lang/crates.io-index" {
CRATES_IO
} else if source.starts_with("sparse+") {
source
} else {
return None;
};
let name = pkg.get("name")?.as_str()?;
let mut version = Version::parse(pkg.get("version")?.as_str()?).ok()?;
version.build = Default::default();
Some((name, url, version))
});
for (name, url, version) in p {
match versions.entry((Box::from(name), Box::from(url))) {
Entry::Vacant(e) => {
e.insert(version);
},
Entry::Occupied(mut e) => {
if *e.get() < version {
e.insert(version);
}
},
}
}
Ok(versions)
}
}
#[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());
}