use std::io::IsTerminal;
pub struct Style {
enabled: bool,
}
impl Style {
pub fn stdout() -> Self {
Self {
enabled: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}
pub fn stderr() -> Self {
Self {
enabled: std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(),
}
}
pub fn paint(&self, code: &str, text: &str) -> String {
if self.enabled {
format!("\x1b[{code}m{text}\x1b[0m")
} else {
text.to_string()
}
}
pub fn green(&self, t: &str) -> String {
self.paint("32", t)
}
pub fn yellow(&self, t: &str) -> String {
self.paint("33", t)
}
pub fn red(&self, t: &str) -> String {
self.paint("31", t)
}
pub fn dim(&self, t: &str) -> String {
self.paint("2", t)
}
pub fn bold(&self, t: &str) -> String {
self.paint("1", t)
}
}
pub fn error(e: &anyhow::Error) {
let s = Style::stderr();
eprintln!("{} {e}", s.red("error:"));
for cause in e.chain().skip(1) {
eprintln!(" {} {cause}", s.dim("caused by:"));
}
}
pub fn warn(message: &str) {
let s = Style::stderr();
eprintln!("{} {message}", s.yellow("warning:"));
}
pub fn table(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut widths: Vec<usize> = headers.iter().map(|h| h.len()).collect();
for row in rows {
for (i, cell) in row.iter().enumerate() {
if i < widths.len() {
widths[i] = widths[i].max(display_width(cell));
}
}
}
let mut out = String::new();
let s = Style::stdout();
let header: Vec<String> = headers
.iter()
.enumerate()
.map(|(i, h)| pad(&h.to_uppercase(), widths[i]))
.collect();
out.push_str(&s.bold(header.join(" ").trim_end()));
out.push('\n');
for row in rows {
let line: Vec<String> = row
.iter()
.enumerate()
.map(|(i, c)| pad(c, widths.get(i).copied().unwrap_or(0)))
.collect();
out.push_str(line.join(" ").trim_end());
out.push('\n');
}
out
}
fn pad(text: &str, width: usize) -> String {
let w = display_width(text);
if w >= width {
text.to_string()
} else {
format!("{text}{}", " ".repeat(width - w))
}
}
fn display_width(text: &str) -> usize {
let mut width = 0;
let mut in_escape = false;
for c in text.chars() {
if in_escape {
if c == 'm' {
in_escape = false;
}
} else if c == '\x1b' {
in_escape = true;
} else {
width += 1;
}
}
width
}
pub fn human_bytes(bytes: u64) -> String {
const UNITS: [(u64, &str); 4] = [
(1u64 << 40, "TB"),
(1 << 30, "GB"),
(1 << 20, "MB"),
(1 << 10, "kB"),
];
for (mult, unit) in UNITS {
if bytes >= mult {
return format!("{:.1} {unit}", bytes as f64 / mult as f64);
}
}
format!("{bytes} B")
}
pub fn human_age(unix_seconds: u64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let age = now.saturating_sub(unix_seconds);
match age {
0..=59 => "just now".to_string(),
60..=3599 => format!("{} minutes ago", age / 60),
3600..=86_399 => format!("{} hours ago", age / 3600),
_ => format!("{} days ago", age / 86_400),
}
}
pub fn json(value: &impl serde::Serialize) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_columns_line_up() {
let out = table(
&["name", "size"],
&[
vec!["python:3.12".into(), "120 MB".into()],
vec!["a".into(), "1 B".into()],
],
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
let col = lines[0].find("SIZE").unwrap();
assert_eq!(lines[1].find("120 MB"), Some(col));
assert_eq!(lines[2].find("1 B"), Some(col));
}
#[test]
fn table_handles_no_rows() {
let out = table(&["a", "b"], &[]);
assert_eq!(out.trim(), "A B");
}
#[test]
fn ansi_sequences_do_not_break_alignment() {
assert_eq!(display_width("\x1b[32mok\x1b[0m"), 2);
assert_eq!(display_width("ok"), 2);
assert_eq!(
pad("\x1b[32mok\x1b[0m", 4).len(),
"\x1b[32mok\x1b[0m".len() + 2
);
}
#[test]
fn byte_counts_read_naturally() {
assert_eq!(human_bytes(512), "512 B");
assert_eq!(human_bytes(1536), "1.5 kB");
assert_eq!(human_bytes(120 * (1 << 20)), "120.0 MB");
}
#[test]
fn ages_read_naturally() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
assert_eq!(human_age(now), "just now");
assert_eq!(human_age(now - 120), "2 minutes ago");
assert_eq!(human_age(now - 7200), "2 hours ago");
assert_eq!(human_age(now - 200_000), "2 days ago");
assert_eq!(human_age(now + 10_000), "just now");
}
#[test]
fn styling_is_a_no_op_when_disabled() {
let s = Style { enabled: false };
assert_eq!(s.green("ok"), "ok");
let s = Style { enabled: true };
assert!(s.green("ok").contains("\x1b[32m"));
}
}