use anstyle::{AnsiColor, Style};
pub const HEAD: Style = Style::new().bold();
pub const OK: Style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Green)));
pub const WARN: Style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Yellow)));
pub const BAD: Style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Red)));
pub const DIM: Style = Style::new().dimmed();
pub const NAME: Style = Style::new().fg_color(Some(anstyle::Color::Ansi(AnsiColor::Cyan)));
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Color {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct Palette {
styled: bool,
}
impl Palette {
pub fn resolve(pref: Color, no_color: Option<&str>, is_tty: bool) -> Self {
let forbidden = no_color.is_some_and(|v| !v.is_empty());
let styled = match pref {
Color::Always => true,
Color::Never => false,
Color::Auto => is_tty && !forbidden,
};
Self { styled }
}
pub fn plain() -> Self {
Self { styled: false }
}
pub fn paint(&self, style: Style, text: &str) -> String {
if self.styled {
format!("{style}{text}{style:#}")
} else {
text.to_string()
}
}
}
pub fn display_width(s: &str) -> usize {
let mut width = 0;
let mut chars = s.chars();
while let Some(c) = chars.next() {
match c {
ESC => skip_escape_sequence(&mut chars),
c if is_combining(c) => {}
_ => width += 1,
}
}
width
}
const ESC: char = '\x1b';
const BEL: char = '\x07';
fn skip_escape_sequence(chars: &mut std::str::Chars) {
match chars.next() {
Some('[') => {
chars.find(|c| ('\x40'..='\x7e').contains(c));
}
Some(']') => {
let mut prev = '\0';
for c in chars.by_ref() {
if c == BEL || (c == '\\' && prev == ESC) {
break;
}
prev = c;
}
}
_ => {}
}
}
fn is_combining(c: char) -> bool {
matches!(c, '\u{0300}'..='\u{036f}' | '\u{1ab0}'..='\u{1aff}' | '\u{20d0}'..='\u{20ff}')
}
pub struct Cell {
text: String,
style: Option<Style>,
}
impl Cell {
pub fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
style: None,
}
}
pub fn styled(text: impl Into<String>, style: Style) -> Self {
Self {
text: text.into(),
style: Some(style),
}
}
fn render(&self, p: &Palette) -> String {
match self.style {
Some(s) => p.paint(s, &self.text),
None => self.text.clone(),
}
}
}
#[derive(Default)]
pub struct Table {
rows: Vec<Vec<Cell>>,
indent: usize,
gap: usize,
}
const INDENT: usize = 2;
const GUTTER: usize = 2;
impl Table {
pub fn new() -> Self {
Self {
rows: Vec::new(),
indent: INDENT,
gap: GUTTER,
}
}
pub fn indent(mut self, n: usize) -> Self {
self.indent = n;
self
}
pub fn row(mut self, cells: Vec<Cell>) -> Self {
self.rows.push(cells);
self
}
pub fn render(&self, p: &Palette) -> String {
let columns = self.rows.iter().map(Vec::len).max().unwrap_or(0);
let widths: Vec<usize> = (0..columns)
.map(|i| {
self.rows
.iter()
.filter_map(|r| r.get(i))
.map(|c| display_width(&c.text))
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
for row in &self.rows {
let mut line = " ".repeat(self.indent);
for (i, cell) in row.iter().enumerate() {
let rendered = cell.render(p);
if i + 1 == row.len() {
line.push_str(&rendered);
} else {
let pad = widths[i].saturating_sub(display_width(&cell.text)) + self.gap;
line.push_str(&rendered);
line.push_str(&" ".repeat(pad));
}
}
out.push_str(line.trim_end());
out.push('\n');
}
out
}
}
pub fn heading(p: &Palette, text: &str) -> String {
format!("{}\n", p.paint(HEAD, text))
}
pub fn nothing(p: &Palette, why: &str) -> String {
format!(" {}\n", p.paint(DIM, &format!("({why})")))
}
const ME: &str = "omh";
pub fn warning(p: &Palette, msg: &str) -> String {
format!("{}: {msg}\n", p.paint(WARN, ME))
}
pub fn hint(p: &Palette, msg: &str) -> String {
format!("{}\n", p.paint(DIM, msg))
}
pub fn problem(p: &Palette, e: &anyhow::Error) -> String {
let mut out = format!("{}: {}\n", p.paint(BAD, ME), e);
for cause in e.chain().skip(1) {
out.push_str(&format!(" {} {cause}\n", p.paint(DIM, "because")));
}
out
}
pub trait Report {
fn human(&self, p: &Palette) -> String;
fn json(&self) -> serde_json::Value;
fn asides(&self) -> Asides {
Asides::default()
}
}
#[derive(Debug, Default, Clone)]
pub struct Asides {
pub warnings: Vec<String>,
pub hints: Vec<String>,
}
impl Asides {
pub fn warn(mut self, msg: impl Into<String>) -> Self {
self.warnings.push(msg.into());
self
}
pub fn hint(mut self, msg: impl Into<String>) -> Self {
self.hints.push(msg.into());
self
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum Format {
#[default]
Human,
Json,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Ctx {
pub format: Format,
pub palette: Palette,
}
impl Ctx {
pub fn say<R: Report + ?Sized>(&self, report: &R) {
print!("{}", emit(report, self.format, &self.palette));
let _ = std::io::Write::flush(&mut std::io::stdout());
if self.format == Format::Json {
return;
}
let asides = report.asides();
for warning in &asides.warnings {
self.warn(warning);
}
for hint in &asides.hints {
self.hint(hint);
}
}
pub fn warn(&self, msg: &str) {
eprint!("{}", warning(&self.palette, msg));
}
pub fn announce(&self, msg: &str) {
if self.format == Format::Human {
eprintln!("{}: {msg}", self.palette.paint(HEAD, ME));
}
}
pub fn progress(&self, msg: &str) {
if self.format == Format::Human {
eprintln!("{}", self.palette.paint(DIM, msg));
}
}
pub fn hint(&self, msg: &str) {
if self.format == Format::Human {
eprint!("{}", hint(&self.palette, msg));
}
}
#[cfg(test)]
pub fn plain() -> Self {
Self {
format: Format::Human,
palette: Palette::plain(),
}
}
}
pub fn emit<R: Report + ?Sized>(report: &R, format: Format, p: &Palette) -> String {
match format {
Format::Human => report.human(p),
Format::Json => {
let mut s = serde_json::to_string_pretty(&report.json()).unwrap_or_default();
s.push('\n');
s
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_column_is_as_wide_as_it_draws_and_not_as_wide_as_its_bytes() {
let table = Table::new()
.row(vec![Cell::plain("✓"), Cell::plain("second")])
.row(vec![Cell::plain("x"), Cell::plain("second")]);
let out = table.render(&Palette::plain());
let offsets: Vec<usize> = out
.lines()
.map(|l| {
let byte = l.find("second").expect("every row has a second column");
l[..byte].chars().count()
})
.collect();
assert_eq!(
offsets[0], offsets[1],
"a one-column mark and a one-column letter must leave the second \
column in the same place — got {out:?}"
);
}
#[test]
fn an_escape_costs_no_columns() {
assert_eq!(display_width("\x1b[32mabc\x1b[0m"), display_width("abc"));
assert_eq!(display_width("abc"), 3);
}
#[test]
fn one_long_value_moves_every_row_and_not_just_its_own() {
let table = Table::new()
.row(vec![Cell::plain("s01"), Cell::plain("up")])
.row(vec![
Cell::plain("a-very-long-session-id"),
Cell::plain("stopped"),
]);
let out = table.render(&Palette::plain());
let offsets: Vec<usize> = out
.lines()
.map(|l| l.rfind(char::is_whitespace).unwrap() + 1)
.collect();
assert_eq!(
offsets[0], offsets[1],
"both rows' second column starts at the same offset — got {out:?}"
);
assert!(
out.lines().all(|l| !l.contains(" up ")),
"and no row is padded past its last cell"
);
}
#[test]
fn forcing_colour_on_cannot_put_an_escape_in_the_machine_format() {
struct Both;
impl Report for Both {
fn human(&self, p: &Palette) -> String {
p.paint(OK, "green")
}
fn json(&self) -> serde_json::Value {
serde_json::json!({ "state": "green" })
}
}
let loud = Palette::resolve(Color::Always, None, true);
assert!(
emit(&Both, Format::Human, &loud).contains('\x1b'),
"the human format still paints when asked to"
);
assert!(
!emit(&Both, Format::Json, &loud).contains('\x1b'),
"but the machine format cannot, whatever the palette says"
);
assert!(
serde_json::from_str::<serde_json::Value>(&emit(&Both, Format::Json, &loud)).is_ok(),
"and what it emits parses"
);
}
#[test]
fn an_error_reports_every_cause_and_not_merely_the_outermost() {
let e = anyhow::anyhow!("No space left on device")
.context("writing /home/u/.omh/facts.json")
.context("remembering what this image contains");
let rendered = problem(&Palette::plain(), &e);
for link in [
"remembering what this image contains",
"writing /home/u/.omh/facts.json",
"No space left on device",
] {
assert!(
rendered.contains(link),
"the chain must survive rendering, and {link:?} did not — got {rendered:?}"
);
}
assert!(
rendered.starts_with("omh:"),
"and a diagnostic says who is speaking — got {rendered:?}"
);
}
#[test]
fn no_color_speaks_for_a_user_who_has_not_said_otherwise() {
let cases = [
(
Color::Auto,
None,
true,
true,
"a terminal, nothing forbidding",
),
(Color::Auto, None, false, false, "a pipe stays plain"),
(Color::Auto, Some("1"), true, false, "NO_COLOR beats auto"),
(
Color::Auto,
Some(""),
true,
true,
"but empty NO_COLOR is unset",
),
(
Color::Always,
Some("1"),
false,
true,
"an explicit flag beats NO_COLOR",
),
(Color::Never, None, true, false, "and never means never"),
];
for (pref, env, tty, want, why) in cases {
let paints = Palette::resolve(pref, env, tty).paint(OK, "x") != "x";
assert_eq!(
paints, want,
"{why}: {pref:?} / NO_COLOR={env:?} / tty={tty}"
);
}
}
}