use std::time::Duration;
pub fn stderr_color() -> bool {
use std::io::IsTerminal;
static COLOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*COLOR.get_or_init(|| std::io::stderr().is_terminal())
}
pub fn stdout_color() -> bool {
use std::io::IsTerminal;
static COLOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*COLOR.get_or_init(|| std::io::stdout().is_terminal())
}
pub fn new_table() -> comfy_table::Table {
use comfy_table::{ContentArrangement, Table, presets::NOTHING};
let mut t = Table::new();
t.load_preset(NOTHING);
t.set_content_arrangement(ContentArrangement::Disabled);
if !stdout_color() {
t.force_no_tty();
}
t
}
pub fn new_wrapped_table() -> comfy_table::Table {
use comfy_table::{ContentArrangement, Table, presets::NOTHING};
let mut t = Table::new();
t.load_preset(NOTHING);
t.set_content_arrangement(ContentArrangement::Dynamic);
if !stdout_color() {
t.force_no_tty();
}
t
}
pub fn restore_sigpipe_default() {
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
pub(crate) fn status(msg: &str) {
if stderr_color() {
eprintln!("\x1b[1m{msg}\x1b[0m");
} else {
eprintln!("{msg}");
}
}
pub(crate) fn success(msg: &str) {
if stderr_color() {
eprintln!("\x1b[32m{msg}\x1b[0m");
} else {
eprintln!("{msg}");
}
}
pub(crate) fn warn(msg: &str) {
if stderr_color() {
eprintln!("\x1b[34m{msg}\x1b[0m");
} else {
eprintln!("{msg}");
}
}
static SPINNER_SAVED_TERMIOS: std::sync::Mutex<Option<libc::termios>> = std::sync::Mutex::new(None);
static SPINNER_ACTIVE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
fn install_spinner_termios_panic_hook() {
static INSTALLED: std::sync::Once = std::sync::Once::new();
INSTALLED.call_once(|| {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if let Ok(guard) = SPINNER_SAVED_TERMIOS.try_lock()
&& let Some(termios) = *guard
{
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
}
}
default(info);
}));
});
}
pub struct Spinner {
pb: Option<indicatif::ProgressBar>,
saved_termios: Option<libc::termios>,
}
impl Spinner {
pub fn start(msg: impl Into<std::borrow::Cow<'static, str>>) -> Self {
debug_assert!(
!SPINNER_ACTIVE.swap(true, std::sync::atomic::Ordering::SeqCst),
"Spinner::start called while another Spinner is already \
active. Nested spinners clobber SPINNER_SAVED_TERMIOS — \
the outer spinner's restore path would reset to the \
already-modified termios state instead of the original. \
If nesting is genuinely needed, refactor the save/restore \
path to depth-count before lifting this assertion.",
);
if !stderr_color() {
return Spinner {
pb: None,
saved_termios: None,
};
}
let pb = indicatif::ProgressBar::new_spinner();
pb.set_style(
indicatif::ProgressStyle::with_template("{spinner:.cyan} {msg}")
.expect("valid template"),
);
pb.set_message(msg);
pb.enable_steady_tick(Duration::from_millis(80));
if pb.is_hidden() {
return Spinner {
pb: None,
saved_termios: None,
};
}
let saved_termios = Self::disable_echo();
Spinner {
pb: Some(pb),
saved_termios,
}
}
fn disable_echo() -> Option<libc::termios> {
use std::io::IsTerminal;
if !std::io::stdin().is_terminal() {
return None;
}
unsafe {
let fd = libc::STDIN_FILENO;
let mut termios: libc::termios = std::mem::zeroed();
if libc::tcgetattr(fd, &mut termios) != 0 {
return None;
}
let saved = termios;
install_spinner_termios_panic_hook();
*SPINNER_SAVED_TERMIOS.lock().unwrap() = Some(saved);
termios.c_lflag &= !libc::ECHO;
libc::tcsetattr(fd, libc::TCSANOW, &termios);
Some(saved)
}
}
fn teardown(&mut self) {
if let Some(termios) = self.saved_termios.take() {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
}
*SPINNER_SAVED_TERMIOS.lock().unwrap() = None;
}
}
pub fn set_message(&self, msg: impl Into<std::borrow::Cow<'static, str>>) {
if let Some(ref pb) = self.pb {
pb.set_message(msg);
}
}
pub fn finish(mut self, msg: impl Into<std::borrow::Cow<'static, str>>) {
self.teardown();
match self.pb.take() {
Some(pb) => pb.finish_with_message(msg),
None => eprintln!("{}", msg.into()),
}
}
pub fn println(&self, msg: impl AsRef<str>) {
match self.pb {
Some(ref pb) => pb.println(msg),
None => eprintln!("{}", msg.as_ref()),
}
}
pub fn suspend<F: FnOnce() -> R, R>(&self, f: F) -> R {
match self.pb {
Some(ref pb) => pb.suspend(f),
None => f(),
}
}
pub fn with_progress<T, E, F>(
start_msg: impl Into<std::borrow::Cow<'static, str>>,
success_msg: impl Into<std::borrow::Cow<'static, str>>,
f: F,
) -> Result<T, E>
where
F: FnOnce(&Spinner) -> Result<T, E>,
{
let sp = Spinner::start(start_msg);
let result = f(&sp);
match result {
Ok(v) => {
sp.finish(success_msg);
Ok(v)
}
Err(e) => {
drop(sp);
Err(e)
}
}
}
}
impl Drop for Spinner {
fn drop(&mut self) {
self.teardown();
if let Some(pb) = self.pb.take() {
pb.finish_and_clear();
}
SPINNER_ACTIVE.store(false, std::sync::atomic::Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spinner_drop_without_finish_does_not_panic_in_non_tty() {
let sp = Spinner::start("test");
drop(sp);
}
#[test]
fn spinner_finish_then_drop_is_idempotent() {
let sp = Spinner::start("test");
sp.finish("done");
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "Spinner::start called while another Spinner is already active")]
fn spinner_nested_start_panics_under_debug_assertions() {
let _outer = Spinner::start("outer");
let _inner = Spinner::start("inner");
}
#[test]
fn spinner_start_releases_guard_on_drop() {
{
let _sp = Spinner::start("first");
}
let _sp = Spinner::start("second");
}
#[test]
fn spinner_set_message_no_panic_on_non_tty() {
let sp = Spinner::start("initial");
sp.set_message("updated");
drop(sp);
}
#[test]
fn spinner_println_no_panic_on_non_tty() {
let sp = Spinner::start("operation");
sp.println("interleaved log line");
drop(sp);
}
#[test]
fn spinner_suspend_returns_closure_value_on_non_tty() {
let sp = Spinner::start("operation");
let v: u32 = sp.suspend(|| 42);
assert_eq!(v, 42);
let s: String = sp.suspend(|| "hello".to_string());
assert_eq!(s, "hello");
drop(sp);
}
#[test]
fn spinner_with_progress_returns_ok_value_on_success() {
let result: Result<u32, String> = Spinner::with_progress("starting", "done", |sp| {
sp.set_message("midway");
Ok(123)
});
assert_eq!(result, Ok(123));
}
#[test]
fn spinner_with_progress_propagates_err_without_finish_message() {
let result: Result<(), String> = Spinner::with_progress("starting", "done", |_sp| {
Err("synthetic failure".to_string())
});
assert_eq!(result, Err("synthetic failure".to_string()));
}
#[test]
fn spinner_with_progress_sequential_pair_succeeds() {
let r1: Result<u8, String> = Spinner::with_progress("first", "first done", |_| Ok(1));
assert_eq!(r1, Ok(1));
let r2: Result<u8, String> = Spinner::with_progress("second", "second done", |_| Ok(2));
assert_eq!(r2, Ok(2));
}
#[test]
fn stderr_color_returns_consistent_value_across_calls() {
let a = stderr_color();
let b = stderr_color();
assert_eq!(a, b, "stderr_color must be cached and stable per process",);
}
#[test]
fn stdout_color_returns_consistent_value_across_calls() {
let a = stdout_color();
let b = stdout_color();
assert_eq!(a, b, "stdout_color must be cached and stable per process",);
}
#[test]
fn restore_sigpipe_default_does_not_panic() {
restore_sigpipe_default();
restore_sigpipe_default();
}
#[test]
fn new_table_uses_borderless_preset() {
let mut t = new_table();
t.set_header(["A", "B"]);
t.add_row(["1", "2"]);
let rendered = t.to_string();
for ch in ['│', '─', '┼', '┴', '┬', '├', '┤'] {
assert!(
!rendered.contains(ch),
"borderless table must not contain box-drawing char `{ch}`: {rendered}",
);
}
assert!(rendered.contains("A"), "header A must render: {rendered}");
assert!(rendered.contains("1"), "row cell 1 must render: {rendered}");
}
#[test]
fn new_wrapped_table_uses_borderless_preset() {
let mut t = new_wrapped_table();
t.set_header(["A"]);
t.add_row(["x"]);
let rendered = t.to_string();
for ch in ['│', '─', '┼'] {
assert!(
!rendered.contains(ch),
"borderless wrapped table must not contain box-drawing char `{ch}`: {rendered}",
);
}
}
#[test]
fn install_spinner_termios_panic_hook_is_idempotent() {
install_spinner_termios_panic_hook();
install_spinner_termios_panic_hook();
install_spinner_termios_panic_hook();
}
}