Pull the line-formatting logic out of finish_stage() into a standalone public format_stage_line() so that sync.rs can build stage lines without needing a live ProgressBar (e.g. for static multi-line blocks printed after the spinner is cleared). The new function applies Theme::info().bold() to the label and Theme::timing() to the elapsed column, giving every stage line consistent color treatment. finish_stage() now delegates to it. Includes a unit test asserting the formatted output contains the expected icon, label, summary, and elapsed components.
225 lines
6.7 KiB
Rust
225 lines
6.7 KiB
Rust
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
|
use std::io::Write;
|
|
use std::sync::LazyLock;
|
|
use std::time::Duration;
|
|
use tracing_subscriber::fmt::MakeWriter;
|
|
|
|
use crate::cli::render::{Icons, Theme};
|
|
|
|
static MULTI: LazyLock<MultiProgress> = LazyLock::new(MultiProgress::new);
|
|
|
|
pub fn multi() -> &'static MultiProgress {
|
|
&MULTI
|
|
}
|
|
|
|
/// Stage spinner with icon prefix and elapsed time on the right.
|
|
///
|
|
/// Template: `{spinner:.cyan} {prefix} {wide_msg} {elapsed_style:.dim}`
|
|
pub fn stage_spinner_v2(icon: &str, label: &str, msg: &str, robot_mode: bool) -> ProgressBar {
|
|
if robot_mode {
|
|
return ProgressBar::hidden();
|
|
}
|
|
let pb = multi().add(ProgressBar::new_spinner());
|
|
pb.set_style(
|
|
ProgressStyle::default_spinner()
|
|
.template(" {spinner:.cyan} {prefix} {wide_msg}")
|
|
.expect("valid template"),
|
|
);
|
|
pb.enable_steady_tick(Duration::from_millis(60));
|
|
pb.set_prefix(format!("{icon} {label}"));
|
|
pb.set_message(msg.to_string());
|
|
pb
|
|
}
|
|
|
|
/// Nested progress bar with count, throughput, and ETA.
|
|
///
|
|
/// Template: ` {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}`
|
|
pub fn nested_progress(msg: &str, len: u64, robot_mode: bool) -> ProgressBar {
|
|
if robot_mode {
|
|
return ProgressBar::hidden();
|
|
}
|
|
let pb = multi().add(ProgressBar::new(len));
|
|
pb.set_style(
|
|
ProgressStyle::default_bar()
|
|
.template(
|
|
" {spinner:.dim} {msg} {bar:30.cyan/dark_gray} {pos}/{len} {per_sec:.dim} {eta:.dim}",
|
|
)
|
|
.expect("valid template")
|
|
.progress_chars(Icons::progress_chars()),
|
|
);
|
|
pb.enable_steady_tick(Duration::from_millis(60));
|
|
pb.set_message(msg.to_string());
|
|
pb
|
|
}
|
|
|
|
/// Replace a spinner with a static completion line showing icon, label, summary, and elapsed.
|
|
///
|
|
/// Output: ` ✓ Label summary elapsed`
|
|
pub fn finish_stage(pb: &ProgressBar, icon: &str, label: &str, summary: &str, elapsed: Duration) {
|
|
let line = format_stage_line(icon, label, summary, elapsed);
|
|
pb.set_style(ProgressStyle::with_template("{msg}").expect("valid template"));
|
|
pb.finish_with_message(line);
|
|
}
|
|
|
|
/// Build a static stage line showing icon, label, summary, and elapsed.
|
|
///
|
|
/// Output: ` ✓ Label summary elapsed`
|
|
pub fn format_stage_line(icon: &str, label: &str, summary: &str, elapsed: Duration) -> String {
|
|
let elapsed_str = format_elapsed(elapsed);
|
|
let styled_label = Theme::info().bold().render(&format!("{label:<12}"));
|
|
let styled_elapsed = Theme::timing().render(&format!("{elapsed_str:>8}"));
|
|
format!(" {icon} {styled_label}{summary:>40} {styled_elapsed}")
|
|
}
|
|
|
|
/// Format a Duration as a compact human string (e.g. "1.2s", "42ms", "1m 5s").
|
|
fn format_elapsed(d: Duration) -> String {
|
|
let ms = d.as_millis();
|
|
if ms < 1000 {
|
|
format!("{ms}ms")
|
|
} else if ms < 60_000 {
|
|
format!("{:.1}s", ms as f64 / 1000.0)
|
|
} else {
|
|
let secs = d.as_secs();
|
|
let m = secs / 60;
|
|
let s = secs % 60;
|
|
format!("{m}m {s}s")
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SuspendingWriter;
|
|
|
|
pub struct SuspendingWriterInner {
|
|
buf: Vec<u8>,
|
|
}
|
|
|
|
impl Write for SuspendingWriterInner {
|
|
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
|
self.buf.extend_from_slice(data);
|
|
Ok(data.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> std::io::Result<()> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for SuspendingWriterInner {
|
|
fn drop(&mut self) {
|
|
if !self.buf.is_empty() {
|
|
let buf = std::mem::take(&mut self.buf);
|
|
MULTI.suspend(|| {
|
|
let _ = std::io::stderr().write_all(&buf);
|
|
let _ = std::io::stderr().flush();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a> MakeWriter<'a> for SuspendingWriter {
|
|
type Writer = SuspendingWriterInner;
|
|
|
|
fn make_writer(&'a self) -> Self::Writer {
|
|
SuspendingWriterInner { buf: Vec::new() }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn multi_returns_same_instance() {
|
|
let a = multi() as *const MultiProgress;
|
|
let b = multi() as *const MultiProgress;
|
|
assert!(std::ptr::eq(a, b));
|
|
}
|
|
|
|
#[test]
|
|
fn added_bar_is_tracked() {
|
|
let bar = multi().add(ProgressBar::new_spinner());
|
|
bar.set_message("test");
|
|
bar.finish_and_clear();
|
|
}
|
|
|
|
#[test]
|
|
fn hidden_bar_works_with_multi() {
|
|
let bar = multi().add(ProgressBar::hidden());
|
|
bar.set_message("hidden");
|
|
bar.finish_and_clear();
|
|
}
|
|
|
|
#[test]
|
|
fn suspending_writer_buffers_and_flushes() {
|
|
let writer = SuspendingWriter;
|
|
let mut w = MakeWriter::make_writer(&writer);
|
|
let n = w.write(b"test log line\n").unwrap();
|
|
assert_eq!(n, 14);
|
|
drop(w);
|
|
}
|
|
|
|
#[test]
|
|
fn suspending_writer_empty_does_not_flush() {
|
|
let writer = SuspendingWriter;
|
|
let w = MakeWriter::make_writer(&writer);
|
|
drop(w);
|
|
}
|
|
|
|
// ── Progress API tests ──
|
|
|
|
#[test]
|
|
fn stage_spinner_v2_robot_mode_returns_hidden() {
|
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", true);
|
|
assert!(pb.is_hidden());
|
|
}
|
|
|
|
#[test]
|
|
fn stage_spinner_v2_human_mode_sets_properties() {
|
|
let pb = stage_spinner_v2("\u{2714}", "Issues", "fetching...", false);
|
|
assert!(pb.prefix().contains("Issues"));
|
|
assert_eq!(pb.message(), "fetching...");
|
|
pb.finish_and_clear();
|
|
}
|
|
|
|
#[test]
|
|
fn nested_progress_robot_mode_returns_hidden() {
|
|
let pb = nested_progress("Embedding...", 100, true);
|
|
assert!(pb.is_hidden());
|
|
}
|
|
|
|
#[test]
|
|
fn nested_progress_human_mode_sets_length() {
|
|
let pb = nested_progress("Embedding...", 100, false);
|
|
assert_eq!(pb.length(), Some(100));
|
|
assert_eq!(pb.message(), "Embedding...");
|
|
pb.finish_and_clear();
|
|
}
|
|
|
|
#[test]
|
|
fn format_elapsed_sub_second() {
|
|
assert_eq!(format_elapsed(Duration::from_millis(42)), "42ms");
|
|
assert_eq!(format_elapsed(Duration::from_millis(999)), "999ms");
|
|
}
|
|
|
|
#[test]
|
|
fn format_elapsed_seconds() {
|
|
assert_eq!(format_elapsed(Duration::from_millis(1200)), "1.2s");
|
|
assert_eq!(format_elapsed(Duration::from_millis(5000)), "5.0s");
|
|
}
|
|
|
|
#[test]
|
|
fn format_elapsed_minutes() {
|
|
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m 5s");
|
|
assert_eq!(format_elapsed(Duration::from_secs(120)), "2m 0s");
|
|
}
|
|
|
|
#[test]
|
|
fn format_stage_line_includes_label_summary_and_elapsed() {
|
|
let line = format_stage_line("✔", "Issues", "10 issues", Duration::from_millis(4200));
|
|
assert!(line.contains("✔"));
|
|
assert!(line.contains("Issues"));
|
|
assert!(line.contains("10 issues"));
|
|
assert!(line.contains("4.2s"));
|
|
}
|
|
}
|