#![doc = include_str!("../t/LIBRARY.md")]
use {
anyhow::{Context, Result, anyhow},
dirs::home_dir,
rayon::prelude::*,
regex::RegexSet,
reqwest::blocking::Client,
serde::{Deserialize, Serialize},
sprint::{Command, Pipe, Shell},
std::{
collections::BTreeMap,
fs::File,
path::{Path, PathBuf},
sync::LazyLock,
},
};
static CLIENT: LazyLock<Client> = LazyLock::new(|| {
let mut client = Client::builder().user_agent("cargo-list");
if let Ok(url) = std::env::var("CARGO_LIST_PROXY") {
match reqwest::Proxy::all(&url) {
Ok(proxy) => {
client = client.proxy(proxy);
}
Err(e) => {
eprintln!(
"\
Warning: The `CARGO_LIST_PROXY` environment variable is set but is invalid \
({e}); ignoring!\
",
);
}
}
}
client.build().expect("create reqwest client")
});
#[derive(Debug, Default, Serialize, Eq, PartialEq, Hash, Clone)]
pub enum Kind {
Local,
Git,
#[default]
External,
}
use Kind::{External, Git, Local};
pub const ALL_KINDS: [Kind; 3] = [Local, Git, External];
impl Kind {
fn from(source: &str) -> Kind {
if source.starts_with("git+") {
Git
} else if source.starts_with("path+") {
Local
} else {
External
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Crates {
installs: BTreeMap<String, Crate>,
#[serde(skip)]
pub active_toolchain: String,
#[serde(skip)]
pub active_version: String,
}
impl Crates {
pub fn from(path: &Path) -> Result<Crates> {
Crates::from_include(path, &[])
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.installs.is_empty()
}
#[must_use]
pub fn crates(&self) -> BTreeMap<&str, &Crate> {
self.installs
.values()
.map(|x| (x.name.as_str(), x))
.collect()
}
#[allow(clippy::missing_panics_doc)]
pub fn from_include(path: &Path, patterns: &[&str]) -> Result<Crates> {
let mut crates: Crates = serde_json::from_reader(File::open(path)?)?;
if !patterns.is_empty() {
let set = RegexSet::new(patterns)?;
crates.installs = crates
.installs
.into_par_iter()
.filter_map(|(k, v)| {
if set.is_match(k.split_once(' ').unwrap().0) {
Some((k, v))
} else {
None
}
})
.collect();
}
crates.active_toolchain = active_toolchain();
crates.active_version = crates
.active_toolchain
.lines()
.filter_map(|line| {
line.split(' ')
.skip_while(|&word| word != "rustc")
.nth(1)
.map(ToString::to_string)
})
.nth(0)
.unwrap();
let errors = crates
.installs
.par_iter_mut()
.filter_map(|(k, v)| {
v.init(k, &crates.active_version)
.with_context(|| format!("Failed to process crate '{k}'"))
.err()
})
.collect::<Vec<_>>();
if errors.is_empty() {
Ok(crates)
} else {
Err(anyhow!(format!(
"Errors: {}",
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
)))
}
}
}
#[derive(Debug, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub struct Crate {
#[serde(skip_deserializing)]
pub name: String,
#[serde(skip_deserializing)]
pub kind: Kind,
#[serde(skip_deserializing)]
pub installed: String,
#[serde(skip_deserializing)]
installed_: Option<semver::Version>,
#[serde(skip_deserializing)]
prerelease: bool,
#[serde(skip_deserializing)]
pub available: String,
#[serde(skip_deserializing)]
pub newer: Vec<String>,
#[serde(skip_deserializing)]
pub rust_version: String,
#[serde(skip_deserializing)]
pub outdated: bool,
#[serde(skip_deserializing)]
pub outdated_rust: bool,
#[serde(skip_deserializing)]
source: String,
pub version_req: Option<String>,
bins: Vec<String>,
features: Vec<String>,
all_features: bool,
no_default_features: bool,
profile: String,
target: String,
rustc: String,
}
impl Crate {
fn init(&mut self, k: &str, active_version: &str) -> Result<()> {
let mut s = k.split(' ');
self.name = s.next().unwrap().to_string();
self.installed = s.next().unwrap().to_string();
self.source = s
.next()
.unwrap()
.strip_prefix('(')
.unwrap()
.strip_suffix(')')
.unwrap()
.to_string();
self.installed_ = semver::Version::parse(&self.installed).ok();
self.prerelease = self.installed_.as_ref().is_some_and(|x| !x.pre.is_empty());
self.kind = Kind::from(&self.source);
self.rust_version = self
.rustc
.strip_prefix("rustc ")
.unwrap()
.split_once(' ')
.unwrap()
.0
.to_string();
self.outdated_rust = self.rust_version != active_version;
if self.kind == External {
(self.available, self.newer) = latest(&self.name, &self.version_req, self.prerelease)?;
self.outdated = self.installed != self.available;
}
Ok(())
}
pub fn update_command(&self, pinned: bool) -> Vec<String> {
let mut r = vec!["cargo", "install"];
if self.no_default_features {
r.push("--no-default-features");
}
let features = if self.features.is_empty() {
None
} else {
Some(self.features.join(","))
};
if let Some(features) = &features {
r.push("-F");
r.push(features);
}
if !pinned && let Some(version) = &self.version_req {
r.push("--version");
r.push(version);
}
r.push("--profile");
r.push(&self.profile);
r.push("--target");
r.push(&self.target);
if self.outdated_rust {
r.push("--force");
}
if self.kind == Git {
r.push("--git");
r.push(&self.source[4..self.source.find('#').unwrap()]);
for bin in &self.bins {
r.push(bin);
}
} else {
r.push(&self.name);
}
r.into_iter().map(String::from).collect()
}
}
#[derive(Debug, Deserialize)]
struct Versions {
versions: Vec<Version>,
}
impl Versions {
fn iter(&self) -> std::slice::Iter<'_, Version> {
self.versions.iter()
}
fn available(&self, prerelease: bool) -> Vec<&Version> {
self.iter().filter(|x| x.is_available(prerelease)).collect()
}
}
#[derive(Debug, Deserialize)]
struct Version {
num: semver::Version,
yanked: bool,
}
impl Version {
fn is_available(&self, prerelease: bool) -> bool {
if !prerelease && !self.num.pre.is_empty() {
return false;
}
!self.yanked
}
}
pub fn latest(
name: &str,
version_req: &Option<String>,
prerelease: bool,
) -> Result<(String, Vec<String>)> {
let url = format!("https://crates.io/api/v1/crates/{name}/versions");
let res = CLIENT.get(&url).send()?;
let res = res.error_for_status()?;
let versions = res.json::<Versions>()?;
let available = versions.available(prerelease);
if let Some(req_str) = version_req {
let req = semver::VersionReq::parse(req_str)?;
let mut newer = vec![];
for v in &available {
if req.matches(&v.num) {
return Ok((v.num.to_string(), newer));
}
newer.push(v.num.to_string());
}
Err(anyhow!(
"\
Failed to find an available version matching the requirement `{req_str}` \
(available: {:?})\
",
available
.iter()
.take(5)
.map(|v| v.num.to_string())
.collect::<Vec<_>>()
))
} else if available.is_empty() {
Err(anyhow!("Failed to find any available version"))
} else {
Ok((available[0].num.to_string(), vec![]))
}
}
#[must_use]
pub fn active_toolchain() -> String {
let r = Shell {
print: false,
..Default::default()
}
.run(&[Command {
command: String::from("rustup show active-toolchain -v"),
stdout: Pipe::string(),
..Default::default()
}]);
if let Pipe::String(Some(s)) = &r[0].stdout {
s.clone()
} else {
String::new()
}
}
#[must_use]
pub fn expanduser(path: &str) -> PathBuf {
if path == "~" {
home_dir().unwrap().join(&path[1..])
} else if path.starts_with('~') {
home_dir().unwrap().join(&path[2..])
} else {
PathBuf::from(&path)
}
}