use anyhow::Error;
use std::io::IsTerminal;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Style {
color: bool,
}
impl Style {
pub fn detect(no_color: bool) -> Self {
let stdout_tty = std::io::stdout().is_terminal();
let stderr_tty = std::io::stderr().is_terminal();
let no_color_env = std::env::var_os("NO_COLOR").is_some();
let color = !no_color && !no_color_env && (stdout_tty || stderr_tty);
Style { color }
}
#[cfg(test)]
pub fn fixed(color: bool) -> Self {
Style { color }
}
pub fn color(self) -> bool {
self.color
}
pub fn colorize(self, code: &str, s: &str) -> String {
if self.color {
format!("\x1b[{code}m{s}\x1b[0m")
} else {
s.to_string()
}
}
}
pub fn human_size(bytes: u64) -> String {
const UNITS: &[(&str, u64)] = &[
("TiB", 1 << 40),
("GiB", 1 << 30),
("MiB", 1 << 20),
("KiB", 1 << 10),
];
if bytes < 1024 {
return format!("{bytes} bytes");
}
for (unit, threshold) in UNITS {
if bytes >= *threshold {
let val = bytes as f64 / *threshold as f64;
let s = format!("{val:.1}");
return format!("{} {}", s.trim_end_matches(".0"), unit);
}
}
format!("{bytes} bytes")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warn,
Error,
}
impl Severity {
pub fn label(self) -> &'static str {
match self {
Severity::Info => "info",
Severity::Warn => "warn",
Severity::Error => "error",
}
}
fn code(self) -> &'static str {
match self {
Severity::Info => "36", Severity::Warn => "33", Severity::Error => "31", }
}
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, Clone, Copy)]
pub struct Renderer {
pub style: Style,
}
impl Renderer {
pub fn new(style: Style) -> Self {
Renderer { style }
}
pub fn severity(self, sev: Severity, s: &str) -> String {
self.style.colorize(sev.code(), s)
}
pub fn error(self, err: &Error) -> String {
const MAX_CAUSES: usize = 6;
let mut out = self.style.colorize("1;31", &err.to_string());
let mut causes: Vec<String> = err.chain().skip(1).map(|c| c.to_string()).collect();
if causes.len() > MAX_CAUSES {
causes.truncate(MAX_CAUSES);
causes.push(format!(
"... and {} more",
err.chain().count() - 1 - MAX_CAUSES
));
}
for cause in causes {
out.push_str(&format!("\n caused by: {cause}"));
}
out
}
}
#[cfg(test)]
pub fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\x1b' {
for c2 in chars.by_ref() {
if c2 == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_sizes_are_binary_and_trim_trailing_zeros() {
assert_eq!(human_size(0), "0 bytes");
assert_eq!(human_size(1023), "1023 bytes");
assert_eq!(human_size(1024), "1 KiB");
assert_eq!(human_size(1536), "1.5 KiB");
assert_eq!(human_size(1048576), "1 MiB");
assert_eq!(human_size(1073741824), "1 GiB");
}
#[test]
fn colour_off_emits_no_escape_sequences() {
let s = Style::fixed(false);
assert!(!s.color());
let r = Renderer::new(s);
assert_eq!(r.severity(Severity::Error, "boom"), "boom");
assert_eq!(r.error(&anyhow::anyhow!("boom")), "boom");
}
#[test]
fn colour_on_wraps_and_strips_cleanly() {
let r = Renderer::new(Style::fixed(true));
let painted = r.severity(Severity::Error, "boom");
assert!(painted.contains("\x1b["));
assert_eq!(strip_ansi(&painted), "boom");
}
#[test]
fn severity_orders_from_info_to_error() {
assert!(Severity::Error > Severity::Warn);
assert!(Severity::Warn > Severity::Info);
assert_eq!(Severity::Warn.to_string(), "warn");
}
#[test]
fn an_error_renders_its_cause_chain_indented() {
let r = Renderer::new(Style::fixed(false));
let err = anyhow::anyhow!("outer").context("middle").context("inner");
let text = r.error(&err);
assert!(text.starts_with("inner"), "{text}");
assert!(text.contains(" caused by: middle"), "{text}");
assert!(text.contains(" caused by: outer"), "{text}");
}
}