use std::{
cell::RefCell,
fmt,
io::{IsTerminal, Write},
sync::atomic::{AtomicU8, Ordering},
time::{Duration, Instant},
};
use clap::ValueEnum;
use colored::Colorize;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
#[repr(u8)]
pub enum Verbosity {
Warn,
#[default]
Info,
Debug,
Trace,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[repr(u8)]
pub enum ProgressStyle {
#[default]
Pretty,
Plain,
None,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[repr(u8)]
pub enum ColorChoice {
#[default]
Auto,
True,
False,
}
impl ColorChoice {
pub fn use_color(&self) -> bool {
match self {
Self::Auto => auto_color(std::io::stdout().is_terminal()),
Self::True => true,
Self::False => false,
}
}
fn for_terminal(self, terminal: bool) -> bool {
match self {
Self::Auto => auto_color(terminal),
Self::True => true,
Self::False => false,
}
}
}
fn auto_color(terminal: bool) -> bool {
resolve_auto_color(
terminal,
std::env::var("CLICOLOR").ok().map(|value| value != "0"),
std::env::var("NO_COLOR").is_ok(),
std::env::var("CLICOLOR_FORCE").ok().map(|value| value != "0"),
)
}
fn resolve_auto_color(
terminal: bool,
clicolor: Option<bool>,
no_color: bool,
clicolor_force: Option<bool>,
) -> bool {
if clicolor_force == Some(true) {
true
} else if no_color {
false
} else {
clicolor.unwrap_or(true) && terminal
}
}
static LEVEL: AtomicU8 = AtomicU8::new(Verbosity::Info as u8);
static PROGRESS: AtomicU8 = AtomicU8::new(ProgressStyle::Pretty as u8);
static COLOR: AtomicU8 = AtomicU8::new(ColorChoice::Auto as u8);
pub fn set(verbosity: Verbosity, progress: ProgressStyle, color: ColorChoice) {
LEVEL.store(verbosity as u8, Ordering::Relaxed);
PROGRESS.store(progress as u8, Ordering::Relaxed);
COLOR.store(color as u8, Ordering::Relaxed);
match color {
ColorChoice::Auto => colored::control::unset_override(),
ColorChoice::True => colored::control::set_override(true),
ColorChoice::False => colored::control::set_override(false),
}
}
fn color() -> ColorChoice {
match COLOR.load(Ordering::Relaxed) {
0 => ColorChoice::Auto,
1 => ColorChoice::True,
_ => ColorChoice::False,
}
}
fn prepare_color(choice: ColorChoice, terminal: bool) -> bool {
let enabled = choice.for_terminal(terminal);
colored::control::set_override(enabled);
enabled
}
pub(crate) fn prepare_stdout_color() -> bool {
prepare_color(color(), std::io::stdout().is_terminal())
}
#[doc(hidden)]
pub fn prepare_stderr_color() -> bool {
prepare_color(color(), std::io::stderr().is_terminal())
}
pub fn verbosity() -> Verbosity {
match LEVEL.load(Ordering::Relaxed) {
0 => Verbosity::Warn,
1 => Verbosity::Info,
2 => Verbosity::Debug,
_ => Verbosity::Trace,
}
}
fn progress() -> ProgressStyle {
match PROGRESS.load(Ordering::Relaxed) {
0 => ProgressStyle::Pretty,
1 => ProgressStyle::Plain,
_ => ProgressStyle::None,
}
}
pub fn subprocess_output_visible() -> bool {
verbosity() >= Verbosity::Debug
}
pub fn transfers_are_live() -> bool {
progress() == ProgressStyle::Pretty
&& verbosity() >= Verbosity::Info
&& std::io::stderr().is_terminal()
}
pub fn activity_is_live() -> bool {
progress() == ProgressStyle::Pretty
&& verbosity() == Verbosity::Info
&& std::io::stderr().is_terminal()
}
pub fn emit_info(args: fmt::Arguments) {
if verbosity() >= Verbosity::Info {
write_line(format_args!("{}: {args}", "info".bold()));
}
}
pub fn emit_note(args: fmt::Arguments) {
if verbosity() >= Verbosity::Info {
write_line(args);
}
}
pub fn emit_warning(args: fmt::Arguments) {
write_line(format_args!("{}: {args}", "warning".yellow().bold()));
}
pub(crate) fn emit_child_output(bytes: &[u8]) {
let mut stderr = std::io::stderr().lock();
Transfer::erase(&mut stderr);
let _ = stderr.write_all(bytes);
let _ = stderr.flush();
}
pub fn emit_trace(args: fmt::Arguments) {
if verbosity() >= Verbosity::Trace {
write_line(format_args!("{}: {args}", "trace".magenta().bold()));
}
}
fn write_line(args: fmt::Arguments) {
prepare_stderr_color();
let mut stderr = std::io::stderr().lock();
Transfer::erase(&mut stderr);
let _ = writeln!(stderr, "{args}");
}
pub(crate) fn with_stderr_interaction<T>(interaction: impl FnOnce(&mut dyn Write) -> T) -> T {
let mut stderr = std::io::stderr().lock();
Transfer::erase(&mut stderr);
interaction(&mut stderr)
}
#[macro_export]
macro_rules! info {
($($arg:tt)*) => {
if $crate::report::verbosity() >= $crate::report::Verbosity::Info {
$crate::report::prepare_stderr_color();
$crate::report::emit_info(format_args!($($arg)*));
}
};
}
#[macro_export]
macro_rules! note {
($($arg:tt)*) => {
if $crate::report::verbosity() >= $crate::report::Verbosity::Info {
$crate::report::prepare_stderr_color();
$crate::report::emit_note(format_args!($($arg)*));
}
};
}
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => {{
$crate::report::prepare_stderr_color();
$crate::report::emit_warning(format_args!($($arg)*))
}};
}
#[macro_export]
macro_rules! trace {
($($arg:tt)*) => {
if $crate::report::verbosity() >= $crate::report::Verbosity::Trace {
$crate::report::prepare_stderr_color();
$crate::report::emit_trace(format_args!($($arg)*));
}
};
}
const REDRAW_INTERVAL: Duration = Duration::from_millis(100);
thread_local! {
static LINE_PENDING: RefCell<bool> = const { RefCell::new(false) };
}
pub struct Transfer {
label: String,
started: Instant,
last_drawn: Option<Instant>,
live: bool,
}
impl Transfer {
pub fn begin(label: impl Into<String>) -> Self {
Self {
label: label.into(),
started: Instant::now(),
last_drawn: None,
live: transfers_are_live(),
}
}
pub fn update(&mut self, done: u64, total: u64) {
if !self.live || done == 0 {
return;
}
let now = Instant::now();
if self.last_drawn.is_some_and(|last| now.duration_since(last) < REDRAW_INTERVAL) {
return;
}
self.last_drawn = Some(now);
let elapsed = now.duration_since(self.started).as_secs_f64();
let rate = if elapsed > 0.0 { done as f64 / elapsed } else { 0.0 };
let transferred = if total > 0 {
format!("{}/{}", bytes(done), bytes(total))
} else {
bytes(done)
};
let mut stderr = std::io::stderr().lock();
let _ =
write!(stderr, "\r\x1b[2K {} {transferred} ({}/s)", self.label, bytes(rate as u64));
let _ = stderr.flush();
LINE_PENDING.with_borrow_mut(|pending| *pending = true);
}
fn erase(stderr: &mut impl Write) {
LINE_PENDING.with_borrow_mut(|pending| {
if *pending {
let _ = write!(stderr, "\r\x1b[2K");
let _ = stderr.flush();
*pending = false;
}
});
}
}
impl Drop for Transfer {
fn drop(&mut self) {
if self.live {
let mut stderr = std::io::stderr().lock();
Self::erase(&mut stderr);
}
}
}
pub struct Activity {
label: String,
started: Instant,
last_drawn: Option<Instant>,
live: bool,
}
impl Activity {
pub fn begin(label: impl Into<String>) -> Self {
Self {
label: label.into(),
started: Instant::now(),
last_drawn: None,
live: activity_is_live(),
}
}
pub fn tick(&mut self) {
if !self.live {
return;
}
let now = Instant::now();
if self.last_drawn.is_some_and(|last| now.duration_since(last) < REDRAW_INTERVAL) {
return;
}
self.last_drawn = Some(now);
let mut stderr = std::io::stderr().lock();
let _ = write!(
stderr,
"\r\x1b[2K {} {}",
self.label,
elapsed(now.duration_since(self.started))
);
let _ = stderr.flush();
LINE_PENDING.with_borrow_mut(|pending| *pending = true);
}
}
impl Drop for Activity {
fn drop(&mut self) {
if self.live {
let mut stderr = std::io::stderr().lock();
Transfer::erase(&mut stderr);
}
}
}
fn elapsed(duration: Duration) -> String {
let seconds = duration.as_secs();
if seconds >= 60 {
format!("{}m{:02}s", seconds / 60, seconds % 60)
} else {
format!("{seconds}s")
}
}
fn bytes(count: u64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut value = count as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{count} {}", UNITS[0])
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_wait_is_formatted_in_minutes_once_there_are_any() {
assert_eq!(elapsed(Duration::from_secs(0)), "0s");
assert_eq!(elapsed(Duration::from_secs(59)), "59s");
assert_eq!(elapsed(Duration::from_secs(60)), "1m00s");
assert_eq!(elapsed(Duration::from_secs(72)), "1m12s");
assert_eq!(elapsed(Duration::from_secs(3599)), "59m59s");
assert_eq!(elapsed(Duration::from_millis(1900)), "1s");
}
#[test]
fn only_debug_and_above_reveal_subprocess_output() {
for (level, visible) in [
(Verbosity::Warn, false),
(Verbosity::Info, false),
(Verbosity::Debug, true),
(Verbosity::Trace, true),
] {
set(level, ProgressStyle::Pretty, ColorChoice::Auto);
assert_eq!(subprocess_output_visible(), visible, "at {level:?}");
}
set(Verbosity::Info, ProgressStyle::Pretty, ColorChoice::Auto);
}
#[test]
fn byte_counts_read_as_transfers() {
assert_eq!(bytes(512), "512 B");
assert_eq!(bytes(1536), "1.5 KiB");
assert_eq!(bytes(10 * 1024 * 1024), "10.0 MiB");
}
#[test]
fn automatic_color_uses_the_destination_terminal_and_standard_precedence() {
assert!(resolve_auto_color(true, None, false, None));
assert!(!resolve_auto_color(false, None, false, None));
assert!(!resolve_auto_color(true, Some(false), false, None));
assert!(!resolve_auto_color(true, None, true, None));
assert!(resolve_auto_color(false, Some(false), true, Some(true)));
assert!(!resolve_auto_color(false, None, false, Some(false)));
assert!(resolve_auto_color(true, None, false, Some(false)));
}
}