use anyhow::Result;
use colored::*;
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use tokio::sync::Semaphore;
use tokio::process::Command as AsyncCommand;
use semver::Version;
use crate::errors::CargoFreshError;
use crate::locale::detection::detect_language;
use crate::models::{PackageInfo, PackageSource};
pub mod crates_api;
pub mod crates2;
pub mod crates_toml;
pub mod registry;
pub mod sparse_index;
pub(crate) fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::Client::builder()
.user_agent(concat!("cargo-fresh/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("build reqwest client")
})
}
const MAX_CONCURRENT_INDEX_REQUESTS: usize = 16;
fn build_globset(patterns: &[&str]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for raw in patterns {
let p = raw.trim();
if p.is_empty() {
continue;
}
let normalized = if p.contains(['*', '?', '[']) {
p.to_string()
} else {
format!("*{}*", p)
};
let glob = GlobBuilder::new(&normalized)
.case_insensitive(true)
.build()?;
builder.add(glob);
}
Ok(builder.build()?)
}
pub fn filter_packages(packages: &mut Vec<PackageInfo>, pattern: &str) -> Result<()> {
if pattern.trim().is_empty() {
return Ok(());
}
let set = build_globset(&[pattern])?;
packages.retain(|p| set.is_match(&p.name));
Ok(())
}
pub fn exclude_packages(packages: &mut Vec<PackageInfo>, patterns: &[String]) -> Result<()> {
if patterns.is_empty() {
return Ok(());
}
let refs: Vec<&str> = patterns.iter().map(String::as_str).collect();
let set = build_globset(&refs)?;
packages.retain(|p| !set.is_match(&p.name));
Ok(())
}
pub async fn get_installed_packages() -> Result<Vec<PackageInfo>> {
let output = AsyncCommand::new("cargo")
.args(["install", "--list"])
.output()
.await?;
if !output.status.success() {
let language = detect_language();
return Err(CargoFreshError::CargoListFailed {
source: anyhow::anyhow!("{}", language.get_text("cargo_install_list_failed")),
}
.into());
}
let output_str = String::from_utf8(output.stdout)?;
let mut packages = Vec::new();
let mut version_map: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut seen_packages = HashSet::new();
for line in output_str.lines() {
if let Some((name, version, source)) = parse_package_line(line) {
if !name.is_empty() && !version.is_empty() && seen_packages.insert(name.to_string()) {
version_map.insert(name.to_string(), version.to_string());
packages.push(PackageInfo::with_source(
name.to_string(),
Some(version.to_string()),
source,
));
}
}
}
let install_opts_map = crates2::load_install_opts();
for pkg in &mut packages {
pkg.install_opts = crates2::match_install_opts(&install_opts_map, &pkg.name, &pkg.source);
}
let mutex = INSTALLED_VERSION_CACHE.get_or_init(|| std::sync::Mutex::new(Default::default()));
if let Ok(mut map) = mutex.lock() {
map.clear();
map.extend(version_map);
}
Ok(packages)
}
static INSTALLED_VERSION_CACHE: OnceLock<std::sync::Mutex<std::collections::HashMap<String, String>>> =
OnceLock::new();
pub fn invalidate_installed_version(package_name: &str) {
if let Some(mutex) = INSTALLED_VERSION_CACHE.get() {
if let Ok(mut map) = mutex.lock() {
map.remove(package_name);
}
}
}
pub fn parse_package_line(line: &str) -> Option<(&str, &str, PackageSource)> {
let line_no_colon = line.trim_end().strip_suffix(':')?;
let (name_part, rest) = line_no_colon.split_once(" v")?;
let name = name_part.trim();
let rest = rest.trim();
if name.is_empty() || rest.is_empty() {
return None;
}
let (version, source) = match rest.find('(') {
Some(paren_idx) => {
let version = rest[..paren_idx].trim();
let source_str = rest[paren_idx + 1..].trim_end_matches(')').trim();
(version, parse_source(source_str))
}
None => (rest, PackageSource::Crates),
};
if version.is_empty() {
return None;
}
Some((name, version, source))
}
fn parse_source(s: &str) -> PackageSource {
if let Some(rest) = s.strip_prefix("git+") {
let (url, rev) = match rest.split_once('#') {
Some((u, r)) => (u.to_string(), Some(r.to_string())),
None => (rest.to_string(), None),
};
PackageSource::Git { url, rev }
} else if let Some(rest) = s.strip_prefix("path+file://") {
PackageSource::Path { dir: rest.to_string() }
} else if let Some(rest) = s.strip_prefix("path+") {
PackageSource::Path { dir: rest.to_string() }
} else if s.starts_with("registry+") || s.is_empty() {
PackageSource::Crates
} else {
PackageSource::Unknown(s.to_string())
}
}
pub async fn get_installed_version(package_name: &str) -> Result<Option<String>> {
if let Some(mutex) = INSTALLED_VERSION_CACHE.get() {
if let Ok(map) = mutex.lock() {
if let Some(v) = map.get(package_name) {
return Ok(Some(v.clone()));
}
}
}
let output = AsyncCommand::new("cargo")
.args(["install", "--list"])
.output()
.await?;
if !output.status.success() {
return Ok(None);
}
let output_str = String::from_utf8(output.stdout)?;
for line in output_str.lines() {
if line.contains(package_name) {
if let Some((name, version, _)) = parse_package_line(line) {
if name == package_name {
if let Some(mutex) = INSTALLED_VERSION_CACHE.get() {
if let Ok(mut map) = mutex.lock() {
map.insert(name.to_string(), version.to_string());
}
}
return Ok(Some(version.to_string()));
}
}
}
}
Ok(None)
}
pub fn extract_version_from_line(line: &str) -> Option<String> {
line.find("= \"").and_then(|start| {
line[start + 3..]
.find("\"")
.map(|end| line[start + 3..start + 3 + end].to_string())
})
}
pub fn is_stable_version(version: &str) -> bool {
Version::parse(version)
.map(|v| v.pre.is_empty())
.unwrap_or(true)
}
async fn cargo_search_fallback(
package_name: &str,
include_prerelease: bool,
) -> Result<Option<String>> {
let output = AsyncCommand::new("cargo")
.args(["search", package_name, "--limit", "10"])
.output()
.await?;
if !output.status.success() {
return Ok(None);
}
let output_str = String::from_utf8(output.stdout)?;
let package_prefix = format!("{} =", package_name);
for line in output_str.lines() {
if line.starts_with(&package_prefix) && line.contains('"') {
if let Some(version) = extract_version_from_line(line) {
if include_prerelease || is_stable_version(&version) {
return Ok(Some(version));
}
}
}
}
if !include_prerelease {
return Ok(None);
}
for line in output_str.lines() {
if line.starts_with(&package_prefix) && line.contains('"') {
if let Some(version) = extract_version_from_line(line) {
return Ok(Some(version));
}
}
}
Ok(None)
}
pub fn cargo_search_fallback_disabled(cli_flag: bool) -> bool {
if cli_flag {
return true;
}
std::env::var("CARGO_FRESH_NO_FALLBACK")
.map(|v| !v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or(false)
}
#[derive(Debug)]
pub struct VersionLookup {
pub versions: sparse_index::LatestVersions,
pub error: Option<crate::models::CheckError>,
}
fn classify_sparse_error(e: &sparse_index::SparseIndexError) -> crate::models::CheckError {
use crate::models::{CheckError, CheckErrorKind};
match e {
sparse_index::SparseIndexError::NotFound => CheckError {
kind: CheckErrorKind::NotFound,
message: "package not found in registry index".to_string(),
},
sparse_index::SparseIndexError::Unavailable(err) => CheckError {
kind: CheckErrorKind::Unavailable,
message: err.to_string(),
},
}
}
pub async fn fetch_latest_versions(
package_name: &str,
include_prerelease: bool,
registry_override: Option<&str>,
no_fallback: bool,
verbose: bool,
) -> VersionLookup {
let base = registry::sparse_index_base(registry_override);
let sparse_err = match sparse_index::fetch_latest(http_client(), &base, package_name).await {
Ok(v) => {
return VersionLookup {
versions: v,
error: None,
};
}
Err(e) => e,
};
if no_fallback {
return VersionLookup {
versions: sparse_index::LatestVersions::default(),
error: Some(classify_sparse_error(&sparse_err)),
};
}
if verbose {
crate::display::status_dim(
"Fallback",
&format!("cargo search (slow path) for {}", package_name.cyan()),
);
}
match cargo_search_fallback(package_name, include_prerelease).await {
Ok(Some(v)) => {
let versions = if is_stable_version(&v) {
sparse_index::LatestVersions {
stable: Some(v),
prerelease: None,
}
} else {
sparse_index::LatestVersions {
stable: None,
prerelease: Some(v),
}
};
VersionLookup {
versions,
error: None,
}
}
_ => VersionLookup {
versions: sparse_index::LatestVersions::default(),
error: Some(classify_sparse_error(&sparse_err)),
},
}
}
#[allow(dead_code)]
pub async fn get_latest_version(
package_name: &str,
include_prerelease: bool,
) -> Result<Option<String>> {
let latest =
fetch_latest_versions(package_name, include_prerelease, None, false, false)
.await
.versions;
Ok(if include_prerelease {
latest.prerelease.or(latest.stable)
} else {
latest.stable
})
}
pub fn choose_latest(
stable: Option<&str>,
prerelease: Option<&str>,
current: Option<&str>,
include_prerelease: bool,
) -> Option<String> {
if let Some(s) = stable {
if current.map(|c| c != s).unwrap_or(true) {
return Some(s.to_string());
}
}
if include_prerelease {
if let Some(pre) = prerelease {
if current.map(|c| c != pre).unwrap_or(true) {
return Some(pre.to_string());
}
}
}
stable.map(|s| s.to_string())
}
pub async fn check_package_updates(
packages: &mut [PackageInfo],
verbose: bool,
include_prerelease: bool,
registry_override: Option<String>,
no_fallback: bool,
) -> Result<()> {
let language = detect_language();
let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_INDEX_REQUESTS));
let mut handles = Vec::new();
for (index, package) in packages.iter().enumerate() {
if !package.source.is_crates() {
continue;
}
let package_name = package.name.clone();
let sem = semaphore.clone();
let override_clone = registry_override.clone();
let handle = tokio::spawn(async move {
let _permit = sem.acquire_owned().await.ok();
if verbose {
crate::display::status_dim(
"Check",
&format!(
"{} {}",
language.get_text("checking_package"),
package_name.cyan()
),
);
}
let lookup = fetch_latest_versions(
&package_name,
true,
override_clone.as_deref(),
no_fallback,
verbose,
)
.await;
(index, package_name, lookup)
});
handles.push(handle);
}
for handle in handles {
let Ok((index, package_name, lookup)) = handle.await else {
if verbose {
crate::display::status_warn("Check", language.get_text("check_failed"));
}
continue;
};
let current = packages[index].current_version.clone();
let chosen = choose_latest(
lookup.versions.stable.as_deref(),
lookup.versions.prerelease.as_deref(),
current.as_deref(),
include_prerelease,
);
if verbose {
match &chosen {
Some(v) => crate::display::status_dim(
"Latest",
&format!(
"{} {}: {}",
package_name.cyan(),
language.get_text("latest_version"),
v.green()
),
),
None => crate::display::status_warn(
"Check",
&format!(
"{} {}",
package_name.red(),
language.get_text("unable_to_get_latest_version")
),
),
}
}
packages[index].latest_version = chosen;
packages[index].check_error = lookup.error;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn choose_latest_picks_stable_when_newer() {
assert_eq!(
choose_latest(Some("1.2.0"), Some("2.0.0-rc.1"), Some("1.1.0"), false),
Some("1.2.0".to_string())
);
}
#[test]
fn choose_latest_omits_prerelease_when_flag_off() {
assert_eq!(
choose_latest(Some("1.1.0"), Some("2.0.0-rc.1"), Some("1.1.0"), false),
Some("1.1.0".to_string())
);
}
#[test]
fn choose_latest_picks_prerelease_when_flag_on() {
assert_eq!(
choose_latest(Some("1.1.0"), Some("2.0.0-rc.1"), Some("1.1.0"), true),
Some("2.0.0-rc.1".to_string())
);
}
#[test]
fn choose_latest_no_stable_no_prerelease_when_flag_off() {
assert_eq!(
choose_latest(None, Some("0.1.0-alpha.1"), Some("0.0.1"), false),
None
);
}
#[test]
fn choose_latest_no_stable_picks_prerelease_when_flag_on() {
assert_eq!(
choose_latest(None, Some("0.1.0-alpha.1"), Some("0.0.1"), true),
Some("0.1.0-alpha.1".to_string())
);
}
#[test]
fn choose_latest_returns_stable_when_already_latest() {
assert_eq!(
choose_latest(Some("1.0.0"), None, Some("1.0.0"), false),
Some("1.0.0".to_string())
);
}
#[test]
fn choose_latest_empty_returns_none() {
assert_eq!(choose_latest(None, None, Some("1.0.0"), true), None);
assert_eq!(choose_latest(None, None, None, false), None);
}
fn parse_nv(line: &str) -> Option<(&str, &str)> {
parse_package_line(line).map(|(n, v, _)| (n, v))
}
#[test]
fn parse_package_line_standard() {
let line = "ripgrep v14.1.1:";
assert_eq!(parse_nv(line), Some(("ripgrep", "14.1.1")));
assert_eq!(parse_package_line(line).unwrap().2, PackageSource::Crates);
}
#[test]
fn parse_package_line_with_build_metadata() {
let line = "some-tool v1.2.3+arch64:";
assert_eq!(parse_nv(line), Some(("some-tool", "1.2.3+arch64")));
}
#[test]
fn parse_package_line_with_prerelease() {
let line = "cargo-fresh v0.9.10-rc.1:";
assert_eq!(parse_nv(line), Some(("cargo-fresh", "0.9.10-rc.1")));
}
#[test]
fn parse_package_line_empty() {
assert_eq!(parse_package_line(""), None);
}
#[test]
fn parse_package_line_missing_colon() {
assert_eq!(parse_package_line("ripgrep v14.1.1"), None);
}
#[test]
fn parse_package_line_missing_v_prefix() {
assert_eq!(parse_package_line("ripgrep 14.1.1:"), None);
}
#[test]
fn parse_package_line_binary_subline_returns_none() {
assert_eq!(parse_package_line(" rg"), None);
}
#[test]
fn parse_package_line_registry_source() {
let line = "cargo-update v15.0.1 (registry+https://github.com/rust-lang/crates.io-index):";
let (name, version, source) = parse_package_line(line).unwrap();
assert_eq!(name, "cargo-update");
assert_eq!(version, "15.0.1");
assert_eq!(source, PackageSource::Crates);
}
#[test]
fn parse_package_line_git_source_with_rev() {
let line = "some-tool v0.1.0 (git+https://github.com/foo/bar#abc123):";
let (name, version, source) = parse_package_line(line).unwrap();
assert_eq!(name, "some-tool");
assert_eq!(version, "0.1.0");
assert_eq!(
source,
PackageSource::Git {
url: "https://github.com/foo/bar".to_string(),
rev: Some("abc123".to_string()),
}
);
}
#[test]
fn parse_package_line_git_source_without_rev() {
let line = "some-tool v0.1.0 (git+https://github.com/foo/bar):";
let source = parse_package_line(line).unwrap().2;
assert_eq!(
source,
PackageSource::Git {
url: "https://github.com/foo/bar".to_string(),
rev: None,
}
);
}
#[test]
fn parse_package_line_unknown_source() {
let line = "weird-tool v1.0.0 (sparse+https://example.com/index):";
let source = parse_package_line(line).unwrap().2;
match source {
PackageSource::Unknown(raw) => {
assert_eq!(raw, "sparse+https://example.com/index");
}
other => panic!("expected Unknown, got {:?}", other),
}
}
#[test]
fn parse_package_line_path_source() {
let line = "local-tool v0.1.0 (path+file:///Users/me/repos/local-tool):";
let source = parse_package_line(line).unwrap().2;
assert_eq!(
source,
PackageSource::Path {
dir: "/Users/me/repos/local-tool".to_string(),
}
);
}
#[test]
fn extract_version_from_line_standard() {
let line = r#"ripgrep = "14.1.1" # ripgrep recursively searches..."#;
assert_eq!(extract_version_from_line(line), Some("14.1.1".to_string()));
}
#[test]
fn extract_version_from_line_missing_quotes() {
let line = "ripgrep = 14.1.1";
assert_eq!(extract_version_from_line(line), None);
}
#[test]
fn extract_version_from_line_prerelease() {
let line = r#"my-crate = "1.0.0-beta.2" # description"#;
assert_eq!(extract_version_from_line(line), Some("1.0.0-beta.2".to_string()));
}
#[test]
fn is_stable_version_stable() {
assert!(is_stable_version("1.0.0"));
assert!(is_stable_version("14.1.1"));
assert!(is_stable_version("0.9.10"));
}
#[test]
fn is_stable_version_prerelease() {
assert!(!is_stable_version("1.0.0-alpha"));
assert!(!is_stable_version("1.0.0-beta.2"));
assert!(!is_stable_version("2.0.0-rc.1"));
}
#[test]
fn is_stable_version_with_build_metadata_is_stable() {
assert!(is_stable_version("1.0.0+arch64"));
assert!(is_stable_version("2.3.4+20240101"));
}
#[test]
fn is_stable_version_substring_rc_is_not_misidentified() {
assert!(is_stable_version("1.0.0+arc-build"));
assert!(is_stable_version("1.0.0+rc-meta"));
}
fn pkg(name: &str) -> PackageInfo {
PackageInfo::new(name.to_string(), Some("1.0.0".to_string()))
}
#[test]
fn filter_packages_empty_pattern_no_op() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripgrep")];
filter_packages(&mut pkgs, "").unwrap();
assert_eq!(pkgs.len(), 2);
}
#[test]
fn filter_packages_plain_word_is_substring() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripgrep"), pkg("cargo-update")];
filter_packages(&mut pkgs, "cargo").unwrap();
assert_eq!(pkgs.len(), 2);
assert!(pkgs.iter().all(|p| p.name.contains("cargo")));
}
#[test]
fn filter_packages_glob_prefix() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripcargo"), pkg("cargo-update")];
filter_packages(&mut pkgs, "cargo-*").unwrap();
assert_eq!(pkgs.len(), 2);
assert!(pkgs.iter().all(|p| p.name.starts_with("cargo-")));
}
#[test]
fn filter_packages_glob_suffix() {
let mut pkgs = vec![pkg("cargo-update"), pkg("cargo-edit"), pkg("topgrade")];
filter_packages(&mut pkgs, "*update").unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name, "cargo-update");
}
#[test]
fn filter_packages_no_match_clears_list() {
let mut pkgs = vec![pkg("ripgrep"), pkg("tokei")];
filter_packages(&mut pkgs, "nonexistent").unwrap();
assert!(pkgs.is_empty());
}
#[test]
fn filter_packages_case_insensitive() {
let mut pkgs = vec![pkg("CargoEdit"), pkg("RIPGREP")];
filter_packages(&mut pkgs, "cargo").unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name, "CargoEdit");
}
#[test]
fn exclude_packages_empty_list_no_op() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripgrep")];
exclude_packages(&mut pkgs, &[]).unwrap();
assert_eq!(pkgs.len(), 2);
}
#[test]
fn exclude_packages_removes_matches() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripgrep"), pkg("cargo-update")];
exclude_packages(&mut pkgs, &["cargo-*".to_string()]).unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name, "ripgrep");
}
#[test]
fn exclude_packages_multiple_patterns() {
let mut pkgs = vec![pkg("cargo-edit"), pkg("ripgrep"), pkg("tokei")];
exclude_packages(
&mut pkgs,
&["cargo*".to_string(), "tokei".to_string()],
)
.unwrap();
assert_eq!(pkgs.len(), 1);
assert_eq!(pkgs[0].name, "ripgrep");
}
fn pkg_with_latest(name: &str, current: &str, latest: &str) -> PackageInfo {
PackageInfo {
name: name.to_string(),
current_version: Some(current.to_string()),
latest_version: Some(latest.to_string()),
source: PackageSource::Crates,
install_opts: None,
check_error: None,
prebuilt: None,
}
}
#[test]
fn has_update_normal_upgrade() {
let p = pkg_with_latest("x", "1.0.0", "1.1.0");
assert!(p.has_update());
}
#[test]
fn has_update_same_version() {
let p = pkg_with_latest("x", "1.0.0", "1.0.0");
assert!(!p.has_update());
}
#[test]
fn has_update_rollback_returns_false() {
let p = pkg_with_latest("x", "2.0.0", "1.9.0");
assert!(!p.has_update());
}
#[test]
fn has_update_major_upgrade() {
let p = pkg_with_latest("x", "1.9.0", "2.0.0");
assert!(p.has_update());
}
#[test]
fn has_update_prerelease_to_stable() {
let p = pkg_with_latest("x", "1.0.0-rc.1", "1.0.0");
assert!(p.has_update());
}
#[test]
fn has_update_build_metadata_differs_is_treated_as_update() {
let p = pkg_with_latest("x", "1.0.0", "1.0.0+xyz");
assert!(p.has_update());
}
#[test]
fn has_update_unparseable_falls_back_to_string_compare() {
let p = pkg_with_latest("x", "git-abc123", "git-def456");
assert!(p.has_update());
let same = pkg_with_latest("x", "git-abc123", "git-abc123");
assert!(!same.has_update());
}
#[test]
fn has_update_missing_versions_returns_false() {
let p = PackageInfo::new("x".to_string(), Some("1.0.0".to_string()));
assert!(!p.has_update());
let p2 = PackageInfo::new("x".to_string(), None);
assert!(!p2.has_update());
}
#[test]
fn is_prerelease_detects_pre_segment() {
let p = pkg_with_latest("x", "1.0.0", "2.0.0-alpha.1");
assert!(p.is_prerelease());
}
#[test]
fn is_prerelease_stable_returns_false() {
let p = pkg_with_latest("x", "1.0.0", "2.0.0");
assert!(!p.is_prerelease());
}
#[test]
fn is_prerelease_build_metadata_is_not_prerelease() {
let p = pkg_with_latest("x", "1.0.0", "1.0.0+rc-meta");
assert!(!p.is_prerelease());
}
}