1use std::{error::Error, io::IsTerminal};
2
3use anstyle::{AnsiColor, Style};
4use getset::{CopyGetters, Getters};
5use typed_builder::TypedBuilder;
6use unicode_width::UnicodeWidthStr;
7
8const OK_STYLE: Style = AnsiColor::Green.on_default();
10const HINT_STYLE: Style = AnsiColor::Yellow.on_default();
12const FAIL_STYLE: Style = AnsiColor::Red.on_default();
14const LABEL_STYLE: Style = Style::new().bold();
16const DIM_STYLE: Style = Style::new().dimmed();
18const TITLE_STYLE: Style = Style::new().bold();
20
21const OK_GLYPH: &str = "●";
23const HINT_GLYPH: &str = "!";
25const FAIL_GLYPH: &str = "✗";
27
28const TOP_LEFT: &str = "╭";
30const TOP_RIGHT: &str = "╮";
31const BOTTOM_LEFT: &str = "╰";
32const BOTTOM_RIGHT: &str = "╯";
33const VERTICAL: &str = "│";
34const HORIZONTAL: &str = "─";
35const BOX_PADDING: usize = 2;
37const COLUMN_GAP: usize = 2;
39const SUMMARY_SEPARATOR: &str = " · ";
41const PLAIN_SEPARATOR: &str = ": ";
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum RowKind {
48 Ok,
50 Hint,
52 Fail,
54 Plain,
56 Dim,
58}
59
60impl RowKind {
61 fn glyph(self) -> Option<&'static str> {
63 match self {
64 RowKind::Ok => Some(OK_GLYPH),
65 RowKind::Hint => Some(HINT_GLYPH),
66 RowKind::Fail => Some(FAIL_GLYPH),
67 RowKind::Plain | RowKind::Dim => None,
68 }
69 }
70
71 fn style(self) -> Style {
73 match self {
74 RowKind::Ok => OK_STYLE,
75 RowKind::Hint => HINT_STYLE,
76 RowKind::Fail => FAIL_STYLE,
77 RowKind::Plain => Style::new(),
78 RowKind::Dim => DIM_STYLE,
79 }
80 }
81}
82
83#[derive(Clone, Debug, Getters, CopyGetters, TypedBuilder)]
85pub struct Row {
86 #[getset(get_copy = "pub")]
88 kind: RowKind,
89 #[builder(default)]
91 #[getset(get = "pub")]
92 label: Option<String>,
93 #[getset(get = "pub")]
95 detail: String,
96}
97
98impl Row {
99 pub fn unlabeled(kind: RowKind, detail: impl Into<String>) -> Self {
101 Row::builder().kind(kind).detail(detail.into()).build()
102 }
103
104 pub fn labeled(kind: RowKind, label: impl Into<String>, detail: impl Into<String>) -> Self {
106 Row::builder()
107 .kind(kind)
108 .label(Some(label.into()))
109 .detail(detail.into())
110 .build()
111 }
112
113 fn plain_line(&self) -> String {
117 match (&self.label, self.kind.glyph()) {
118 (Some(label), Some(glyph)) => {
119 format!("{glyph} {label}{PLAIN_SEPARATOR}{}", self.detail)
120 },
121 _ => self.detail.clone(),
122 }
123 }
124}
125
126#[derive(Clone, Debug, Getters, TypedBuilder)]
129#[getset(get = "pub")]
130pub struct Report {
131 title: String,
133 rows: Vec<Row>,
135 #[builder(default)]
137 summary: Option<String>,
138}
139
140impl Report {
141 pub fn new(title: impl Into<String>, rows: Vec<Row>) -> Self {
143 Report::builder().title(title.into()).rows(rows).build()
144 }
145
146 pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
148 self.summary = Some(summary.into());
149 self
150 }
151}
152
153pub fn summary_line(parts: &[String]) -> String {
155 parts.join(SUMMARY_SEPARATOR)
156}
157
158pub fn print_error(context: &str, error: &dyn Error) {
161 anstream::eprintln!(
162 "{glyph} {context}{PLAIN_SEPARATOR}{error}",
163 glyph = paint(FAIL_GLYPH, FAIL_STYLE, true),
164 );
165}
166
167pub fn print(report: &Report) {
170 if std::io::stdout().is_terminal() {
171 anstream::println!("{}", boxed(report, true));
172 } else {
173 anstream::println!("{}", plain(report));
174 }
175}
176
177pub fn plain(report: &Report) -> String {
180 report
181 .rows
182 .iter()
183 .map(Row::plain_line)
184 .collect::<Vec<_>>()
185 .join("\n")
186}
187
188pub fn boxed(report: &Report, styled: bool) -> String {
191 let label_width = report
192 .rows
193 .iter()
194 .filter_map(|row| row.label.as_deref().map(UnicodeWidthStr::width))
195 .max()
196 .unwrap_or(0);
197 let body: Vec<(String, String)> = report
198 .rows
199 .iter()
200 .map(|row| {
201 (
202 row_content(row, label_width),
203 styled_row(row, label_width, styled),
204 )
205 })
206 .chain(
207 report
208 .summary
209 .iter()
210 .map(|summary| (summary.clone(), paint(summary, DIM_STYLE, styled))),
211 )
212 .collect();
213 let title_span = UnicodeWidthStr::width(report.title.as_str()) + 2;
214 let content_width = body
215 .iter()
216 .map(|(plain, _)| UnicodeWidthStr::width(plain.as_str()))
217 .max()
218 .unwrap_or(0)
219 .max(title_span)
220 + BOX_PADDING * 2;
221 let mut lines = Vec::new();
222 lines.push(top_border(&report.title, content_width, styled));
223 lines.push(spacer_row(content_width, styled));
224 for (index, (plain_row, styled_row)) in body.iter().enumerate() {
225 let is_summary = report.summary.is_some() && index == body.len() - 1;
226 if is_summary {
227 lines.push(spacer_row(content_width, styled));
228 }
229 let fill = content_width - BOX_PADDING - UnicodeWidthStr::width(plain_row.as_str());
230 lines.push(format!(
231 "{border}{pad}{styled_row}{fill}{border_end}",
232 border = paint(VERTICAL, DIM_STYLE, styled),
233 pad = " ".repeat(BOX_PADDING),
234 fill = " ".repeat(fill),
235 border_end = paint(VERTICAL, DIM_STYLE, styled),
236 ));
237 }
238 lines.push(spacer_row(content_width, styled));
239 lines.push(bottom_border(content_width, styled));
240 lines.join("\n")
241}
242
243fn row_content(row: &Row, label_width: usize) -> String {
245 match (&row.label, row.kind.glyph()) {
246 (Some(label), Some(glyph)) => format!(
247 "{glyph} {label}{pad}{gap}{detail}",
248 pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
249 gap = " ".repeat(COLUMN_GAP),
250 detail = row.detail,
251 ),
252 (Some(label), None) => format!(
253 " {label}{pad}{gap}{detail}",
254 pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
255 gap = " ".repeat(COLUMN_GAP),
256 detail = row.detail,
257 ),
258 (None, Some(glyph)) => format!("{glyph} {}", row.detail),
259 (None, None) => row.detail.clone(),
260 }
261}
262
263fn styled_row(row: &Row, label_width: usize, styled: bool) -> String {
265 if !styled {
266 return row_content(row, label_width);
267 }
268 match (&row.label, row.kind.glyph()) {
269 (Some(label), Some(glyph)) => format!(
270 "{glyph} {label}{pad}{gap}{detail}",
271 glyph = paint(glyph, row.kind.style(), styled),
272 label = paint(label, LABEL_STYLE, styled),
273 pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
274 gap = " ".repeat(COLUMN_GAP),
275 detail = row.detail,
276 ),
277 (Some(label), None) => format!(
278 " {label}{pad}{gap}{detail}",
279 label = paint(label, LABEL_STYLE, styled),
280 pad = " ".repeat(label_width - UnicodeWidthStr::width(label.as_str())),
281 gap = " ".repeat(COLUMN_GAP),
282 detail = row.detail,
283 ),
284 (None, Some(glyph)) => format!("{} {}", paint(glyph, row.kind.style(), styled), row.detail),
285 (None, None) => match row.kind {
286 RowKind::Dim => paint(&row.detail, DIM_STYLE, styled),
287 _ => row.detail.clone(),
288 },
289 }
290}
291
292fn paint(text: &str, style: Style, styled: bool) -> String {
294 if styled {
295 format!("{style}{text}{style:#}")
296 } else {
297 text.to_string()
298 }
299}
300
301fn top_border(title: &str, content_width: usize, styled: bool) -> String {
303 let used = UnicodeWidthStr::width(title) + 2;
304 let fill = HORIZONTAL.repeat(content_width.saturating_sub(used));
305 format!(
306 "{corner} {title} {fill}{end}",
307 corner = paint(TOP_LEFT, DIM_STYLE, styled),
308 title = paint(title, TITLE_STYLE, styled),
309 fill = paint(&fill, DIM_STYLE, styled),
310 end = paint(TOP_RIGHT, DIM_STYLE, styled),
311 )
312}
313
314fn spacer_row(content_width: usize, styled: bool) -> String {
316 format!(
317 "{border}{space}{border_end}",
318 border = paint(VERTICAL, DIM_STYLE, styled),
319 space = " ".repeat(content_width),
320 border_end = paint(VERTICAL, DIM_STYLE, styled),
321 )
322}
323
324fn bottom_border(content_width: usize, styled: bool) -> String {
326 format!(
327 "{corner}{fill}{end}",
328 corner = paint(BOTTOM_LEFT, DIM_STYLE, styled),
329 fill = paint(&HORIZONTAL.repeat(content_width), DIM_STYLE, styled),
330 end = paint(BOTTOM_RIGHT, DIM_STYLE, styled),
331 )
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 fn sample() -> Report {
339 Report::new("muster doctor", vec![
340 Row::labeled(RowKind::Ok, "config", "muster.yml is valid"),
341 Row::labeled(RowKind::Hint, "completions", "not registered"),
342 ])
343 .with_summary("1 ok · 1 hint")
344 }
345
346 #[test]
348 fn plain_layout_matches_the_original_lines() {
349 assert_eq!(
350 plain(&sample()),
351 "● config: muster.yml is valid\n! completions: not registered"
352 );
353 let bare = Report::new("muster init", vec![
354 Row::unlabeled(RowKind::Ok, "created muster.yml"),
355 Row::unlabeled(RowKind::Dim, "run `muster` here to start"),
356 ]);
357 assert_eq!(
358 plain(&bare),
359 "created muster.yml\nrun `muster` here to start",
360 "unlabeled rows never gain glyphs in the plain layout"
361 );
362 }
363
364 #[test]
366 fn boxed_layout_frames_and_aligns() {
367 let unstyled = boxed(&sample(), false);
368 let lines: Vec<&str> = unstyled.lines().collect();
369 assert!(lines[0].starts_with("╭ muster doctor "));
370 assert!(lines[0].ends_with("╮"));
371 assert!(lines[2].contains("● config muster.yml is valid"));
372 assert!(lines[3].contains("! completions not registered"));
373 assert!(lines[5].contains("1 ok · 1 hint"));
374 assert!(lines.last().unwrap().starts_with("╰"));
375 let widths: Vec<usize> = lines
376 .iter()
377 .map(|line| UnicodeWidthStr::width(*line))
378 .collect();
379 assert!(
380 widths.windows(2).all(|pair| pair[0] == pair[1]),
381 "every box line has the same display width: {widths:?}"
382 );
383 }
384
385 #[test]
387 fn styling_toggles_ansi_sequences() {
388 assert!(boxed(&sample(), true).contains('\u{1b}'));
389 assert!(!boxed(&sample(), false).contains('\u{1b}'));
390 }
391
392 #[test]
394 fn summary_parts_join_with_dots() {
395 assert_eq!(
396 summary_line(&["5 ok".to_string(), "1 hint".to_string()]),
397 "5 ok · 1 hint"
398 );
399 }
400}