use std::io::{self, Write};
use super::Styling;
#[derive(Debug, Clone, Copy)]
pub struct Ui {
styling: Styling,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Tone {
Good,
Waiting,
Bad,
Plain,
}
impl Ui {
#[must_use]
pub const fn new(styling: Styling) -> Self {
Self { styling }
}
pub fn decorate(&self, out: &mut dyn Write, text: &str) -> io::Result<()> {
if !self.styling.is_enabled() {
return write!(out, "{text}");
}
let table_widths = column_widths(text);
let widest = text
.lines()
.filter_map(parse_row)
.map(|(key, _)| key.chars().count())
.max()
.unwrap_or(0);
let lines: Vec<&str> = text.lines().collect();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
index += 1;
if let Some(widths) = table_widths.as_ref()
&& line.contains('\t')
{
self.write_table_row(out, line, widths)?;
continue;
}
match classify(line, lines.get(index).copied()) {
Line::Blank => writeln!(out)?,
Line::Title(title) => {
writeln!(out, "{}", self.styling.heading(title))?;
writeln!(
out,
"{}",
self.styling.rule(&"─".repeat(title.chars().count()))
)?;
if lines.get(index).copied().is_some_and(is_underline) {
index += 1;
}
}
Line::Row { key, value } => {
let padded = format!("{key:<widest$}");
writeln!(
out,
" {} {}",
self.styling.key(&padded),
self.paint(value, tone_of(key, value))
)?;
}
Line::Prose(text) => writeln!(out, "{text}")?,
}
}
Ok(())
}
pub fn error(
&self,
err: &mut dyn Write,
message: &str,
remedy: Option<&str>,
) -> io::Result<()> {
writeln!(err, "{} {message}", self.styling.failure("error:"),)?;
if let Some(remedy) = remedy {
writeln!(
err,
" {} {}",
self.styling.key("try:"),
self.styling.command(remedy)
)?;
}
Ok(())
}
pub fn warning(&self, err: &mut dyn Write, message: &str) -> io::Result<()> {
writeln!(err, "{} {message}", self.styling.caution("warning:"))
}
fn write_table_row(&self, out: &mut dyn Write, line: &str, widths: &[usize]) -> io::Result<()> {
let cells: Vec<&str> = line.split('\t').collect();
let last = cells.len().saturating_sub(1);
for (index, cell) in cells.iter().enumerate() {
let painted = if index == 0 {
self.styling.heading(cell)
} else {
self.paint(cell, tone_of("", cell))
};
if index == last {
writeln!(out, "{painted}")?;
} else {
let width = widths.get(index).copied().unwrap_or(0);
let padding = width.saturating_sub(cell.chars().count());
write!(out, "{painted}{:padding$} ", "")?;
}
}
Ok(())
}
fn paint(&self, value: &str, tone: Tone) -> String {
match tone {
Tone::Good => self.styling.good(value),
Tone::Waiting => self.styling.caution(value),
Tone::Bad => self.styling.failure(value),
Tone::Plain => value.to_string(),
}
}
}
fn column_widths(text: &str) -> Option<Vec<usize>> {
let rows: Vec<Vec<&str>> = text
.lines()
.filter(|line| line.contains('\t'))
.map(|line| line.split('\t').collect())
.collect();
if rows.is_empty() {
return None;
}
let columns = rows.iter().map(Vec::len).max().unwrap_or(0);
Some(
(0..columns)
.map(|index| {
rows.iter()
.filter_map(|row| row.get(index))
.map(|cell| cell.chars().count())
.max()
.unwrap_or(0)
})
.collect(),
)
}
enum Line<'a> {
Blank,
Title(&'a str),
Row {
key: &'a str,
value: &'a str,
},
Prose(&'a str),
}
fn classify<'a>(line: &'a str, next: Option<&'a str>) -> Line<'a> {
if line.trim().is_empty() {
return Line::Blank;
}
if let Some((key, value)) = parse_row(line) {
return Line::Row { key, value };
}
if line.starts_with(' ') || is_underline(line) {
return Line::Prose(line);
}
let introduces_a_block =
next.is_some_and(|next| is_underline(next) || parse_row(next).is_some());
if introduces_a_block {
return Line::Title(line);
}
Line::Prose(line)
}
fn is_underline(line: &str) -> bool {
let trimmed = line.trim();
trimmed.chars().count() >= 3
&& trimmed
.chars()
.all(|character| matches!(character, '=' | '-' | '─' | '_' | '~'))
}
fn parse_row(line: &str) -> Option<(&str, &str)> {
let indented = line.strip_prefix(" ")?;
if indented.starts_with(' ') {
return None;
}
let gap = indented.find(" ")?;
let (key, rest) = indented.split_at(gap);
let value = rest.trim_start();
if key.is_empty() || value.is_empty() {
return None;
}
Some((key, value))
}
fn tone_of(key: &str, value: &str) -> Tone {
let key = key.to_ascii_lowercase();
let lowered = value.to_ascii_lowercase();
if key == "verdict" {
return if lowered.contains("not") {
Tone::Bad
} else {
Tone::Good
};
}
if key.starts_with("error") {
return Tone::Bad;
}
match lowered.as_str() {
"running" | "healthy" | "active" | "present in the machine-scoped store" | "enabled" => {
Tone::Good
}
"stopped" | "never" | "pending" | "disabled" | "no" | "draining" => Tone::Waiting,
"revoked" | "unreachable" | "stale" | "not installed" => Tone::Bad,
_ => Tone::Plain,
}
}
#[cfg(test)]
mod tests {
use super::*;
const TAB: &str = "\t";
fn rendered(styling: Styling, text: &str) -> String {
let mut out = Vec::new();
Ui::new(styling)
.decorate(&mut out, text)
.expect("writing to a Vec");
String::from_utf8(out).expect("UTF-8")
}
const REPORT: &str = "Service: runner-manager\n installed the Windows Service Control Manager\n state running\n verdict healthy\n";
#[test]
fn plain_styling_passes_the_report_through_byte_for_byte() {
assert_eq!(rendered(Styling::plain(), REPORT), REPORT);
}
#[test]
fn styled_output_keeps_every_key_and_value_it_was_given() {
let styled = rendered(Styling::styled(), REPORT);
let visible: String = strip_escapes(&styled);
for needle in [
"Service: runner-manager",
"installed",
"the Windows Service Control Manager",
"state",
"running",
"verdict",
"healthy",
] {
assert!(
visible.contains(needle),
"decoration lost `{needle}`:\n{visible}"
);
}
assert!(
styled.contains('\u{1b}'),
"a terminal must actually get styling:\n{styled}"
);
}
#[test]
fn values_are_aligned_on_the_widest_key() {
let styled = strip_escapes(&rendered(Styling::styled(), REPORT));
let columns: Vec<usize> = styled
.lines()
.filter(|line| line.starts_with(" "))
.filter_map(|line| line.find(" the ").or_else(|| line.find(" running")))
.collect();
assert!(
columns.windows(2).all(|pair| pair[0] == pair[1]),
"values must start in one column: {columns:?}\n{styled}"
);
}
#[test]
fn a_bad_verdict_and_a_good_one_are_not_painted_the_same() {
let good = rendered(
Styling::styled(),
"Service: x\n verdict healthy\n",
);
let bad = rendered(
Styling::styled(),
"Service: x\n verdict NOT healthy\n",
);
let good_codes: String = escapes_only(&good);
let bad_codes: String = escapes_only(&bad);
assert_ne!(
good_codes, bad_codes,
"a healthy and an unhealthy verdict render identically:\n{good}\n{bad}"
);
}
#[test]
fn a_value_containing_single_spaces_is_not_split_into_a_row() {
let (key, value) =
parse_row(" installed the Windows Service Control Manager")
.expect("a row");
assert_eq!(key, "installed");
assert_eq!(value, "the Windows Service Control Manager");
}
#[test]
fn tab_separated_rows_become_columns_on_a_terminal_and_tabs_in_a_pipe() {
let list = format!(
"short/repo{T}autoscale{T}active{T}enabled=true{T}max=1
a-much-longer/repository-name{T}monitor{T}pending{T}enabled=false{T}max=0
",
T = TAB
);
assert_eq!(
rendered(Styling::plain(), &list),
list,
"a pipe must still get tab-separated fields"
);
let styled = strip_escapes(&rendered(Styling::styled(), &list));
assert!(
!styled.contains(TAB),
"a terminal should get columns, not tabs:
{styled}"
);
let starts: Vec<usize> = styled
.lines()
.filter_map(|line| line.find("autoscale").or_else(|| line.find("monitor")))
.collect();
assert!(
starts.windows(2).all(|pair| pair[0] == pair[1]),
"the second column must start in one place: {starts:?}
{styled}"
);
}
#[test]
fn prose_and_continuations_are_left_alone() {
assert!(matches!(
classify(
"This is a sentence that explains something at length.",
None
),
Line::Prose(_)
));
assert!(matches!(
classify(" a continuation", None),
Line::Prose(_)
));
assert!(matches!(
classify(
"Service: runner-manager",
Some(" installed yes")
),
Line::Title(_)
));
}
fn strip_escapes(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars();
while let Some(character) = chars.next() {
if character == '\u{1b}' {
for skipped in chars.by_ref() {
if skipped == 'm' {
break;
}
}
} else {
out.push(character);
}
}
out
}
fn escapes_only(text: &str) -> String {
let mut out = String::new();
let mut chars = text.chars();
while let Some(character) = chars.next() {
if character == '\u{1b}' {
out.push(character);
for code in chars.by_ref() {
out.push(code);
if code == 'm' {
break;
}
}
}
}
out
}
}