use super::context::{OutputContext, OutputMode};
use indicatif::{MultiProgress, ProgressBar, ProgressDrawTarget, ProgressStyle};
use std::time::Duration;
const SPINNER_TICK_RATE: Duration = Duration::from_millis(80);
const SPINNER_CHARS: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const SPINNER_CHARS_ASCII: &[&str] = &["|", "/", "-", "\\"];
const PROGRESS_CHARS: &str = "█▓░";
const PROGRESS_CHARS_ASCII: &str = "#-·";
fn should_show_progress(ctx: &OutputContext) -> bool {
!ctx.is_json() && !ctx.is_quiet() && ctx.mode() == OutputMode::Human
}
fn spinner_chars(ctx: &OutputContext) -> &'static [&'static str] {
if ctx.theme().supports_unicode() {
SPINNER_CHARS
} else {
SPINNER_CHARS_ASCII
}
}
fn progress_chars(ctx: &OutputContext) -> &'static str {
if ctx.theme().supports_unicode() {
PROGRESS_CHARS
} else {
PROGRESS_CHARS_ASCII
}
}
pub struct Spinner {
inner: ProgressBar,
_visible: bool,
}
impl Spinner {
pub fn new(ctx: &OutputContext, message: &str) -> Self {
let visible = should_show_progress(ctx);
let inner = if visible {
let pb = ProgressBar::new_spinner();
let chars = spinner_chars(ctx);
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(chars)
.template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
pb.set_message(message.to_string());
pb.enable_steady_tick(SPINNER_TICK_RATE);
pb
} else {
ProgressBar::hidden()
};
Self {
inner,
_visible: visible,
}
}
pub fn set_message(&self, msg: &str) {
self.inner.set_message(msg.to_string());
}
pub fn finish_success(&self, msg: &str) {
self.inner.finish_with_message(format!("✓ {}", msg));
}
pub fn finish_error(&self, msg: &str) {
self.inner.finish_with_message(format!("✗ {}", msg));
}
pub fn finish_warning(&self, msg: &str) {
self.inner.finish_with_message(format!("⚠ {}", msg));
}
pub fn abandon(&self) {
self.inner.abandon();
}
pub fn finish_and_clear(&self) {
self.inner.finish_and_clear();
}
pub fn inner(&self) -> &ProgressBar {
&self.inner
}
}
pub struct TransferProgress {
inner: ProgressBar,
}
impl TransferProgress {
pub fn new(ctx: &OutputContext, total: u64, label: &str) -> Self {
let visible = should_show_progress(ctx);
let inner = if visible {
let pb = ProgressBar::new(total);
let chars = progress_chars(ctx);
pb.set_style(
ProgressStyle::default_bar()
.template("{msg:12} [{bar:40.cyan/blue}] {bytes}/{total_bytes} {bytes_per_sec} ETA {eta}")
.expect("valid template")
.progress_chars(chars),
);
pb.set_message(label.to_string());
pb
} else {
ProgressBar::hidden()
};
Self { inner }
}
pub fn new_items(ctx: &OutputContext, total: u64, label: &str) -> Self {
let visible = should_show_progress(ctx);
let inner = if visible {
let pb = ProgressBar::new(total);
let chars = progress_chars(ctx);
pb.set_style(
ProgressStyle::default_bar()
.template("{msg:12} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)")
.expect("valid template")
.progress_chars(chars),
);
pb.set_message(label.to_string());
pb
} else {
ProgressBar::hidden()
};
Self { inner }
}
pub fn set_position(&self, pos: u64) {
self.inner.set_position(pos);
}
pub fn inc(&self, delta: u64) {
self.inner.inc(delta);
}
pub fn set_message(&self, msg: &str) {
self.inner.set_message(msg.to_string());
}
pub fn finish(&self) {
self.inner.finish();
}
pub fn finish_and_clear(&self) {
self.inner.finish_and_clear();
}
pub fn inner(&self) -> &ProgressBar {
&self.inner
}
}
#[derive(Clone)]
pub struct StepProgress {
steps: Vec<StepState>,
current: usize,
ctx_mode: OutputMode,
ctx_unicode: bool,
}
#[derive(Clone)]
struct StepState {
name: String,
status: StepStatus,
message: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StepStatus {
Pending,
InProgress,
Complete,
Failed,
Skipped,
}
impl StepProgress {
pub fn new(ctx: &OutputContext, step_names: &[&str]) -> Self {
let steps = step_names
.iter()
.map(|name| StepState {
name: name.to_string(),
status: StepStatus::Pending,
message: None,
})
.collect();
Self {
steps,
current: 0,
ctx_mode: ctx.mode(),
ctx_unicode: ctx.theme().supports_unicode(),
}
}
pub fn start_step(&mut self, idx: usize) {
if idx < self.steps.len() {
self.steps[idx].status = StepStatus::InProgress;
self.current = idx;
self.print_current();
}
}
pub fn complete_step(&mut self, idx: usize, message: Option<&str>) {
if idx < self.steps.len() {
self.steps[idx].status = StepStatus::Complete;
self.steps[idx].message = message.map(|s| s.to_string());
self.print_current();
}
}
pub fn fail_step(&mut self, idx: usize, message: Option<&str>) {
if idx < self.steps.len() {
self.steps[idx].status = StepStatus::Failed;
self.steps[idx].message = message.map(|s| s.to_string());
self.print_current();
}
}
pub fn skip_step(&mut self, idx: usize) {
if idx < self.steps.len() {
self.steps[idx].status = StepStatus::Skipped;
self.print_current();
}
}
fn print_current(&self) {
if self.ctx_mode == OutputMode::Json || self.ctx_mode == OutputMode::Quiet {
return;
}
let total = self.steps.len();
for (i, step) in self.steps.iter().enumerate() {
let symbol = self.status_symbol(step.status);
let num = format!("[{}/{}]", i + 1, total);
let msg = step
.message
.as_ref()
.map(|m| format!(" ({})", m))
.unwrap_or_default();
eprintln!("{} {} {}{}", num, symbol, step.name, msg);
}
}
fn status_symbol(&self, status: StepStatus) -> &'static str {
if self.ctx_unicode {
match status {
StepStatus::Pending => "○",
StepStatus::InProgress => "◐",
StepStatus::Complete => "✓",
StepStatus::Failed => "✗",
StepStatus::Skipped => "⊘",
}
} else {
match status {
StepStatus::Pending => "[ ]",
StepStatus::InProgress => "[~]",
StepStatus::Complete => "[x]",
StepStatus::Failed => "[!]",
StepStatus::Skipped => "[-]",
}
}
}
}
pub struct MultiProgressManager {
multi: MultiProgress,
visible: bool,
ctx_unicode: bool,
}
impl MultiProgressManager {
pub fn new(ctx: &OutputContext) -> Self {
let visible = should_show_progress(ctx);
let multi = if visible {
MultiProgress::new()
} else {
MultiProgress::with_draw_target(ProgressDrawTarget::hidden())
};
Self {
multi,
visible,
ctx_unicode: ctx.theme().supports_unicode(),
}
}
pub fn add_spinner(&self, prefix: &str, message: &str) -> ProgressBar {
let pb = self.multi.add(ProgressBar::new_spinner());
if self.visible {
let chars = if self.ctx_unicode {
SPINNER_CHARS
} else {
SPINNER_CHARS_ASCII
};
#[allow(clippy::literal_string_with_formatting_args)]
let template = "{prefix:12} {spinner:.cyan} {msg}";
pb.set_style(
ProgressStyle::default_spinner()
.tick_strings(chars)
.template(template)
.expect("valid template"),
);
pb.set_prefix(prefix.to_string());
pb.set_message(message.to_string());
pb.enable_steady_tick(SPINNER_TICK_RATE);
}
pb
}
pub fn add_progress_bar(&self, total: u64, prefix: &str) -> ProgressBar {
let pb = self.multi.add(ProgressBar::new(total));
if self.visible {
let chars = if self.ctx_unicode {
PROGRESS_CHARS
} else {
PROGRESS_CHARS_ASCII
};
pb.set_style(
ProgressStyle::default_bar()
.template("{prefix:12} [{bar:30.cyan/blue}] {percent:3}%")
.expect("valid template")
.progress_chars(chars),
);
pb.set_prefix(prefix.to_string());
}
pb
}
pub fn suspend<F, T>(&self, f: F) -> T
where
F: FnOnce() -> T,
{
self.multi.suspend(f)
}
pub fn hide(&self) {
self.multi.set_draw_target(ProgressDrawTarget::hidden());
}
pub fn show(&self) {
if self.visible {
self.multi.set_draw_target(ProgressDrawTarget::stderr());
}
}
pub fn clear(&self) {
self.multi.clear().ok();
}
pub fn is_visible(&self) -> bool {
self.visible
}
}
pub async fn with_spinner<F, T>(ctx: &OutputContext, message: &str, future: F) -> T
where
F: std::future::Future<Output = T>,
{
let spinner = Spinner::new(ctx, message);
let result = future.await;
spinner.finish_and_clear();
result
}
pub async fn with_spinner_result<F, T, E>(
ctx: &OutputContext,
message: &str,
success_msg: &str,
future: F,
) -> Result<T, E>
where
F: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let spinner = Spinner::new(ctx, message);
let result = future.await;
match &result {
Ok(_) => spinner.finish_success(success_msg),
Err(e) => spinner.finish_error(&e.to_string()),
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::{OutputConfig, OutputContext};
use tracing::info;
fn log_test_start(name: &str) {
info!("TEST START: {}", name);
}
fn make_plain_context() -> OutputContext {
OutputContext::new(OutputConfig {
color: super::super::context::ColorChoice::Never,
..Default::default()
})
}
fn make_quiet_context() -> OutputContext {
OutputContext::new(OutputConfig {
quiet: true,
..Default::default()
})
}
fn make_json_context() -> OutputContext {
OutputContext::new(OutputConfig {
json: true,
..Default::default()
})
}
#[test]
fn test_spinner_lifecycle() {
log_test_start("test_spinner_lifecycle");
let ctx = make_plain_context();
let spinner = Spinner::new(&ctx, "Testing...");
spinner.set_message("Still testing...");
spinner.finish_success("Done");
}
#[test]
fn test_spinner_in_quiet_mode() {
log_test_start("test_spinner_in_quiet_mode");
let ctx = make_quiet_context();
let spinner = Spinner::new(&ctx, "Testing...");
spinner.finish_success("Done");
}
#[test]
fn test_spinner_in_json_mode() {
log_test_start("test_spinner_in_json_mode");
let ctx = make_json_context();
let spinner = Spinner::new(&ctx, "Testing...");
spinner.finish_error("Failed");
}
#[test]
fn test_spinner_error_finish() {
log_test_start("test_spinner_error_finish");
let ctx = make_plain_context();
let spinner = Spinner::new(&ctx, "Working...");
spinner.finish_error("Connection failed");
}
#[test]
fn test_spinner_warning_finish() {
log_test_start("test_spinner_warning_finish");
let ctx = make_plain_context();
let spinner = Spinner::new(&ctx, "Working...");
spinner.finish_warning("Partial success");
}
#[test]
fn test_spinner_abandon() {
log_test_start("test_spinner_abandon");
let ctx = make_plain_context();
let spinner = Spinner::new(&ctx, "Working...");
spinner.abandon();
}
#[test]
fn test_transfer_progress_lifecycle() {
log_test_start("test_transfer_progress_lifecycle");
let ctx = make_plain_context();
let bar = TransferProgress::new(&ctx, 100, "Syncing");
bar.set_position(50);
bar.inc(25);
bar.finish();
}
#[test]
fn test_transfer_progress_items() {
log_test_start("test_transfer_progress_items");
let ctx = make_plain_context();
let bar = TransferProgress::new_items(&ctx, 10, "Items");
bar.set_position(5);
bar.finish_and_clear();
}
#[test]
fn test_transfer_progress_quiet() {
log_test_start("test_transfer_progress_quiet");
let ctx = make_quiet_context();
let bar = TransferProgress::new(&ctx, 100, "test");
bar.set_position(50);
bar.finish();
}
#[test]
fn test_step_progress_lifecycle() {
log_test_start("test_step_progress_lifecycle");
let ctx = make_plain_context();
let mut steps = StepProgress::new(&ctx, &["Sync", "Compile", "Retrieve"]);
steps.start_step(0);
steps.complete_step(0, Some("2.3s"));
steps.start_step(1);
steps.complete_step(1, None);
steps.start_step(2);
steps.complete_step(2, Some("45ms"));
}
#[test]
fn test_step_progress_failure() {
log_test_start("test_step_progress_failure");
let ctx = make_plain_context();
let mut steps = StepProgress::new(&ctx, &["Sync", "Compile"]);
steps.start_step(0);
steps.fail_step(0, Some("Connection refused"));
}
#[test]
fn test_step_progress_skip() {
log_test_start("test_step_progress_skip");
let ctx = make_plain_context();
let mut steps = StepProgress::new(&ctx, &["Sync", "Compile"]);
steps.skip_step(0);
}
#[test]
fn test_multi_progress_manager() {
log_test_start("test_multi_progress_manager");
let ctx = make_plain_context();
let manager = MultiProgressManager::new(&ctx);
let pb1 = manager.add_spinner("worker-1", "Connecting...");
let pb2 = manager.add_spinner("worker-2", "Connecting...");
pb1.finish_with_message("OK");
pb2.finish_with_message("OK");
}
#[test]
fn test_multi_progress_suspend() {
log_test_start("test_multi_progress_suspend");
let ctx = make_plain_context();
let manager = MultiProgressManager::new(&ctx);
let result = manager.suspend(|| {
42
});
assert_eq!(result, 42);
}
#[test]
fn test_multi_progress_visibility() {
log_test_start("test_multi_progress_visibility");
let ctx = make_quiet_context();
let manager = MultiProgressManager::new(&ctx);
assert!(!manager.is_visible());
let ctx_plain = make_plain_context();
let manager_plain = MultiProgressManager::new(&ctx_plain);
let _ = manager_plain.is_visible();
}
#[test]
fn test_multi_progress_bar() {
log_test_start("test_multi_progress_bar");
let ctx = make_plain_context();
let manager = MultiProgressManager::new(&ctx);
let pb = manager.add_progress_bar(100, "Task");
pb.set_position(50);
pb.finish();
}
#[test]
fn test_should_show_progress() {
log_test_start("test_should_show_progress");
let ctx_quiet = make_quiet_context();
assert!(!should_show_progress(&ctx_quiet));
let ctx_json = make_json_context();
assert!(!should_show_progress(&ctx_json));
}
#[test]
fn test_spinner_chars_selection() {
log_test_start("test_spinner_chars_selection");
let ctx = make_plain_context();
let chars = spinner_chars(&ctx);
assert!(!chars.is_empty());
}
#[test]
fn test_progress_chars_selection() {
log_test_start("test_progress_chars_selection");
let ctx = make_plain_context();
let chars = progress_chars(&ctx);
assert!(!chars.is_empty());
}
}