use anyhow::Result;
use colored::*;
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::process::Output;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use tokio::process::Command;
use tokio::sync::mpsc;
use crate::display::{pb_status_dim, pb_status_err, pb_status_warn, status, status_dim};
use crate::downloader::{
self,
events::{DownloaderError, ProgressEvent},
InstallSpec,
};
use crate::locale::detection::detect_language;
use crate::models::{
InstallMethod, InstallOpts, PackageSource, UpdateResult, MAX_RETRY_ATTEMPTS, PROGRESS_TICK_MS,
RETRY_DELAY_MS, VERSION_UPDATE_DELAY_MS,
};
use crate::package::{get_installed_version, invalidate_installed_version};
pub fn multi_progress() -> &'static MultiProgress {
static MP: OnceLock<MultiProgress> = OnceLock::new();
MP.get_or_init(|| {
use std::io::IsTerminal;
let mp = MultiProgress::new();
if !std::io::stderr().is_terminal() || crate::display::is_json_mode() {
mp.set_draw_target(ProgressDrawTarget::hidden());
}
mp
})
}
fn download_bar_style(name_width: usize) -> ProgressStyle {
ProgressStyle::with_template(&format!(
"{{msg:>{name_width}.cyan}} [{{bar:24.cyan/blue}}] {{bytes:>8}}/{{total_bytes:>8}} ({{percent:>3}}%) {{binary_bytes_per_sec:>10}} ETA {{eta:>3}}"
))
.unwrap()
.progress_chars("##-")
}
fn spinner_style(name_width: usize) -> ProgressStyle {
ProgressStyle::default_spinner()
.template(&format!("{{msg:>{name_width}.cyan}} {{spinner:.green}} {{prefix}}"))
.unwrap()
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"])
}
fn static_style(name_width: usize) -> ProgressStyle {
ProgressStyle::with_template(&format!("{{msg:>{name_width}.cyan}} {{prefix}}"))
.unwrap()
}
pub struct UpdatePlan {
rows: Vec<ProgressBar>,
name_width: usize,
}
impl UpdatePlan {
pub fn new(names: &[String]) -> Self {
let name_width = names.iter().map(|n| n.chars().count()).max().unwrap_or(0).max(10);
let mp = multi_progress();
let rows: Vec<ProgressBar> = names
.iter()
.map(|n| {
let pb = mp.add(ProgressBar::new_spinner());
pb.set_style(static_style(name_width));
pb.set_message(n.clone());
pb.set_prefix("pending".dimmed().to_string());
pb
})
.collect();
Self { rows, name_width }
}
pub fn row(&self, i: usize) -> ProgressBar { self.rows[i].clone() }
pub fn name_width(&self) -> usize { self.name_width }
}
pub fn create_progress_bar(package_name: &str) -> ProgressBar {
let pb = multi_progress().add(ProgressBar::new_spinner());
let nw = package_name.chars().count().max(10);
pb.set_style(spinner_style(nw));
pb.set_message(package_name.to_string());
pb.set_prefix("pending".dimmed().to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(PROGRESS_TICK_MS));
pb
}
pub fn activate_row(pb: &ProgressBar, name_width: usize, verb: &str) {
pb.set_style(spinner_style(name_width));
pb.set_prefix(verb.green().bold().to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(PROGRESS_TICK_MS));
}
pub fn switch_to_download_bar(pb: &ProgressBar, name_width: usize, total: u64) {
pb.disable_steady_tick();
pb.set_style(download_bar_style(name_width));
pb.set_length(total);
}
pub fn switch_to_spinner_phase(pb: &ProgressBar, name_width: usize, verb: &str) {
pb.set_style(spinner_style(name_width));
pb.set_prefix(verb.green().bold().to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(PROGRESS_TICK_MS));
}
pub fn finalize_installed(pb: &ProgressBar, name_width: usize) {
pb.disable_steady_tick();
pb.set_style(static_style(name_width));
let total = pb.length().unwrap_or(0);
let prefix = if total > 0 {
format!(
"{} {:>10.2} MiB",
"installed".green().bold(),
total as f64 / 1_048_576.0
)
} else {
"installed".green().bold().to_string()
};
pb.set_prefix(prefix);
pb.finish();
}
pub fn finalize_failed(pb: &ProgressBar, name_width: usize, detail: &str) {
pb.disable_steady_tick();
pb.set_style(static_style(name_width));
pb.set_prefix(format!("{} {}", "failed".red().bold(), detail.dimmed()));
pb.finish();
}
pub fn finalize_aborted(pb: &ProgressBar, name_width: usize) {
pb.disable_steady_tick();
pb.set_style(static_style(name_width));
pb.set_prefix("aborted".yellow().bold().to_string());
pb.finish();
}
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(
package_name: &str,
version: Option<&str>,
source: &PackageSource,
opts: Option<&InstallOpts>,
) -> Vec<String> {
let mut args: Vec<String> = match source {
PackageSource::Crates => {
let mut a: Vec<String> = vec!["install".into()];
a.push("--force".into());
a.push(package_name.into());
if let Some(v) = version {
a.push("--version".into());
a.push(v.into());
}
a
}
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)
.stdin(std::process::Stdio::null())
.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 _ = (pb, language); 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()),
);
}
}
async fn try_downloader_install(
pb: &ProgressBar,
name_width: usize,
package_name: &str,
version: &str,
old_version: &Option<String>,
cancel_arc: Arc<AtomicBool>,
verbose: bool,
) -> Result<bool, DownloaderError> {
let client = crate::package::http_client();
let repo_url = crate::package::crates_api::fetch_repo_url(client, package_name).await;
if repo_url.is_none() {
pb_status_dim(
pb,
"Downloader",
&format!("{package_name}: no repo URL, falling back to cargo install"),
);
return Ok(false);
}
let bins = crate::package::registry::cargo_home()
.map(|home| crate::package::crates2::lookup_bins(&home, package_name))
.unwrap_or_default();
let (tx, mut rx) = mpsc::unbounded_channel::<ProgressEvent>();
let spec = InstallSpec {
name: package_name.to_string(),
version: version.to_string(),
repo_url,
bins,
};
let pb_clone = pb.clone();
let verbose_flag = verbose;
let event_handle = tokio::spawn(async move {
let mut bar_initialized = false;
while let Some(event) = rx.recv().await {
match event {
ProgressEvent::Resolving { .. } => {
activate_row(&pb_clone, name_width, "resolving");
}
ProgressEvent::UrlCandidate { url, .. } => {
if verbose_flag {
pb_status_dim(&pb_clone, "Trying", &url.dimmed().to_string());
}
}
ProgressEvent::Downloading { got, total, .. } => {
match (bar_initialized, total) {
(false, Some(t)) if t > 0 => {
switch_to_download_bar(&pb_clone, name_width, t);
pb_clone.set_position(got);
bar_initialized = true;
}
(true, _) => {
pb_clone.set_position(got);
}
_ => {
pb_clone.set_prefix(format!(
"{} {:.1} MiB",
"downloading".green().bold(),
got as f64 / 1_048_576.0
));
}
}
}
ProgressEvent::Verifying { .. } => {
if bar_initialized {
switch_to_spinner_phase(&pb_clone, name_width, "verifying");
bar_initialized = false;
} else {
pb_clone.set_prefix("verifying".green().bold().to_string());
}
}
ProgressEvent::Extracting { .. } => {
if bar_initialized {
switch_to_spinner_phase(&pb_clone, name_width, "extracting");
bar_initialized = false;
} else {
pb_clone.set_prefix("extracting".green().bold().to_string());
}
}
ProgressEvent::Installing { .. } => {
pb_clone.set_prefix("installing".green().bold().to_string());
}
ProgressEvent::Done { .. } | ProgressEvent::Failed { .. } => {
}
}
}
});
let result = downloader::download_and_install(
client,
spec,
old_version.clone(),
tx,
cancel_arc,
)
.await;
let _ = event_handle.await;
match result {
Ok(_outcome) => Ok(true),
Err(DownloaderError::Cancelled) => Err(DownloaderError::Cancelled),
Err(DownloaderError::Unsupported(reason)) => {
pb_status_dim(
pb,
"Downloader",
&format!("{package_name}: unsupported ({reason:?}), falling back to cargo install"),
);
Ok(false)
}
Err(DownloaderError::Failed { kind, source }) => {
pb_status_dim(
pb,
"Fallback",
&format!(
"{package_name}: downloader failed ({kind:?}: {source}), falling back to cargo install"
),
);
Ok(false)
}
}
}
#[allow(clippy::too_many_arguments)]
pub async fn update_package(
package_name: &str,
target_version: Option<&str>,
source: &PackageSource,
install_opts: Option<&InstallOpts>,
dry_run: bool,
verbose: bool,
cancel: Arc<AtomicBool>,
row: Option<(ProgressBar, usize)>,
) -> Result<Option<UpdateResult>> {
if cancel.load(Ordering::SeqCst) {
return Ok(None);
}
let language = detect_language();
let old_version = get_installed_version(package_name).await.ok().flatten();
let opts_allow_downloader = install_opts.is_none_or(|o| o.is_default());
if verbose {
if install_opts.is_none() && matches!(source, PackageSource::Crates) {
status_dim(
"Check",
&format!("{package_name}: no install metadata, using default features"),
);
} else if !opts_allow_downloader && matches!(source, PackageSource::Crates) {
status_dim(
"Check",
&format!("{package_name}: custom features, skipping downloader"),
);
}
}
let cargo_install_args = build_args(package_name, target_version, source, install_opts);
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())
};
if matches!(source, PackageSource::Crates) && opts_allow_downloader {
status(
"Would run",
&format!("{header}: self-hosted downloader → cargo {}", cargo_install_args.join(" ")),
);
} else {
status(
"Would run",
&format!("{}: cargo {}", header, cargo_install_args.join(" ")),
);
}
return Ok(Some(UpdateResult::new(
package_name.to_string(),
old_version.clone(),
old_version,
true,
)));
}
let (pb, name_width) = match row {
Some((pb, w)) => {
activate_row(&pb, w, "starting");
(pb, w)
}
None => {
let pb = create_progress_bar(package_name);
let w = package_name.chars().count().max(10);
(pb, w)
}
};
let _pb_guard = PbGuard(&pb);
let slow_handle = spawn_slow_warning(pb.clone(), package_name.to_string());
let _slow_guard = SlowGuard(slow_handle);
match source {
PackageSource::Crates | PackageSource::Git { .. } | PackageSource::Path { .. } => {
}
PackageSource::Unknown(_) => {
pb_status_warn(&pb, "Skip", &format!("{} {}", package_name, source.marker()));
return Ok(Some(UpdateResult::new(
package_name.to_string(),
old_version,
None,
false,
)));
}
}
if matches!(source, PackageSource::Crates) && opts_allow_downloader {
if let Some(v) = target_version {
match try_downloader_install(&pb, name_width, package_name, v, &old_version, cancel.clone(), verbose).await {
Ok(true) => {
let result = verify_and_report_update(&pb, package_name, &old_version)
.await
.with_install_method(InstallMethod::Downloader);
return Ok(Some(result));
}
Ok(false) => {
}
Err(DownloaderError::Cancelled) => {
pb_status_warn(&pb, "Aborted", &package_name.cyan().to_string());
return Ok(None);
}
Err(_) => unreachable!("try_downloader_install only returns Cancelled as Err"),
}
}
}
pb.disable_steady_tick();
pb.set_length(0);
pb.set_position(0);
pb.set_style(spinner_style(name_width));
pb.set_prefix("compiling from source".yellow().bold().to_string());
pb.enable_steady_tick(std::time::Duration::from_millis(PROGRESS_TICK_MS));
let mut selector = CommandSelector::new(cargo_install_args, None);
for attempt in 1..=MAX_RETRY_ATTEMPTS {
if cancel.load(Ordering::SeqCst) {
pb_status_warn(&pb, "Aborted", &package_name.cyan().to_string());
return Ok(None);
}
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
.with_install_method(InstallMethod::CargoInstall);
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(Some(result));
}
if cancel.load(Ordering::SeqCst) {
pb_status_warn(&pb, "Aborted", &package_name.cyan().to_string());
return Ok(None);
}
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() {
let result = verify_and_report_update(&pb, package_name, &old_version)
.await
.with_install_method(InstallMethod::CargoInstall);
return Ok(Some(result));
}
if cancel.load(Ordering::SeqCst) {
pb_status_warn(&pb, "Aborted", &package_name.cyan().to_string());
return Ok(None);
}
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(Some(UpdateResult::new(
package_name.to_string(),
old_version,
None,
false,
)));
}
Ok(Some(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()
}
#[tokio::test]
async fn cancelled_before_start_returns_none() {
use std::sync::{Arc, atomic::AtomicBool};
let cancel = Arc::new(AtomicBool::new(true));
let result = super::update_package(
"cargo-fresh-no-such-package",
Some("9.9.9"),
&PackageSource::Crates,
None,
false, false, cancel,
None, )
.await
.expect("update_package 不应返回 Err");
assert!(
result.is_none(),
"已取消时 update_package 必须返回 None,而不是一个结果"
);
}
#[test]
fn selector_starts_on_primary() {
let primary = s(&["install", "--force", "mdbook"]);
let sel = CommandSelector::new(primary.clone(), None);
assert_eq!(sel.current(), primary.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("ripgrep", Some("14.1.1"), &PackageSource::Crates, None);
assert_eq!(
got,
s(&["install", "--force", "ripgrep", "--version", "14.1.1"])
);
}
#[test]
fn cargo_install_has_no_no_confirm() {
let got = build_args("cargo-deny", Some("0.19.7"), &PackageSource::Crates, None);
assert!(
!got.contains(&"--no-confirm".to_string()),
"cargo install 不该带 --no-confirm。实际: {got:?}"
);
}
#[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("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("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("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("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("tool", None, &PackageSource::Crates, Some(&opts));
assert_eq!(got, s(&["install", "--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("tool", None, &src, Some(&opts));
assert_eq!(
got,
s(&["install", "--unknown-source-marker", "custom-reg", "tool"])
);
}
}