use anstyle::{AnsiColor, Color, RgbColor, Style};
use serde::Serialize;
use time::OffsetDateTime;
pub fn json<T: Serialize>(v: &T) {
println!("{}", serde_json::to_string_pretty(v).expect("serializable"));
}
pub fn warn(msg: impl AsRef<str>) {
let warn = err().warn;
anstream::eprintln!("{warn}warning:{warn:#} {}", msg.as_ref());
}
pub fn error(msg: impl AsRef<str>) {
let error = err().error;
anstream::eprintln!("{error}error:{error:#} {}", msg.as_ref());
}
pub const LINK: Style = Style::new().underline();
pub const DIM: Style = Style::new().dimmed();
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Styles {
pub brand: Style,
pub done: Style,
pub warn: Style,
pub error: Style,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColourLevel {
True {
light: bool,
},
Ansi16,
Plain,
}
pub fn colour_level(colour: bool, colorterm: Option<&str>, colorfgbg: Option<&str>) -> ColourLevel {
if !colour {
return ColourLevel::Plain;
}
if !matches!(colorterm, Some("truecolor" | "24bit")) {
return ColourLevel::Ansi16;
}
let background = colorfgbg.and_then(|v| v.rsplit(';').next()).map(str::trim);
ColourLevel::True {
light: matches!(background, Some("7" | "15")),
}
}
pub fn styles(level: ColourLevel) -> Styles {
let rgb = |r, g, b| Color::Rgb(RgbColor(r, g, b));
let [brand, done, warn, error] = match level {
ColourLevel::Plain => {
let plain = Style::new();
return Styles {
brand: plain,
done: plain,
warn: plain,
error: plain,
};
}
ColourLevel::Ansi16 => [
Color::Ansi(AnsiColor::Magenta),
Color::Ansi(AnsiColor::Cyan),
Color::Ansi(AnsiColor::Yellow),
Color::Ansi(AnsiColor::Red),
],
ColourLevel::True { light: false } => [
rgb(217, 139, 191),
rgb(111, 194, 184),
rgb(219, 174, 94),
rgb(254, 149, 127),
],
ColourLevel::True { light: true } => [
rgb(123, 45, 94),
rgb(31, 111, 106),
rgb(111, 79, 5),
rgb(179, 64, 42),
],
};
let bold = |c| Style::new().bold().fg_color(Some(c));
Styles {
brand: bold(brand),
done: bold(done),
warn: bold(warn),
error: bold(error),
}
}
pub const GLYPH: &str = "\u{2042}";
pub fn banner(styles: Styles) -> String {
let plain = |s: Style| Style::new().fg_color(s.get_fg_color());
let (brand, done) = (plain(styles.brand), plain(styles.done));
format!("{brand}{GLYPH}{brand:#} semora {DIM}·{DIM:#} {done}Your projects, considered.{done:#}")
}
fn styles_of(choice: anstream::ColorChoice) -> Styles {
let var = |name: &str| std::env::var(name).ok();
styles(colour_level(
choice != anstream::ColorChoice::Never,
var("COLORTERM").as_deref(),
var("COLORFGBG").as_deref(),
))
}
pub fn out() -> Styles {
styles_of(anstream::AutoStream::choice(&std::io::stdout()))
}
pub fn err() -> Styles {
styles_of(anstream::AutoStream::choice(&std::io::stderr()))
}
pub fn ts(t: OffsetDateTime) -> String {
let fmt = time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]");
t.format(&fmt).unwrap_or_default()
}
pub fn size(bytes: u64) -> String {
match bytes {
b if b >= 1024 * 1024 => format!("{:.1} MB", b as f64 / (1024.0 * 1024.0)),
b if b >= 1024 => format!("{:.1} KB", b as f64 / 1024.0),
b => format!("{b} B"),
}
}
pub fn clean_name(name: &str) -> String {
name.chars()
.filter(|c| !c.is_control())
.map(|c| match c {
'[' => '(',
']' => ')',
c => c,
})
.take(80)
.collect::<String>()
.trim()
.to_string()
}
#[cfg(test)]
mod colour_tests {
use anstyle::{AnsiColor, Color, RgbColor, Style};
use super::{ColourLevel, banner, colour_level, styles};
#[test]
fn the_banner_is_plain_text_without_colour_and_coloured_by_meaning_with_it() {
assert_eq!(
banner(styles(ColourLevel::Plain))
.replace("\x1b[2m", "")
.replace("\x1b[0m", ""),
"\u{2042} semora · Your projects, considered."
);
assert_eq!(
banner(styles(ColourLevel::Ansi16)),
"\x1b[35m\u{2042}\x1b[0m semora \x1b[2m·\x1b[0m \x1b[36mYour projects, considered.\x1b[0m"
);
assert_eq!(
banner(styles(ColourLevel::True { light: false })),
"\x1b[38;2;217;139;191m\u{2042}\x1b[0m semora \x1b[2m·\x1b[0m \x1b[38;2;111;194;184mYour projects, considered.\x1b[0m"
);
}
#[test]
fn colour_is_truecolour_then_sixteen_then_none() {
use ColourLevel::{Ansi16, Plain, True};
assert_eq!(colour_level(false, Some("truecolor"), Some("0;15")), Plain);
assert_eq!(colour_level(false, None, None), Plain);
assert_eq!(colour_level(true, None, None), Ansi16);
assert_eq!(colour_level(true, Some(""), Some("0;15")), Ansi16);
assert_eq!(colour_level(true, Some("256"), None), Ansi16);
for claim in ["truecolor", "24bit"] {
assert_eq!(
colour_level(true, Some(claim), None),
True { light: false },
"dark unless the terminal says otherwise"
);
}
}
#[test]
fn a_terminal_is_light_only_when_colorfgbg_names_a_light_background() {
let light = |fgbg| {
colour_level(true, Some("truecolor"), fgbg) == (ColourLevel::True { light: true })
};
assert!(light(Some("0;15")));
assert!(light(Some("0;7")));
assert!(
light(Some("0;default;15")),
"rxvt's three fields: the last is the background"
);
assert!(
!light(Some("15;0")),
"a light foreground on a dark background"
);
assert!(!light(Some("0;8")));
assert!(!light(Some("")));
assert!(!light(Some("default;default")));
assert!(!light(None));
}
#[test]
fn each_level_has_the_brand_s_colours_in_bold() {
let bold = |c| Style::new().bold().fg_color(Some(c));
let rgb = |r, g, b| bold(Color::Rgb(RgbColor(r, g, b)));
let ansi = |c| bold(Color::Ansi(c));
let all = |level| {
let s = styles(level);
[s.brand, s.done, s.warn, s.error]
};
assert_eq!(all(ColourLevel::Plain), [Style::new(); 4]);
assert_eq!(
all(ColourLevel::Ansi16),
[
ansi(AnsiColor::Magenta),
ansi(AnsiColor::Cyan),
ansi(AnsiColor::Yellow),
ansi(AnsiColor::Red)
]
);
assert_eq!(
all(ColourLevel::True { light: false }),
[
rgb(217, 139, 191),
rgb(111, 194, 184),
rgb(219, 174, 94),
rgb(254, 149, 127)
]
);
assert_eq!(
all(ColourLevel::True { light: true }),
[
rgb(123, 45, 94),
rgb(31, 111, 106),
rgb(111, 79, 5),
rgb(179, 64, 42)
]
);
}
}