use anyhow::Result;
use colored::*;
use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::process::Output;
use tokio::process::Command;
use crate::display::{pb_status, pb_status_dim, pb_status_err, pb_status_warn, status, status_dim};
use crate::locale::detection::detect_language;
use crate::models::{
InstallOpts, PackageSource, UpdateResult, MAX_RETRY_ATTEMPTS, PROGRESS_TICK_MS,
RETRY_DELAY_MS, VERSION_UPDATE_DELAY_MS,
};
use crate::package::{
ensure_binstall_available, get_installed_version, invalidate_installed_version,
is_binstall_available,
};
pub fn create_progress_bar(package_name: &str) -> ProgressBar {
use std::io::IsTerminal;
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner:.green} {msg} {elapsed:.dim}")
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
);
pb.set_message(package_name.cyan().to_string());
if !std::io::stderr().is_terminal() || crate::display::is_json_mode() {
pb.set_draw_target(ProgressDrawTarget::hidden());
}
pb
}
const SLOW_PACKAGE_THRESHOLD_SECS: u64 = 30;
fn spawn_slow_warning(pb: ProgressBar, package_name: String) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(SLOW_PACKAGE_THRESHOLD_SECS)).await;
let elapsed = pb.elapsed().as_secs();
pb_status_dim(
&pb,
"Slow",
&format!(
"{} running for {}s (likely building from source)",
package_name.cyan(),
elapsed
),
);
})
}
struct PbGuard<'a>(&'a ProgressBar);
impl Drop for PbGuard<'_> {
fn drop(&mut self) {
self.0.finish_and_clear();
}
}
struct SlowGuard(tokio::task::JoinHandle<()>);
impl Drop for SlowGuard {
fn drop(&mut self) {
self.0.abort();
}
}
fn build_args(
use_binstall: bool,
package_name: &str,
version: Option<&str>,
source: &PackageSource,
opts: Option<&InstallOpts>,
) -> Vec<String> {
let mut args: Vec<String> = match source {
PackageSource::Crates => {
let subcmd = if use_binstall { "binstall" } else { "install" };
match version {
Some(v) => vec![
subcmd.into(),
"--force".into(),
package_name.into(),
"--version".into(),
v.into(),
],
None => vec![subcmd.into(), "--force".into(), package_name.into()],
}
}
PackageSource::Git { url, rev } => {
let mut a: Vec<String> =
vec!["install".into(), "--git".into(), url.clone()];
if let Some(r) = rev {
a.push("--rev".into());
a.push(r.clone());
}
a.push("--force".into());
a.push(package_name.into());
a
}
PackageSource::Path { dir } => vec![
"install".into(),
"--path".into(),
dir.clone(),
"--force".into(),
package_name.into(),
],
PackageSource::Unknown(raw) => vec![
"install".into(),
"--unknown-source-marker".into(),
raw.clone(),
package_name.into(),
],
};
if let (Some(o), false) = (opts, matches!(source, PackageSource::Unknown(_))) {
if o.no_default_features {
args.push("--no-default-features".into());
}
if o.all_features {
args.push("--all-features".into());
}
if !o.features.is_empty() {
args.push("--features".into());
args.push(o.features.join(","));
}
}
args
}
struct CommandSelector {
primary: Vec<String>,
fallback: Option<Vec<String>>,
fell_back: bool,
}
impl CommandSelector {
fn new(primary: Vec<String>, fallback: Option<Vec<String>>) -> Self {
Self {
primary,
fallback,
fell_back: false,
}
}
fn current(&self) -> &[String] {
match (self.fell_back, &self.fallback) {
(true, Some(fb)) => fb,
_ => &self.primary,
}
}
fn switch_to_fallback(&mut self) -> bool {
if !self.fell_back && self.fallback.is_some() {
self.fell_back = true;
true
} else {
false
}
}
}
async fn run_cargo(pb: &ProgressBar, args: &[String]) -> Result<Output> {
pb_status_dim(pb, "Running", &format!("cargo {}", args.join(" ")));
pb.enable_steady_tick(std::time::Duration::from_millis(PROGRESS_TICK_MS));
let output = Command::new("cargo")
.args(args)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.await?;
pb.disable_steady_tick();
Ok(output)
}
async fn verify_and_report_update(
pb: &ProgressBar,
package_name: &str,
old_version: &Option<String>,
) -> UpdateResult {
let language = detect_language();
invalidate_installed_version(package_name);
tokio::time::sleep(tokio::time::Duration::from_millis(VERSION_UPDATE_DELAY_MS)).await;
match get_installed_version(package_name).await {
Ok(Some(new_version)) if old_version.as_ref() != Some(&new_version) => {
let unknown = language.get_text("unknown_version").to_string();
let old_str = old_version.as_ref().unwrap_or(&unknown);
pb_status(
pb,
"Updated",
&format!(
"{} {} -> {}",
package_name.cyan(),
old_str.red(),
new_version.green()
),
);
UpdateResult::new(
package_name.to_string(),
old_version.clone(),
Some(new_version),
true,
)
}
Ok(Some(_)) => {
pb_status_warn(
pb,
"Unchanged",
&format!(
"{} {}",
package_name.cyan(),
language.get_text("version_unchanged").dimmed()
),
);
UpdateResult::new(
package_name.to_string(),
old_version.clone(),
old_version.clone(),
true,
)
}
_ => {
pb_status_warn(
pb,
"Warning",
&format!(
"{} {}",
package_name.cyan(),
detect_language()
.get_text("package_update_verification_failed")
.replace("{} ", "")
.dimmed()
),
);
UpdateResult::new(package_name.to_string(), old_version.clone(), None, true)
}
}
}
fn report_command_failure(pb: &ProgressBar, package_name: &str, output: &Output) {
pb_status_err(
pb,
"Failed",
&format!(
"{} (exit code: {})",
package_name.red(),
output.status.code().unwrap_or(-1)
),
);
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.is_empty() {
pb_status_dim(
pb,
"stderr",
&format!("{}", stderr.trim().dimmed()),
);
}
}
pub async fn update_package(
package_name: &str,
target_version: Option<&str>,
source: &PackageSource,
install_opts: Option<&InstallOpts>,
dry_run: bool,
install_binstall: bool,
verbose: bool,
) -> Result<UpdateResult> {
let language = detect_language();
let old_version = get_installed_version(package_name).await.ok().flatten();
let opts_allow_binstall = install_opts.is_none_or(|o| o.is_default());
let use_binstall = match source {
PackageSource::Crates => {
if is_binstall_available() && opts_allow_binstall {
true
} else if install_binstall && !dry_run && opts_allow_binstall {
ensure_binstall_available().await.unwrap_or(false)
} else {
if opts_allow_binstall {
status_dim("Hint", language.get_text("binstall_hint"));
} else if verbose {
status_dim(
"Check",
&format!("{package_name}: custom features, skipping binstall"),
);
}
false
}
}
_ => false,
};
if verbose && install_opts.is_none() && matches!(source, PackageSource::Crates) {
status_dim(
"Check",
&format!("{package_name} no install metadata, using default features"),
);
}
let primary_args =
build_args(use_binstall, package_name, target_version, source, install_opts);
let fallback_args = if use_binstall {
Some(build_args(false, package_name, target_version, source, install_opts))
} else {
None
};
if dry_run {
let marker = source.marker();
let header = if marker.is_empty() {
package_name.cyan().bold().to_string()
} else {
format!("{} {}", package_name.cyan().bold(), marker.dimmed())
};
status(
"Would run",
&format!("{}: cargo {}", header, primary_args.join(" ")),
);
if let Some(fb) = &fallback_args {
status_dim(
"Fallback",
&format!("cargo {}", fb.join(" ")),
);
}
return Ok(UpdateResult::new(
package_name.to_string(),
old_version.clone(),
old_version,
true,
));
}
let pb = create_progress_bar(package_name);
let _pb_guard = PbGuard(&pb);
let slow_handle = spawn_slow_warning(pb.clone(), package_name.to_string());
let _slow_guard = SlowGuard(slow_handle);
if let Some(ref version) = old_version {
pb_status_dim(
&pb,
language.get_text("current_version_label"),
&version.blue().to_string(),
);
}
match (source, use_binstall) {
(PackageSource::Crates, true) => {
pb_status_dim(&pb, "Using", language.get_text("using_binstall"));
}
(PackageSource::Crates, false) => {
pb_status_dim(&pb, "Using", language.get_text("using_install_fallback"));
}
(PackageSource::Git { .. }, _) | (PackageSource::Path { .. }, _) => {
pb_status_dim(
&pb,
"Using",
&format!(
"{} {}",
language.get_text("using_install_fallback"),
source.marker().dimmed(),
));
}
(PackageSource::Unknown(_), _) => {
pb_status_warn(&pb, "Skip", &format!("{} {}", package_name, source.marker()));
return Ok(UpdateResult::new(
package_name.to_string(),
old_version,
None,
false,
));
}
}
let mut selector = CommandSelector::new(primary_args, fallback_args);
for attempt in 1..=MAX_RETRY_ATTEMPTS {
if attempt > 1 {
pb.set_message(language.format_text(
"retry_attempt",
&[
("attempt", &attempt.to_string()),
("name", &package_name.cyan().to_string()),
],
));
}
let output = run_cargo(&pb, selector.current()).await?;
if output.status.success() {
let result = verify_and_report_update(&pb, package_name, &old_version).await;
if result.new_version.is_none() && attempt < MAX_RETRY_ATTEMPTS {
pb_status_dim(&pb, "Retry", language.get_text("waiting_retry"));
tokio::time::sleep(tokio::time::Duration::from_millis(RETRY_DELAY_MS)).await;
continue;
}
return Ok(result);
}
if selector.switch_to_fallback() {
pb_status_warn(&pb, "Fallback", language.get_text("binstall_failed_fallback"));
let fb_output = run_cargo(&pb, selector.current()).await?;
if fb_output.status.success() {
return Ok(verify_and_report_update(&pb, package_name, &old_version).await);
}
report_command_failure(&pb, package_name, &fb_output);
} else {
report_command_failure(&pb, package_name, &output);
}
if attempt < MAX_RETRY_ATTEMPTS {
pb_status_dim(&pb, "Retry", language.get_text("waiting_retry"));
tokio::time::sleep(tokio::time::Duration::from_millis(RETRY_DELAY_MS)).await;
continue;
}
return Ok(UpdateResult::new(
package_name.to_string(),
old_version,
None,
false,
));
}
Ok(UpdateResult::new(
package_name.to_string(),
old_version,
None,
false,
))
}
#[cfg(test)]
mod tests {
use super::{build_args, CommandSelector};
use crate::models::{InstallOpts, PackageSource};
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn selector_starts_on_primary() {
let primary = s(&["binstall", "--force", "mdbook"]);
let fallback = s(&["install", "--force", "mdbook"]);
let sel = CommandSelector::new(primary.clone(), Some(fallback));
assert_eq!(sel.current(), primary.as_slice());
}
#[test]
fn selector_sticks_to_fallback_across_retries() {
let primary = s(&["binstall", "--force", "mdbook", "--version", "0.5.3"]);
let fallback = s(&["install", "--force", "mdbook", "--version", "0.5.3"]);
let mut sel = CommandSelector::new(primary.clone(), Some(fallback.clone()));
assert_eq!(sel.current(), primary.as_slice());
assert!(sel.switch_to_fallback());
assert_eq!(sel.current(), fallback.as_slice());
assert!(!sel.switch_to_fallback());
assert_eq!(sel.current(), fallback.as_slice());
assert!(!sel.switch_to_fallback());
assert_eq!(sel.current(), fallback.as_slice());
}
#[test]
fn selector_without_fallback_always_primary() {
let primary = s(&["install", "--force", "ripgrep"]);
let mut sel = CommandSelector::new(primary.clone(), None);
assert_eq!(sel.current(), primary.as_slice());
assert!(!sel.switch_to_fallback());
assert_eq!(sel.current(), primary.as_slice());
}
#[test]
fn crates_default_opts_no_extra_flags() {
let got = build_args(false, "ripgrep", Some("14.1.1"), &PackageSource::Crates, None);
assert_eq!(
got,
s(&["install", "--force", "ripgrep", "--version", "14.1.1"])
);
}
#[test]
fn crates_with_features() {
let opts = InstallOpts {
no_default_features: false,
all_features: false,
features: vec!["pcre2".into(), "simd".into()],
};
let got = build_args(false, "ripgrep", None, &PackageSource::Crates, Some(&opts));
assert_eq!(
got,
s(&["install", "--force", "ripgrep", "--features", "pcre2,simd"])
);
}
#[test]
fn crates_no_default_and_all_features() {
let opts = InstallOpts {
no_default_features: true,
all_features: true,
features: vec![],
};
let got = build_args(false, "x", None, &PackageSource::Crates, Some(&opts));
assert_eq!(
got,
s(&[
"install",
"--force",
"x",
"--no-default-features",
"--all-features"
])
);
}
#[test]
fn git_source_with_features() {
let opts = InstallOpts {
no_default_features: false,
all_features: false,
features: vec!["a".into()],
};
let src = PackageSource::Git {
url: "https://github.com/x/y".into(),
rev: Some("abc".into()),
};
let got = build_args(false, "y", None, &src, Some(&opts));
assert_eq!(
got,
s(&[
"install", "--git", "https://github.com/x/y", "--rev", "abc",
"--force", "y", "--features", "a"
])
);
}
#[test]
fn path_source_with_no_default_features() {
let opts = InstallOpts {
no_default_features: true,
all_features: false,
features: vec![],
};
let src = PackageSource::Path { dir: "/tmp/p".into() };
let got = build_args(false, "p", None, &src, Some(&opts));
assert_eq!(
got,
s(&[
"install", "--path", "/tmp/p", "--force", "p",
"--no-default-features"
])
);
}
#[test]
fn default_opts_some_but_empty_adds_nothing() {
let opts = InstallOpts::default();
let got = build_args(true, "tool", None, &PackageSource::Crates, Some(&opts));
assert_eq!(got, s(&["binstall", "--force", "tool"]));
}
#[test]
fn unknown_source_ignores_opts() {
let opts = InstallOpts {
no_default_features: true,
all_features: true,
features: vec!["x".into()],
};
let src = PackageSource::Unknown("custom-reg".into());
let got = build_args(false, "tool", None, &src, Some(&opts));
assert_eq!(
got,
s(&["install", "--unknown-source-marker", "custom-reg", "tool"])
);
}
}