#![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,
}
impl ColorChoice {
pub(crate) fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"always" => ColorChoice::Always,
"never" => ColorChoice::Never,
_ => ColorChoice::Auto,
}
}
}
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])
}
}
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 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")
}
}
#[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,
}
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::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 determinate = total > 0;
let fraction = if determinate {
done as f64 / total as f64
} else {
0.0
};
let counts = if determinate {
let pct = (fraction * 100.0).clamp(0.0, 100.0);
format!("{pct:>3.0}% {done}/{total}")
} else {
format!("{done} objs")
};
let bytes = format!(
"{}/{}",
human_bytes(snap.bytes_out),
human_bytes(snap.bytes_in)
);
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)
);
Self {
spinner: spinner_glyph(m.spinner_frame, ascii).to_string(),
label,
counts,
bytes,
rates,
conc: format!("{}/{}", snap.in_flight, m.jobs),
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: m.eta.map(|d| format!("eta {}", human_eta(d))),
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);
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,
}
impl RenderState {
fn init(meter: &Meter) -> 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(),
};
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.last = now;
self.last_snap = snap;
self.spinner_frame = self.spinner_frame.wrapping_add(1);
RenderMetrics {
rate_in: self.rate_in,
rate_out: self.rate_out,
obj_per_sec: self.obj_per_sec,
eta: compute_eta(&snap, self.obj_per_sec),
rss: sample_rss(),
cpu_pct: self.cpu.poll(),
jobs,
spinner_frame: self.spinner_frame,
}
}
}
impl ProgressReporter {
pub(crate) fn start(
meter: Arc<Meter>,
jobs: usize,
active: bool,
color: bool,
ascii: bool,
) -> 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 handle = std::thread::spawn(move || {
let mut state = RenderState::init(&meter);
while !stop_thread.load(Ordering::Relaxed) {
std::thread::sleep(TICK);
if stop_thread.load(Ordering::Relaxed) {
break;
}
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();
}
});
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(snap: &MeterSnapshot, obj_per_sec: f64) -> Option<Duration> {
if snap.objects_total == 0 || obj_per_sec <= 0.0 {
return None;
}
let done = snap.objects_done + snap.objects_skipped;
let remaining = snap.objects_total.saturating_sub(done);
if remaining == 0 {
return Some(Duration::ZERO);
}
let secs = remaining as f64 / obj_per_sec;
if !secs.is_finite() || secs < 0.0 {
return None;
}
Some(Duration::from_secs_f64(secs))
}
#[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_total: total,
objects_skipped: skipped,
in_flight,
phase,
}
}
#[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));
assert_eq!(ColorChoice::parse("always"), ColorChoice::Always);
assert_eq!(ColorChoice::parse("NEVER"), ColorChoice::Never);
assert_eq!(ColorChoice::parse("auto"), ColorChoice::Auto);
assert_eq!(ColorChoice::parse("garbage"), ColorChoice::Auto);
}
#[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,
};
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"), "counts 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("eta 42s"), "eta 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 should be omitted: {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,
};
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"), "concurrency 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 objs"),
"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,
};
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);
reporter.finish();
}
}