Skip to main content

muster/adapter/cli/
report.rs

1use std::{error::Error, io::IsTerminal};
2
3use anstyle::{AnsiColor, Style};
4use getset::{CopyGetters, Getters};
5use typed_builder::TypedBuilder;
6use unicode_width::UnicodeWidthStr;
7
8/// Style of a passing row's glyph.
9const OK_STYLE: Style = AnsiColor::Green.on_default();
10/// Style of an advisory row's glyph.
11const HINT_STYLE: Style = AnsiColor::Yellow.on_default();
12/// Style of a failing row's glyph.
13const FAIL_STYLE: Style = AnsiColor::Red.on_default();
14/// Style of a row's label column.
15const LABEL_STYLE: Style = Style::new().bold();
16/// Style of de-emphasized rows and the box border.
17const DIM_STYLE: Style = Style::new().dimmed();
18/// Style of the box title.
19const TITLE_STYLE: Style = Style::new().bold();
20
21/// Glyph of a passing row.
22const OK_GLYPH: &str = "●";
23/// Glyph of an advisory row.
24const HINT_GLYPH: &str = "!";
25/// Glyph of a failing row.
26const FAIL_GLYPH: &str = "✗";
27
28/// Box-drawing pieces matching the TUI's rounded panes.
29const TOP_LEFT: &str = "╭";
30const TOP_RIGHT: &str = "╮";
31const BOTTOM_LEFT: &str = "╰";
32const BOTTOM_RIGHT: &str = "╯";
33const VERTICAL: &str = "│";
34const HORIZONTAL: &str = "─";
35/// Blank columns between the border and a row's content, each side.
36const BOX_PADDING: usize = 2;
37/// Blank columns between a padded label and its detail.
38const COLUMN_GAP: usize = 2;
39/// Separator between the parts of a summary line.
40const SUMMARY_SEPARATOR: &str = " · ";
41/// Separator between a plain row's label and detail.
42const PLAIN_SEPARATOR: &str = ": ";
43
44/// How a row is glyphed and colored in the boxed layout. The plain layout
45/// shows glyphs only for labeled rows, preserving the pipeable line format.
46#[derive(Clone, Copy, Debug, PartialEq, Eq)]
47pub enum RowKind {
48    /// Healthy: green dot.
49    Ok,
50    /// Advisory: yellow marker.
51    Hint,
52    /// Broken: red cross.
53    Fail,
54    /// Unstyled content.
55    Plain,
56    /// De-emphasized content (dimmed in the box).
57    Dim,
58}
59
60impl RowKind {
61    /// The status glyph for glyphed kinds.
62    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    /// The glyph's color style.
72    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/// One line of command output: an optional labeled fact with a status kind.
84#[derive(Clone, Debug, Getters, CopyGetters, TypedBuilder)]
85pub struct Row {
86    /// Status kind driving glyph and color.
87    #[getset(get_copy = "pub")]
88    kind: RowKind,
89    /// Optional bold label column.
90    #[builder(default)]
91    #[getset(get = "pub")]
92    label: Option<String>,
93    /// The fact itself.
94    #[getset(get = "pub")]
95    detail: String,
96}
97
98impl Row {
99    /// An unlabeled row of the given kind.
100    pub fn unlabeled(kind: RowKind, detail: impl Into<String>) -> Self {
101        Row::builder().kind(kind).detail(detail.into()).build()
102    }
103
104    /// A labeled, glyphed row.
105    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    /// The row in the plain line-per-fact layout: labeled rows keep the
114    /// `glyph label: detail` shape, unlabeled rows are the bare detail, so
115    /// piped output matches the pre-styling format byte for byte.
116    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/// A command's full output: a titled set of rows and an optional summary
127/// shown only in the boxed layout.
128#[derive(Clone, Debug, Getters, TypedBuilder)]
129#[getset(get = "pub")]
130pub struct Report {
131    /// Box title, e.g. `muster doctor`.
132    title: String,
133    /// The facts, in display order.
134    rows: Vec<Row>,
135    /// Optional closing line, e.g. `5 ok · 1 hint`.
136    #[builder(default)]
137    summary: Option<String>,
138}
139
140impl Report {
141    /// A report over `rows` titled `title`.
142    pub fn new(title: impl Into<String>, rows: Vec<Row>) -> Self {
143        Report::builder().title(title.into()).rows(rows).build()
144    }
145
146    /// Attaches the boxed-layout summary line.
147    pub fn with_summary(mut self, summary: impl Into<String>) -> Self {
148        self.summary = Some(summary.into());
149        self
150    }
151}
152
153/// Joins non-empty summary parts with the standard separator.
154pub fn summary_line(parts: &[String]) -> String {
155    parts.join(SUMMARY_SEPARATOR)
156}
157
158/// Prints a failure to stderr in the CLI's voice: a red cross, the app
159/// name, and the error. anstream strips styling when stderr is redirected.
160pub 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
167/// Prints the report: the boxed styled layout on a terminal, the plain
168/// line-per-fact layout when piped. anstream strips colors as needed.
169pub 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
177/// The plain layout: one unstyled line per row, matching the CLI's original
178/// output exactly.
179pub fn plain(report: &Report) -> String {
180    report
181        .rows
182        .iter()
183        .map(Row::plain_line)
184        .collect::<Vec<_>>()
185        .join("\n")
186}
187
188/// The boxed layout: a rounded, titled frame around aligned rows, in the
189/// TUI's visual vocabulary. `styled` disables ANSI styling for tests.
190pub 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
243/// The unstyled content of one boxed row, used for width computation.
244fn 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
263/// One boxed row with its glyph colored, label bolded, and dim rows dimmed.
264fn 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
292/// Wraps `text` in `style` when styling is on.
293fn 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
301/// The `╭ title ────╮` line.
302fn 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
314/// A `│      │` spacer line.
315fn 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
324/// The `╰────╯` closing line.
325fn 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    /// The plain layout reproduces the original line-per-fact output.
347    #[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    /// The box frames aligned rows under the title, closing with the summary.
365    #[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    /// Styled output carries ANSI sequences; unstyled carries none.
386    #[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    /// Summary parts join with the dot separator.
393    #[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}