use color_print::cformat;
pub use imp::Progress;
#[cfg(feature = "cli")]
mod imp {
use std::io::{IsTerminal, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use color_print::cformat;
use crossterm::{
QueueableCommand,
cursor::MoveToColumn,
terminal::{Clear, ClearType},
};
use super::{format_bytes, format_count};
const SPINNER_FRAMES: &[char] = &['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const TICK_INTERVAL: Duration = Duration::from_millis(100);
const STARTUP_DELAY: Duration = Duration::from_millis(300);
struct Shared {
files: AtomicUsize,
bytes: AtomicU64,
done: AtomicBool,
verb: &'static str,
}
struct Inner {
shared: Arc<Shared>,
ticker: JoinHandle<()>,
}
pub struct Progress(Option<Inner>);
impl Progress {
pub fn start(verb: &'static str) -> Self {
if std::io::stderr().is_terminal() {
Self::enabled(verb)
} else {
Self::disabled()
}
}
pub fn disabled() -> Self {
Self(None)
}
fn enabled(verb: &'static str) -> Self {
let shared = Arc::new(Shared {
files: AtomicUsize::new(0),
bytes: AtomicU64::new(0),
done: AtomicBool::new(false),
verb,
});
let ticker = {
let shared = Arc::clone(&shared);
thread::spawn(move || ticker_loop(&shared))
};
Self(Some(Inner { shared, ticker }))
}
pub fn record(&self, bytes: u64) {
if let Some(inner) = &self.0 {
inner.shared.files.fetch_add(1, Ordering::Relaxed);
inner.shared.bytes.fetch_add(bytes, Ordering::Relaxed);
}
}
pub fn finish(self) {
drop(self);
}
}
impl Drop for Progress {
fn drop(&mut self) {
if let Some(inner) = self.0.take() {
inner.shared.done.store(true, Ordering::Relaxed);
inner.ticker.thread().unpark();
let _ = inner.ticker.join();
let _ = clear_line(&mut std::io::stderr().lock());
}
}
}
fn ticker_loop(shared: &Shared) {
let start = Instant::now();
while start.elapsed() < STARTUP_DELAY {
if shared.done.load(Ordering::Relaxed) {
return;
}
thread::park_timeout(STARTUP_DELAY - start.elapsed());
}
while !shared.done.load(Ordering::Relaxed) {
let frame_idx = (start.elapsed().as_millis() / TICK_INTERVAL.as_millis()) as usize
% SPINNER_FRAMES.len();
let files = shared.files.load(Ordering::Relaxed);
let bytes = shared.bytes.load(Ordering::Relaxed);
let line = format_line(shared.verb, files, bytes, SPINNER_FRAMES[frame_idx]);
let _ = render_line(&mut std::io::stderr().lock(), &line);
thread::park_timeout(TICK_INTERVAL);
}
}
fn format_line(verb: &str, files: usize, bytes: u64, spinner: char) -> String {
if files == 0 {
cformat!("<cyan>{spinner}</> {verb}...")
} else {
let word = if files == 1 { "file" } else { "files" };
cformat!(
"<cyan>{spinner}</> {verb} {} {} · {}",
format_count(files),
word,
format_bytes(bytes),
)
}
}
fn render_line<W: Write>(w: &mut W, line: &str) -> std::io::Result<()> {
w.queue(MoveToColumn(0))?;
w.queue(Clear(ClearType::CurrentLine))?;
write!(w, "{line}")?;
w.flush()
}
fn clear_line<W: Write>(w: &mut W) -> std::io::Result<()> {
w.queue(MoveToColumn(0))?;
w.queue(Clear(ClearType::CurrentLine))?;
w.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_line_empty() {
let line = format_line("Copying", 0, 0, '⠋');
assert!(line.contains("Copying..."));
assert!(line.contains('⠋'));
}
#[test]
fn test_format_line_singular() {
let line = format_line("Copying", 1, 42, '⠙');
assert!(line.contains("1 file "));
assert!(line.contains("42 B"));
}
#[test]
fn test_format_line_plural() {
let line = format_line("Removing", 2_500, 5 * 1024 * 1024, '⠹');
assert!(line.contains("Removing"));
assert!(line.contains("2,500 files"));
assert!(line.contains("5.0 MiB"));
}
#[test]
fn test_render_line_writes_text_with_prefix_control_bytes() {
let mut buf = Vec::new();
render_line(&mut buf, "hello").unwrap();
assert!(buf.ends_with(b"hello"));
assert!(buf.len() > b"hello".len());
}
#[test]
fn test_clear_line_writes_control_bytes() {
let mut buf = Vec::new();
clear_line(&mut buf).unwrap();
assert!(!buf.is_empty());
}
#[test]
fn test_start_in_non_tty_is_disabled() {
assert!(Progress::start("Copying").0.is_none());
}
#[test]
fn test_enabled_lifecycle_counters_propagate() {
let p = Progress::enabled("Copying");
p.record(1024);
p.record(2048);
let inner = p.0.as_ref().expect("expected enabled");
assert_eq!(inner.shared.files.load(Ordering::Relaxed), 2);
assert_eq!(inner.shared.bytes.load(Ordering::Relaxed), 3072);
p.finish();
}
#[test]
fn test_enabled_renders_after_startup_delay() {
let p = Progress::enabled("Removing");
p.record(100);
std::thread::sleep(STARTUP_DELAY + TICK_INTERVAL + Duration::from_millis(50));
p.finish();
}
}
}
#[cfg(not(feature = "cli"))]
mod imp {
pub struct Progress;
impl Progress {
pub fn start(_verb: &'static str) -> Self {
Self
}
pub fn disabled() -> Self {
Self
}
pub fn record(&self, _bytes: u64) {}
pub fn finish(self) {}
}
}
fn format_count(n: usize) -> String {
let s = n.to_string();
let bytes = s.as_bytes();
let mut out = String::with_capacity(s.len() + s.len() / 3);
for (i, b) in bytes.iter().enumerate() {
if i > 0 && (bytes.len() - i).is_multiple_of(3) {
out.push(',');
}
out.push(*b as char);
}
out
}
pub fn format_bytes(n: u64) -> String {
const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
let mut size = n as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{n} {}", UNITS[unit])
} else {
format!("{size:.1} {}", UNITS[unit])
}
}
pub fn format_stats_paren(files: usize, bytes: u64) -> String {
if files == 0 {
return String::new();
}
let word = if files == 1 { "file" } else { "files" };
let close = cformat!("<bright-black>)</>");
cformat!(
" <bright-black>({} {word} · {}</>{close}",
format_count(files),
format_bytes(bytes),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_count() {
assert_eq!(format_count(0), "0");
assert_eq!(format_count(42), "42");
assert_eq!(format_count(999), "999");
assert_eq!(format_count(1_000), "1,000");
assert_eq!(format_count(12_345), "12,345");
assert_eq!(format_count(1_234_567), "1,234,567");
}
#[test]
fn test_format_bytes() {
assert_eq!(format_bytes(0), "0 B");
assert_eq!(format_bytes(512), "512 B");
assert_eq!(format_bytes(1024), "1.0 KiB");
assert_eq!(format_bytes(1_536), "1.5 KiB");
assert_eq!(format_bytes(1_048_576), "1.0 MiB");
assert_eq!(format_bytes(1_610_612_736), "1.5 GiB");
}
#[test]
fn test_format_stats_paren_empty_is_blank() {
assert_eq!(format_stats_paren(0, 0), "");
}
#[test]
fn test_format_stats_paren_singular() {
let s = format_stats_paren(1, 42);
assert!(s.contains("1 file"));
assert!(s.contains("42 B"));
}
#[test]
fn test_format_stats_paren_plural() {
let s = format_stats_paren(2_500, 5 * 1024 * 1024);
assert!(s.contains("2,500 files"));
assert!(s.contains("5.0 MiB"));
}
#[test]
fn test_disabled_record_is_noop() {
let p = Progress::disabled();
p.record(1_000_000);
p.record(2_000_000);
p.finish();
}
}