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 = 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, } impl Write for SuspendingWriterInner { fn write(&mut self, data: &[u8]) -> std::io::Result { 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")); } }