use std::{error::Error, io::IsTerminal};
use anstyle::{AnsiColor, Style};
use getset::{CopyGetters, Getters};
use typed_builder::TypedBuilder;
use unicode_width::UnicodeWidthStr;
const OK_STYLE: Style = AnsiColor::Green.on_default();
const HINT_STYLE: Style = AnsiColor::Yellow.on_default();
const FAIL_STYLE: Style = AnsiColor::Red.on_default();
const LABEL_STYLE: Style = Style::new().bold();
const DIM_STYLE: Style = Style::new().dimmed();
const TITLE_STYLE: Style = Style::new().bold();
const OK_GLYPH: &str = "●";
const HINT_GLYPH: &str = "!";
const FAIL_GLYPH: &str = "✗";
const TOP_LEFT: &str = "╭";
const TOP_RIGHT: &str = "╮";
const BOTTOM_LEFT: &str = "╰";
const BOTTOM_RIGHT: &str = "╯";
const VERTICAL: &str = "│";
const HORIZONTAL: &str = "─";
const BOX_PADDING: usize = 2;
const COLUMN_GAP: usize = 2;
const SUMMARY_SEPARATOR: &str = " · ";
const PLAIN_SEPARATOR: &str = ": ";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RowKind {
Ok,
Hint,
Fail,
Plain,
Dim,
}
impl RowKind {
fn glyph(self) -> Option<&'static str> {
match self {
RowKind::Ok => Some(OK_GLYPH),
RowKind::Hint => Some(HINT_GLYPH),
RowKind::Fail => Some(FAIL_GLYPH),
RowKind::Plain | RowKind::Dim => None,
}
}
fn style(self) -> Style {
match self {
RowKind::Ok => OK_STYLE,
RowKind::Hint => HINT_STYLE,
RowKind::Fail => FAIL_STYLE,
RowKind::Plain => Style::new(),
RowKind::Dim => DIM_STYLE,
}
}
}
#[derive(Clone, Debug, Getters, CopyGetters, TypedBuilder)]
pub struct Row {
#[getset(get_copy = "pub")]
kind: RowKind,
#[builder(default)]
#[getset(get = "pub")]
label: Option<String>,
#[getset(get = "pub")]
detail: String,
}
impl Row {
pub fn unlabeled(kind: RowKind, detail: impl Into<String>) -> Self {
Row::builder().kind(kind).detail(detail.into()).build()
}
pub fn labeled(kind: RowKind, label: impl Into<String>, detail: impl Into<String>) -> Self {
Row::builder()
.kind(kind)
.label(Some(label.into()))
.detail(detail.into())
.build()
}
fn plain_line(&self) -> String {
match (&self.label, self.kind.glyph()) {
(Some(label), Some(glyph)) => {
format!("{glyph} {label}{PLAIN_SEPARATOR}{}", self.detail)
},
_ => self.detail.clone(),
}
}
}
#[derive(Clone, Debug, Getters, TypedBuilder)]
#[getset(get = "pub")]
pub struct Report {
title: String,
rows: Vec<Row>,
#[builder(default)]
summary: Option<String>,
}
impl Report {
pub fn new(title: impl Into<String>, rows: Vec<Row>) -> Self {
Report::builder().title(title.into()).rows(rows).build()
}
pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
self.summary = Some(summary.into());
self
}
}
pub fn summary_line(parts: &[String]) -> String {
parts.join(SUMMARY_SEPARATOR)
}
pub fn print_error(context: &str, error: &dyn Error) {
anstream::eprintln!(
"{glyph} {context}{PLAIN_SEPARATOR}{error}",
glyph = paint(FAIL_GLYPH, FAIL_STYLE, true),
);
}
pub fn print(report: &Report) {
if std::io::stdout().is_terminal() {
anstream::println!("{}", boxed(report, true));
} else {
anstream::println!("{}", plain(report));
}
}
pub fn plain(report: &Report) -> String {
report
.rows
.iter()
.map(Row::plain_line)
.collect::<Vec<_>>()
.join("\n")
}
pub fn boxed(report: &Report, styled: bool) -> String {
let label_width = report
.rows
.iter()
.filter_map(|row| row.label.as_deref().map(UnicodeWidthStr::width))
.max()
.unwrap_or(0);
let body: Vec<(String, String)> = report
.rows
.iter()
.map(|row| {
(
row_content(row, label_width),
styled_row(row, label_width, styled),
)
})
.chain(
report
.summary
.iter()
.map(|summary| (summary.clone(), paint(summary, DIM_STYLE, styled))),
)
.collect();
let title_span = UnicodeWidthStr::width(report.title.as_str()) + 2;
let content_width = body
.iter()
.map(|(plain, _)| UnicodeWidthStr::width(plain.as_str()))
.max()
.unwrap_or(0)
.max(title_span)
+ BOX_PADDING * 2;
let mut lines = Vec::new();
lines.push(top_border(&report.title, content_width, styled));
lines.push(spacer_row(content_width, styled));
for (index, (plain_row, styled_row)) in body.iter().enumerate() {
let is_summary = report.summary.is_some() && index == body.len() - 1;
if is_summary {
lines.push(spacer_row(content_width, styled));
}
let fill = content_width - BOX_PADDING - UnicodeWidthStr::width(plain_row.as_str());
lines.push(format!(
"{border}{pad}{styled_row}{fill}{border_end}",
border = paint(VERTICAL, DIM_STYLE, styled),
pad = " ".repeat(BOX_PADDING),
fill = " ".repeat(fill),
border_end = paint(VERTICAL, DIM_STYLE, styled),
));
}
lines.push(spacer_row(content_width, styled));
lines.push(bottom_border(content_width, styled));
lines.join("\n")
}
fn row_content(row: &Row, label_width: usize) -> String {
match (&row.label, row.kind.glyph()) {
(Some(label), Some(glyph)) => format!(
"{glyph} {label}{pad}{gap}{detail}",
pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
gap = " ".repeat(COLUMN_GAP),
detail = row.detail,
),
(Some(label), None) => format!(
" {label}{pad}{gap}{detail}",
pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
gap = " ".repeat(COLUMN_GAP),
detail = row.detail,
),
(None, Some(glyph)) => format!("{glyph} {}", row.detail),
(None, None) => row.detail.clone(),
}
}
fn styled_row(row: &Row, label_width: usize, styled: bool) -> String {
if !styled {
return row_content(row, label_width);
}
match (&row.label, row.kind.glyph()) {
(Some(label), Some(glyph)) => format!(
"{glyph} {label}{pad}{gap}{detail}",
glyph = paint(glyph, row.kind.style(), styled),
label = paint(label, LABEL_STYLE, styled),
pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
gap = " ".repeat(COLUMN_GAP),
detail = row.detail,
),
(Some(label), None) => format!(
" {label}{pad}{gap}{detail}",
label = paint(label, LABEL_STYLE, styled),
pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
gap = " ".repeat(COLUMN_GAP),
detail = row.detail,
),
(None, Some(glyph)) => format!("{} {}", paint(glyph, row.kind.style(), styled), row.detail),
(None, None) => match row.kind {
RowKind::Dim => paint(&row.detail, DIM_STYLE, styled),
_ => row.detail.clone(),
},
}
}
fn paint(text: &str, style: Style, styled: bool) -> String {
if styled {
format!("{style}{text}{style:#}")
} else {
text.to_string()
}
}
fn top_border(title: &str, content_width: usize, styled: bool) -> String {
let used = UnicodeWidthStr::width(title) + 2;
let fill = HORIZONTAL.repeat(content_width.saturating_sub(used));
format!(
"{corner} {title} {fill}{end}",
corner = paint(TOP_LEFT, DIM_STYLE, styled),
title = paint(title, TITLE_STYLE, styled),
fill = paint(&fill, DIM_STYLE, styled),
end = paint(TOP_RIGHT, DIM_STYLE, styled),
)
}
fn spacer_row(content_width: usize, styled: bool) -> String {
format!(
"{border}{space}{border_end}",
border = paint(VERTICAL, DIM_STYLE, styled),
space = " ".repeat(content_width),
border_end = paint(VERTICAL, DIM_STYLE, styled),
)
}
fn bottom_border(content_width: usize, styled: bool) -> String {
format!(
"{corner}{fill}{end}",
corner = paint(BOTTOM_LEFT, DIM_STYLE, styled),
fill = paint(&HORIZONTAL.repeat(content_width), DIM_STYLE, styled),
end = paint(BOTTOM_RIGHT, DIM_STYLE, styled),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Report {
Report::new("muster doctor", vec![
Row::labeled(RowKind::Ok, "config", "muster.yml is valid"),
Row::labeled(RowKind::Hint, "completions", "not registered"),
])
.with_summary("1 ok · 1 hint")
}
#[test]
fn plain_layout_matches_the_original_lines() {
assert_eq!(
plain(&sample()),
"● config: muster.yml is valid\n! completions: not registered"
);
let bare = Report::new("muster init", vec![
Row::unlabeled(RowKind::Ok, "created muster.yml"),
Row::unlabeled(RowKind::Dim, "run `muster` here to start"),
]);
assert_eq!(
plain(&bare),
"created muster.yml\nrun `muster` here to start",
"unlabeled rows never gain glyphs in the plain layout"
);
}
#[test]
fn boxed_layout_frames_and_aligns() {
let unstyled = boxed(&sample(), false);
let lines: Vec<&str> = unstyled.lines().collect();
assert!(lines[0].starts_with("╭ muster doctor "));
assert!(lines[0].ends_with("╮"));
assert!(lines[2].contains("● config muster.yml is valid"));
assert!(lines[3].contains("! completions not registered"));
assert!(lines[5].contains("1 ok · 1 hint"));
assert!(lines.last().unwrap().starts_with("╰"));
let widths: Vec<usize> = lines
.iter()
.map(|line| UnicodeWidthStr::width(*line))
.collect();
assert!(
widths.windows(2).all(|pair| pair[0] == pair[1]),
"every box line has the same display width: {widths:?}"
);
}
#[test]
fn styling_toggles_ansi_sequences() {
assert!(boxed(&sample(), true).contains('\u{1b}'));
assert!(!boxed(&sample(), false).contains('\u{1b}'));
}
#[test]
fn summary_parts_join_with_dots() {
assert_eq!(
summary_line(&["5 ok".to_string(), "1 hint".to_string()]),
"5 ok · 1 hint"
);
}
}