use std::io::IsTerminal;
pub use anstyle::{AnsiColor, Style};
pub use anstream::ColorChoice;
mod table;
pub use table::{fit_cell, Table};
pub fn set_color_choice(choice: ColorChoice) {
choice.write_global();
let _ = anstream::AutoStream::auto(std::io::stdout());
let _ = anstream::AutoStream::auto(std::io::stderr());
}
fn no_color() -> bool {
std::env::var_os("NO_COLOR").is_some_and(|v| !v.is_empty())
}
fn colored_with(choice: ColorChoice, is_terminal: bool) -> bool {
match choice {
ColorChoice::Always | ColorChoice::AlwaysAnsi => true,
ColorChoice::Never => false,
ColorChoice::Auto => is_terminal && !no_color(),
}
}
fn colored(is_terminal: bool) -> bool {
colored_with(ColorChoice::global(), is_terminal)
}
pub fn stdout_colored() -> bool {
colored(std::io::stdout().is_terminal())
}
pub fn stderr_colored() -> bool {
colored(std::io::stderr().is_terminal())
}
static PROGRESS_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static PROJECT: std::sync::RwLock<String> = std::sync::RwLock::new(String::new());
pub fn set_project(name: &str) {
if let Ok(mut slot) = PROJECT.write() {
name.clone_into(&mut slot);
}
}
pub fn identity_style(label: &str) -> Style {
let key = PROJECT
.read()
.ok()
.and_then(|p| {
(!p.is_empty())
.then(|| label.strip_prefix(&format!("{p}-")).map(str::to_string))
.flatten()
})
.unwrap_or_else(|| label.to_string());
service_style(&key)
}
pub fn set_progress(enabled: bool) {
PROGRESS_ENABLED.store(enabled, std::sync::atomic::Ordering::Relaxed);
}
pub fn progress_enabled() -> bool {
PROGRESS_ENABLED.load(std::sync::atomic::Ordering::Relaxed)
}
pub fn progress_line(kind: &str, name: &str, action: &str) {
use std::io::Write;
if !progress_enabled() {
return;
}
let verb = action_style(action);
let ident = identity_style(name);
let _ = writeln!(
anstream::stderr(),
" {kind} {}{name}{} {}{action}{}",
ident.render(),
ident.render_reset(),
verb.render(),
verb.render_reset()
);
}
fn action_style(action: &str) -> Style {
let a = action.to_ascii_lowercase();
if a.starts_with("remov") || a.starts_with("kill") || a.starts_with("delet") {
Style::new().fg_color(Some(AnsiColor::Red.into()))
} else if a.starts_with("stop") || a.starts_with("paus") || a.starts_with("restart") {
Style::new().fg_color(Some(AnsiColor::Yellow.into()))
} else if a.starts_with("exist") || a.starts_with("running") || a.starts_with("skip") {
Style::new().dimmed()
} else {
Style::new().fg_color(Some(AnsiColor::Green.into()))
}
}
pub fn progress_note(msg: &str) {
use std::io::Write;
if !progress_enabled() {
return;
}
let _ = writeln!(anstream::stderr(), "{msg}");
}
pub fn result_line(msg: &str) -> std::io::Result<()> {
use std::io::Write;
if !progress_enabled() {
return Ok(());
}
match writeln!(anstream::stdout(), "{msg}") {
Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => Err(e),
_ => Ok(()),
}
}
pub fn print_labelled(label: &str, value: &str) {
print_labelled_with(label, value, None);
}
pub fn print_labelled_with(label: &str, value: &str, good: Option<bool>) {
use std::io::Write;
let dim = Style::new().dimmed();
let padded = format!("{label}:");
let explicit = good.map(|ok| {
Style::new().fg_color(Some(
if ok { AnsiColor::Green } else { AnsiColor::Red }.into(),
))
});
let styled_value = match explicit.or_else(|| status_style(value)) {
Some(style) => paint(style, value, true),
None => value.to_string(),
};
let _ = writeln!(
anstream::stdout(),
"{}{padded:<11}{} {styled_value}",
dim.render(),
dim.render_reset()
);
}
pub fn bold() -> Style {
Style::new().bold()
}
pub fn print_bold_header(cols: &str) {
use std::io::Write;
let s = bold();
let _ = writeln!(
anstream::stdout(),
"{}{cols}{}",
s.render(),
s.render_reset()
);
}
pub fn print_identity_header(name: &str) {
use std::io::Write;
let style = identity_style(name).bold();
let _ = writeln!(
anstream::stdout(),
"{}{name}{}",
style.render(),
style.render_reset()
);
}
pub fn error_style() -> Style {
Style::new().bold().fg_color(Some(AnsiColor::Red.into()))
}
pub fn paint(style: Style, text: &str, enabled: bool) -> String {
if enabled {
format!("{}{text}{}", style.render(), style.render_reset())
} else {
text.to_string()
}
}
const SERVICE_PALETTE: [AnsiColor; 6] = [
AnsiColor::Cyan,
AnsiColor::Magenta,
AnsiColor::Blue,
AnsiColor::BrightCyan,
AnsiColor::BrightMagenta,
AnsiColor::BrightBlue,
];
fn palette_index(name: &str) -> usize {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in name.bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
(h % SERVICE_PALETTE.len() as u64) as usize
}
pub fn service_style(name: &str) -> Style {
Style::new().fg_color(Some(SERVICE_PALETTE[palette_index(name)].into()))
}
fn is_clean_exit(lower: &str) -> bool {
let Some(rest) = lower
.split_once("exit")
.map(|(_, r)| r.trim_start_matches(['e', 'd', ' ', '(']))
else {
return false;
};
rest.starts_with('0')
&& !rest
.trim_start_matches('0')
.starts_with(|c: char| c.is_ascii_digit())
}
fn status_style(status: &str) -> Option<Style> {
let s = status.to_ascii_lowercase();
if s.contains("exit") && is_clean_exit(&s) {
return Some(Style::new().dimmed());
}
let colour = if s.contains("unhealthy")
|| s.contains("exit")
|| s.contains("dead")
|| s.contains("failed")
|| s.contains("not-found")
|| s.contains("error")
{
AnsiColor::Red
} else if s.contains("running")
|| s.contains("healthy")
|| s.starts_with("up")
|| s == "active"
|| s == "enabled"
|| s == "yes"
{
AnsiColor::Green
} else if s.contains("paus")
|| s.contains("restart")
|| s.contains("starting")
|| s.contains("stopping")
|| s.contains("removing")
|| s.contains("activating")
{
AnsiColor::Yellow
} else if s.contains("created") || s == "inactive" || s == "disabled" || s == "no" {
return Some(Style::new().dimmed());
} else {
return None;
};
Some(Style::new().fg_color(Some(colour.into())))
}
pub fn action_or_status_style(word: &str) -> Option<Style> {
let w = word.to_ascii_lowercase();
if w.starts_with("die")
|| w.starts_with("kill")
|| w.starts_with("destroy")
|| w.starts_with("remove")
{
return Some(Style::new().fg_color(Some(AnsiColor::Red.into())));
}
if w.starts_with("start") || w.starts_with("create") || w.starts_with("health") {
return Some(Style::new().fg_color(Some(AnsiColor::Green.into())));
}
if w.starts_with("stop") || w.starts_with("pause") || w.starts_with("restart") {
return Some(Style::new().fg_color(Some(AnsiColor::Yellow.into())));
}
status_style(word)
}
pub(crate) fn paint_status_cell(padded: &str) -> String {
let trimmed = padded.trim_end();
let pad = &padded[trimmed.len()..];
let body = trimmed
.split(", ")
.map(|seg| match status_style(seg) {
Some(style) => paint(style, seg, true),
None => seg.to_string(),
})
.collect::<Vec<_>>()
.join(", ");
format!("{body}{pad}")
}
pub fn status_cell(status: &str, width: usize) -> String {
let padded = format!("{status:<width$}");
match status_style(status) {
Some(style) => paint(style, &padded, stdout_colored()),
None => padded,
}
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;