use std::io::IsTerminal;
use anstyle::{AnsiColor, Style};
pub use anstream::ColorChoice;
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())
}
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 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 status_style(status: &str) -> Option<Style> {
let s = status.to_ascii_lowercase();
let colour = if s.contains("unhealthy") || s.contains("exit") || s.contains("dead") {
AnsiColor::Red
} else if s.contains("running") || s.contains("healthy") || s.starts_with("up") {
AnsiColor::Green
} else if s.contains("paus") || s.contains("restart") || s.contains("starting") {
AnsiColor::Yellow
} else if s.contains("created") {
return Some(Style::new().dimmed());
} else {
return None;
};
Some(Style::new().fg_color(Some(colour.into())))
}
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)]
mod tests {
use super::*;
#[test]
fn service_colour_is_stable_per_name() {
assert_eq!(palette_index("web"), palette_index("web"));
let distinct: std::collections::HashSet<usize> =
["web", "db", "cache", "worker", "proxy", "queue"]
.iter()
.map(|n| palette_index(n))
.collect();
assert!(distinct.len() > 1, "palette should spread service names");
assert!(palette_index("web") < SERVICE_PALETTE.len());
}
#[test]
fn paint_gates_on_enabled() {
let plain = paint(bold(), "hi", false);
assert_eq!(plain, "hi");
let coloured = paint(bold(), "hi", true);
assert!(coloured.contains("hi"));
assert!(coloured.len() > "hi".len(), "enabled paint adds ANSI codes");
assert!(coloured.starts_with('\u{1b}'), "starts with an ESC");
}
#[test]
fn colour_choice_resolution() {
temp_env::with_var_unset("NO_COLOR", || {
assert!(!colored_with(ColorChoice::Never, true));
assert!(colored_with(ColorChoice::Always, false));
assert!(colored_with(ColorChoice::Auto, true));
assert!(!colored_with(ColorChoice::Auto, false));
});
temp_env::with_var("NO_COLOR", Some("1"), || {
assert!(!colored_with(ColorChoice::Auto, true));
assert!(colored_with(ColorChoice::Always, true));
});
}
#[test]
fn status_style_is_semantic() {
assert_ne!(status_style("running"), status_style("exited (1)"));
assert_ne!(status_style("unhealthy"), status_style("healthy"));
assert!(status_style("Up 2 minutes").is_some());
assert!(status_style("paused").is_some());
assert!(status_style("created").is_some());
assert!(status_style("weird-state").is_none());
}
#[test]
fn status_cell_pads_and_keeps_status() {
let cell = status_cell("ok", 6);
assert!(cell.contains("ok"));
assert!(cell.len() >= 6);
}
}