#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_lossless
)]
use std::io::Write;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use snapdir_core::{Meter, MeterSnapshot, Phase};
const ANSI_RESET: &str = "\x1b[0m";
const ANSI_DIM: &str = "\x1b[2m";
const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_CYAN: &str = "\x1b[36m";
const ANSI_GREEN: &str = "\x1b[32m";
const CLEAR_LINE: &str = "\r\x1b[K";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub(crate) enum ColorChoice {
#[default]
Auto,
Always,
Never,
}
pub(crate) fn should_render(is_tty: bool, no_progress: bool, term: Option<&str>) -> bool {
is_tty && !no_progress && term != Some("dumb")
}
pub(crate) fn use_color(choice: ColorChoice, is_tty: bool, no_color_env: bool) -> bool {
match choice {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => is_tty && !no_color_env,
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct Style {
pub(crate) color: bool,
}
impl Style {
fn wrap(self, code: &str, text: &str) -> String {
if self.color {
format!("{code}{text}{ANSI_RESET}")
} else {
text.to_owned()
}
}
pub(crate) fn dim(self, text: &str) -> String {
self.wrap(ANSI_DIM, text)
}
pub(crate) fn bold(self, text: &str) -> String {
self.wrap(ANSI_BOLD, text)
}
pub(crate) fn cyan(self, text: &str) -> String {
self.wrap(ANSI_CYAN, text)
}
pub(crate) fn green(self, text: &str) -> String {
self.wrap(ANSI_GREEN, text)
}
}
const KIB: f64 = 1024.0;
const BYTE_UNITS: [&str; 6] = ["B", "KB", "MB", "GB", "TB", "PB"];
pub(crate) fn human_bytes(n: u64) -> String {
if n < 1024 {
return format!("{n} B");
}
let mut value = n as f64;
let mut unit = 0usize;
while value >= KIB && unit < BYTE_UNITS.len() - 1 {
value /= KIB;
unit += 1;
}
if value < 10.0 {
format!("{value:.1} {}", BYTE_UNITS[unit])
} else {
format!("{value:.0} {}", BYTE_UNITS[unit])
}
}
fn unit_index_for(n: u64) -> usize {
if n < 1024 {
return 0;
}
let mut value = n as f64;
let mut unit = 0usize;
while value >= KIB && unit < BYTE_UNITS.len() - 1 {
value /= KIB;
unit += 1;
}
unit
}
fn bytes_in_unit(n: u64, unit: usize) -> String {
let exp = i32::try_from(unit).unwrap_or(0);
let divisor = KIB.powi(exp);
let value = n as f64 / divisor;
format!("{value:.1}")
}
pub(crate) fn human_rate(bytes_per_sec: f64) -> String {
if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
return "0 B/s".to_owned();
}
let bytes = bytes_per_sec.round() as u64;
format!("{}/s", human_bytes(bytes))
}
fn decimal_digits(n: u64) -> usize {
let mut n = n;
let mut digits = 1usize;
while n >= 10 {
n /= 10;
digits += 1;
}
digits
}
fn human_eta(d: Duration) -> String {
let total = d.as_secs();
if total >= 3600 {
let h = total / 3600;
let m = (total % 3600) / 60;
format!("{h}h{m:02}m")
} else if total >= 60 {
let m = total / 60;
let s = total % 60;
format!("{m}m{s:02}s")
} else {
format!("{total}s")
}
}
const ETA_SLOT: usize = 6;
fn eta_slot(eta: Option<Duration>) -> String {
let raw = match eta {
Some(d) => human_eta(d),
None => "--".to_owned(),
};
let w = visible_width(&raw);
if w >= ETA_SLOT {
raw.chars().take(ETA_SLOT).collect()
} else {
format!("{}{}", " ".repeat(ETA_SLOT - w), raw)
}
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct RenderMetrics {
pub(crate) rate_in: f64,
pub(crate) rate_out: f64,
pub(crate) obj_per_sec: f64,
pub(crate) eta: Option<Duration>,
pub(crate) rss: Option<u64>,
pub(crate) cpu_pct: Option<f64>,
pub(crate) jobs: usize,
pub(crate) spinner_frame: usize,
pub(crate) size_unit: Option<usize>,
pub(crate) byte_total_est: Option<u64>,
pub(crate) adaptive_fraction: Option<f64>,
}
const SPINNER_MODERN: [char; 10] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const BAR_FILL_MODERN: char = '█';
const BAR_EMPTY_MODERN: char = '░';
const BAR_OPEN_MODERN: char = '▕';
const BAR_CLOSE_MODERN: char = '▏';
const ARROW_DOWN_MODERN: char = '↓';
const ARROW_UP_MODERN: char = '↑';
const SPINNER_ASCII: [char; 4] = ['|', '/', '-', '\\'];
const BAR_FILL_ASCII: char = '#';
const BAR_EMPTY_ASCII: char = ' ';
const BAR_OPEN_ASCII: char = '[';
const BAR_CLOSE_ASCII: char = ']';
fn spinner_glyph(frame: usize, ascii: bool) -> char {
if ascii {
SPINNER_ASCII[frame % SPINNER_ASCII.len()]
} else {
SPINNER_MODERN[frame % SPINNER_MODERN.len()]
}
}
fn render_bar(fraction: f64, width: usize, ascii: bool) -> String {
let (fill, empty, open, close) = if ascii {
(
BAR_FILL_ASCII,
BAR_EMPTY_ASCII,
BAR_OPEN_ASCII,
BAR_CLOSE_ASCII,
)
} else {
(
BAR_FILL_MODERN,
BAR_EMPTY_MODERN,
BAR_OPEN_MODERN,
BAR_CLOSE_MODERN,
)
};
let frac = fraction.clamp(0.0, 1.0);
let filled = (frac * width as f64).round() as usize;
let filled = filled.min(width);
let mut s = String::with_capacity(width + 2);
s.push(open);
for _ in 0..filled {
s.push(fill);
}
for _ in 0..(width - filled) {
s.push(empty);
}
s.push(close);
s
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Optional {
Eta,
Cpu,
Mem,
ObjPerSec,
}
fn visible_width(s: &str) -> usize {
s.chars().count()
}
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(crate) fn format_line(
snap: &MeterSnapshot,
m: &RenderMetrics,
width: usize,
style: &Style,
ascii: bool,
) -> String {
format_line_named(snap, m, width, style, ascii, None)
}
#[allow(clippy::trivially_copy_pass_by_ref)]
pub(crate) fn format_line_named(
snap: &MeterSnapshot,
m: &RenderMetrics,
width: usize,
style: &Style,
ascii: bool,
cmd: Option<&str>,
) -> String {
let fields = LineFields::build(snap, m, ascii, cmd);
fields.fit(width, *style, ascii)
}
struct LineFields {
spinner: String,
label: String,
counts: String,
bytes: String,
rates: String,
conc: String,
obj: Option<String>,
mem: Option<String>,
cpu: Option<String>,
eta: Option<String>,
determinate: bool,
fraction: f64,
}
const DROP_ORDER: [Optional; 4] = [
Optional::Eta,
Optional::Cpu,
Optional::Mem,
Optional::ObjPerSec,
];
const BAR_WIDTH_INIT: usize = 20;
impl LineFields {
fn build(snap: &MeterSnapshot, m: &RenderMetrics, ascii: bool, cmd: Option<&str>) -> Self {
let phase_word = match snap.phase {
Phase::Discovering => "discovering",
Phase::Hashing => "hashing",
Phase::Transfer => "transfer",
Phase::Idle => "idle",
};
let label = match cmd {
Some(c) => format!("{c} {phase_word}"),
None => phase_word.to_owned(),
};
let done = snap.objects_done + snap.objects_skipped;
let total = snap.objects_total;
let discovering = matches!(snap.phase, Phase::Discovering);
let determinate = !discovering && total > 0;
let fraction = if determinate {
done as f64 / total as f64
} else {
0.0
};
let counts = if discovering {
format!("{} files", snap.objects_discovered)
} else if determinate {
let pct = (fraction * 100.0).clamp(0.0, 100.0);
let total_digits = decimal_digits(total);
format!("{pct:>3.0}% {done:>total_digits$}/{total} files")
} else {
format!("{done} files")
};
let unit = m
.size_unit
.unwrap_or_else(|| unit_index_for(snap.bytes_out));
let unit_label = BYTE_UNITS[unit];
let bytes = match m.byte_total_est {
Some(total_bytes) if total_bytes >= snap.bytes_out => format!(
"{}/{} {unit_label}",
bytes_in_unit(snap.bytes_out, unit),
bytes_in_unit(total_bytes, unit),
),
_ => format!("{} {unit_label}", bytes_in_unit(snap.bytes_out, unit)),
};
let (down_sym, up_sym) = if ascii {
("down".to_owned(), "up".to_owned())
} else {
(ARROW_DOWN_MODERN.to_string(), ARROW_UP_MODERN.to_string())
};
let rates = format!(
"{down_sym}{} {up_sym}{}",
human_rate(m.rate_in),
human_rate(m.rate_out)
);
let conc = if snap.current_limit > 0 {
match m.adaptive_fraction {
Some(f) => format!("jobs {}/{} (auto {f:.1})", snap.in_flight, m.jobs),
None => format!("jobs {}/{} (auto)", snap.in_flight, m.jobs),
}
} else {
format!("{}/{}", snap.in_flight, m.jobs)
};
Self {
spinner: spinner_glyph(m.spinner_frame, ascii).to_string(),
label,
counts,
bytes,
rates,
conc,
obj: Some(format!("{:.0} obj/s", m.obj_per_sec)),
mem: m.rss.map(|r| format!("mem {}", human_bytes(r))),
cpu: m.cpu_pct.map(|c| format!("cpu {c:.0}%")),
eta: Some(format!("eta {}", eta_slot(m.eta))),
determinate,
fraction,
}
}
fn parts(&self, dropped: &[Optional], bar_width: usize, ascii: bool) -> Vec<String> {
let mut parts: Vec<String> = Vec::with_capacity(11);
parts.push(self.spinner.clone());
parts.push(self.label.clone());
parts.push(self.counts.clone());
if self.determinate {
parts.push(render_bar(self.fraction, bar_width, ascii));
}
parts.push(self.bytes.clone());
parts.push(self.rates.clone());
parts.push(self.conc.clone());
let kept = |o: Optional| !dropped.contains(&o);
if kept(Optional::ObjPerSec) {
if let Some(f) = &self.obj {
parts.push(f.clone());
}
}
if kept(Optional::Mem) {
if let Some(f) = &self.mem {
parts.push(f.clone());
}
}
if kept(Optional::Cpu) {
if let Some(f) = &self.cpu {
parts.push(f.clone());
}
}
if kept(Optional::Eta) {
if let Some(f) = &self.eta {
parts.push(f.clone());
}
}
parts
}
fn fit(&self, width: usize, style: Style, ascii: bool) -> String {
let mut dropped: Vec<Optional> = Vec::new();
let mut bar_width = BAR_WIDTH_INIT;
loop {
let parts = self.parts(&dropped, bar_width, ascii);
let plain = parts.join(" ");
if visible_width(&plain) <= width {
return style_line(&parts, style);
}
if dropped.len() < DROP_ORDER.len() {
dropped.push(DROP_ORDER[dropped.len()]);
} else if bar_width >= 4 {
bar_width -= 4;
} else if bar_width > 0 {
bar_width = 0;
} else {
return truncate_to(&plain, width);
}
}
}
}
fn style_line(parts: &[String], style: Style) -> String {
let mut out: Vec<String> = Vec::with_capacity(parts.len());
for (i, p) in parts.iter().enumerate() {
let styled = match i {
0 => style.cyan(p), 1 => style.bold(p), 2 => style.green(p), _ => style.dim(p), };
out.push(styled);
}
out.join(" ")
}
fn truncate_to(s: &str, width: usize) -> String {
if visible_width(s) <= width {
return s.to_owned();
}
if width == 0 {
return String::new();
}
if width == 1 {
return "…".to_owned();
}
let keep = width - 1;
let truncated: String = s.chars().take(keep).collect();
format!("{truncated}…")
}
pub(crate) fn term_width() -> Option<usize> {
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
let rc = libc::ioctl(
libc::STDERR_FILENO,
libc::TIOCGWINSZ as _,
std::ptr::addr_of_mut!(ws),
);
if rc == 0 && ws.ws_col > 0 {
return Some(ws.ws_col as usize);
}
}
std::env::var("COLUMNS")
.ok()
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|&c| c > 0)
}
pub(crate) fn sample_rss() -> Option<u64> {
#[cfg(target_os = "linux")]
{
let statm = std::fs::read_to_string("/proc/self/statm").ok()?;
let resident_pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?;
let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) };
if page_size <= 0 {
return None;
}
Some(resident_pages.saturating_mul(page_size as u64))
}
#[cfg(target_os = "macos")]
{
#[allow(deprecated)]
unsafe {
let mut info: libc::mach_task_basic_info = std::mem::zeroed();
let mut count: libc::mach_msg_type_number_t =
(std::mem::size_of::<libc::mach_task_basic_info>()
/ std::mem::size_of::<libc::integer_t>())
as libc::mach_msg_type_number_t;
let kr = libc::task_info(
libc::mach_task_self_,
libc::MACH_TASK_BASIC_INFO,
std::ptr::addr_of_mut!(info).cast(),
std::ptr::addr_of_mut!(count),
);
if kr == libc::KERN_SUCCESS {
Some(info.resident_size)
} else {
None
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
fn rusage_cpu_secs() -> Option<f64> {
unsafe {
let mut ru: libc::rusage = std::mem::zeroed();
if libc::getrusage(libc::RUSAGE_SELF, std::ptr::addr_of_mut!(ru)) != 0 {
return None;
}
let secs = |tv: libc::timeval| tv.tv_sec as f64 + tv.tv_usec as f64 / 1_000_000.0;
Some(secs(ru.ru_utime) + secs(ru.ru_stime))
}
}
pub(crate) struct CpuSampler {
prev: Option<(Instant, f64)>,
cores: f64,
}
impl CpuSampler {
pub(crate) fn new() -> Self {
let cores = std::thread::available_parallelism().map_or(1.0, |n| n.get() as f64);
Self { prev: None, cores }
}
pub(crate) fn poll(&mut self) -> Option<f64> {
let now = Instant::now();
let cpu = rusage_cpu_secs()?;
match self.prev {
None => {
self.prev = Some((now, cpu));
None
}
Some((prev_t, prev_cpu)) => {
let wall = now.duration_since(prev_t).as_secs_f64();
self.prev = Some((now, cpu));
if wall <= 0.0 {
return None;
}
let cpu_delta = (cpu - prev_cpu).max(0.0);
let pct = (cpu_delta / wall) / self.cores * 100.0;
Some(pct.clamp(0.0, 100.0 * self.cores))
}
}
}
}
const EWMA_ALPHA: f64 = 0.3;
const TICK: Duration = Duration::from_millis(100);
const WARMUP_TICK: Duration = Duration::from_millis(5);
const WARMUP_WINDOW: Duration = Duration::from_millis(200);
pub(crate) struct ProgressReporter {
stop: Arc<AtomicBool>,
handle: Option<JoinHandle<()>>,
active: bool,
}
struct RenderState {
last: Instant,
last_snap: MeterSnapshot,
rate_in: f64,
rate_out: f64,
obj_per_sec: f64,
spinner_frame: usize,
cpu: CpuSampler,
smooth_bps: f64,
displayed_eta: Option<Duration>,
last_eta_update: Option<Instant>,
size_unit: Option<usize>,
adaptive_fraction: Option<f64>,
}
const ETA_RATE_ALPHA: f64 = 0.1;
const ETA_REFRESH: Duration = Duration::from_secs(2);
const ETA_DAMP: f64 = 0.3;
const ETA_MIN_OBJECTS: u64 = 4;
impl RenderState {
fn init(meter: &Meter, adaptive_fraction: Option<f64>) -> Self {
let mut state = Self {
last: Instant::now(),
last_snap: meter.snapshot(),
rate_in: 0.0,
rate_out: 0.0,
obj_per_sec: 0.0,
spinner_frame: 0,
cpu: CpuSampler::new(),
smooth_bps: 0.0,
displayed_eta: None,
last_eta_update: None,
size_unit: None,
adaptive_fraction,
};
let _ = state.cpu.poll();
state
}
fn tick(&mut self, snap: MeterSnapshot, jobs: usize) -> RenderMetrics {
let now = Instant::now();
let dt = now.duration_since(self.last).as_secs_f64();
if dt > 0.0 {
let d_in = snap.bytes_in.saturating_sub(self.last_snap.bytes_in) as f64;
let d_out = snap.bytes_out.saturating_sub(self.last_snap.bytes_out) as f64;
let prev_obj = self.last_snap.objects_done + self.last_snap.objects_skipped;
let now_obj = snap.objects_done + snap.objects_skipped;
let d_obj = now_obj.saturating_sub(prev_obj) as f64;
self.rate_in = ewma(self.rate_in, d_in / dt);
self.rate_out = ewma(self.rate_out, d_out / dt);
self.obj_per_sec = ewma(self.obj_per_sec, d_obj / dt);
self.smooth_bps =
ETA_RATE_ALPHA * (d_out / dt) + (1.0 - ETA_RATE_ALPHA) * self.smooth_bps;
}
self.last = now;
self.last_snap = snap;
self.spinner_frame = self.spinner_frame.wrapping_add(1);
let byte_total_est = estimate_total_bytes(&snap);
if self.size_unit.is_none() {
if let Some(total) = byte_total_est {
self.size_unit = Some(unit_index_for(total.max(snap.bytes_out)));
} else if snap.bytes_out >= 1024 {
self.size_unit = Some(unit_index_for(snap.bytes_out));
}
}
let eta = self.update_eta(&snap, byte_total_est, now);
RenderMetrics {
rate_in: self.rate_in,
rate_out: self.rate_out,
obj_per_sec: self.obj_per_sec,
eta,
rss: sample_rss(),
cpu_pct: self.cpu.poll(),
jobs,
spinner_frame: self.spinner_frame,
size_unit: self.size_unit,
byte_total_est,
adaptive_fraction: self.adaptive_fraction,
}
}
fn update_eta(
&mut self,
snap: &MeterSnapshot,
byte_total_est: Option<u64>,
now: Instant,
) -> Option<Duration> {
let done = snap.objects_done + snap.objects_skipped;
let fresh = if done >= ETA_MIN_OBJECTS {
compute_eta_bytes(snap, byte_total_est, self.smooth_bps)
} else {
None
};
let Some(fresh) = fresh else {
return self.displayed_eta;
};
let due = match self.last_eta_update {
None => true, Some(last) => now.duration_since(last) >= ETA_REFRESH,
};
if due {
let next = match self.displayed_eta {
Some(prev) => damp_duration(prev, fresh, ETA_DAMP),
None => fresh,
};
self.displayed_eta = Some(next);
self.last_eta_update = Some(now);
}
self.displayed_eta
}
}
fn damp_duration(from: Duration, to: Duration, frac: f64) -> Duration {
let a = from.as_secs_f64();
let b = to.as_secs_f64();
let next = a + (b - a) * frac.clamp(0.0, 1.0);
Duration::from_secs_f64(next.max(0.0))
}
fn estimate_total_bytes(snap: &MeterSnapshot) -> Option<u64> {
if snap.objects_total == 0 {
return None;
}
let completed = snap.objects_done + snap.objects_skipped;
if completed == 0 || snap.bytes_out == 0 {
return None;
}
let avg = snap.bytes_out as f64 / completed as f64;
let total = avg * snap.objects_total as f64;
if !total.is_finite() || total < 0.0 {
return None;
}
Some(total as u64)
}
fn render_frame(
meter: &Meter,
state: &mut RenderState,
jobs: usize,
style: Style,
ascii: bool,
stderr_lock: &Mutex<()>,
) {
let snap = meter.snapshot();
let metrics = state.tick(snap, jobs);
let width = term_width().unwrap_or(80);
let line = format_line(&snap, &metrics, width, &style, ascii);
let _guard = stderr_lock.lock();
let mut err = std::io::stderr().lock();
let _ = write!(err, "{CLEAR_LINE}{line}");
let _ = err.flush();
}
impl ProgressReporter {
pub(crate) fn start(
meter: Arc<Meter>,
jobs: usize,
active: bool,
color: bool,
ascii: bool,
adaptive_fraction: Option<f64>,
) -> ProgressReporter {
let stop = Arc::new(AtomicBool::new(false));
if !active {
return ProgressReporter {
stop,
handle: None,
active: false,
};
}
let style = Style { color };
let stop_thread = Arc::clone(&stop);
let stderr_lock: Arc<Mutex<()>> = Arc::new(Mutex::new(()));
let mut state = RenderState::init(&meter, adaptive_fraction);
render_frame(&meter, &mut state, jobs, style, ascii, &stderr_lock);
let handle = std::thread::spawn(move || {
let mut elapsed = Duration::ZERO;
while !stop_thread.load(Ordering::Relaxed) {
let interval = if elapsed < WARMUP_WINDOW {
WARMUP_TICK
} else {
TICK
};
std::thread::sleep(interval);
elapsed += interval;
if stop_thread.load(Ordering::Relaxed) {
break;
}
render_frame(&meter, &mut state, jobs, style, ascii, &stderr_lock);
}
});
ProgressReporter {
stop,
handle: Some(handle),
active: true,
}
}
pub(crate) fn finish(mut self) {
if !self.active {
return;
}
self.stop.store(true, Ordering::Relaxed);
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "{CLEAR_LINE}");
let _ = err.flush();
}
}
fn ewma(prev: f64, sample: f64) -> f64 {
EWMA_ALPHA * sample + (1.0 - EWMA_ALPHA) * prev
}
fn compute_eta_bytes(
snap: &MeterSnapshot,
byte_total_est: Option<u64>,
smooth_bps: f64,
) -> Option<Duration> {
if snap.objects_total == 0 {
return None;
}
let done = snap.objects_done + snap.objects_skipped;
if snap.objects_total.saturating_sub(done) == 0 {
return Some(Duration::ZERO);
}
if let Some(total) = byte_total_est {
if smooth_bps > 0.0 && total >= snap.bytes_out {
let remaining = (total - snap.bytes_out) as f64;
let secs = remaining / smooth_bps;
if secs.is_finite() && secs >= 0.0 {
return Some(Duration::from_secs_f64(secs));
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(
bytes_in: u64,
bytes_out: u64,
done: u64,
total: u64,
skipped: u64,
in_flight: u64,
phase: Phase,
) -> MeterSnapshot {
MeterSnapshot {
bytes_in,
bytes_out,
objects_done: done,
objects_discovered: total,
objects_total: total,
objects_skipped: skipped,
in_flight,
phase,
current_limit: 0,
target_rate: 0,
}
}
#[test]
fn progress_render_should_render_logic() {
assert!(should_render(true, false, None));
assert!(should_render(true, false, Some("xterm")));
assert!(!should_render(false, false, Some("xterm"))); assert!(!should_render(true, true, Some("xterm"))); assert!(!should_render(true, false, Some("dumb")));
assert!(use_color(ColorChoice::Always, false, true));
assert!(use_color(ColorChoice::Always, false, false));
assert!(!use_color(ColorChoice::Never, true, false));
assert!(!use_color(ColorChoice::Never, true, true));
assert!(use_color(ColorChoice::Auto, true, false)); assert!(!use_color(ColorChoice::Auto, true, true)); assert!(!use_color(ColorChoice::Auto, false, false)); }
#[test]
fn progress_render_humanizers() {
assert_eq!(human_bytes(0), "0 B");
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(1023), "1023 B");
assert_eq!(human_bytes(1024), "1.0 KB");
assert_eq!(human_bytes(1536), "1.5 KB");
assert_eq!(human_bytes(10 * 1024), "10 KB");
assert_eq!(human_bytes(412 * 1024 * 1024), "412 MB");
assert_eq!(
human_bytes((1.2 * 1024.0 * 1024.0 * 1024.0) as u64),
"1.2 GB"
);
assert_eq!(human_rate(0.0), "0 B/s");
assert_eq!(human_rate(-5.0), "0 B/s");
assert_eq!(human_rate(f64::NAN), "0 B/s");
assert_eq!(human_rate(148.0 * 1024.0 * 1024.0), "148 MB/s");
assert_eq!(human_rate(1536.0), "1.5 KB/s");
assert_eq!(human_eta(Duration::from_secs(12)), "12s");
assert_eq!(human_eta(Duration::from_secs(65)), "1m05s");
assert_eq!(human_eta(Duration::from_secs(3783)), "1h03m"); }
#[test]
fn progress_render_format_line_modern() {
let s = snap(
200 * 1024 * 1024, 100 * 1024 * 1024, 30, 100, 10, 4, Phase::Transfer,
);
let m = RenderMetrics {
rate_in: 148.0 * 1024.0 * 1024.0,
rate_out: 50.0 * 1024.0 * 1024.0,
obj_per_sec: 12.0,
eta: Some(Duration::from_secs(42)),
rss: Some(64 * 1024 * 1024),
cpu_pct: Some(85.0),
jobs: 16,
spinner_frame: 0,
..Default::default()
};
let style = Style { color: false };
let line = format_line(&s, &m, 200, &style, false);
assert!(line.contains("40%"), "percent missing: {line}");
assert!(line.contains("40/100 files"), "files count missing: {line}");
assert!(line.contains('↓'), "down arrow missing: {line}");
assert!(line.contains('↑'), "up arrow missing: {line}");
assert!(line.contains("148 MB/s"), "rate_in missing: {line}");
assert!(line.contains("4/16"), "concurrency missing: {line}");
assert!(line.contains("mem 64 MB"), "mem missing: {line}");
assert!(line.contains("cpu 85%"), "cpu missing: {line}");
assert!(line.contains("42s"), "eta value missing: {line}");
assert!(line.contains("eta "), "eta label missing: {line}");
assert!(line.contains("transfer"), "phase missing: {line}");
assert!(
line.contains('█') || line.contains('░'),
"bar missing: {line}"
);
assert!(line.contains("12 obj/s"), "obj/s missing: {line}");
let m2 = RenderMetrics {
eta: None,
rss: None,
cpu_pct: None,
..m
};
let line2 = format_line(&s, &m2, 200, &style, false);
assert!(!line2.contains("mem "), "mem should be omitted: {line2}");
assert!(!line2.contains("cpu "), "cpu should be omitted: {line2}");
assert!(line2.contains("eta "), "eta label missing: {line2}");
assert!(line2.contains("--"), "eta placeholder missing: {line2}");
}
#[test]
fn progress_render_format_line_fallback() {
let s = snap(
8 * 1024 * 1024,
4 * 1024 * 1024,
8,
16,
0,
8,
Phase::Hashing,
);
let m = RenderMetrics {
rate_in: 2.0 * 1024.0 * 1024.0,
rate_out: 0.0,
obj_per_sec: 3.0,
eta: None,
rss: None,
cpu_pct: None,
jobs: 16,
spinner_frame: 1,
..Default::default()
};
let style = Style { color: false };
let line = format_line(&s, &m, 200, &style, true);
assert!(line.contains("down"), "ascii down missing: {line}");
assert!(line.contains("up"), "ascii up missing: {line}");
assert!(line.contains("8/16 files"), "files count missing: {line}");
assert!(line.contains("50%"), "percent missing: {line}");
assert!(
line.contains('[') && line.contains(']'),
"ascii bar caps missing: {line}"
);
assert!(line.contains('#'), "ascii bar fill missing: {line}");
assert!(line.contains("hashing"), "phase missing: {line}");
assert!(!line.contains('↓'), "unexpected unicode arrow: {line}");
assert!(!line.contains('█'), "unexpected unicode bar: {line}");
}
#[test]
fn progress_render_format_line_indeterminate() {
let s = snap(1024, 0, 5, 0, 0, 2, Phase::Hashing);
let m = RenderMetrics {
jobs: 4,
..Default::default()
};
let style = Style { color: false };
let line = format_line(&s, &m, 200, &style, false);
assert!(
line.contains("5 files"),
"indeterminate count missing: {line}"
);
assert!(!line.contains('%'), "no percent when indeterminate: {line}");
assert!(!line.contains('█'), "no bar when indeterminate: {line}");
assert!(!line.contains('░'), "no bar when indeterminate: {line}");
}
#[test]
fn progress_render_fits_width() {
let s = snap(
200 * 1024 * 1024,
100 * 1024 * 1024,
30,
100,
10,
4,
Phase::Transfer,
);
let m = RenderMetrics {
rate_in: 148.0 * 1024.0 * 1024.0,
rate_out: 50.0 * 1024.0 * 1024.0,
obj_per_sec: 12.0,
eta: Some(Duration::from_secs(42)),
rss: Some(64 * 1024 * 1024),
cpu_pct: Some(85.0),
jobs: 16,
spinner_frame: 0,
..Default::default()
};
let style = Style { color: false };
for &width in &[10usize, 20, 30, 40, 60, 80] {
let line = format_line(&s, &m, width, &style, false);
let cols = line.chars().count();
assert!(
cols <= width,
"width {width}: line is {cols} cols: {line:?}"
);
}
let narrow = format_line(&s, &m, 40, &style, false);
assert!(!narrow.contains("eta "), "eta should drop first: {narrow}");
}
#[test]
fn progress_render_metrics_best_effort() {
if let Some(rss) = sample_rss() {
assert!(rss > 0, "rss should be positive when sampled: {rss}");
assert!(
rss < 1024u64 * 1024 * 1024 * 1024,
"rss implausibly large: {rss}"
);
}
let mut sampler = CpuSampler::new();
let _ = sampler.poll();
let mut acc = 0u64;
for i in 0..1_000_000u64 {
acc = acc.wrapping_add(i);
}
std::hint::black_box(acc);
if let Some(pct) = sampler.poll() {
assert!(pct >= 0.0, "cpu pct negative: {pct}");
assert!(pct.is_finite(), "cpu pct not finite: {pct}");
}
}
#[test]
fn progress_render_term_width_no_panic() {
let _ = term_width();
}
#[test]
fn progress_render_reporter_inactive_is_inert() {
let meter = Arc::new(Meter::new());
let reporter = ProgressReporter::start(meter, 4, false, false, false, None);
reporter.finish();
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn col_of(hay: &str, needle: &str) -> usize {
let plain = strip_ansi(hay);
let byte_idx = plain.find(needle).unwrap_or_else(|| {
panic!("needle {needle:?} not found in line {plain:?}");
});
plain[..byte_idx].chars().count()
}
#[test]
fn progress_render_width_stable_columns() {
let style = Style { color: false };
let total = 61u64;
let unit = Some(unit_index_for(2 * 1024 * 1024 * 1024));
let est = Some(2 * 1024 * 1024 * 1024u64);
let mut files_cols = Vec::new();
let mut rate_cols = Vec::new();
let mut eta_cols = Vec::new();
for (done, bytes_out) in [
(8u64, 120 * 1024 * 1024u64),
(38, 1100 * 1024 * 1024),
(60, 1900 * 1024 * 1024),
] {
let s = snap(0, bytes_out, done, total, 0, 4, Phase::Transfer);
let m = RenderMetrics {
rate_in: 0.0,
rate_out: 18_000_000.0,
obj_per_sec: 5.0,
eta: Some(Duration::from_secs(80)),
rss: None,
cpu_pct: None,
jobs: 16,
spinner_frame: 0,
size_unit: unit,
byte_total_est: est,
adaptive_fraction: None,
};
let line = format_line(&s, &m, 200, &style, false);
files_cols.push(col_of(&line, "files"));
rate_cols.push(col_of(&line, "/s"));
eta_cols.push(col_of(&line, "eta "));
}
assert!(
files_cols.windows(2).all(|w| w[0] == w[1]),
"`files` column reflowed: {files_cols:?}"
);
assert!(
rate_cols.windows(2).all(|w| w[0] == w[1]),
"rate column reflowed: {rate_cols:?}"
);
assert!(
eta_cols.windows(2).all(|w| w[0] == w[1]),
"eta column reflowed: {eta_cols:?}"
);
}
#[test]
fn progress_render_files_vs_size_labels() {
let s = snap(0, 1_400_000_000, 38, 61, 0, 6, Phase::Transfer);
let m = RenderMetrics {
rate_out: 18_000_000.0,
jobs: 16,
size_unit: Some(unit_index_for(2_000_000_000)),
byte_total_est: Some(2_000_000_000),
..Default::default()
};
let style = Style { color: false };
let line = strip_ansi(&format_line(&s, &m, 200, &style, false));
assert!(line.contains("38/61 files"), "files label missing: {line}");
assert!(
line.contains(" GB") && line.contains('/'),
"unit-suffixed size missing: {line}"
);
}
#[test]
fn progress_render_eta_held_then_smooth() {
let meter = Meter::new();
meter.set_phase(Phase::Transfer);
let mut state = RenderState::init(&meter, None);
let total = 100u64;
let obj_bytes = 10_000_000u64; let mut t = 0u64;
let mut displayed: Vec<Option<Duration>> = Vec::new();
for i in 0..30u64 {
let done = (i + 1).min(total);
let s = snap(0, done * obj_bytes, done, total, 0, 4, Phase::Transfer);
std::thread::sleep(Duration::from_millis(100));
let m = state.tick(s, 16);
displayed.push(m.eta);
t += 1;
}
let _ = t;
let first_some = displayed.iter().position(Option::is_some);
assert!(first_some.is_some(), "eta never established: {displayed:?}");
let start = first_some.unwrap();
let vals: Vec<Duration> = displayed[start..].iter().filter_map(|x| *x).collect();
let has_held = vals.windows(2).any(|w| w[0] == w[1]);
assert!(has_held, "eta never held between refreshes: {vals:?}");
for w in vals.windows(2) {
let a = w[0].as_secs_f64();
let b = w[1].as_secs_f64();
assert!(
(b - a) <= a.max(1.0),
"eta jumped upward sharply: {a} -> {b}"
);
}
assert!(
vals.last().unwrap() <= vals.first().unwrap(),
"eta did not trend downward: {vals:?}"
);
}
#[test]
fn progress_render_no_eta_before_signal() {
let meter = Meter::new();
let mut state = RenderState::init(&meter, None);
let s = snap(0, 5_000_000, 1, 100, 0, 4, Phase::Transfer);
std::thread::sleep(Duration::from_millis(100));
let m = state.tick(s, 16);
assert!(m.eta.is_none(), "eta should be None before signal");
let style = Style { color: false };
let line = format_line(&s, &m, 200, &style, false);
assert!(line.contains("eta "), "eta label missing: {line}");
assert!(line.contains("--"), "eta placeholder missing: {line}");
}
#[test]
fn progress_render_adaptive_readout() {
let style = Style { color: false };
let mut s = snap(0, 1_000_000, 10, 100, 0, 6, Phase::Transfer);
s.current_limit = 4_000_000;
s.target_rate = 5_000_000;
let m = RenderMetrics {
jobs: 16,
size_unit: Some(2),
adaptive_fraction: Some(0.8),
..Default::default()
};
let line = strip_ansi(&format_line(&s, &m, 200, &style, false));
assert!(line.contains("jobs 6/16"), "adaptive jobs missing: {line}");
assert!(line.contains("(auto 0.8)"), "true fraction missing: {line}");
let m_nofrac = RenderMetrics {
adaptive_fraction: None,
..m
};
let line_nofrac = strip_ansi(&format_line(&s, &m_nofrac, 200, &style, false));
assert!(
line_nofrac.contains("jobs 6/16 (auto)"),
"bare auto fallback missing: {line_nofrac}"
);
assert!(
!line_nofrac.contains("(auto 0"),
"unexpected number in bare auto: {line_nofrac}"
);
let s2 = snap(0, 1_000_000, 10, 100, 0, 6, Phase::Transfer);
let line2 = strip_ansi(&format_line(&s2, &m, 200, &style, false));
assert!(line2.contains("6/16"), "plain conc missing: {line2}");
assert!(
!line2.contains("auto"),
"unexpected auto indicator: {line2}"
);
}
#[test]
fn progress_render_adaptive_fraction_threaded_through_tick() {
let meter = Meter::new();
let mut state = RenderState::init(&meter, Some(0.5));
let mut s = snap(0, 1_000_000, 10, 100, 0, 6, Phase::Transfer);
s.current_limit = 4_000_000;
let m = state.tick(s, 16);
assert_eq!(m.adaptive_fraction, Some(0.5));
let style = Style { color: false };
let line = strip_ansi(&format_line(&s, &m, 200, &style, false));
assert!(
line.contains("(auto 0.5)"),
"threaded fraction not rendered: {line}"
);
}
}