use std::{
env,
fmt::Arguments,
io::{self, BufWriter, IsTerminal, StdoutLock, Write},
time::{Duration, Instant},
};
use crate::cli::{Outcome, args::Verbosity};
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
const BAR_CELLS: usize = 24;
const LABEL_COLUMN: usize = 10;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
#[derive(Clone, Copy)]
pub struct Palette {
pub reset: &'static str,
pub bold: &'static str,
pub dim: &'static str,
pub red: &'static str,
pub green: &'static str,
pub yellow: &'static str,
pub cyan: &'static str,
pub magenta: &'static str,
}
impl Palette {
pub const OFF: Self = Self {
reset: "",
bold: "",
dim: "",
red: "",
green: "",
yellow: "",
cyan: "",
magenta: "",
};
pub const ON: Self = Self {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[91m",
green: "\x1b[92m",
yellow: "\x1b[93m",
cyan: "\x1b[96m",
magenta: "\x1b[95m",
};
}
fn supports_ansi(choice: ColorChoice, is_terminal: bool) -> bool {
match choice {
ColorChoice::Never => return false,
ColorChoice::Always => return true,
ColorChoice::Auto => {}
}
if !is_terminal {
return false;
}
if env::var_os("NO_COLOR").is_some_and(|value| !value.is_empty()) {
return false;
}
if cfg!(windows) {
[
"WT_SESSION",
"TERM_PROGRAM",
"ConEmuANSI",
"ANSICON",
"TERM",
]
.iter()
.any(|name| env::var_os(name).is_some())
} else {
env::var("TERM").as_deref() != Ok("dumb")
}
}
pub fn stderr_palette(choice: ColorChoice) -> Palette {
if supports_ansi(choice, io::stderr().is_terminal()) {
Palette::ON
} else {
Palette::OFF
}
}
pub struct Reporter {
out: BufWriter<StdoutLock<'static>>,
palette: Palette,
verbosity: Verbosity,
bar: Option<Bar>,
}
impl Reporter {
pub fn new(color: ColorChoice, verbosity: Verbosity, progress: bool) -> Self {
let stdout = io::stdout();
let palette = if supports_ansi(color, stdout.is_terminal()) {
Palette::ON
} else {
Palette::OFF
};
let stderr = io::stderr();
let bar = (progress && stderr.is_terminal() && verbosity != Verbosity::Quiet)
.then(|| Bar::new(supports_ansi(color, true)));
Self {
out: BufWriter::new(stdout.lock()),
palette,
verbosity,
bar,
}
}
pub fn palette(&self) -> Palette {
self.palette
}
pub fn is_verbose(&self) -> bool {
self.verbosity == Verbosity::Verbose
}
fn write(&mut self, args: Arguments<'_>) -> Outcome {
self.erase_bar();
self.out.write_fmt(args)?;
self.out.write_all(b"\n").map_err(Into::into)
}
fn quiet(&self) -> bool {
self.verbosity == Verbosity::Quiet
}
pub fn line(&mut self, args: Arguments<'_>) -> Outcome {
self.write(args)
}
pub fn info(&mut self, args: Arguments<'_>) -> Outcome {
if self.quiet() {
return Ok(());
}
self.write(args)
}
pub fn detail(&mut self, args: Arguments<'_>) -> Outcome {
if self.verbosity != Verbosity::Verbose {
return Ok(());
}
let Palette { dim, reset, .. } = self.palette;
self.write(format_args!("{dim}{:>LABEL_COLUMN$} {args}{reset}", "·"))
}
pub fn status(&mut self, color: &str, label: &str, args: Arguments<'_>) -> Outcome {
if self.quiet() {
return Ok(());
}
let Palette { bold, reset, .. } = self.palette;
self.write(format_args!(
"{color}{bold}{label:>LABEL_COLUMN$}{reset} {args}"
))
}
pub fn sub(&mut self, args: Arguments<'_>) -> Outcome {
if self.quiet() {
return Ok(());
}
let Palette { dim, reset, .. } = self.palette;
self.write(format_args!("{:>LABEL_COLUMN$} {dim}{args}{reset}", ""))
}
pub fn success(&mut self, label: &str, path: &std::path::Path, detail: &str) -> Outcome {
let Palette {
green, bold, reset, ..
} = self.palette;
self.status(
green,
label,
format_args!("{bold}{}{reset}", super::path::display(path)),
)?;
self.sub(format_args!("{detail}"))
}
pub fn start_bar(&mut self, label: &'static str, total: u64) {
if let Some(bar) = &mut self.bar {
bar.start(label, total);
}
}
pub fn advance(&mut self, bytes: u64) {
if let Some(bar) = &mut self.bar {
bar.advance(bytes);
}
}
pub fn finish_bar(&mut self) {
if let Some(bar) = &mut self.bar {
bar.erase();
}
}
fn erase_bar(&mut self) {
if let Some(bar) = &mut self.bar
&& bar.drawn()
{
let _ = self.out.flush();
bar.erase();
}
}
pub fn flush(&mut self) -> io::Result<()> {
self.finish_bar();
self.out.flush()
}
}
impl Drop for Reporter {
fn drop(&mut self) {
let _ = self.flush();
}
}
struct Bar {
label: &'static str,
total: u64,
done: u64,
started: Instant,
last_draw: Instant,
width: usize,
ansi: bool,
last_line: String,
cursor_hidden: bool,
}
impl Bar {
fn new(ansi: bool) -> Self {
let now = Instant::now();
Self {
label: "",
total: 0,
done: 0,
started: now,
last_draw: now,
width: 0,
ansi,
last_line: String::new(),
cursor_hidden: false,
}
}
fn drawn(&self) -> bool {
!self.last_line.is_empty()
}
fn start(&mut self, label: &'static str, total: u64) {
let now = Instant::now();
self.label = label;
self.total = total;
self.done = 0;
self.started = now;
self.last_draw = now - REDRAW_INTERVAL;
self.last_line.clear();
self.draw();
}
fn advance(&mut self, bytes: u64) {
self.done = self.done.saturating_add(bytes);
if self.last_draw.elapsed() >= REDRAW_INTERVAL {
self.draw();
}
}
fn draw(&mut self) {
let fraction = if self.total == 0 {
1.0
} else {
(self.done as f64 / self.total as f64).clamp(0.0, 1.0)
};
let elapsed = self.started.elapsed().as_secs_f64();
let speed = if elapsed > 0.0 {
self.done as f64 / elapsed
} else {
0.0
};
let eta = if self.done == 0 || fraction >= 1.0 {
None
} else {
Some(Duration::from_secs_f64(
elapsed * (self.total - self.done) as f64 / self.done as f64,
))
};
let filled = (fraction * BAR_CELLS as f64).round() as usize;
let (full, empty) = if self.ansi {
('â–ˆ', 'â–‘')
} else {
('#', '.')
};
let done: String = std::iter::repeat_n(full, filled).collect();
let todo: String = std::iter::repeat_n(empty, BAR_CELLS - filled).collect();
let p = if self.ansi { Palette::ON } else { Palette::OFF };
let stats = format!(
"{:>3.0}% {} / {} {}/s eta {}",
fraction * 100.0,
format_bytes(self.done as f64),
format_bytes(self.total as f64),
format_bytes(speed),
format_duration(eta),
);
let line = format!(
"{c}{b}{:>10}{r} {g}{done}{r}{d}{todo}{r} {y}{stats}{r}",
self.label,
c = p.cyan,
b = p.bold,
g = p.green,
d = p.dim,
y = p.yellow,
r = p.reset,
);
self.last_draw = Instant::now();
if line == self.last_line {
return;
}
let frame = self.frame(&line);
let mut stderr = io::stderr().lock();
let _ = stderr.write_all(frame.as_bytes());
let _ = stderr.flush();
self.last_line = line;
}
fn frame(&mut self, line: &str) -> String {
let mut frame = String::with_capacity(line.len() + 16);
frame.push('\r');
if self.ansi {
if !self.cursor_hidden {
frame.push_str("\x1b[?25l");
self.cursor_hidden = true;
}
frame.push_str(line);
frame.push_str("\x1b[K");
} else {
let visible = line.chars().count();
frame.push_str(line);
for _ in visible..self.width {
frame.push(' ');
}
self.width = visible;
}
frame
}
fn erase(&mut self) {
if !self.drawn() && !self.cursor_hidden {
return;
}
let mut frame = String::with_capacity(24);
if self.ansi {
frame.push_str("\r\x1b[K");
if self.cursor_hidden {
frame.push_str("\x1b[?25h");
self.cursor_hidden = false;
}
} else {
frame.push('\r');
for _ in 0..self.width {
frame.push(' ');
}
frame.push('\r');
}
let mut stderr = io::stderr().lock();
let _ = stderr.write_all(frame.as_bytes());
let _ = stderr.flush();
self.width = 0;
self.last_line.clear();
}
}
impl Drop for Bar {
fn drop(&mut self) {
if self.cursor_hidden {
let mut stderr = io::stderr().lock();
let _ = stderr.write_all(b"\r\x1b[K\x1b[?25h");
let _ = stderr.flush();
}
}
}
pub fn format_bytes(mut bytes: f64) -> String {
const UNITS: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
let mut unit = 0;
while bytes >= 1024.0 && unit + 1 < UNITS.len() {
bytes /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes:.0} B")
} else {
format!("{bytes:.1} {}", UNITS[unit])
}
}
pub fn format_duration(duration: Option<Duration>) -> String {
let Some(duration) = duration else {
return "--".to_owned();
};
let seconds = duration.as_secs();
if seconds >= 3600 {
format!("{}h{:02}m", seconds / 3600, seconds / 60 % 60)
} else if seconds >= 60 {
format!("{}m{:02}s", seconds / 60, seconds % 60)
} else if seconds > 0 {
format!("{:.1}s", duration.as_secs_f64())
} else {
format!("{}ms", duration.as_millis())
}
}
pub fn counted(count: u64, singular: &str, plural: &str) -> String {
if count == 1 {
format!("{count} {singular}")
} else {
format!("{count} {plural}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_byte_counts_at_each_scale() {
assert_eq!(format_bytes(0.0), "0 B");
assert_eq!(format_bytes(999.0), "999 B");
assert_eq!(format_bytes(1024.0), "1.0 KiB");
assert_eq!(format_bytes(1536.0), "1.5 KiB");
assert_eq!(format_bytes(3.0 * 1024.0 * 1024.0 * 1024.0), "3.0 GiB");
}
#[test]
fn formats_durations_by_magnitude() {
assert_eq!(format_duration(None), "--");
assert_eq!(format_duration(Some(Duration::from_millis(840))), "840ms");
assert_eq!(format_duration(Some(Duration::from_secs(9))), "9.0s");
assert_eq!(format_duration(Some(Duration::from_secs(64))), "1m04s");
assert_eq!(format_duration(Some(Duration::from_secs(7860))), "2h11m");
}
#[test]
fn pluralizes_only_when_needed() {
assert_eq!(counted(1, "file", "files"), "1 file");
assert_eq!(counted(0, "file", "files"), "0 files");
assert_eq!(counted(2, "directory", "directories"), "2 directories");
}
#[test]
fn a_frame_writes_over_the_line_instead_of_blanking_it() {
let mut bar = Bar::new(true);
let frame = bar.frame("packing");
assert!(
!frame.contains("\x1b[2K"),
"frame erases the whole line first: {frame:?}"
);
let content = frame.find("packing").unwrap();
let clear = frame.find("\x1b[K").unwrap();
assert!(clear > content, "clear must follow the text: {frame:?}");
}
#[test]
fn the_cursor_is_hidden_once_and_restored_on_erase() {
let mut bar = Bar::new(true);
assert!(bar.frame("a").contains("\x1b[?25l"));
assert!(!bar.frame("b").contains("\x1b[?25l"));
bar.last_line.push_str("something");
bar.erase();
assert!(!bar.cursor_hidden);
}
#[test]
fn an_unchanged_frame_is_not_repainted() {
let mut bar = Bar::new(true);
bar.start("packing", 1000);
let painted = bar.last_line.clone();
assert!(bar.drawn());
bar.last_draw = Instant::now() - REDRAW_INTERVAL * 2;
bar.advance(0);
assert_eq!(bar.last_line, painted);
}
#[test]
fn without_ansi_a_frame_pads_over_the_previous_one() {
let mut bar = Bar::new(false);
bar.width = 20;
let frame = bar.frame("short");
assert!(!frame.contains('\x1b'), "escapes leaked: {frame:?}");
assert_eq!(frame, format!("\r{:<20}", "short"));
}
#[test]
fn color_choice_overrides_terminal_detection() {
assert!(supports_ansi(ColorChoice::Always, false));
assert!(!supports_ansi(ColorChoice::Never, true));
assert!(!supports_ansi(ColorChoice::Auto, false));
}
}