use std::{
env,
fmt::Arguments,
io::{self, BufWriter, IsTerminal, StderrLock, StdoutLock, Write},
time::{Duration, Instant},
};
use crate::cli::{Outcome, args::Verbosity};
const REDRAW_INTERVAL: Duration = Duration::from_millis(50);
const BAR_CELLS: usize = 24;
#[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
}
pub fn line(&mut self, args: Arguments<'_>) -> Outcome {
self.erase_bar();
writeln!(self.out, "{args}").map_err(Into::into)
}
pub fn info(&mut self, args: Arguments<'_>) -> Outcome {
if self.verbosity == Verbosity::Quiet {
return Ok(());
}
self.line(args)
}
pub fn detail(&mut self, args: Arguments<'_>) -> Outcome {
if self.verbosity != Verbosity::Verbose {
return Ok(());
}
let Palette { dim, reset, .. } = self.palette;
self.erase_bar();
writeln!(self.out, "{dim}{:>10} {args}{reset}", "·").map_err(Into::into)
}
pub fn status(&mut self, color: &str, label: &str, args: Arguments<'_>) -> Outcome {
if self.verbosity == Verbosity::Quiet {
return Ok(());
}
let Palette { bold, reset, .. } = self.palette;
self.erase_bar();
writeln!(self.out, "{color}{bold}{label:>10}{reset} {args}").map_err(Into::into)
}
pub fn sub(&mut self, args: Arguments<'_>) -> Outcome {
if self.verbosity == Verbosity::Quiet {
return Ok(());
}
let Palette { dim, reset, .. } = self.palette;
self.erase_bar();
writeln!(self.out, "{:>10} {dim}{args}{reset}", "").map_err(Into::into)
}
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,
drawn: bool,
ansi: 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,
drawn: false,
ansi,
}
}
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.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,
);
let mut stderr = io::stderr().lock();
let _ = self.paint(&mut stderr, &line);
let _ = stderr.flush();
self.width = 10 + 2 + BAR_CELLS + 2 + stats.chars().count();
self.drawn = true;
self.last_draw = Instant::now();
}
fn paint(&self, stderr: &mut StderrLock<'_>, line: &str) -> io::Result<()> {
if self.ansi {
write!(stderr, "\r\x1b[2K{line}")
} else {
write!(stderr, "\r{line:<width$}", width = self.width)
}
}
fn erase(&mut self) {
if !self.drawn {
return;
}
let mut stderr = io::stderr().lock();
if self.ansi {
let _ = write!(stderr, "\r\x1b[2K");
} else {
let _ = write!(stderr, "\r{:width$}\r", "", width = self.width);
}
let _ = stderr.flush();
self.drawn = false;
self.width = 0;
}
}
pub fn display_path(path: &std::path::Path) -> String {
let text = path.to_string_lossy();
if cfg!(windows) {
text.replace('\\', "/")
} else {
text.into_owned()
}
}
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 color_choice_overrides_terminal_detection() {
assert!(supports_ansi(ColorChoice::Always, false));
assert!(!supports_ansi(ColorChoice::Never, true));
assert!(!supports_ansi(ColorChoice::Auto, false));
}
}