use anyhow::Result;
use colored::*;
use std::collections::HashSet;
use std::process::Command;
use std::sync::OnceLock;
use semver::Version;
use crate::locale::detection::detect_language;
use crate::models::PackageInfo;
static BINSTALL_AVAILABLE: OnceLock<bool> = OnceLock::new();
pub fn is_binstall_available() -> bool {
*BINSTALL_AVAILABLE.get_or_init(|| {
Command::new("cargo")
.args(["binstall", "--help"])
.output()
.map(|output| output.status.success())
.unwrap_or(false)
})
}
pub async fn install_binstall() -> Result<bool> {
let language = detect_language();
println!("{}", language.get_text("installing_binstall").yellow());
let output = Command::new("cargo")
.args(["install", "cargo-binstall"])
.output()?;
if output.status.success() {
println!(
"✅ {}",
language.get_text("binstall_installed_successfully").green()
);
Ok(true)
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
println!(
"❌ {}: {}",
language.get_text("binstall_install_failed").red(),
stderr
);
Ok(false)
}
}
pub async fn ensure_binstall_available() -> Result<bool> {
if is_binstall_available() {
return Ok(true);
}
let language = detect_language();
println!("🔍 {}", language.get_text("binstall_not_found").yellow());
println!(
"⚡ {}",
language.get_text("attempting_to_install_binstall").cyan()
);
let result = install_binstall().await?;
if result {
let _ = BINSTALL_AVAILABLE.set(true);
}
Ok(result)
}
pub fn filter_packages(packages: &mut Vec<PackageInfo>, pattern: &str) -> Result<()> {
if pattern.is_empty() {
return Ok(());
}
let pattern_lower = pattern.to_lowercase();
packages.retain(|package| {
let name_lower = package.name.to_lowercase();
if pattern_lower.contains('*') {
name_lower.contains(&pattern_lower.replace('*', ""))
|| name_lower.starts_with(&pattern_lower.replace('*', ""))
|| name_lower.ends_with(&pattern_lower.replace('*', ""))
} else {
name_lower.contains(&pattern_lower)
}
});
Ok(())
}
pub async fn get_installed_packages() -> Result<Vec<PackageInfo>> {
let output = Command::new("cargo").args(["install", "--list"]).output()?;
if !output.status.success() {
let language = detect_language();
anyhow::bail!("{}", language.get_text("cargo_install_list_failed"));
}
let output_str = String::from_utf8(output.stdout)?;
let mut packages = Vec::new();
let mut seen_packages = HashSet::new();
for line in output_str.lines() {
if let Some((name, version)) = parse_package_line(line) {
if !name.is_empty() && !version.is_empty() && seen_packages.insert(name) {
packages.push(PackageInfo::new(
name.to_string(),
Some(version.to_string()),
));
}
}
}
Ok(packages)
}
pub fn parse_package_line(line: &str) -> Option<(&str, &str)> {
if !line.contains(" v") || !line.contains(":") {
return None;
}
let parts: Vec<&str> = line.split(" v").collect();
if parts.len() != 2 {
return None;
}
let package_name = parts[0].trim();
let version_part = parts[1].split(':').next()?.trim();
if package_name.is_empty() || version_part.is_empty() {
return None;
}
Some((package_name, version_part))
}
pub async fn get_installed_version(package_name: &str) -> Result<Option<String>> {
let output = Command::new("cargo").args(["install", "--list"]).output()?;
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 {
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)
}
pub async fn get_latest_version(
package_name: &str,
include_prerelease: bool,
) -> Result<Option<String>> {
let output = Command::new("cargo")
.args(["search", package_name, "--limit", "10"])
.output()?;
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 async fn check_package_updates(
packages: &mut [PackageInfo],
verbose: bool,
include_prerelease: bool,
) -> Result<()> {
let language = detect_language();
let mut handles = Vec::new();
for (index, package) in packages.iter().enumerate() {
let package_name = package.name.clone();
let handle = tokio::spawn(async move {
if verbose {
println!(
"{} {}...",
language.get_text("checking_package"),
package_name.cyan()
);
}
let result = get_latest_version(&package_name, include_prerelease).await;
(index, package_name, result)
});
handles.push(handle);
}
for handle in handles {
match handle.await {
Ok((index, package_name, result)) => match result {
Ok(Some(version)) => {
packages[index].latest_version = Some(version.clone());
if verbose {
println!(
" {} {}: {}",
package_name,
language.get_text("latest_version"),
version.green()
);
}
}
Ok(None) => {
if verbose {
println!(
" {} {}",
package_name.red(),
language.get_text("unable_to_get_latest_version")
);
}
}
Err(e) => {
if verbose {
println!(
" {} {}: {}",
package_name.red(),
language.get_text("check_failed"),
e
);
}
}
},
Err(e) => {
if verbose {
println!("Task failed: {}", e);
}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_package_line_standard() {
let line = "ripgrep v14.1.1:";
assert_eq!(parse_package_line(line), Some(("ripgrep", "14.1.1")));
}
#[test]
fn parse_package_line_with_build_metadata() {
let line = "some-tool v1.2.3+arch64:";
assert_eq!(parse_package_line(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_package_line(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 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_exact_substring_match() {
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_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");
}
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()),
}
}
#[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());
}
}